From 1a4c00a1eb0c9804161f0e17a3a3f6ebad2bfba4 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 6 Apr 2026 16:01:27 +0800 Subject: [PATCH] Refactor single-agent execution chain --- ...ntroller_desktop_external_acp_routing.dart | 96 ++ ...p_controller_desktop_settings_runtime.dart | 6 +- .../app_controller_desktop_single_agent.dart | 969 +----------------- ...oller_desktop_single_agent_ai_gateway.dart | 465 +++++++++ ...ler_desktop_single_agent_go_task_flow.dart | 354 +++++++ ..._desktop_single_agent_status_messages.dart | 136 +++ ...app_controller_desktop_thread_actions.dart | 8 +- ...app_controller_desktop_thread_binding.dart | 14 +- ...pp_controller_desktop_thread_sessions.dart | 2 +- ...op_thread_sessions_collaboration_impl.dart | 2 +- ...app_controller_desktop_thread_storage.dart | 9 + ...ontroller_desktop_workspace_execution.dart | 16 +- lib/runtime/runtime_models_connection.dart | 13 +- .../runtime_models_settings_snapshot.dart | 13 +- test/quality/wave1_file_size_guard_test.dart | 5 + 15 files changed, 1116 insertions(+), 992 deletions(-) create mode 100644 lib/app/app_controller_desktop_single_agent_ai_gateway.dart create mode 100644 lib/app/app_controller_desktop_single_agent_go_task_flow.dart create mode 100644 lib/app/app_controller_desktop_single_agent_status_messages.dart diff --git a/lib/app/app_controller_desktop_external_acp_routing.dart b/lib/app/app_controller_desktop_external_acp_routing.dart index 99d03c14..f59b10cd 100644 --- a/lib/app/app_controller_desktop_external_acp_routing.dart +++ b/lib/app/app_controller_desktop_external_acp_routing.dart @@ -75,4 +75,100 @@ extension AppControllerDesktopExternalAcpRouting on AppController { ); await goTaskServiceClientInternal.syncExternalProviders(providers); } + + ExternalCodeAgentAcpRoutingConfig buildExternalAcpRoutingForSessionInternal( + String sessionKey, { + String? explicitExecutionTarget, + }) { + final normalizedSessionKey = normalizedAssistantSessionKeyInternal( + sessionKey, + ); + final thread = assistantThreadRecordsInternal[normalizedSessionKey]; + final sessionTarget = assistantExecutionTargetForSession( + normalizedSessionKey, + ); + final preferredGatewayTarget = switch (sessionTarget) { + AssistantExecutionTarget.auto => 'local', + AssistantExecutionTarget.local => 'local', + AssistantExecutionTarget.remote => 'remote', + AssistantExecutionTarget.singleAgent => + settings.assistantExecutionTarget == AssistantExecutionTarget.remote + ? 'remote' + : 'local', + }; + final availableSkills = + assistantImportedSkillsForSession(normalizedSessionKey) + .map((item) { + return ExternalCodeAgentAcpAvailableSkill( + id: item.key, + label: item.label, + description: item.description, + ); + }) + .toList(growable: false); + final selectedSkills = + assistantSelectedSkillsForSession(normalizedSessionKey) + .map((item) { + return item.label.trim().isNotEmpty ? item.label : item.key; + }) + .where((item) => item.trim().isNotEmpty) + .toList(growable: false); + + final resolvedExplicitExecutionTarget = + sessionTarget == AssistantExecutionTarget.auto + ? '' + : explicitExecutionTarget?.trim().isNotEmpty == true + ? explicitExecutionTarget!.trim() + : (thread?.hasExplicitExecutionTargetSelection ?? false) + ? _routingExecutionTargetValueInternal( + assistantExecutionTargetForSession(normalizedSessionKey), + ) + : ''; + final resolvedExplicitProviderId = + sessionTarget == AssistantExecutionTarget.auto + ? '' + : thread?.hasExplicitProviderSelection ?? false + ? singleAgentProviderForSession(normalizedSessionKey).providerId + : ''; + final resolvedExplicitModel = thread?.hasExplicitModelSelection ?? false + ? (sessionTarget == AssistantExecutionTarget.auto + ? '' + : assistantModelForSession(normalizedSessionKey)) + : ''; + final resolvedExplicitSkills = thread?.hasExplicitSkillSelection ?? false + ? selectedSkills + : const []; + final hasExplicitSelection = + resolvedExplicitExecutionTarget.isNotEmpty || + resolvedExplicitProviderId.isNotEmpty || + resolvedExplicitModel.trim().isNotEmpty || + resolvedExplicitSkills.isNotEmpty; + + if (!hasExplicitSelection) { + return ExternalCodeAgentAcpRoutingConfig.auto( + preferredGatewayTarget: preferredGatewayTarget, + availableSkills: availableSkills, + ); + } + + return ExternalCodeAgentAcpRoutingConfig( + mode: ExternalCodeAgentAcpRoutingMode.explicit, + preferredGatewayTarget: preferredGatewayTarget, + explicitExecutionTarget: resolvedExplicitExecutionTarget, + explicitProviderId: resolvedExplicitProviderId, + explicitModel: resolvedExplicitModel, + explicitSkills: resolvedExplicitSkills, + allowSkillInstall: false, + availableSkills: availableSkills, + ); + } + + String _routingExecutionTargetValueInternal(AssistantExecutionTarget target) { + return switch (target) { + AssistantExecutionTarget.auto => 'singleAgent', + AssistantExecutionTarget.singleAgent => 'singleAgent', + AssistantExecutionTarget.local => 'local', + AssistantExecutionTarget.remote => 'remote', + }; + } } diff --git a/lib/app/app_controller_desktop_settings_runtime.dart b/lib/app/app_controller_desktop_settings_runtime.dart index 45e09c3d..09c95bbf 100644 --- a/lib/app/app_controller_desktop_settings_runtime.dart +++ b/lib/app/app_controller_desktop_settings_runtime.dart @@ -532,7 +532,7 @@ extension AppControllerDesktopSettingsRuntime on AppController { if (disposedInternal) { return; } - final startupTarget = sanitizeExecutionTargetInternal( + final startupTarget = sanitizePersistedExecutionTargetInternal( settings.assistantExecutionTarget, ); agentsControllerInternal.restoreSelection( @@ -796,7 +796,7 @@ extension AppControllerDesktopSettingsRuntime on AppController { Future applyPersistedGatewaySettingsInternal( SettingsSnapshot snapshot, ) async { - final target = sanitizeExecutionTargetInternal( + final target = sanitizePersistedExecutionTargetInternal( snapshot.assistantExecutionTarget, ); final sessionKey = normalizedAssistantSessionKeyInternal( @@ -805,6 +805,8 @@ extension AppControllerDesktopSettingsRuntime on AppController { upsertTaskThreadInternal( sessionKey, executionTarget: target, + gatewayEntryState: gatewayEntryStateForTargetInternal(target), + latestResolvedRuntimeModel: '', updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); recomputeTasksInternal(); diff --git a/lib/app/app_controller_desktop_single_agent.dart b/lib/app/app_controller_desktop_single_agent.dart index 316b9380..c0e09804 100644 --- a/lib/app/app_controller_desktop_single_agent.dart +++ b/lib/app/app_controller_desktop_single_agent.dart @@ -1,52 +1,7 @@ -// ignore_for_file: unused_import, unnecessary_import - -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'package:flutter/material.dart'; -import 'app_metadata.dart'; -import 'app_capabilities.dart'; -import 'app_store_policy.dart'; -import 'ui_feature_manifest.dart'; -import '../i18n/app_language.dart'; -import '../models/app_models.dart'; -import '../runtime/device_identity_store.dart'; -import '../runtime/aris_bundle.dart'; -import '../runtime/go_core.dart'; -import '../runtime/runtime_bootstrap.dart'; -import '../runtime/desktop_platform_service.dart'; -import '../runtime/gateway_runtime.dart'; -import '../runtime/runtime_controllers.dart'; -import '../runtime/runtime_models.dart'; -import '../runtime/secure_config_store.dart'; -import '../runtime/embedded_agent_launch_policy.dart'; -import '../runtime/runtime_coordinator.dart'; -import '../runtime/direct_single_agent_app_server_client.dart'; -import '../runtime/gateway_acp_client.dart'; -import '../runtime/codex_runtime.dart'; -import '../runtime/codex_config_bridge.dart'; -import '../runtime/code_agent_node_orchestrator.dart'; -import '../runtime/assistant_artifacts.dart'; -import '../runtime/desktop_thread_artifact_service.dart'; -import '../runtime/go_task_service_client.dart'; -import '../runtime/mode_switcher.dart'; -import '../runtime/agent_registry.dart'; -import '../runtime/multi_agent_orchestrator.dart'; -import '../runtime/platform_environment.dart'; -import '../runtime/single_agent_runner.dart'; -import '../runtime/skill_directory_access.dart'; import 'app_controller_desktop_core.dart'; -import 'app_controller_desktop_navigation.dart'; -import 'app_controller_desktop_gateway.dart'; -import 'app_controller_desktop_settings.dart'; -import 'app_controller_desktop_thread_sessions.dart'; -import 'app_controller_desktop_thread_actions.dart'; -import 'app_controller_desktop_workspace_execution.dart'; -import 'app_controller_desktop_settings_runtime.dart'; -import 'app_controller_desktop_thread_storage.dart'; -import 'app_controller_desktop_skill_permissions.dart'; -import 'app_controller_desktop_external_acp_routing.dart'; -import 'app_controller_desktop_runtime_helpers.dart'; +import 'app_controller_desktop_single_agent_ai_gateway.dart'; +import 'app_controller_desktop_single_agent_go_task_flow.dart'; +import '../runtime/runtime_models.dart'; extension AppControllerDesktopSingleAgent on AppController { Future sendSingleAgentMessageInternal( @@ -54,919 +9,21 @@ extension AppControllerDesktopSingleAgent on AppController { required String thinking, required List attachments, required List localAttachments, - }) async { - final sessionKey = normalizedAssistantSessionKeyInternal( - sessionsControllerInternal.currentSessionKey, + }) { + return sendSingleAgentMessageDesktopGoTaskFlowInternal( + this, + message, + thinking: thinking, + attachments: attachments, + localAttachments: localAttachments, ); - final trimmed = message.trim(); - if (trimmed.isEmpty && attachments.isEmpty) { - return; - } - await enqueueThreadTurnInternal(sessionKey, () async { - final sessionTarget = assistantExecutionTargetForSession(sessionKey); - final userText = trimmed.isEmpty ? 'See attached.' : trimmed; - appendAssistantThreadMessageInternal( - sessionKey, - GatewayChatMessage( - id: nextLocalMessageIdInternal(), - role: 'user', - text: userText, - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: null, - stopReason: null, - pending: false, - error: false, - ), - ); - aiGatewayPendingSessionKeysInternal.add(sessionKey); - recomputeTasksInternal(); - notifyIfActiveInternal(); - - try { - final routing = buildExternalAcpRoutingForSessionInternal(sessionKey); - final selection = singleAgentProviderForSession(sessionKey); - await syncExternalAcpProvidersInternal(); - final capabilities = await goTaskServiceClientInternal - .loadExternalAcpCapabilities( - target: AssistantExecutionTarget.singleAgent, - forceRefresh: true, - ); - final availableProviders = configuredSingleAgentProviders - .where(capabilities.providers.contains) - .toList(growable: false); - final provider = selection == SingleAgentProvider.auto - ? (availableProviders.isEmpty ? null : availableProviders.first) - : (capabilities.providers.contains(selection) ? selection : null); - final fallbackReason = provider == null - ? (selection == SingleAgentProvider.auto - ? appText( - '当前没有可用的 GoTaskService Provider。', - 'No GoTaskService provider is currently available.', - ) - : appText( - '当前 GoTaskService 不支持 ${selection.label}。', - 'GoTaskService does not currently support ${selection.label}.', - )) - : null; - if (provider == null && !routing.isAuto) { - if (singleAgentUsesAiChatFallbackForSession(sessionKey)) { - appendSingleAgentFallbackStatusMessageInternal( - sessionKey, - fallbackReason, - ); - await sendAiGatewayMessageInternal( - message, - thinking: thinking, - attachments: attachments, - sessionKeyOverride: sessionKey, - appendUserMessage: false, - managePendingState: false, - ); - } else { - appendAssistantThreadMessageInternal( - sessionKey, - GatewayChatMessage( - id: nextLocalMessageIdInternal(), - role: 'assistant', - text: singleAgentUnavailableLabelInternal( - sessionKey, - fallbackReason, - ), - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: singleAgentRuntimeDebugToolNameInternal( - provider?.label ?? selection.label, - ), - stopReason: null, - pending: false, - error: false, - ), - ); - } - return; - } - final effectiveProvider = sessionTarget == AssistantExecutionTarget.auto - ? SingleAgentProvider.auto - : (provider ?? SingleAgentProvider.auto); - - appendSingleAgentRuntimeStatusMessageInternal( - sessionKey, - effectiveProvider, - ); - final workingDirectory = - resolveSingleAgentWorkingDirectoryForSessionInternal( - sessionKey, - provider: provider, - ); - if (workingDirectory == null || workingDirectory.trim().isEmpty) { - final error = StateError( - appText( - '当前线程缺少可运行的工作路径,无法启动单机智能体。', - 'This thread does not have a runnable workspace path, so Single Agent cannot start.', - ), - ); - appendAssistantThreadMessageInternal( - sessionKey, - assistantErrorMessageInternal(error.message), - ); - throw error; - } - - final selectedSkills = assistantSelectedSkillsForSession(sessionKey) - .map((item) => item.label.trim().isNotEmpty ? item.label : item.key) - .where((item) => item.trim().isNotEmpty) - .toList(growable: false); - final result = await goTaskServiceClientInternal.executeTask( - GoTaskServiceRequest( - sessionId: sessionKey, - threadId: sessionKey, - target: AssistantExecutionTarget.singleAgent, - prompt: message, - workingDirectory: workingDirectory, - model: sessionTarget == AssistantExecutionTarget.auto - ? '' - : assistantModelForSession(sessionKey), - thinking: thinking, - selectedSkills: selectedSkills, - inlineAttachments: attachments, - localAttachments: localAttachments, - aiGatewayBaseUrl: aiGatewayUrl, - aiGatewayApiKey: await loadAiGatewayApiKey(), - agentId: '', - metadata: const {}, - routing: routing, - provider: effectiveProvider, - ), - onUpdate: (update) { - if (update.isDelta) { - appendAiGatewayStreamingTextInternal(sessionKey, update.text); - notifyIfActiveInternal(); - } - }, - ); - final resolvedRuntimeModel = result.resolvedModel.trim(); - final resolvedGatewayEntryState = goTaskServiceGatewayEntryState( - requestedTarget: sessionTarget, - result: result, - ); - upsertTaskThreadInternal( - sessionKey, - gatewayEntryState: resolvedGatewayEntryState, - latestResolvedRuntimeModel: resolvedRuntimeModel, - lifecycleStatus: 'ready', - lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - lastResultCode: result.success ? 'success' : 'error', - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); - final resolvedWorkspaceKind = result.resolvedWorkspaceRefKind; - final resolvedWorkingDirectory = result.resolvedWorkingDirectory.trim(); - if (resolvedWorkspaceKind != null && - resolvedWorkingDirectory.isNotEmpty) { - final existingThread = requireTaskThreadForSessionInternal( - sessionKey, - ); - upsertTaskThreadInternal( - sessionKey, - workspaceBinding: WorkspaceBinding( - workspaceId: existingThread.workspaceBinding.workspaceId, - workspaceKind: - resolvedWorkspaceKind == WorkspaceRefKind.remotePath - ? WorkspaceKind.remoteFs - : WorkspaceKind.localFs, - workspacePath: resolvedWorkingDirectory, - displayPath: resolvedWorkingDirectory, - writable: existingThread.workspaceBinding.writable, - ), - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); - } - clearAiGatewayStreamingTextInternal(sessionKey); - if (!result.success && - singleAgentUsesAiChatFallbackForSession(sessionKey)) { - if (singleAgentUsesAiChatFallbackForSession(sessionKey)) { - appendSingleAgentFallbackStatusMessageInternal( - sessionKey, - result.errorMessage, - ); - upsertTaskThreadInternal( - sessionKey, - gatewayEntryState: 'only-chat', - latestResolvedRuntimeModel: resolvedAiGatewayModel, - lifecycleStatus: 'ready', - lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - lastResultCode: 'fallback', - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); - await sendAiGatewayMessageInternal( - message, - thinking: thinking, - attachments: attachments, - sessionKeyOverride: sessionKey, - appendUserMessage: false, - managePendingState: false, - ); - } else { - appendAssistantThreadMessageInternal( - sessionKey, - GatewayChatMessage( - id: nextLocalMessageIdInternal(), - role: 'assistant', - text: singleAgentUnavailableLabelInternal( - sessionKey, - result.errorMessage, - ), - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: singleAgentRuntimeDebugToolNameInternal( - effectiveProvider.label, - ), - stopReason: null, - pending: false, - error: false, - ), - ); - } - return; - } - - if (!result.success) { - appendAssistantThreadMessageInternal( - sessionKey, - assistantErrorMessageInternal( - appText( - 'GoTaskService 执行失败:${result.errorMessage}', - 'GoTaskService execution failed: ${result.errorMessage}', - ), - ), - ); - return; - } - - if (result.message.trim().isEmpty) { - appendAssistantThreadMessageInternal( - sessionKey, - assistantErrorMessageInternal( - appText( - 'GoTaskService 没有返回可显示的输出。', - 'GoTaskService returned no displayable output.', - ), - ), - ); - return; - } - - appendAssistantThreadMessageInternal( - sessionKey, - GatewayChatMessage( - id: nextLocalMessageIdInternal(), - role: 'assistant', - text: result.message, - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: null, - stopReason: null, - pending: false, - error: false, - ), - ); - } catch (error) { - clearAiGatewayStreamingTextInternal(sessionKey); - upsertTaskThreadInternal( - sessionKey, - lifecycleStatus: 'ready', - lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - lastResultCode: 'error', - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); - appendAssistantThreadMessageInternal( - sessionKey, - assistantErrorMessageInternal(error.toString()), - ); - } finally { - clearAiGatewayStreamingTextInternal(sessionKey); - aiGatewayPendingSessionKeysInternal.remove(sessionKey); - recomputeTasksInternal(); - notifyIfActiveInternal(); - } - }); } - Future sendAiGatewayMessageInternal( - String message, { - required String thinking, - required List attachments, - String? sessionKeyOverride, - bool appendUserMessage = true, - bool managePendingState = true, - }) async { - final sessionKey = normalizedAssistantSessionKeyInternal( - sessionKeyOverride ?? sessionsControllerInternal.currentSessionKey, - ); - final trimmed = message.trim(); - if (trimmed.isEmpty && attachments.isEmpty) { - return; - } - - final baseUrl = normalizeAiGatewayBaseUrlInternal(aiGatewayUrl); - if (baseUrl == null) { - appendAssistantThreadMessageInternal( - sessionKey, - assistantErrorMessageInternal( - appText( - 'LLM API Endpoint 未配置,无法发送对话。', - 'LLM API Endpoint is not configured, so the conversation could not be sent.', - ), - ), - ); - return; - } - - final apiKey = await loadAiGatewayApiKey(); - final allowsAnonymous = - isLoopbackHostInternal(baseUrl.host) && - (baseUrl.host.trim().toLowerCase() == '127.0.0.1' || - baseUrl.host.trim().toLowerCase() == 'localhost'); - if (apiKey.isEmpty && !allowsAnonymous) { - appendAssistantThreadMessageInternal( - sessionKey, - assistantErrorMessageInternal( - appText( - 'LLM API Token 未配置,无法发送对话。', - 'LLM API Token is not configured, so the conversation could not be sent.', - ), - ), - ); - return; - } - - final model = resolvedAiGatewayModel; - if (model.isEmpty) { - appendAssistantThreadMessageInternal( - sessionKey, - assistantErrorMessageInternal( - appText( - '当前没有可用的 LLM API 对话模型。请先在 设置 -> 集成 中同步并选择可用模型。', - 'No LLM API chat model is available yet. Sync and select a supported model in Settings -> Integrations first.', - ), - ), - ); - return; - } - - if (appendUserMessage) { - final userText = trimmed.isEmpty ? 'See attached.' : trimmed; - appendAssistantThreadMessageInternal( - sessionKey, - GatewayChatMessage( - id: nextLocalMessageIdInternal(), - role: 'user', - text: userText, - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: null, - stopReason: null, - pending: false, - error: false, - ), - ); - } - if (managePendingState) { - aiGatewayPendingSessionKeysInternal.add(sessionKey); - recomputeTasksInternal(); - notifyIfActiveInternal(); - } - - try { - final assistantText = await requestAiGatewayCompletionInternal( - baseUrl: baseUrl, - apiKey: apiKey, - model: model, - thinking: thinking, - sessionKey: sessionKey, - ); - appendAssistantThreadMessageInternal( - sessionKey, - GatewayChatMessage( - id: nextLocalMessageIdInternal(), - role: 'assistant', - text: assistantText, - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: null, - stopReason: null, - pending: false, - error: false, - ), - ); - upsertTaskThreadInternal( - sessionKey, - gatewayEntryState: 'only-chat', - latestResolvedRuntimeModel: model, - lifecycleStatus: 'ready', - lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - lastResultCode: 'success', - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); - } on AiGatewayAbortExceptionInternal catch (error) { - final partial = error.partialText.trim(); - if (partial.isNotEmpty) { - appendAssistantThreadMessageInternal( - sessionKey, - GatewayChatMessage( - id: nextLocalMessageIdInternal(), - role: 'assistant', - text: partial, - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: null, - stopReason: 'aborted', - pending: false, - error: false, - ), - ); - } - upsertTaskThreadInternal( - sessionKey, - lifecycleStatus: 'ready', - lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - lastResultCode: 'aborted', - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); - } catch (error) { - upsertTaskThreadInternal( - sessionKey, - lifecycleStatus: 'ready', - lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - lastResultCode: 'error', - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); - appendAssistantThreadMessageInternal( - sessionKey, - assistantErrorMessageInternal(aiGatewayErrorLabelInternal(error)), - ); - } finally { - aiGatewayStreamingClientsInternal.remove(sessionKey); - clearAiGatewayStreamingTextInternal(sessionKey); - if (managePendingState) { - aiGatewayPendingSessionKeysInternal.remove(sessionKey); - recomputeTasksInternal(); - notifyIfActiveInternal(); - } - } - } - - Future requestAiGatewayCompletionInternal({ - required Uri baseUrl, - required String apiKey, - required String model, - required String thinking, - required String sessionKey, - }) async { - final uri = aiGatewayChatUriInternal(baseUrl); - final client = HttpClient() - ..connectionTimeout = const Duration(seconds: 20); - aiGatewayStreamingClientsInternal[sessionKey] = client; - try { - final request = await client - .postUrl(uri) - .timeout(const Duration(seconds: 20)); - request.headers.set( - HttpHeaders.acceptHeader, - 'text/event-stream, application/json', - ); - request.headers.set( - HttpHeaders.contentTypeHeader, - 'application/json; charset=utf-8', - ); - final trimmedApiKey = apiKey.trim(); - if (trimmedApiKey.isNotEmpty) { - request.headers.set( - HttpHeaders.authorizationHeader, - 'Bearer $trimmedApiKey', - ); - request.headers.set('x-api-key', trimmedApiKey); - } - final payload = { - 'model': model, - 'stream': true, - 'messages': buildAiGatewayRequestMessagesInternal(sessionKey), - }; - final normalizedThinking = thinking.trim().toLowerCase(); - if (normalizedThinking.isNotEmpty && normalizedThinking != 'off') { - payload['reasoning_effort'] = normalizedThinking; - } - request.add(utf8.encode(jsonEncode(payload))); - final response = await request.close().timeout( - const Duration(seconds: 60), - ); - if (response.statusCode < 200 || response.statusCode >= 300) { - final body = await response.transform(utf8.decoder).join(); - throw AiGatewayChatExceptionInternal( - formatAiGatewayHttpErrorInternal( - response.statusCode, - extractAiGatewayErrorDetailInternal(body), - ), - ); - } - final contentType = - response.headers.contentType?.mimeType.toLowerCase() ?? - response.headers - .value(HttpHeaders.contentTypeHeader) - ?.toLowerCase() ?? - ''; - if (contentType.contains('text/event-stream')) { - final streamed = await readAiGatewayStreamingResponseInternal( - response: response, - sessionKey: sessionKey, - ); - if (streamed.trim().isEmpty) { - throw const FormatException('Missing assistant content'); - } - return streamed.trim(); - } - return await readAiGatewayJsonCompletionInternal(response); - } catch (error) { - if (consumeAiGatewayAbortInternal(sessionKey)) { - throw AiGatewayAbortExceptionInternal( - aiGatewayStreamingTextBySessionInternal[sessionKey] ?? '', - ); - } - rethrow; - } finally { - aiGatewayStreamingClientsInternal.remove(sessionKey); - client.close(force: true); - } - } - - List> buildAiGatewayRequestMessagesInternal( - String sessionKey, - ) { - final history = [ - ...(gatewayHistoryCacheInternal[sessionKey] ?? - const []), - ...(assistantThreadMessagesInternal[sessionKey] ?? - const []), - ]; - return history - .where((message) { - final role = message.role.trim().toLowerCase(); - return (role == 'user' || role == 'assistant') && - (message.toolName ?? '').trim().isEmpty && - message.text.trim().isNotEmpty; - }) - .map( - (message) => { - 'role': message.role.trim().toLowerCase() == 'assistant' - ? 'assistant' - : 'user', - 'content': message.text.trim(), - }, - ) - .toList(growable: false); - } - - Future readAiGatewayJsonCompletionInternal( - HttpClientResponse response, - ) async { - final body = await response.transform(utf8.decoder).join(); - final decoded = jsonDecode(extractFirstJsonDocumentInternal(body)); - final assistantText = extractAiGatewayAssistantTextInternal(decoded); - if (assistantText.trim().isEmpty) { - throw const FormatException('Missing assistant content'); - } - return assistantText.trim(); - } - - Future readAiGatewayStreamingResponseInternal({ - required HttpClientResponse response, - required String sessionKey, - }) async { - final buffer = StringBuffer(); - final eventLines = []; - - void processEvent(String payload) { - final trimmed = payload.trim(); - if (trimmed.isEmpty) { - return; - } - if (trimmed == '[DONE]') { - return; - } - final deltaText = extractAiGatewayStreamTextInternal(trimmed); - if (deltaText.isEmpty) { - return; - } - final current = buffer.toString(); - if (current.isEmpty || deltaText == current) { - buffer - ..clear() - ..write(deltaText); - } else if (deltaText.startsWith(current)) { - buffer - ..clear() - ..write(deltaText); - } else { - buffer.write(deltaText); - } - setAiGatewayStreamingTextInternal(sessionKey, buffer.toString()); - } - - await for (final line - in response.transform(utf8.decoder).transform(const LineSplitter())) { - if (consumeAiGatewayAbortInternal(sessionKey)) { - throw AiGatewayAbortExceptionInternal(buffer.toString()); - } - if (line.isEmpty) { - if (eventLines.isNotEmpty) { - processEvent(eventLines.join('\n')); - eventLines.clear(); - } - continue; - } - if (line.startsWith('data:')) { - eventLines.add(line.substring(5).trimLeft()); - } - } - - if (eventLines.isNotEmpty) { - processEvent(eventLines.join('\n')); - } - - return buffer.toString(); - } - - String extractAiGatewayStreamTextInternal(String payload) { - final decoded = jsonDecode(extractFirstJsonDocumentInternal(payload)); - final map = asMap(decoded); - final choices = asList(map['choices']); - if (choices.isNotEmpty) { - final firstChoice = asMap(choices.first); - final delta = asMap(firstChoice['delta']); - final deltaContent = extractAiGatewayContentInternal(delta['content']); - if (deltaContent.isNotEmpty) { - return deltaContent; - } - } - return extractAiGatewayAssistantTextInternal(decoded); - } - - Future abortAiGatewayRunInternal(String sessionKey) async { - final normalizedSessionKey = normalizedAssistantSessionKeyInternal( - sessionKey, - ); - aiGatewayAbortedSessionKeysInternal.add(normalizedSessionKey); - final client = aiGatewayStreamingClientsInternal.remove( - normalizedSessionKey, - ); - if (client != null) { - try { - client.close(force: true); - } catch (_) { - // Best effort only. - } - } - aiGatewayPendingSessionKeysInternal.remove(normalizedSessionKey); - clearAiGatewayStreamingTextInternal(normalizedSessionKey); - upsertTaskThreadInternal( - normalizedSessionKey, - lifecycleStatus: 'ready', - lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - lastResultCode: 'aborted', - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); - recomputeTasksInternal(); - notifyIfActiveInternal(); - } - - bool consumeAiGatewayAbortInternal(String sessionKey) { - return aiGatewayAbortedSessionKeysInternal.remove( - normalizedAssistantSessionKeyInternal(sessionKey), - ); + Future abortAiGatewayRunInternal(String sessionKey) { + return abortAiGatewaySingleAgentRunDesktopInternal(this, sessionKey); } GatewayChatMessage assistantErrorMessageInternal(String text) { - return GatewayChatMessage( - id: nextLocalMessageIdInternal(), - role: 'assistant', - text: text, - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: null, - stopReason: null, - pending: false, - error: true, - ); - } - - String? singleAgentRuntimeDebugToolNameInternal(String label) { - if (!showsSingleAgentRuntimeDebugMessagesInternal) { - return null; - } - final trimmed = label.trim(); - if (trimmed.isEmpty) { - return null; - } - return trimmed; - } - - void appendSingleAgentRuntimeStatusMessageInternal( - String sessionKey, - SingleAgentProvider provider, - ) { - if (!showsSingleAgentRuntimeDebugMessagesInternal) { - return; - } - appendAssistantThreadMessageInternal( - sessionKey, - GatewayChatMessage( - id: nextLocalMessageIdInternal(), - role: 'assistant', - text: appText( - '单机智能体已切换到 ${provider.label} 执行当前任务。', - 'Single Agent is using ${provider.label} for this task.', - ), - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: provider.label, - stopReason: null, - pending: false, - error: false, - ), - ); - } - - void appendSingleAgentFallbackStatusMessageInternal( - String sessionKey, - String? reason, - ) { - if (!showsSingleAgentRuntimeDebugMessagesInternal) { - return; - } - appendAssistantThreadMessageInternal( - sessionKey, - GatewayChatMessage( - id: nextLocalMessageIdInternal(), - role: 'assistant', - text: singleAgentFallbackLabelInternal(reason), - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: 'AI Chat fallback', - stopReason: null, - pending: false, - error: false, - ), - ); - } - - String singleAgentFallbackLabelInternal(String? reason) { - final detail = reason?.trim() ?? ''; - return detail.isEmpty - ? appText( - '未发现可用的外部 Agent ACP 端点,已回退到 AI Chat。', - 'No external Agent ACP endpoint is available. Falling back to AI Chat.', - ) - : appText( - '外部 Agent ACP 连接不可用,已回退到 AI Chat:$detail', - 'External Agent ACP connection is unavailable. Falling back to AI Chat: $detail', - ); - } - - String singleAgentUnavailableLabelInternal( - String sessionKey, - String? reason, - ) { - final normalizedSessionKey = normalizedAssistantSessionKeyInternal( - sessionKey, - ); - final detail = reason?.trim() ?? ''; - final selection = singleAgentProviderForSession(normalizedSessionKey); - if (singleAgentShouldSuggestAutoSwitchForSession(normalizedSessionKey)) { - return detail.isEmpty - ? appText( - '当前线程固定为 ${selection.label},但它在这台设备上不可用。检测到其他外部 Agent ACP 端点时不会自动改线,可切到 Auto。', - 'This thread is pinned to ${selection.label}, but it is unavailable on this device. XWorkmate will not reroute to another external Agent ACP endpoint automatically. Switch to Auto instead.', - ) - : appText( - '当前线程固定为 ${selection.label}:$detail 检测到其他外部 Agent ACP 端点时不会自动改线,可切到 Auto。', - 'This thread is pinned to ${selection.label}: $detail XWorkmate will not reroute to another external Agent ACP endpoint automatically. Switch to Auto instead.', - ); - } - if (singleAgentNeedsAiGatewayConfigurationForSession( - normalizedSessionKey, - )) { - return detail.isEmpty - ? appText( - '当前没有可用的外部 Agent ACP 端点,也没有可用的 AI Chat fallback。请先配置外部 Agent 连接,或配置 LLM API。', - 'No external Agent ACP endpoint is available, and AI Chat fallback is not configured. Configure an external Agent connection or configure LLM API first.', - ) - : appText( - '$detail 当前没有可用的外部 Agent ACP 端点,也没有可用的 AI Chat fallback。请先配置外部 Agent 连接,或配置 LLM API。', - '$detail No external Agent ACP endpoint is available, and AI Chat fallback is not configured. Configure an external Agent connection or configure LLM API first.', - ); - } - return detail.isEmpty - ? appText( - '当前线程的外部 Agent ACP 连接尚未就绪。', - 'The external Agent ACP connection for this thread is not ready yet.', - ) - : appText( - '当前线程的外部 Agent ACP 连接尚未就绪:$detail', - 'The external Agent ACP connection for this thread is not ready yet: $detail', - ); - } - - ExternalCodeAgentAcpRoutingConfig buildExternalAcpRoutingForSessionInternal( - String sessionKey, { - String? explicitExecutionTarget, - }) { - final normalizedSessionKey = normalizedAssistantSessionKeyInternal( - sessionKey, - ); - final thread = assistantThreadRecordsInternal[normalizedSessionKey]; - final sessionTarget = assistantExecutionTargetForSession( - normalizedSessionKey, - ); - final preferredGatewayTarget = switch (sessionTarget) { - AssistantExecutionTarget.auto => 'local', - AssistantExecutionTarget.local => 'local', - AssistantExecutionTarget.remote => 'remote', - AssistantExecutionTarget.singleAgent => - settings.assistantExecutionTarget == AssistantExecutionTarget.remote - ? 'remote' - : 'local', - }; - final availableSkills = - assistantImportedSkillsForSession(normalizedSessionKey) - .map( - (item) => ExternalCodeAgentAcpAvailableSkill( - id: item.key, - label: item.label, - description: item.description, - ), - ) - .toList(growable: false); - final selectedSkills = - assistantSelectedSkillsForSession(normalizedSessionKey) - .map((item) => item.label.trim().isNotEmpty ? item.label : item.key) - .where((item) => item.trim().isNotEmpty) - .toList(growable: false); - - final resolvedExplicitExecutionTarget = - sessionTarget == AssistantExecutionTarget.auto - ? '' - : explicitExecutionTarget?.trim().isNotEmpty == true - ? explicitExecutionTarget!.trim() - : (thread?.hasExplicitExecutionTargetSelection ?? false) - ? _routingExecutionTargetValue( - assistantExecutionTargetForSession(normalizedSessionKey), - ) - : ''; - final resolvedExplicitProviderId = - sessionTarget == AssistantExecutionTarget.auto - ? '' - : thread?.hasExplicitProviderSelection ?? false - ? singleAgentProviderForSession(normalizedSessionKey).providerId - : ''; - final resolvedExplicitModel = thread?.hasExplicitModelSelection ?? false - ? (sessionTarget == AssistantExecutionTarget.auto - ? '' - : assistantModelForSession(normalizedSessionKey)) - : ''; - final resolvedExplicitSkills = thread?.hasExplicitSkillSelection ?? false - ? selectedSkills - : const []; - final hasExplicitSelection = - resolvedExplicitExecutionTarget.isNotEmpty || - resolvedExplicitProviderId.isNotEmpty || - resolvedExplicitModel.trim().isNotEmpty || - resolvedExplicitSkills.isNotEmpty; - - if (!hasExplicitSelection) { - return ExternalCodeAgentAcpRoutingConfig.auto( - preferredGatewayTarget: preferredGatewayTarget, - availableSkills: availableSkills, - ); - } - - return ExternalCodeAgentAcpRoutingConfig( - mode: ExternalCodeAgentAcpRoutingMode.explicit, - preferredGatewayTarget: preferredGatewayTarget, - explicitExecutionTarget: resolvedExplicitExecutionTarget, - explicitProviderId: resolvedExplicitProviderId, - explicitModel: resolvedExplicitModel, - explicitSkills: resolvedExplicitSkills, - allowSkillInstall: false, - availableSkills: availableSkills, - ); - } - - String _routingExecutionTargetValue(AssistantExecutionTarget target) { - return switch (target) { - AssistantExecutionTarget.auto => 'singleAgent', - AssistantExecutionTarget.singleAgent => 'singleAgent', - AssistantExecutionTarget.local => 'local', - AssistantExecutionTarget.remote => 'remote', - }; + return assistantErrorMessageSingleAgentDesktopInternal(this, text); } } diff --git a/lib/app/app_controller_desktop_single_agent_ai_gateway.dart b/lib/app/app_controller_desktop_single_agent_ai_gateway.dart new file mode 100644 index 00000000..0e1d672d --- /dev/null +++ b/lib/app/app_controller_desktop_single_agent_ai_gateway.dart @@ -0,0 +1,465 @@ +// ignore_for_file: unused_import, unnecessary_import + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import '../i18n/app_language.dart'; +import '../models/app_models.dart'; +import '../runtime/gateway_runtime_helpers.dart'; +import '../runtime/runtime_models.dart'; +import 'app_controller_desktop_core.dart'; +import 'app_controller_desktop_runtime_helpers.dart'; +import 'app_controller_desktop_skill_permissions.dart'; +import 'app_controller_desktop_thread_sessions.dart'; +import 'app_controller_desktop_thread_storage.dart'; + +GatewayChatMessage assistantErrorMessageSingleAgentDesktopInternal( + AppController controller, + String text, +) { + return GatewayChatMessage( + id: controller.nextLocalMessageIdInternal(), + role: 'assistant', + text: text, + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: true, + ); +} + +Future sendAiGatewaySingleAgentMessageDesktopInternal( + AppController controller, + String message, { + required String thinking, + required List attachments, + String? sessionKeyOverride, + bool appendUserMessage = true, + bool managePendingState = true, +}) async { + final sessionKey = controller.normalizedAssistantSessionKeyInternal( + sessionKeyOverride ?? + controller.sessionsControllerInternal.currentSessionKey, + ); + final trimmed = message.trim(); + if (trimmed.isEmpty && attachments.isEmpty) { + return; + } + + final baseUrl = controller.normalizeAiGatewayBaseUrlInternal( + controller.aiGatewayUrl, + ); + if (baseUrl == null) { + controller.appendAssistantThreadMessageInternal( + sessionKey, + assistantErrorMessageSingleAgentDesktopInternal( + controller, + appText( + 'LLM API Endpoint 未配置,无法发送对话。', + 'LLM API Endpoint is not configured, so the conversation could not be sent.', + ), + ), + ); + return; + } + + final apiKey = await controller.loadAiGatewayApiKey(); + final allowsAnonymous = + controller.isLoopbackHostInternal(baseUrl.host) && + (baseUrl.host.trim().toLowerCase() == '127.0.0.1' || + baseUrl.host.trim().toLowerCase() == 'localhost'); + if (apiKey.isEmpty && !allowsAnonymous) { + controller.appendAssistantThreadMessageInternal( + sessionKey, + assistantErrorMessageSingleAgentDesktopInternal( + controller, + appText( + 'LLM API Token 未配置,无法发送对话。', + 'LLM API Token is not configured, so the conversation could not be sent.', + ), + ), + ); + return; + } + + final model = controller.resolvedAiGatewayModel; + if (model.isEmpty) { + controller.appendAssistantThreadMessageInternal( + sessionKey, + assistantErrorMessageSingleAgentDesktopInternal( + controller, + appText( + '当前没有可用的 LLM API 对话模型。请先在 设置 -> 集成 中同步并选择可用模型。', + 'No LLM API chat model is available yet. Sync and select a supported model in Settings -> Integrations first.', + ), + ), + ); + return; + } + + if (appendUserMessage) { + final userText = trimmed.isEmpty ? 'See attached.' : trimmed; + controller.appendAssistantThreadMessageInternal( + sessionKey, + GatewayChatMessage( + id: controller.nextLocalMessageIdInternal(), + role: 'user', + text: userText, + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + ); + } + if (managePendingState) { + controller.aiGatewayPendingSessionKeysInternal.add(sessionKey); + controller.recomputeTasksInternal(); + controller.notifyIfActiveInternal(); + } + + try { + final assistantText = + await requestAiGatewaySingleAgentCompletionDesktopInternal( + controller, + baseUrl: baseUrl, + apiKey: apiKey, + model: model, + thinking: thinking, + sessionKey: sessionKey, + ); + controller.appendAssistantThreadMessageInternal( + sessionKey, + GatewayChatMessage( + id: controller.nextLocalMessageIdInternal(), + role: 'assistant', + text: assistantText, + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + ); + controller.upsertTaskThreadInternal( + sessionKey, + gatewayEntryState: 'only-chat', + latestResolvedRuntimeModel: model, + lifecycleStatus: 'ready', + lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + lastResultCode: 'success', + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + } on AiGatewayAbortExceptionInternal catch (error) { + final partial = error.partialText.trim(); + if (partial.isNotEmpty) { + controller.appendAssistantThreadMessageInternal( + sessionKey, + GatewayChatMessage( + id: controller.nextLocalMessageIdInternal(), + role: 'assistant', + text: partial, + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: null, + stopReason: 'aborted', + pending: false, + error: false, + ), + ); + } + controller.upsertTaskThreadInternal( + sessionKey, + lifecycleStatus: 'ready', + lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + lastResultCode: 'aborted', + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + } catch (error) { + controller.upsertTaskThreadInternal( + sessionKey, + lifecycleStatus: 'ready', + lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + lastResultCode: 'error', + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + controller.appendAssistantThreadMessageInternal( + sessionKey, + assistantErrorMessageSingleAgentDesktopInternal( + controller, + controller.aiGatewayErrorLabelInternal(error), + ), + ); + } finally { + controller.aiGatewayStreamingClientsInternal.remove(sessionKey); + controller.clearAiGatewayStreamingTextInternal(sessionKey); + if (managePendingState) { + controller.aiGatewayPendingSessionKeysInternal.remove(sessionKey); + controller.recomputeTasksInternal(); + controller.notifyIfActiveInternal(); + } + } +} + +Future requestAiGatewaySingleAgentCompletionDesktopInternal( + AppController controller, { + required Uri baseUrl, + required String apiKey, + required String model, + required String thinking, + required String sessionKey, +}) async { + final uri = controller.aiGatewayChatUriInternal(baseUrl); + final client = HttpClient()..connectionTimeout = const Duration(seconds: 20); + controller.aiGatewayStreamingClientsInternal[sessionKey] = client; + try { + final request = await client + .postUrl(uri) + .timeout(const Duration(seconds: 20)); + request.headers.set( + HttpHeaders.acceptHeader, + 'text/event-stream, application/json', + ); + request.headers.set( + HttpHeaders.contentTypeHeader, + 'application/json; charset=utf-8', + ); + final trimmedApiKey = apiKey.trim(); + if (trimmedApiKey.isNotEmpty) { + request.headers.set( + HttpHeaders.authorizationHeader, + 'Bearer $trimmedApiKey', + ); + request.headers.set('x-api-key', trimmedApiKey); + } + final payload = { + 'model': model, + 'stream': true, + 'messages': buildAiGatewaySingleAgentRequestMessagesDesktopInternal( + controller, + sessionKey, + ), + }; + final normalizedThinking = thinking.trim().toLowerCase(); + if (normalizedThinking.isNotEmpty && normalizedThinking != 'off') { + payload['reasoning_effort'] = normalizedThinking; + } + request.add(utf8.encode(jsonEncode(payload))); + final response = await request.close().timeout(const Duration(seconds: 60)); + if (response.statusCode < 200 || response.statusCode >= 300) { + final body = await response.transform(utf8.decoder).join(); + throw AiGatewayChatExceptionInternal( + controller.formatAiGatewayHttpErrorInternal( + response.statusCode, + controller.extractAiGatewayErrorDetailInternal(body), + ), + ); + } + final contentType = + response.headers.contentType?.mimeType.toLowerCase() ?? + response.headers.value(HttpHeaders.contentTypeHeader)?.toLowerCase() ?? + ''; + if (contentType.contains('text/event-stream')) { + final streamed = await readAiGatewayStreamingResponseDesktopInternal( + controller, + response: response, + sessionKey: sessionKey, + ); + if (streamed.trim().isEmpty) { + throw const FormatException('Missing assistant content'); + } + return streamed.trim(); + } + return await readAiGatewayJsonCompletionDesktopInternal( + controller, + response, + ); + } catch (error) { + if (consumeAiGatewaySingleAgentAbortDesktopInternal( + controller, + sessionKey, + )) { + throw AiGatewayAbortExceptionInternal( + controller.aiGatewayStreamingTextBySessionInternal[sessionKey] ?? '', + ); + } + rethrow; + } finally { + controller.aiGatewayStreamingClientsInternal.remove(sessionKey); + client.close(force: true); + } +} + +List> +buildAiGatewaySingleAgentRequestMessagesDesktopInternal( + AppController controller, + String sessionKey, +) { + final history = [ + ...(controller.gatewayHistoryCacheInternal[sessionKey] ?? + const []), + ...(controller.assistantThreadMessagesInternal[sessionKey] ?? + const []), + ]; + return history + .where((message) { + final role = message.role.trim().toLowerCase(); + return (role == 'user' || role == 'assistant') && + (message.toolName ?? '').trim().isEmpty && + message.text.trim().isNotEmpty; + }) + .map( + (message) => { + 'role': message.role.trim().toLowerCase() == 'assistant' + ? 'assistant' + : 'user', + 'content': message.text.trim(), + }, + ) + .toList(growable: false); +} + +Future readAiGatewayJsonCompletionDesktopInternal( + AppController controller, + HttpClientResponse response, +) async { + final body = await response.transform(utf8.decoder).join(); + final decoded = jsonDecode(controller.extractFirstJsonDocumentInternal(body)); + final assistantText = controller.extractAiGatewayAssistantTextInternal( + decoded, + ); + if (assistantText.trim().isEmpty) { + throw const FormatException('Missing assistant content'); + } + return assistantText.trim(); +} + +Future readAiGatewayStreamingResponseDesktopInternal( + AppController controller, { + required HttpClientResponse response, + required String sessionKey, +}) async { + final buffer = StringBuffer(); + final eventLines = []; + + void processEvent(String payload) { + final trimmed = payload.trim(); + if (trimmed.isEmpty || trimmed == '[DONE]') { + return; + } + final deltaText = extractAiGatewayStreamTextDesktopInternal( + controller, + trimmed, + ); + if (deltaText.isEmpty) { + return; + } + final current = buffer.toString(); + if (current.isEmpty || deltaText == current) { + buffer + ..clear() + ..write(deltaText); + } else if (deltaText.startsWith(current)) { + buffer + ..clear() + ..write(deltaText); + } else { + buffer.write(deltaText); + } + controller.setAiGatewayStreamingTextInternal(sessionKey, buffer.toString()); + } + + await for (final line + in response.transform(utf8.decoder).transform(const LineSplitter())) { + if (consumeAiGatewaySingleAgentAbortDesktopInternal( + controller, + sessionKey, + )) { + throw AiGatewayAbortExceptionInternal(buffer.toString()); + } + if (line.isEmpty) { + if (eventLines.isNotEmpty) { + processEvent(eventLines.join('\n')); + eventLines.clear(); + } + continue; + } + if (line.startsWith('data:')) { + eventLines.add(line.substring(5).trimLeft()); + } + } + + if (eventLines.isNotEmpty) { + processEvent(eventLines.join('\n')); + } + + return buffer.toString(); +} + +String extractAiGatewayStreamTextDesktopInternal( + AppController controller, + String payload, +) { + final decoded = jsonDecode( + controller.extractFirstJsonDocumentInternal(payload), + ); + final map = asMap(decoded); + final choices = asList(map['choices']); + if (choices.isNotEmpty) { + final firstChoice = asMap(choices.first); + final delta = asMap(firstChoice['delta']); + final deltaContent = controller.extractAiGatewayContentInternal( + delta['content'], + ); + if (deltaContent.isNotEmpty) { + return deltaContent; + } + } + return controller.extractAiGatewayAssistantTextInternal(decoded); +} + +Future abortAiGatewaySingleAgentRunDesktopInternal( + AppController controller, + String sessionKey, +) async { + final normalizedSessionKey = controller.normalizedAssistantSessionKeyInternal( + sessionKey, + ); + controller.aiGatewayAbortedSessionKeysInternal.add(normalizedSessionKey); + final client = controller.aiGatewayStreamingClientsInternal.remove( + normalizedSessionKey, + ); + if (client != null) { + try { + client.close(force: true); + } catch (_) { + // Best effort only. + } + } + controller.aiGatewayPendingSessionKeysInternal.remove(normalizedSessionKey); + controller.clearAiGatewayStreamingTextInternal(normalizedSessionKey); + controller.upsertTaskThreadInternal( + normalizedSessionKey, + lifecycleStatus: 'ready', + lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + lastResultCode: 'aborted', + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + controller.recomputeTasksInternal(); + controller.notifyIfActiveInternal(); +} + +bool consumeAiGatewaySingleAgentAbortDesktopInternal( + AppController controller, + String sessionKey, +) { + return controller.aiGatewayAbortedSessionKeysInternal.remove( + controller.normalizedAssistantSessionKeyInternal(sessionKey), + ); +} diff --git a/lib/app/app_controller_desktop_single_agent_go_task_flow.dart b/lib/app/app_controller_desktop_single_agent_go_task_flow.dart new file mode 100644 index 00000000..2474875d --- /dev/null +++ b/lib/app/app_controller_desktop_single_agent_go_task_flow.dart @@ -0,0 +1,354 @@ +// ignore_for_file: unused_import, unnecessary_import + +import 'dart:async'; +import 'package:flutter/material.dart'; +import '../i18n/app_language.dart'; +import '../models/app_models.dart'; +import '../runtime/go_task_service_client.dart'; +import '../runtime/runtime_models.dart'; +import 'app_controller_desktop_core.dart'; +import 'app_controller_desktop_external_acp_routing.dart'; +import 'app_controller_desktop_runtime_helpers.dart'; +import 'app_controller_desktop_single_agent_ai_gateway.dart'; +import 'app_controller_desktop_single_agent_status_messages.dart'; +import 'app_controller_desktop_thread_sessions.dart'; +import 'app_controller_desktop_thread_storage.dart'; +import 'app_controller_desktop_skill_permissions.dart'; + +Future sendSingleAgentMessageDesktopGoTaskFlowInternal( + AppController controller, + String message, { + required String thinking, + required List attachments, + required List localAttachments, +}) async { + final sessionKey = controller.normalizedAssistantSessionKeyInternal( + controller.sessionsControllerInternal.currentSessionKey, + ); + final trimmed = message.trim(); + if (trimmed.isEmpty && attachments.isEmpty) { + return; + } + await controller.enqueueThreadTurnInternal(sessionKey, () async { + final sessionTarget = controller.assistantExecutionTargetForSession( + sessionKey, + ); + final userText = trimmed.isEmpty ? 'See attached.' : trimmed; + controller.appendAssistantThreadMessageInternal( + sessionKey, + GatewayChatMessage( + id: controller.nextLocalMessageIdInternal(), + role: 'user', + text: userText, + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + ); + controller.aiGatewayPendingSessionKeysInternal.add(sessionKey); + controller.recomputeTasksInternal(); + controller.notifyIfActiveInternal(); + + try { + final routing = controller.buildExternalAcpRoutingForSessionInternal( + sessionKey, + ); + final selection = controller.singleAgentProviderForSession(sessionKey); + await controller.syncExternalAcpProvidersInternal(); + final capabilities = await controller.goTaskServiceClientInternal + .loadExternalAcpCapabilities( + target: AssistantExecutionTarget.singleAgent, + forceRefresh: true, + ); + final availableProviders = controller.configuredSingleAgentProviders + .where(capabilities.providers.contains) + .toList(growable: false); + final provider = selection == SingleAgentProvider.auto + ? (availableProviders.isEmpty ? null : availableProviders.first) + : (capabilities.providers.contains(selection) ? selection : null); + final fallbackReason = provider == null + ? (selection == SingleAgentProvider.auto + ? appText( + '当前没有可用的 GoTaskService Provider。', + 'No GoTaskService provider is currently available.', + ) + : appText( + '当前 GoTaskService 不支持 ${selection.label}。', + 'GoTaskService does not currently support ${selection.label}.', + )) + : null; + if (provider == null && !routing.isAuto) { + if (controller.singleAgentUsesAiChatFallbackForSession(sessionKey)) { + appendSingleAgentFallbackStatusDesktopInternal( + controller, + sessionKey, + fallbackReason, + ); + await sendAiGatewaySingleAgentMessageDesktopInternal( + controller, + message, + thinking: thinking, + attachments: attachments, + sessionKeyOverride: sessionKey, + appendUserMessage: false, + managePendingState: false, + ); + } else { + controller.appendAssistantThreadMessageInternal( + sessionKey, + GatewayChatMessage( + id: controller.nextLocalMessageIdInternal(), + role: 'assistant', + text: singleAgentUnavailableLabelDesktopInternal( + controller, + sessionKey, + fallbackReason, + ), + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: singleAgentRuntimeDebugToolNameDesktopInternal( + controller, + provider?.label ?? selection.label, + ), + stopReason: null, + pending: false, + error: false, + ), + ); + } + return; + } + final effectiveProvider = sessionTarget == AssistantExecutionTarget.auto + ? SingleAgentProvider.auto + : (provider ?? SingleAgentProvider.auto); + + appendSingleAgentRuntimeStatusDesktopInternal( + controller, + sessionKey, + effectiveProvider, + ); + final workingDirectory = controller + .resolveSingleAgentWorkingDirectoryForSessionInternal( + sessionKey, + provider: provider, + ); + if (workingDirectory == null || workingDirectory.trim().isEmpty) { + final error = StateError( + appText( + '当前线程缺少可运行的工作路径,无法启动单机智能体。', + 'This thread does not have a runnable workspace path, so Single Agent cannot start.', + ), + ); + controller.appendAssistantThreadMessageInternal( + sessionKey, + assistantErrorMessageSingleAgentDesktopInternal( + controller, + error.message, + ), + ); + throw error; + } + + final selectedSkills = controller + .assistantSelectedSkillsForSession(sessionKey) + .map((item) => item.label.trim().isNotEmpty ? item.label : item.key) + .where((item) => item.trim().isNotEmpty) + .toList(growable: false); + final result = await controller.goTaskServiceClientInternal.executeTask( + GoTaskServiceRequest( + sessionId: sessionKey, + threadId: sessionKey, + target: AssistantExecutionTarget.singleAgent, + prompt: message, + workingDirectory: workingDirectory, + model: sessionTarget == AssistantExecutionTarget.auto + ? '' + : controller.assistantModelForSession(sessionKey), + thinking: thinking, + selectedSkills: selectedSkills, + inlineAttachments: attachments, + localAttachments: localAttachments, + aiGatewayBaseUrl: controller.aiGatewayUrl, + aiGatewayApiKey: await controller.loadAiGatewayApiKey(), + agentId: '', + metadata: const {}, + routing: routing, + provider: effectiveProvider, + ), + onUpdate: (update) { + if (update.isDelta) { + controller.appendAiGatewayStreamingTextInternal( + sessionKey, + update.text, + ); + controller.notifyIfActiveInternal(); + } + }, + ); + _applySingleAgentGoTaskResultDesktopInternal( + controller, + sessionKey: sessionKey, + sessionTarget: sessionTarget, + message: message, + thinking: thinking, + attachments: attachments, + result: result, + ); + } catch (error) { + controller.clearAiGatewayStreamingTextInternal(sessionKey); + controller.upsertTaskThreadInternal( + sessionKey, + lifecycleStatus: 'ready', + lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + lastResultCode: 'error', + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + controller.appendAssistantThreadMessageInternal( + sessionKey, + assistantErrorMessageSingleAgentDesktopInternal( + controller, + error.toString(), + ), + ); + } finally { + controller.clearAiGatewayStreamingTextInternal(sessionKey); + controller.aiGatewayPendingSessionKeysInternal.remove(sessionKey); + controller.recomputeTasksInternal(); + controller.notifyIfActiveInternal(); + } + }); +} + +void _applySingleAgentGoTaskResultDesktopInternal( + AppController controller, { + required String sessionKey, + required AssistantExecutionTarget sessionTarget, + required String message, + required String thinking, + required List attachments, + required GoTaskServiceResult result, +}) { + final resolvedRuntimeModel = result.resolvedModel.trim(); + final resolvedGatewayEntryState = goTaskServiceGatewayEntryState( + requestedTarget: sessionTarget, + result: result, + ); + controller.upsertTaskThreadInternal( + sessionKey, + gatewayEntryState: resolvedGatewayEntryState, + latestResolvedRuntimeModel: resolvedRuntimeModel, + lifecycleStatus: 'ready', + lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + lastResultCode: result.success ? 'success' : 'error', + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + _updateSingleAgentWorkspaceBindingFromResultDesktopInternal( + controller, + sessionKey, + result, + ); + controller.clearAiGatewayStreamingTextInternal(sessionKey); + if (!result.success && + controller.singleAgentUsesAiChatFallbackForSession(sessionKey)) { + appendSingleAgentFallbackStatusDesktopInternal( + controller, + sessionKey, + result.errorMessage, + ); + controller.upsertTaskThreadInternal( + sessionKey, + gatewayEntryState: 'only-chat', + latestResolvedRuntimeModel: controller.resolvedAiGatewayModel, + lifecycleStatus: 'ready', + lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + lastResultCode: 'fallback', + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + unawaited( + sendAiGatewaySingleAgentMessageDesktopInternal( + controller, + message, + thinking: thinking, + attachments: attachments, + sessionKeyOverride: sessionKey, + appendUserMessage: false, + managePendingState: false, + ), + ); + return; + } + + if (!result.success) { + controller.appendAssistantThreadMessageInternal( + sessionKey, + assistantErrorMessageSingleAgentDesktopInternal( + controller, + appText( + 'GoTaskService 执行失败:${result.errorMessage}', + 'GoTaskService execution failed: ${result.errorMessage}', + ), + ), + ); + return; + } + + if (result.message.trim().isEmpty) { + controller.appendAssistantThreadMessageInternal( + sessionKey, + assistantErrorMessageSingleAgentDesktopInternal( + controller, + appText( + 'GoTaskService 没有返回可显示的输出。', + 'GoTaskService returned no displayable output.', + ), + ), + ); + return; + } + + controller.appendAssistantThreadMessageInternal( + sessionKey, + GatewayChatMessage( + id: controller.nextLocalMessageIdInternal(), + role: 'assistant', + text: result.message, + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + ); +} + +void _updateSingleAgentWorkspaceBindingFromResultDesktopInternal( + AppController controller, + String sessionKey, + GoTaskServiceResult result, +) { + final resolvedWorkspaceKind = result.resolvedWorkspaceRefKind; + final resolvedWorkingDirectory = result.resolvedWorkingDirectory.trim(); + if (resolvedWorkspaceKind == null || resolvedWorkingDirectory.isEmpty) { + return; + } + final existingThread = controller.requireTaskThreadForSessionInternal( + sessionKey, + ); + controller.upsertTaskThreadInternal( + sessionKey, + workspaceBinding: WorkspaceBinding( + workspaceId: existingThread.workspaceBinding.workspaceId, + workspaceKind: resolvedWorkspaceKind == WorkspaceRefKind.remotePath + ? WorkspaceKind.remoteFs + : WorkspaceKind.localFs, + workspacePath: resolvedWorkingDirectory, + displayPath: resolvedWorkingDirectory, + writable: existingThread.workspaceBinding.writable, + ), + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); +} diff --git a/lib/app/app_controller_desktop_single_agent_status_messages.dart b/lib/app/app_controller_desktop_single_agent_status_messages.dart new file mode 100644 index 00000000..97a7eaf3 --- /dev/null +++ b/lib/app/app_controller_desktop_single_agent_status_messages.dart @@ -0,0 +1,136 @@ +// ignore_for_file: unused_import, unnecessary_import + +import 'package:flutter/material.dart'; +import '../i18n/app_language.dart'; +import '../runtime/runtime_models.dart'; +import 'app_controller_desktop_core.dart'; +import 'app_controller_desktop_runtime_helpers.dart'; +import 'app_controller_desktop_thread_sessions.dart'; +import 'app_controller_desktop_thread_storage.dart'; + +String? singleAgentRuntimeDebugToolNameDesktopInternal( + AppController controller, + String label, +) { + if (!controller.showsSingleAgentRuntimeDebugMessagesInternal) { + return null; + } + final trimmed = label.trim(); + if (trimmed.isEmpty) { + return null; + } + return trimmed; +} + +void appendSingleAgentRuntimeStatusDesktopInternal( + AppController controller, + String sessionKey, + SingleAgentProvider provider, +) { + if (!controller.showsSingleAgentRuntimeDebugMessagesInternal) { + return; + } + controller.appendAssistantThreadMessageInternal( + sessionKey, + GatewayChatMessage( + id: controller.nextLocalMessageIdInternal(), + role: 'assistant', + text: appText( + '单机智能体已切换到 ${provider.label} 执行当前任务。', + 'Single Agent is using ${provider.label} for this task.', + ), + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: provider.label, + stopReason: null, + pending: false, + error: false, + ), + ); +} + +void appendSingleAgentFallbackStatusDesktopInternal( + AppController controller, + String sessionKey, + String? reason, +) { + if (!controller.showsSingleAgentRuntimeDebugMessagesInternal) { + return; + } + controller.appendAssistantThreadMessageInternal( + sessionKey, + GatewayChatMessage( + id: controller.nextLocalMessageIdInternal(), + role: 'assistant', + text: singleAgentFallbackLabelDesktopInternal(reason), + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: 'AI Chat fallback', + stopReason: null, + pending: false, + error: false, + ), + ); +} + +String singleAgentFallbackLabelDesktopInternal(String? reason) { + final detail = reason?.trim() ?? ''; + return detail.isEmpty + ? appText( + '未发现可用的外部 Agent ACP 端点,已回退到 AI Chat。', + 'No external Agent ACP endpoint is available. Falling back to AI Chat.', + ) + : appText( + '外部 Agent ACP 连接不可用,已回退到 AI Chat:$detail', + 'External Agent ACP connection is unavailable. Falling back to AI Chat: $detail', + ); +} + +String singleAgentUnavailableLabelDesktopInternal( + AppController controller, + String sessionKey, + String? reason, +) { + final normalizedSessionKey = controller.normalizedAssistantSessionKeyInternal( + sessionKey, + ); + final detail = reason?.trim() ?? ''; + final selection = controller.singleAgentProviderForSession( + normalizedSessionKey, + ); + if (controller.singleAgentShouldSuggestAutoSwitchForSession( + normalizedSessionKey, + )) { + return detail.isEmpty + ? appText( + '当前线程固定为 ${selection.label},但它在这台设备上不可用。检测到其他外部 Agent ACP 端点时不会自动改线,可切到 Auto。', + 'This thread is pinned to ${selection.label}, but it is unavailable on this device. XWorkmate will not reroute to another external Agent ACP endpoint automatically. Switch to Auto instead.', + ) + : appText( + '当前线程固定为 ${selection.label}:$detail 检测到其他外部 Agent ACP 端点时不会自动改线,可切到 Auto。', + 'This thread is pinned to ${selection.label}: $detail XWorkmate will not reroute to another external Agent ACP endpoint automatically. Switch to Auto instead.', + ); + } + if (controller.singleAgentNeedsAiGatewayConfigurationForSession( + normalizedSessionKey, + )) { + return detail.isEmpty + ? appText( + '当前没有可用的外部 Agent ACP 端点,也没有可用的 AI Chat fallback。请先配置外部 Agent 连接,或配置 LLM API。', + 'No external Agent ACP endpoint is available, and AI Chat fallback is not configured. Configure an external Agent connection or configure LLM API first.', + ) + : appText( + '$detail 当前没有可用的外部 Agent ACP 端点,也没有可用的 AI Chat fallback。请先配置外部 Agent 连接,或配置 LLM API。', + '$detail No external Agent ACP endpoint is available, and AI Chat fallback is not configured. Configure an external Agent connection or configure LLM API first.', + ); + } + return detail.isEmpty + ? appText( + '当前线程的外部 Agent ACP 连接尚未就绪。', + 'The external Agent ACP connection for this thread is not ready yet.', + ) + : appText( + '当前线程的外部 Agent ACP 连接尚未就绪:$detail', + 'The external Agent ACP connection for this thread is not ready yet: $detail', + ); +} diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index 8527c935..fd1edb19 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -39,6 +39,7 @@ import 'app_controller_desktop_core.dart'; import 'app_controller_desktop_navigation.dart'; import 'app_controller_desktop_gateway.dart'; import 'app_controller_desktop_settings.dart'; +import 'app_controller_desktop_external_acp_routing.dart'; import 'app_controller_desktop_single_agent.dart'; import 'app_controller_desktop_thread_binding.dart'; import 'app_controller_desktop_thread_sessions.dart'; @@ -479,10 +480,11 @@ extension AppControllerDesktopThreadActions on AppController { ); if (aiGatewayPendingSessionKeysInternal.contains(sessionKey)) { await goTaskServiceClientInternal.cancelTask( - route: assistantExecutionTargetForSession(sessionKey) == - AssistantExecutionTarget.singleAgent || + route: assistantExecutionTargetForSession(sessionKey) == - AssistantExecutionTarget.auto + AssistantExecutionTarget.singleAgent || + assistantExecutionTargetForSession(sessionKey) == + AssistantExecutionTarget.auto ? GoTaskServiceRoute.externalAcpSingle : GoTaskServiceRoute.openClawTask, target: assistantExecutionTargetForSession(sessionKey), diff --git a/lib/app/app_controller_desktop_thread_binding.dart b/lib/app/app_controller_desktop_thread_binding.dart index 2d3c7f05..89f827c9 100644 --- a/lib/app/app_controller_desktop_thread_binding.dart +++ b/lib/app/app_controller_desktop_thread_binding.dart @@ -76,6 +76,11 @@ extension AppControllerDesktopThreadBinding on AppController { return '/owners/$realm/$subjectType/$subjectId/threads/$normalizedSessionKey'; } + bool isOwnerScopedRemoteWorkspacePathInternal(String path) { + final normalizedPath = path.trim(); + return normalizedPath.startsWith('/owners/'); + } + String threadWorkspaceDirectoryNameInternal(String sessionKey) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, @@ -139,9 +144,14 @@ extension AppControllerDesktopThreadBinding on AppController { required ThreadOwnerScope ownerScope, WorkspaceBinding? existingBinding, }) { - if (existingBinding != null && + final preservesRemoteSingleAgentBinding = + existingBinding != null && existingBinding.workspaceKind == WorkspaceKind.remoteFs && - existingBinding.workspacePath.trim().isNotEmpty) { + existingBinding.workspacePath.trim().isNotEmpty && + !isOwnerScopedRemoteWorkspacePathInternal( + existingBinding.workspacePath, + ); + if (preservesRemoteSingleAgentBinding) { return existingBinding.copyWith( displayPath: existingBinding.displayPath.trim().isEmpty ? existingBinding.workspacePath diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart index e71b1d3c..167424e3 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -661,7 +661,7 @@ extension AppControllerDesktopThreadSessions on AppController { sessionKey, ); final record = taskThreadForSessionInternal(normalizedSessionKey); - return sanitizeExecutionTargetInternal( + return sanitizePersistedExecutionTargetInternal( record == null ? settings.assistantExecutionTarget : assistantExecutionTargetFromExecutionMode( diff --git a/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart b/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart index a6431761..14e6be53 100644 --- a/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart +++ b/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart @@ -314,7 +314,7 @@ List assistantModelChoicesForSessionThreadSessionInternal( final normalizedSessionKey = normalizeAssistantSessionKeyThreadInternal( sessionKey, ); - final target = controller.sanitizeExecutionTargetInternal( + final target = controller.sanitizePersistedExecutionTargetInternal( controller.taskThreadForSessionInternal(normalizedSessionKey) == null ? controller.settings.assistantExecutionTarget : assistantExecutionTargetFromExecutionMode( diff --git a/lib/app/app_controller_desktop_thread_storage.dart b/lib/app/app_controller_desktop_thread_storage.dart index 6d1df021..ac7ad2d9 100644 --- a/lib/app/app_controller_desktop_thread_storage.dart +++ b/lib/app/app_controller_desktop_thread_storage.dart @@ -223,6 +223,15 @@ extension AppControllerDesktopThreadStorage on AppController { ).sanitizeExecutionTarget(target); } + AssistantExecutionTarget sanitizePersistedExecutionTargetInternal( + AssistantExecutionTarget? target, + ) { + if (target == AssistantExecutionTarget.auto) { + return AssistantExecutionTarget.auto; + } + return sanitizeExecutionTargetInternal(target); + } + MultiAgentConfig resolveMultiAgentConfigInternal(SettingsSnapshot snapshot) { final defaults = MultiAgentConfig.defaults(); final current = snapshot.multiAgent; diff --git a/lib/app/app_controller_desktop_workspace_execution.dart b/lib/app/app_controller_desktop_workspace_execution.dart index 79ff7344..b77da32f 100644 --- a/lib/app/app_controller_desktop_workspace_execution.dart +++ b/lib/app/app_controller_desktop_workspace_execution.dart @@ -52,7 +52,7 @@ extension AppControllerDesktopWorkspaceExecution on AppController { Future setAssistantExecutionTarget( AssistantExecutionTarget target, ) async { - final resolvedTarget = sanitizeExecutionTargetInternal(target); + final resolvedTarget = sanitizePersistedExecutionTargetInternal(target); final currentTarget = assistantExecutionTargetForSession( sessionsControllerInternal.currentSessionKey, ); @@ -78,6 +78,8 @@ extension AppControllerDesktopWorkspaceExecution on AppController { sessionsControllerInternal.currentSessionKey, executionTarget: resolvedTarget, executionTargetSource: ThreadSelectionSource.explicit, + gatewayEntryState: gatewayEntryStateForTargetInternal(resolvedTarget), + latestResolvedRuntimeModel: '', updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); recomputeTasksInternal(); @@ -177,7 +179,7 @@ extension AppControllerDesktopWorkspaceExecution on AppController { required String sessionKey, required bool persistDefaultSelection, }) async { - final resolvedTarget = sanitizeExecutionTargetInternal(target); + final resolvedTarget = sanitizePersistedExecutionTargetInternal(target); final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, ); @@ -356,12 +358,10 @@ extension AppControllerDesktopWorkspaceExecution on AppController { singleAgentProviderForSession(currentSessionKey), updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); - unawaited( - ensureDesktopTaskThreadBindingInternal( - normalizedSessionKey, - executionTarget: resolvedTarget, - ), - ); + // Re-read the current thread target when the async binding sync runs so a + // just-created thread cannot be rebound back to a stale target if the user + // switches execution mode immediately afterwards. + unawaited(ensureDesktopTaskThreadBindingInternal(normalizedSessionKey)); unawaited(persistAssistantLastSessionKeyInternal(normalizedSessionKey)); notifyIfActiveInternal(); } diff --git a/lib/runtime/runtime_models_connection.dart b/lib/runtime/runtime_models_connection.dart index 751415cf..4056a455 100644 --- a/lib/runtime/runtime_models_connection.dart +++ b/lib/runtime/runtime_models_connection.dart @@ -206,8 +206,6 @@ class SingleAgentProvider { final SingleAgentProviderSource source; bool get isAuto => providerId == auto.providerId; - bool get isBuiltInReserved => - source == SingleAgentProviderSource.builtInReserved; bool get isExternalExtension => source == SingleAgentProviderSource.externalExtension; @@ -278,11 +276,7 @@ extension SingleAgentProviderCopy on SingleAgentProvider { }) => SingleAgentProvider.fromJsonValue(value, label: label, badge: badge); } -enum SingleAgentProviderSource { externalExtension, builtInReserved } - -SingleAgentProvider normalizeSingleAgentProviderSelection( - SingleAgentProvider provider, -) => provider; +enum SingleAgentProviderSource { externalExtension } List normalizeSingleAgentProviderList( Iterable providers, @@ -290,9 +284,8 @@ List normalizeSingleAgentProviderList( final normalized = []; final seen = {}; for (final provider in providers) { - final resolved = normalizeSingleAgentProviderSelection(provider); - if (seen.add(resolved.providerId)) { - normalized.add(resolved); + if (seen.add(provider.providerId)) { + normalized.add(provider); } } return normalized; diff --git a/lib/runtime/runtime_models_settings_snapshot.dart b/lib/runtime/runtime_models_settings_snapshot.dart index 1e512bc4..4e03d65a 100644 --- a/lib/runtime/runtime_models_settings_snapshot.dart +++ b/lib/runtime/runtime_models_settings_snapshot.dart @@ -526,17 +526,14 @@ class SettingsSnapshot { } SingleAgentProvider resolveSingleAgentProvider(SingleAgentProvider provider) { - final normalizedSelection = normalizeSingleAgentProviderSelection(provider); - if (normalizedSelection.isAuto) { + if (provider.isAuto) { return SingleAgentProvider.auto; } - final profile = externalAcpEndpointForProviderId( - normalizedSelection.providerId, - ); + final profile = externalAcpEndpointForProviderId(provider.providerId); if (profile != null) { return profile.toProvider(); } - return normalizedSelection; + return provider; } SingleAgentProvider singleAgentProviderForId(String providerId) { @@ -544,9 +541,7 @@ class SettingsSnapshot { if (resolved.isEmpty || resolved == SingleAgentProvider.auto.providerId) { return SingleAgentProvider.auto; } - final normalizedSelection = normalizeSingleAgentProviderSelection( - SingleAgentProvider.fromJsonValue(resolved), - ); + final normalizedSelection = SingleAgentProvider.fromJsonValue(resolved); final profile = externalAcpEndpointForProviderId( normalizedSelection.providerId, ); diff --git a/test/quality/wave1_file_size_guard_test.dart b/test/quality/wave1_file_size_guard_test.dart index 474735cd..9f221332 100644 --- a/test/quality/wave1_file_size_guard_test.dart +++ b/test/quality/wave1_file_size_guard_test.dart @@ -26,6 +26,11 @@ void main() { // Tightened in T2/T3 after assistant + app/runtime closure split. 'lib/features/assistant/assistant_page_main.dart': 1000, 'lib/app/app_controller_desktop_runtime_helpers.dart': 800, + 'lib/app/app_controller_desktop_single_agent.dart': 200, + 'lib/app/app_controller_desktop_single_agent_ai_gateway.dart': 800, + 'lib/app/app_controller_desktop_single_agent_go_task_flow.dart': 800, + 'lib/app/app_controller_desktop_single_agent_status_messages.dart': 400, + 'lib/app/app_controller_desktop_external_acp_routing.dart': 400, 'lib/app/app_controller_desktop_thread_sessions.dart': 800, 'lib/app/app_controller_desktop_runtime_coordination_impl.dart': 800, 'lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart':