diff --git a/AGENTS.md b/AGENTS.md index 364eb7ea..bfa65c3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,10 @@ This section defines the reusable refactor workflow for this repo. When trigger conditions are met, the workflow is executed by default without additional confirmation prompts. +Normative source: +- Use this section as the single enforcement source for refactor execution rules. +- ADR documents only record decision background and must not introduce conflicting rules. + ### Workflow Composition The standard combines: diff --git a/docs/architecture/refactor-style-no-part-adr.md b/docs/architecture/refactor-style-no-part-adr.md new file mode 100644 index 00000000..4e201f39 --- /dev/null +++ b/docs/architecture/refactor-style-no-part-adr.md @@ -0,0 +1,38 @@ +# ADR: Refactor Style Baseline Uses No-`part` File Organization + +- Status: Accepted +- Date: 2026-03-28 +- Owner: XWorkmate maintainers + +## Context + +The codebase previously mixed multiple split styles (`part`-based and import-based splits), which created unclear review standards and inconsistent refactor outcomes. + +The repository has already migrated away from Dart `part` declarations in production/test code, while workflow and skill references still mention both styles. + +## Decision + +We standardize on **no-`part`** organization for this repository: + +- Use import-based closure files and explicit ownership boundaries. +- Keep one business closure per file family (root + closure-owned supporting files). +- Avoid introducing new `part` / `part of` declarations. + +## Single Source of Enforcement + +`AGENTS.md` section **Refactor Workflow Standard** is the only normative enforcement source. + +This ADR is historical rationale and does not define additional runtime enforcement rules. +If this ADR and `AGENTS.md` ever diverge, `AGENTS.md` wins. + +## Consequences + +- Refactor reviews no longer debate split style; they focus on closure ownership and behavior safety. +- File-size and closure guards should target implementation-bearing files instead of thin export anchors. +- Existing helper files remain valid only when closure-owned; generic helper sprawl is disallowed. + +## Verification Checklist + +- No new `part` / `part of` usage in `lib/` and `test/`. +- Refactor plans and implementations follow `RED -> GREEN -> REFACTOR -> REGRESSION`. +- Triggered refactor tasks satisfy the `Done Criteria` in `AGENTS.md`. diff --git a/docs/architecture/stage4-helper-ownership-20260328.md b/docs/architecture/stage4-helper-ownership-20260328.md new file mode 100644 index 00000000..2a78f8ec --- /dev/null +++ b/docs/architecture/stage4-helper-ownership-20260328.md @@ -0,0 +1,28 @@ +# Stage 4 Helper Ownership (2026-03-28) + +## Scope + +Scan command: + +```bash +rg --files lib test | rg "(helper|helpers|utils)" +``` + +Result: no generic `utils` directory/file; helper files are domain-scoped. + +## Ownership Mapping + +| File | Business closure owner | Status | +|---|---|---| +| `lib/app/app_controller_desktop_runtime_helpers.dart` | Desktop runtime base helpers (streaming text, URL parsing, observer notifications) | Kept, already reduced and scoped | +| `lib/runtime/gateway_runtime_helpers.dart` | Gateway runtime core/helper closure | Kept, domain-owned | +| `lib/runtime/direct_single_agent_app_server_client_helpers.dart` | Single-agent app-server client closure | Kept, domain-owned | +| `lib/app/app_controller_web_helpers.dart` | Web AppController helper closure | Kept, domain-owned | +| `lib/web/web_assistant_page_helpers.dart` | Web assistant page closure | Kept, domain-owned | +| `lib/features/assistant/assistant_page_composer_state_helpers.dart` | Assistant composer state closure | Kept, domain-owned | + +## Stage-4 Conclusion + +- No cross-domain `utils` bucket was found under `lib/` and `test/`. +- Existing helper files are already tied to explicit business closures. +- Governance decision: continue to allow `*_helpers.dart` only when the file name contains explicit domain ownership (feature/runtime/controller scope), and avoid introducing shared catch-all helpers. diff --git a/lib/app/app_controller_desktop_runtime_coordination_impl.dart b/lib/app/app_controller_desktop_runtime_coordination_impl.dart new file mode 100644 index 00000000..fb2f429a --- /dev/null +++ b/lib/app/app_controller_desktop_runtime_coordination_impl.dart @@ -0,0 +1,384 @@ +// ignore_for_file: unused_import, unnecessary_import, invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member + +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/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_single_agent.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'; + +Future refreshAcpCapabilitiesRuntimeInternal( + AppController controller, { + bool forceRefresh = false, + bool persistMountTargets = false, +}) async { + GatewayAcpCapabilities capabilities; + try { + capabilities = await controller.gatewayAcpClientInternal.loadCapabilities( + forceRefresh: forceRefresh, + ); + } catch (_) { + capabilities = const GatewayAcpCapabilities.empty(); + } + if (persistMountTargets && !controller.disposedInternal) { + final currentConfig = controller.settings.multiAgent; + final nextTargets = mergeAcpCapabilitiesIntoMountTargetsRuntimeInternal( + controller, + currentConfig.mountTargets, + capabilities, + ); + final nextConfig = currentConfig.copyWith(mountTargets: nextTargets); + if (jsonEncode(nextConfig.toJson()) != jsonEncode(currentConfig.toJson())) { + await controller.settingsControllerInternal.saveSnapshot( + controller.settings.copyWith(multiAgent: nextConfig), + ); + controller.multiAgentOrchestratorInternal.updateConfig(nextConfig); + } + } + if (!controller.disposedInternal) { + controller.notifyListeners(); + } +} + +Future refreshSingleAgentCapabilitiesRuntimeInternal( + AppController controller, { + bool forceRefresh = false, +}) async { + final gatewayToken = await controller.settingsController.loadGatewayToken(); + final next = {}; + for (final provider in controller.configuredSingleAgentProviders) { + final profile = controller.settings.externalAcpEndpointForProvider(provider); + if (!profile.enabled || profile.endpoint.trim().isEmpty) { + next[provider] = const DirectSingleAgentCapabilities.unavailable( + endpoint: '', + ); + continue; + } + try { + next[provider] = await controller.singleAgentAppServerClientInternal + .loadCapabilities( + provider: provider, + forceRefresh: forceRefresh, + gatewayToken: gatewayToken, + ); + } catch (_) { + next[provider] = const DirectSingleAgentCapabilities.unavailable( + endpoint: '', + ); + } + } + controller.singleAgentCapabilitiesByProviderInternal = next; + if (!controller.disposedInternal) { + controller.notifyListeners(); + } +} + +List mergeAcpCapabilitiesIntoMountTargetsRuntimeInternal( + AppController controller, + List current, + GatewayAcpCapabilities capabilities, +) { + final source = current.isEmpty ? ManagedMountTargetState.defaults() : current; + final providers = capabilities.providers.map((item) => item.providerId).toSet(); + return source + .map((item) { + final available = switch (item.targetId) { + 'codex' => providers.contains('codex'), + 'opencode' => providers.contains('opencode'), + 'claude' => providers.contains('claude'), + 'gemini' => providers.contains('gemini'), + 'aris' => capabilities.multiAgent, + 'openclaw' => capabilities.multiAgent || capabilities.singleAgent, + _ => false, + }; + return item.copyWith( + available: available, + discoveryState: available ? 'ready' : 'unavailable', + syncState: available ? item.syncState : 'idle', + detail: available + ? appText( + '来源:Gateway ACP capabilities', + 'Source: Gateway ACP capabilities', + ) + : appText( + 'Gateway ACP 未报告该能力。', + 'Gateway ACP did not report this capability.', + ), + ); + }) + .toList(growable: false); +} + +String? assistantWorkingDirectoryForSessionRuntimeInternal( + AppController controller, + String sessionKey, +) { + final candidate = controller.assistantWorkspaceRefForSession(sessionKey).trim(); + if (candidate.isEmpty) { + return null; + } + return candidate; +} + +String? resolveLocalAssistantWorkingDirectoryForSessionRuntimeInternal( + AppController controller, + String sessionKey, { + bool requireLocalExistence = true, +}) { + if (controller.assistantWorkspaceRefKindForSession(sessionKey) != + WorkspaceRefKind.localPath) { + return null; + } + final candidate = assistantWorkingDirectoryForSessionRuntimeInternal( + controller, + sessionKey, + ); + if (candidate == null) { + return null; + } + final directory = Directory(candidate); + if (directory.existsSync()) { + return directory.path; + } + if (requireLocalExistence) { + return null; + } + return candidate; +} + +String? resolveSingleAgentWorkingDirectoryForSessionRuntimeInternal( + AppController controller, + String sessionKey, { + SingleAgentProvider? provider, +}) { + final workspaceKind = controller.assistantWorkspaceRefKindForSession( + sessionKey, + ); + if (workspaceKind == WorkspaceRefKind.objectStore) { + return null; + } + if (workspaceKind == WorkspaceRefKind.remotePath) { + return assistantWorkingDirectoryForSessionRuntimeInternal( + controller, + sessionKey, + ); + } + return resolveLocalAssistantWorkingDirectoryForSessionRuntimeInternal( + controller, + sessionKey, + requireLocalExistence: + provider == null || + singleAgentProviderRequiresLocalPathRuntimeInternal(controller, provider), + ); +} + +bool singleAgentProviderRequiresLocalPathRuntimeInternal( + AppController controller, + SingleAgentProvider provider, +) { + final endpoint = resolveSingleAgentEndpointRuntimeInternal( + controller, + provider, + ); + if (endpoint == null) { + return true; + } + final scheme = endpoint.scheme.trim().toLowerCase(); + if (scheme == 'wss' || scheme == 'https') { + return false; + } + final host = endpoint.host.trim(); + if (host.isEmpty) { + return true; + } + final address = InternetAddress.tryParse(host); + if (address != null) { + return !(address.isLoopback || address.type == InternetAddressType.unix); + } + final normalizedHost = host.toLowerCase(); + if (normalizedHost == 'localhost') { + return true; + } + return false; +} + +CodeAgentNodeState buildCodeAgentNodeStateRuntimeInternal( + AppController controller, +) { + return CodeAgentNodeState( + selectedAgentId: controller.agentsControllerInternal.selectedAgentId, + gatewayConnected: controller.runtimeInternal.isConnected, + executionTarget: controller.currentAssistantExecutionTarget, + runtimeMode: controller.effectiveCodeAgentRuntimeMode, + bridgeEnabled: controller.isCodexBridgeEnabledInternal, + bridgeState: controller.codexCooperationStateInternal.name, + preferredProviderId: 'codex', + resolvedCodexCliPath: controller.resolvedCodexCliPathInternal, + configuredCodexCliPath: controller.configuredCodexCliPath, + ); +} + +GatewayMode bridgeGatewayModeRuntimeInternal(AppController controller) { + if (!controller.runtimeInternal.isConnected) { + return GatewayMode.offline; + } + return switch (controller.currentAssistantExecutionTarget) { + AssistantExecutionTarget.singleAgent => GatewayMode.offline, + AssistantExecutionTarget.local => GatewayMode.local, + AssistantExecutionTarget.remote => GatewayMode.remote, + }; +} + +Future ensureCodexGatewayRegistrationRuntimeInternal( + AppController controller, +) async { + if (!controller.isCodexBridgeEnabledInternal) { + return; + } + + if (!controller.runtimeInternal.isConnected) { + controller.codexCooperationStateInternal = CodexCooperationState.bridgeOnly; + controller.codeAgentBridgeRegistryInternal.clearRegistration(); + controller.notifyListeners(); + return; + } + + if (controller.codeAgentBridgeRegistryInternal.isRegistered) { + controller.codexCooperationStateInternal = CodexCooperationState.registered; + controller.notifyListeners(); + return; + } + + try { + final dispatch = controller.codeAgentNodeOrchestratorInternal + .buildGatewayDispatch(buildCodeAgentNodeStateRuntimeInternal(controller)); + await controller.codeAgentBridgeRegistryInternal.register( + agentType: 'code-agent-bridge', + name: 'XWorkmate Codex Bridge', + version: kAppVersion, + transport: 'stdio-bridge', + capabilities: const [ + AgentCapability( + name: 'chat', + description: 'Bridge external Codex CLI chat turns.', + ), + AgentCapability( + name: 'code-edit', + description: 'Bridge code editing tasks through Codex CLI.', + ), + AgentCapability( + name: 'memory-sync', + description: 'Coordinate memory sync through OpenClaw Gateway.', + ), + ], + metadata: { + ...dispatch.metadata, + 'providerId': 'codex', + 'runtimeMode': controller.effectiveCodeAgentRuntimeMode.name, + 'gatewayMode': bridgeGatewayModeRuntimeInternal(controller).name, + 'binaryConfigured': + (controller.resolvedCodexCliPath ?? controller.configuredCodexCliPath) + .trim() + .isNotEmpty, + 'capabilities': const [ + 'chat', + 'code-edit', + 'gateway-bridge', + 'memory-sync', + ], + }, + ); + controller.codexCooperationStateInternal = CodexCooperationState.registered; + controller.codexBridgeErrorInternal = null; + } catch (error) { + controller.codexCooperationStateInternal = CodexCooperationState.bridgeOnly; + controller.codexBridgeErrorInternal = error.toString(); + } + + controller.notifyListeners(); +} + +void clearCodexGatewayRegistrationRuntimeInternal(AppController controller) { + controller.codeAgentBridgeRegistryInternal.clearRegistration(); + if (controller.isCodexBridgeEnabledInternal) { + controller.codexCooperationStateInternal = CodexCooperationState.bridgeOnly; + } else { + controller.codexCooperationStateInternal = CodexCooperationState.notStarted; + } + controller.notifyListeners(); +} + +void recomputeTasksRuntimeInternal(AppController controller) { + controller.tasksControllerInternal.recompute( + sessions: controller.sessions, + cronJobs: controller.cronJobsControllerInternal.items, + currentSessionKey: controller.sessionsControllerInternal.currentSessionKey, + hasPendingRun: controller.hasAssistantPendingRun, + activeAgentName: controller.agentsControllerInternal.activeAgentName, + ); +} + +Uri? resolveSingleAgentEndpointRuntimeInternal( + AppController controller, + SingleAgentProvider provider, +) { + final endpoint = controller.settings + .externalAcpEndpointForProvider(provider) + .endpoint + .trim(); + if (endpoint.isEmpty) { + return null; + } + final normalizedInput = endpoint.contains('://') ? endpoint : 'ws://$endpoint'; + final uri = Uri.tryParse(normalizedInput); + if (uri == null || uri.host.trim().isEmpty) { + return null; + } + final scheme = uri.scheme.trim().toLowerCase(); + if (scheme != 'ws' && + scheme != 'wss' && + scheme != 'http' && + scheme != 'https') { + return null; + } + return uri; +} diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index 9c3f034d..f2cf3188 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -45,6 +45,7 @@ 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_runtime_coordination_impl.dart'; // ignore_for_file: invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member extension AppControllerDesktopRuntimeHelpers on AppController { @@ -379,64 +380,18 @@ extension AppControllerDesktopRuntimeHelpers on AppController { Future refreshAcpCapabilitiesInternal({ bool forceRefresh = false, bool persistMountTargets = false, - }) async { - GatewayAcpCapabilities capabilities; - try { - capabilities = await gatewayAcpClientInternal.loadCapabilities( - forceRefresh: forceRefresh, - ); - } catch (_) { - capabilities = const GatewayAcpCapabilities.empty(); - } - if (persistMountTargets && !disposedInternal) { - final currentConfig = settings.multiAgent; - final nextTargets = mergeAcpCapabilitiesIntoMountTargetsInternal( - currentConfig.mountTargets, - capabilities, - ); - final nextConfig = currentConfig.copyWith(mountTargets: nextTargets); - if (jsonEncode(nextConfig.toJson()) != - jsonEncode(currentConfig.toJson())) { - await settingsControllerInternal.saveSnapshot( - settings.copyWith(multiAgent: nextConfig), - ); - multiAgentOrchestratorInternal.updateConfig(nextConfig); - } - } - notifyIfActiveInternal(); - } + }) => refreshAcpCapabilitiesRuntimeInternal( + this, + forceRefresh: forceRefresh, + persistMountTargets: persistMountTargets, + ); Future refreshSingleAgentCapabilitiesInternal({ bool forceRefresh = false, - }) async { - final gatewayToken = await settingsController.loadGatewayToken(); - final next = {}; - for (final provider in configuredSingleAgentProviders) { - final profile = settings.externalAcpEndpointForProvider(provider); - if (!profile.enabled || profile.endpoint.trim().isEmpty) { - next[provider] = const DirectSingleAgentCapabilities.unavailable( - endpoint: '', - ); - continue; - } - try { - next[provider] = await singleAgentAppServerClientInternal - .loadCapabilities( - provider: provider, - forceRefresh: forceRefresh, - gatewayToken: gatewayToken, - ); - } catch (_) { - next[provider] = const DirectSingleAgentCapabilities.unavailable( - endpoint: '', - ); - } - } - singleAgentCapabilitiesByProviderInternal = next; - if (!disposedInternal) { - notifyIfActiveInternal(); - } - } + }) => refreshSingleAgentCapabilitiesRuntimeInternal( + this, + forceRefresh: forceRefresh, + ); Future refreshResolvedCodexCliPathInternal() async { if (effectiveCodeAgentRuntimeMode != CodeAgentRuntimeMode.externalCli) { @@ -471,116 +426,36 @@ extension AppControllerDesktopRuntimeHelpers on AppController { List mergeAcpCapabilitiesIntoMountTargetsInternal( List current, GatewayAcpCapabilities capabilities, - ) { - final source = current.isEmpty - ? ManagedMountTargetState.defaults() - : current; - final providers = capabilities.providers - .map((item) => item.providerId) - .toSet(); - return source - .map((item) { - final available = switch (item.targetId) { - 'codex' => providers.contains('codex'), - 'opencode' => providers.contains('opencode'), - 'claude' => providers.contains('claude'), - 'gemini' => providers.contains('gemini'), - 'aris' => capabilities.multiAgent, - 'openclaw' => capabilities.multiAgent || capabilities.singleAgent, - _ => false, - }; - return item.copyWith( - available: available, - discoveryState: available ? 'ready' : 'unavailable', - syncState: available ? item.syncState : 'idle', - detail: available - ? appText( - '来源:Gateway ACP capabilities', - 'Source: Gateway ACP capabilities', - ) - : appText( - 'Gateway ACP 未报告该能力。', - 'Gateway ACP did not report this capability.', - ), - ); - }) - .toList(growable: false); - } + ) => mergeAcpCapabilitiesIntoMountTargetsRuntimeInternal( + this, + current, + capabilities, + ); - String? assistantWorkingDirectoryForSessionInternal(String sessionKey) { - final candidate = assistantWorkspaceRefForSession(sessionKey).trim(); - if (candidate.isEmpty) { - return null; - } - return candidate; - } + String? assistantWorkingDirectoryForSessionInternal(String sessionKey) => + assistantWorkingDirectoryForSessionRuntimeInternal(this, sessionKey); String? resolveLocalAssistantWorkingDirectoryForSessionInternal( String sessionKey, { bool requireLocalExistence = true, - }) { - if (assistantWorkspaceRefKindForSession(sessionKey) != - WorkspaceRefKind.localPath) { - return null; - } - final candidate = assistantWorkingDirectoryForSessionInternal(sessionKey); - if (candidate == null) { - return null; - } - final directory = Directory(candidate); - if (directory.existsSync()) { - return directory.path; - } - if (requireLocalExistence) { - return null; - } - return candidate; - } + }) => resolveLocalAssistantWorkingDirectoryForSessionRuntimeInternal( + this, + sessionKey, + requireLocalExistence: requireLocalExistence, + ); String? resolveSingleAgentWorkingDirectoryForSessionInternal( String sessionKey, { SingleAgentProvider? provider, - }) { - final workspaceKind = assistantWorkspaceRefKindForSession(sessionKey); - if (workspaceKind == WorkspaceRefKind.objectStore) { - return null; - } - if (workspaceKind == WorkspaceRefKind.remotePath) { - return assistantWorkingDirectoryForSessionInternal(sessionKey); - } - return resolveLocalAssistantWorkingDirectoryForSessionInternal( - sessionKey, - requireLocalExistence: - provider == null || - singleAgentProviderRequiresLocalPathInternal(provider), - ); - } + }) => resolveSingleAgentWorkingDirectoryForSessionRuntimeInternal( + this, + sessionKey, + provider: provider, + ); bool singleAgentProviderRequiresLocalPathInternal( SingleAgentProvider provider, - ) { - final endpoint = resolveSingleAgentEndpointInternal(provider); - if (endpoint == null) { - return true; - } - final scheme = endpoint.scheme.trim().toLowerCase(); - if (scheme == 'wss' || scheme == 'https') { - return false; - } - final host = endpoint.host.trim(); - if (host.isEmpty) { - return true; - } - final address = InternetAddress.tryParse(host); - if (address != null) { - return !(address.isLoopback || address.type == InternetAddressType.unix); - } - final normalizedHost = host.toLowerCase(); - if (normalizedHost == 'localhost') { - return true; - } - return false; - } + ) => singleAgentProviderRequiresLocalPathRuntimeInternal(this, provider); void registerCodexExternalProviderInternal() { runtimeCoordinatorInternal.registerExternalCodeAgent( @@ -603,117 +478,19 @@ extension AppControllerDesktopRuntimeHelpers on AppController { ); } - CodeAgentNodeState buildCodeAgentNodeStateInternal() { - return CodeAgentNodeState( - selectedAgentId: agentsControllerInternal.selectedAgentId, - gatewayConnected: runtimeInternal.isConnected, - executionTarget: currentAssistantExecutionTarget, - runtimeMode: effectiveCodeAgentRuntimeMode, - bridgeEnabled: isCodexBridgeEnabledInternal, - bridgeState: codexCooperationStateInternal.name, - preferredProviderId: 'codex', - resolvedCodexCliPath: resolvedCodexCliPathInternal, - configuredCodexCliPath: configuredCodexCliPath, - ); - } + CodeAgentNodeState buildCodeAgentNodeStateInternal() => + buildCodeAgentNodeStateRuntimeInternal(this); - GatewayMode bridgeGatewayModeInternal() { - if (!runtimeInternal.isConnected) { - return GatewayMode.offline; - } - return switch (currentAssistantExecutionTarget) { - AssistantExecutionTarget.singleAgent => GatewayMode.offline, - AssistantExecutionTarget.local => GatewayMode.local, - AssistantExecutionTarget.remote => GatewayMode.remote, - }; - } + GatewayMode bridgeGatewayModeInternal() => + bridgeGatewayModeRuntimeInternal(this); - Future ensureCodexGatewayRegistrationInternal() async { - if (!isCodexBridgeEnabledInternal) { - return; - } + Future ensureCodexGatewayRegistrationInternal() => + ensureCodexGatewayRegistrationRuntimeInternal(this); - if (!runtimeInternal.isConnected) { - codexCooperationStateInternal = CodexCooperationState.bridgeOnly; - codeAgentBridgeRegistryInternal.clearRegistration(); - notifyListeners(); - return; - } + void clearCodexGatewayRegistrationInternal() => + clearCodexGatewayRegistrationRuntimeInternal(this); - if (codeAgentBridgeRegistryInternal.isRegistered) { - codexCooperationStateInternal = CodexCooperationState.registered; - notifyListeners(); - return; - } - - try { - final dispatch = codeAgentNodeOrchestratorInternal.buildGatewayDispatch( - buildCodeAgentNodeStateInternal(), - ); - await codeAgentBridgeRegistryInternal.register( - agentType: 'code-agent-bridge', - name: 'XWorkmate Codex Bridge', - version: kAppVersion, - transport: 'stdio-bridge', - capabilities: const [ - AgentCapability( - name: 'chat', - description: 'Bridge external Codex CLI chat turns.', - ), - AgentCapability( - name: 'code-edit', - description: 'Bridge code editing tasks through Codex CLI.', - ), - AgentCapability( - name: 'memory-sync', - description: 'Coordinate memory sync through OpenClaw Gateway.', - ), - ], - metadata: { - ...dispatch.metadata, - 'providerId': 'codex', - 'runtimeMode': effectiveCodeAgentRuntimeMode.name, - 'gatewayMode': bridgeGatewayModeInternal().name, - 'binaryConfigured': (resolvedCodexCliPath ?? configuredCodexCliPath) - .trim() - .isNotEmpty, - 'capabilities': const [ - 'chat', - 'code-edit', - 'gateway-bridge', - 'memory-sync', - ], - }, - ); - codexCooperationStateInternal = CodexCooperationState.registered; - codexBridgeErrorInternal = null; - } catch (error) { - codexCooperationStateInternal = CodexCooperationState.bridgeOnly; - codexBridgeErrorInternal = error.toString(); - } - - notifyListeners(); - } - - void clearCodexGatewayRegistrationInternal() { - codeAgentBridgeRegistryInternal.clearRegistration(); - if (isCodexBridgeEnabledInternal) { - codexCooperationStateInternal = CodexCooperationState.bridgeOnly; - } else { - codexCooperationStateInternal = CodexCooperationState.notStarted; - } - notifyListeners(); - } - - void recomputeTasksInternal() { - tasksControllerInternal.recompute( - sessions: sessions, - cronJobs: cronJobsControllerInternal.items, - currentSessionKey: sessionsControllerInternal.currentSessionKey, - hasPendingRun: hasAssistantPendingRun, - activeAgentName: agentsControllerInternal.activeAgentName, - ); - } + void recomputeTasksInternal() => recomputeTasksRuntimeInternal(this); void attachChildListenersInternal() { runtimeCoordinatorInternal.addListener(relayChildChangeInternal); diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart index efd4d201..36282161 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -45,6 +45,7 @@ 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_runtime_helpers.dart'; +import 'app_controller_desktop_thread_sessions_collaboration_impl.dart'; // ignore_for_file: invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member extension AppControllerDesktopThreadSessions on AppController { @@ -430,293 +431,45 @@ extension AppControllerDesktopThreadSessions on AppController { String get assistantConnectionStatusLabel => currentAssistantConnectionState.primaryLabel; + String get assistantConnectionTargetLabel => + currentAssistantConnectionState.detailLabel; - String get assistantConnectionTargetLabel { - return currentAssistantConnectionState.detailLabel; - } - - Future loadAiGatewayApiKey() async { - return (await storeInternal.loadAiGatewayApiKey())?.trim() ?? ''; - } - - Future saveMultiAgentConfig(MultiAgentConfig config) async { - final resolved = resolveMultiAgentConfigInternal( - settings.copyWith(multiAgent: config), - ); - await AppControllerDesktopSettings(this).saveSettings( - settings.copyWith(multiAgent: resolved), - refreshAfterSave: false, - ); - await refreshMultiAgentMounts(sync: resolved.autoSync); - } - - Future refreshMultiAgentMounts({bool sync = false}) async { - await refreshAcpCapabilitiesInternal(persistMountTargets: true); - } - + Future loadAiGatewayApiKey() => + loadAiGatewayApiKeyThreadSessionInternal(this); + Future saveMultiAgentConfig(MultiAgentConfig config) => + saveMultiAgentConfigThreadSessionInternal(this, config); + Future refreshMultiAgentMounts({bool sync = false}) => + refreshMultiAgentMountsThreadSessionInternal(this, sync: sync); Future runMultiAgentCollaboration({ required String rawPrompt, required String composedPrompt, required List attachments, required List selectedSkillLabels, - }) async { - final sessionKey = currentSessionKey.trim().isEmpty - ? 'main' - : currentSessionKey; - await enqueueThreadTurnInternal(sessionKey, () async { - final aiGatewayApiKey = await loadAiGatewayApiKey(); - multiAgentRunPendingInternal = true; - appendLocalSessionMessageInternal( - sessionKey, - GatewayChatMessage( - id: nextLocalMessageIdInternal(), - role: 'user', - text: rawPrompt, - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: null, - stopReason: null, - pending: false, - error: false, - ), - ); - recomputeTasksInternal(); - try { - final taskStream = gatewayAcpClientInternal.runMultiAgent( - GatewayAcpMultiAgentRequest( - sessionId: sessionKey, - threadId: sessionKey, - prompt: composedPrompt, - workingDirectory: - assistantWorkingDirectoryForSessionInternal(sessionKey) ?? - Directory.current.path, - attachments: attachments, - selectedSkills: selectedSkillLabels, - aiGatewayBaseUrl: aiGatewayUrl, - aiGatewayApiKey: aiGatewayApiKey, - resumeSession: true, - ), - ); - await for (final event in taskStream) { - if (event.type == 'result') { - final success = event.data['success'] == true; - final finalScore = event.data['finalScore']; - final iterations = event.data['iterations']; - appendLocalSessionMessageInternal( - sessionKey, - GatewayChatMessage( - id: nextLocalMessageIdInternal(), - role: 'assistant', - text: success - ? appText( - '多 Agent 协作完成,评分 ${finalScore ?? '-'},迭代 ${iterations ?? 0} 次。', - 'Multi-agent collaboration completed with score ${finalScore ?? '-'} after ${iterations ?? 0} iteration(s).', - ) - : appText( - '多 Agent 协作失败:${event.data['error'] ?? event.message}', - 'Multi-agent collaboration failed: ${event.data['error'] ?? event.message}', - ), - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: null, - stopReason: null, - pending: false, - error: !success, - ), - ); - continue; - } - appendLocalSessionMessageInternal( - sessionKey, - GatewayChatMessage( - id: nextLocalMessageIdInternal(), - role: 'assistant', - text: event.message, - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: event.title, - stopReason: null, - pending: event.pending, - error: event.error, - ), - ); - } - } on GatewayAcpException catch (error) { - appendLocalSessionMessageInternal( - sessionKey, - GatewayChatMessage( - id: nextLocalMessageIdInternal(), - role: 'assistant', - text: appText( - '多 Agent 协作不可用(Gateway ACP):${error.message}', - 'Multi-agent collaboration is unavailable (Gateway ACP): ${error.message}', - ), - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: 'Multi-Agent', - stopReason: null, - pending: false, - error: true, - ), - ); - } catch (error) { - appendLocalSessionMessageInternal( - sessionKey, - GatewayChatMessage( - id: nextLocalMessageIdInternal(), - role: 'assistant', - text: error.toString(), - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: 'Multi-Agent', - stopReason: null, - pending: false, - error: true, - ), - ); - } finally { - multiAgentRunPendingInternal = false; - recomputeTasksInternal(); - notifyIfActiveInternal(); - } - }); - } - - Future openOnlineWorkspace() async { - const url = 'https://www.svc.plus/Xworkmate'; - try { - if (Platform.isMacOS) { - await Process.run('open', [url]); - return; - } - if (Platform.isWindows) { - await Process.run('cmd', ['/c', 'start', '', url]); - return; - } - if (Platform.isLinux) { - await Process.run('xdg-open', [url]); - } - } catch (_) { - // Best effort only. Do not surface a blocking error from a convenience link. - } - } - - List get aiGatewayModelChoices { - return aiGatewayConversationModelChoices; - } - - List get connectedGatewayModelChoices { - if (connection.status != RuntimeConnectionStatus.connected) { - return const []; - } - return modelsControllerInternal.items - .map((item) => item.id.trim()) - .where((item) => item.isNotEmpty) - .toList(growable: false); - } - - List get assistantModelChoices { - return assistantModelChoicesForSessionInternal(currentSessionKey); - } - - List assistantModelChoicesForSessionInternal(String sessionKey) { - final target = assistantExecutionTargetForSession(sessionKey); - if (target == AssistantExecutionTarget.singleAgent) { - if (singleAgentUsesAiChatFallbackForSession(sessionKey)) { - return aiGatewayConversationModelChoices; - } - final selectedModel = - assistantThreadRecordsInternal[normalizedAssistantSessionKeyInternal( - sessionKey, - )] - ?.assistantModelId - .trim(); - if (selectedModel?.isNotEmpty == true) { - return [selectedModel!]; - } - return const []; - } - final runtimeModels = connectedGatewayModelChoices; - if (runtimeModels.isNotEmpty) { - return runtimeModels; - } - final resolved = resolvedDefaultModel.trim(); - if (resolved.isNotEmpty) { - return [resolved]; - } - final localDefault = settings.ollamaLocal.defaultModel.trim(); - if (localDefault.isNotEmpty) { - return [localDefault]; - } - return const []; - } - - String get resolvedDefaultModel { - final current = settings.defaultModel.trim(); - if (current.isNotEmpty) { - return current; - } - final localDefault = settings.ollamaLocal.defaultModel.trim(); - if (localDefault.isNotEmpty) { - return localDefault; - } - final runtimeModels = connectedGatewayModelChoices; - if (runtimeModels.isNotEmpty) { - return runtimeModels.first; - } - final aiGatewayChoices = aiGatewayConversationModelChoices; - if (aiGatewayChoices.isNotEmpty) { - return aiGatewayChoices.first; - } - return ''; - } - - bool get canQuickConnectGateway { - final target = currentAssistantExecutionTarget; - if (target == AssistantExecutionTarget.singleAgent) { - return false; - } - final profile = gatewayProfileForAssistantExecutionTargetInternal(target); - if (profile.useSetupCode && profile.setupCode.trim().isNotEmpty) { - return true; - } - final host = profile.host.trim(); - if (host.isEmpty || profile.port <= 0) { - return false; - } - if (profile.mode == RuntimeConnectionMode.local) { - return true; - } - final defaults = switch (target) { - AssistantExecutionTarget.singleAgent => - GatewayConnectionProfile.emptySlot(index: kGatewayRemoteProfileIndex), - AssistantExecutionTarget.local => - GatewayConnectionProfile.defaultsLocal(), - AssistantExecutionTarget.remote => - GatewayConnectionProfile.defaultsRemote(), - }; - return hasStoredGatewayCredential || - host != defaults.host || - profile.port != defaults.port || - profile.tls != defaults.tls || - profile.mode != defaults.mode; - } - - String joinConnectionPartsInternal(List parts) { - final normalized = parts - .map((item) => item.trim()) - .where((item) => item.isNotEmpty) - .toList(growable: false); - return normalized.join(' · '); - } - - String gatewayAddressLabelInternal(GatewayConnectionProfile profile) { - final host = profile.host.trim(); - if (host.isEmpty || profile.port <= 0) { - return appText('未连接目标', 'No target'); - } - return '$host:${profile.port}'; - } + }) => runMultiAgentCollaborationThreadSessionInternal( + this, + rawPrompt: rawPrompt, + composedPrompt: composedPrompt, + attachments: attachments, + selectedSkillLabels: selectedSkillLabels, + ); + Future openOnlineWorkspace() => + openOnlineWorkspaceThreadSessionInternal(this); + List get aiGatewayModelChoices => + aiGatewayModelChoicesThreadSessionInternal(this); + List get connectedGatewayModelChoices => + connectedGatewayModelChoicesThreadSessionInternal(this); + List get assistantModelChoices => + assistantModelChoicesThreadSessionInternal(this); + List assistantModelChoicesForSessionInternal(String sessionKey) => + assistantModelChoicesForSessionThreadSessionInternal(this, sessionKey); + String get resolvedDefaultModel => + resolvedDefaultModelThreadSessionInternal(this); + bool get canQuickConnectGateway => + canQuickConnectGatewayThreadSessionInternal(this); + String joinConnectionPartsInternal(List parts) => + joinConnectionPartsThreadSessionInternal(parts); + String gatewayAddressLabelInternal(GatewayConnectionProfile profile) => + gatewayAddressLabelThreadSessionInternal(profile); List get secretReferences => settingsControllerInternal.buildSecretReferences(); diff --git a/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart b/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart new file mode 100644 index 00000000..e8b02bd9 --- /dev/null +++ b/lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart @@ -0,0 +1,381 @@ +// 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/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_single_agent.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_runtime_helpers.dart'; + +Future loadAiGatewayApiKeyThreadSessionInternal( + AppController controller, +) async { + return (await controller.storeInternal.loadAiGatewayApiKey())?.trim() ?? ''; +} + +Future saveMultiAgentConfigThreadSessionInternal( + AppController controller, + MultiAgentConfig config, +) async { + final resolved = controller.resolveMultiAgentConfigInternal( + controller.settings.copyWith(multiAgent: config), + ); + await AppControllerDesktopSettings(controller).saveSettings( + controller.settings.copyWith(multiAgent: resolved), + refreshAfterSave: false, + ); + await refreshMultiAgentMountsThreadSessionInternal( + controller, + sync: resolved.autoSync, + ); +} + +Future refreshMultiAgentMountsThreadSessionInternal( + AppController controller, { + bool sync = false, +}) async { + await controller.refreshAcpCapabilitiesInternal(persistMountTargets: true); +} + +Future runMultiAgentCollaborationThreadSessionInternal( + AppController controller, { + required String rawPrompt, + required String composedPrompt, + required List attachments, + required List selectedSkillLabels, +}) async { + final sessionKey = controller.currentSessionKey.trim().isEmpty + ? 'main' + : controller.currentSessionKey; + await controller.enqueueThreadTurnInternal(sessionKey, () async { + final aiGatewayApiKey = await loadAiGatewayApiKeyThreadSessionInternal( + controller, + ); + controller.multiAgentRunPendingInternal = true; + controller.appendLocalSessionMessageInternal( + sessionKey, + GatewayChatMessage( + id: controller.nextLocalMessageIdInternal(), + role: 'user', + text: rawPrompt, + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + ); + controller.recomputeTasksInternal(); + try { + final taskStream = controller.gatewayAcpClientInternal.runMultiAgent( + GatewayAcpMultiAgentRequest( + sessionId: sessionKey, + threadId: sessionKey, + prompt: composedPrompt, + workingDirectory: + controller.assistantWorkingDirectoryForSessionInternal( + sessionKey, + ) ?? + Directory.current.path, + attachments: attachments, + selectedSkills: selectedSkillLabels, + aiGatewayBaseUrl: controller.aiGatewayUrl, + aiGatewayApiKey: aiGatewayApiKey, + resumeSession: true, + ), + ); + await for (final event in taskStream) { + if (event.type == 'result') { + final success = event.data['success'] == true; + final finalScore = event.data['finalScore']; + final iterations = event.data['iterations']; + controller.appendLocalSessionMessageInternal( + sessionKey, + GatewayChatMessage( + id: controller.nextLocalMessageIdInternal(), + role: 'assistant', + text: success + ? appText( + '多 Agent 协作完成,评分 ${finalScore ?? '-'},迭代 ${iterations ?? 0} 次。', + 'Multi-agent collaboration completed with score ${finalScore ?? '-'} after ${iterations ?? 0} iteration(s).', + ) + : appText( + '多 Agent 协作失败:${event.data['error'] ?? event.message}', + 'Multi-agent collaboration failed: ${event.data['error'] ?? event.message}', + ), + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: !success, + ), + ); + continue; + } + controller.appendLocalSessionMessageInternal( + sessionKey, + GatewayChatMessage( + id: controller.nextLocalMessageIdInternal(), + role: 'assistant', + text: event.message, + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: event.title, + stopReason: null, + pending: event.pending, + error: event.error, + ), + ); + } + } on GatewayAcpException catch (error) { + controller.appendLocalSessionMessageInternal( + sessionKey, + GatewayChatMessage( + id: controller.nextLocalMessageIdInternal(), + role: 'assistant', + text: appText( + '多 Agent 协作不可用(Gateway ACP):${error.message}', + 'Multi-agent collaboration is unavailable (Gateway ACP): ${error.message}', + ), + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: 'Multi-Agent', + stopReason: null, + pending: false, + error: true, + ), + ); + } catch (error) { + controller.appendLocalSessionMessageInternal( + sessionKey, + GatewayChatMessage( + id: controller.nextLocalMessageIdInternal(), + role: 'assistant', + text: error.toString(), + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: 'Multi-Agent', + stopReason: null, + pending: false, + error: true, + ), + ); + } finally { + controller.multiAgentRunPendingInternal = false; + controller.recomputeTasksInternal(); + controller.notifyIfActiveInternal(); + } + }); +} + +Future openOnlineWorkspaceThreadSessionInternal( + AppController controller, +) async { + const url = 'https://www.svc.plus/Xworkmate'; + try { + if (Platform.isMacOS) { + await Process.run('open', [url]); + return; + } + if (Platform.isWindows) { + await Process.run('cmd', ['/c', 'start', '', url]); + return; + } + if (Platform.isLinux) { + await Process.run('xdg-open', [url]); + } + } catch (_) { + // Best effort only. Do not surface a blocking error from a convenience link. + } +} + +List aiGatewayModelChoicesThreadSessionInternal( + AppController controller, +) { + return controller.aiGatewayConversationModelChoices; +} + +List connectedGatewayModelChoicesThreadSessionInternal( + AppController controller, +) { + if (controller.connection.status != RuntimeConnectionStatus.connected) { + return const []; + } + return controller.modelsControllerInternal.items + .map((item) => item.id.trim()) + .where((item) => item.isNotEmpty) + .toList(growable: false); +} + +List assistantModelChoicesThreadSessionInternal( + AppController controller, +) { + return assistantModelChoicesForSessionThreadSessionInternal( + controller, + controller.currentSessionKey, + ); +} + +List assistantModelChoicesForSessionThreadSessionInternal( + AppController controller, + String sessionKey, +) { + final normalizedSessionKey = normalizeAssistantSessionKeyThreadInternal( + sessionKey, + ); + final target = controller.sanitizeExecutionTargetInternal( + controller.assistantThreadRecordsInternal[normalizedSessionKey] + ?.executionTarget ?? + controller.settings.assistantExecutionTarget, + ); + if (target == AssistantExecutionTarget.singleAgent) { + final singleAgentUsesAiGatewayFallback = + !controller.hasAnyAvailableSingleAgentProvider && + controller.canUseAiGatewayConversation; + if (singleAgentUsesAiGatewayFallback) { + return controller.aiGatewayConversationModelChoices; + } + final selectedModel = + controller + .assistantThreadRecordsInternal[normalizedSessionKey] + ?.assistantModelId + .trim(); + if (selectedModel?.isNotEmpty == true) { + return [selectedModel!]; + } + return const []; + } + final runtimeModels = connectedGatewayModelChoicesThreadSessionInternal( + controller, + ); + if (runtimeModels.isNotEmpty) { + return runtimeModels; + } + final resolved = resolvedDefaultModelThreadSessionInternal(controller).trim(); + if (resolved.isNotEmpty) { + return [resolved]; + } + final localDefault = controller.settings.ollamaLocal.defaultModel.trim(); + if (localDefault.isNotEmpty) { + return [localDefault]; + } + return const []; +} + +String resolvedDefaultModelThreadSessionInternal(AppController controller) { + final current = controller.settings.defaultModel.trim(); + if (current.isNotEmpty) { + return current; + } + final localDefault = controller.settings.ollamaLocal.defaultModel.trim(); + if (localDefault.isNotEmpty) { + return localDefault; + } + final runtimeModels = connectedGatewayModelChoicesThreadSessionInternal( + controller, + ); + if (runtimeModels.isNotEmpty) { + return runtimeModels.first; + } + final aiGatewayChoices = controller.aiGatewayConversationModelChoices; + if (aiGatewayChoices.isNotEmpty) { + return aiGatewayChoices.first; + } + return ''; +} + +bool canQuickConnectGatewayThreadSessionInternal(AppController controller) { + final target = controller.currentAssistantExecutionTarget; + if (target == AssistantExecutionTarget.singleAgent) { + return false; + } + final profile = controller.gatewayProfileForAssistantExecutionTargetInternal( + target, + ); + if (profile.useSetupCode && profile.setupCode.trim().isNotEmpty) { + return true; + } + final host = profile.host.trim(); + if (host.isEmpty || profile.port <= 0) { + return false; + } + if (profile.mode == RuntimeConnectionMode.local) { + return true; + } + final defaults = switch (target) { + AssistantExecutionTarget.singleAgent => + GatewayConnectionProfile.emptySlot(index: kGatewayRemoteProfileIndex), + AssistantExecutionTarget.local => GatewayConnectionProfile.defaultsLocal(), + AssistantExecutionTarget.remote => + GatewayConnectionProfile.defaultsRemote(), + }; + return controller.hasStoredGatewayCredential || + host != defaults.host || + profile.port != defaults.port || + profile.tls != defaults.tls || + profile.mode != defaults.mode; +} + +String normalizeAssistantSessionKeyThreadInternal(String sessionKey) { + final trimmed = sessionKey.trim(); + return trimmed.isEmpty ? 'main' : trimmed; +} + +String joinConnectionPartsThreadSessionInternal(List parts) { + final normalized = parts + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) + .toList(growable: false); + return normalized.join(' · '); +} + +String gatewayAddressLabelThreadSessionInternal( + GatewayConnectionProfile profile, +) { + final host = profile.host.trim(); + if (host.isEmpty || profile.port <= 0) { + return appText('未连接目标', 'No target'); + } + return '$host:${profile.port}'; +} diff --git a/lib/features/assistant/assistant_page_main.dart b/lib/features/assistant/assistant_page_main.dart index 5c71ed31..493c19fc 100644 --- a/lib/features/assistant/assistant_page_main.dart +++ b/lib/features/assistant/assistant_page_main.dart @@ -36,6 +36,8 @@ import 'assistant_page_composer_skill_models.dart'; import 'assistant_page_composer_skill_picker.dart'; import 'assistant_page_composer_clipboard.dart'; import 'assistant_page_components_core.dart'; +import 'assistant_page_state_closure.dart'; +import 'assistant_page_state_actions.dart'; const double assistantComposerDefaultInputHeightInternal = 78; const double assistantWorkspaceMinConversationHeightInternal = 180; @@ -425,1184 +427,6 @@ class AssistantPageStateInternal extends State { }, ); } - - Widget buildMainWorkspaceInternal({ - required AppController controller, - required List timelineItems, - required AssistantTaskEntryInternal currentTask, - }) { - return LayoutBuilder( - builder: (context, constraints) { - final palette = context.palette; - final mediaQuery = MediaQuery.of(context); - final composerBottomInset = math.max( - mediaQuery.viewPadding.bottom, - mediaQuery.viewInsets.bottom, - ); - final composerBottomSpacing = composerBottomInset > 0 - ? composerBottomInset + assistantComposerSafeAreaGapInternal - : assistantComposerSafeAreaGapInternal; - final baseComposerHeight = constraints.maxHeight >= 900 - ? assistantComposerBaseHeightTallInternal - : assistantComposerBaseHeightCompactInternal; - final composerContentWidth = math.max(240.0, constraints.maxWidth - 32); - final availableWorkspaceHeight = math.max( - 0.0, - constraints.maxHeight - assistantVerticalResizeHandleHeightInternal, - ); - final attachmentExtraHeight = - estimatedComposerWrapSectionHeightInternal( - itemCount: attachmentsInternal.length, - availableWidth: composerContentWidth, - averageChipWidth: 168, - ); - final selectedSkillExtraHeight = - estimatedComposerWrapSectionHeightInternal( - itemCount: selectedSkillKeysForInternal(controller).length, - availableWidth: composerContentWidth, - averageChipWidth: 132, - ); - final fallbackComposerContentHeight = - baseComposerHeight + - math.max( - 0.0, - composerInputHeightInternal - - assistantComposerDefaultInputHeightInternal, - ) + - attachmentExtraHeight + - selectedSkillExtraHeight; - final composerContentHeight = composerMeasuredContentHeightInternal > 0 - ? composerMeasuredContentHeightInternal - : fallbackComposerContentHeight; - final defaultComposerHeight = math.min( - availableWorkspaceHeight, - composerContentHeight + composerBottomSpacing, - ); - final composerHeightUpperBound = math.min( - availableWorkspaceHeight, - math.max( - assistantWorkspaceMinLowerPaneHeightInternal + - composerBottomSpacing, - availableWorkspaceHeight - - assistantWorkspaceMinConversationHeightInternal, - ), - ); - final composerHeightLowerBound = math.min( - assistantWorkspaceMinLowerPaneHeightInternal + composerBottomSpacing, - composerHeightUpperBound, - ); - final composerHeight = - (defaultComposerHeight + workspaceLowerPaneHeightAdjustmentInternal) - .clamp(composerHeightLowerBound, composerHeightUpperBound) - .toDouble(); - - return SurfaceCard( - borderRadius: 0, - padding: EdgeInsets.zero, - tone: SurfaceCardTone.chrome, - child: Column( - children: [ - Expanded( - child: KeyedSubtree( - key: const Key('assistant-conversation-shell'), - child: ConversationAreaInternal( - controller: controller, - currentTask: currentTask, - items: timelineItems, - messageViewMode: controller.currentAssistantMessageViewMode, - bottomContentInset: composerBottomSpacing, - topTrailingInset: artifactPaneCollapsedInternal - ? assistantCollapsedArtifactToggleClearanceInternal - : 0, - scrollController: conversationControllerInternal, - onOpenDetail: widget.onOpenDetail, - onFocusComposer: focusComposerInternal, - onOpenGateway: openGatewaySettingsInternal, - onOpenAiGatewaySettings: openAiGatewaySettingsInternal, - onReconnectGateway: - connectFromSavedSettingsOrShowDialogInternal, - onMessageViewModeChanged: - controller.setAssistantMessageViewMode, - ), - ), - ), - ColoredBox( - color: palette.canvas, - child: SizedBox( - key: const Key('assistant-workspace-resize-handle'), - height: assistantVerticalResizeHandleHeightInternal, - child: PaneResizeHandle( - axis: Axis.vertical, - onDelta: (delta) { - setState(() { - final nextComposerHeight = (composerHeight - delta) - .clamp( - composerHeightLowerBound, - composerHeightUpperBound, - ) - .toDouble(); - workspaceLowerPaneHeightAdjustmentInternal = - nextComposerHeight - defaultComposerHeight; - }); - }, - ), - ), - ), - SizedBox( - key: const Key('assistant-composer-shell'), - height: composerHeight, - child: AssistantLowerPaneInternal( - bottomContentInset: composerBottomSpacing, - inputController: inputControllerInternal, - focusNode: composerFocusNodeInternal, - thinkingLabel: thinkingLabelInternal, - showModelControl: !controller.isSingleAgentMode - ? true - : controller.currentSingleAgentShouldShowModelControl, - modelLabel: controller.isSingleAgentMode - ? controller.currentSingleAgentModelDisplayLabel - : controller.resolvedAssistantModel.isEmpty - ? appText('未选择模型', 'No model selected') - : controller.resolvedAssistantModel, - modelOptions: controller.assistantModelChoices, - attachments: attachmentsInternal, - availableSkills: availableSkillOptionsInternal(controller), - selectedSkillKeys: selectedSkillKeysForInternal(controller), - controller: controller, - onRemoveAttachment: (attachment) { - setState(() { - attachmentsInternal = attachmentsInternal - .where((item) => item.path != attachment.path) - .toList(growable: false); - }); - }, - onToggleSkill: (key) { - unawaited( - controller.toggleAssistantSkillForSession( - controller.currentSessionKey, - key, - ), - ); - focusComposerInternal(); - }, - onThinkingChanged: (value) { - setState(() => thinkingLabelInternal = value); - }, - onModelChanged: (modelId) => - controller.selectAssistantModelForSession( - controller.currentSessionKey, - modelId, - ), - onOpenGateway: openGatewaySettingsInternal, - onOpenAiGatewaySettings: openAiGatewaySettingsInternal, - onReconnectGateway: - connectFromSavedSettingsOrShowDialogInternal, - onPickAttachments: pickAttachmentsInternal, - onAddAttachment: (attachment) { - setState(() { - attachmentsInternal = [ - ...attachmentsInternal, - attachment, - ]; - }); - }, - onPasteImageAttachment: - widget.clipboardImageReader ?? - readClipboardImageAsXFileInternal, - onComposerContentHeightChanged: - handleComposerContentHeightChangedInternal, - onComposerInputHeightChanged: - handleComposerInputHeightChangedInternal, - onSend: submitPromptInternal, - ), - ), - ], - ), - ); - }, - ); - } - - Widget buildWorkspaceWithArtifactsInternal({ - required AppController controller, - required AssistantTaskEntryInternal currentTask, - required Widget child, - }) { - return LayoutBuilder( - builder: (context, constraints) { - final maxPaneWidth = math.min( - 560.0, - math.max( - assistantArtifactPaneMinWidthInternal, - constraints.maxWidth * 0.48, - ), - ); - final paneWidth = artifactPaneWidthInternal - .clamp(assistantArtifactPaneMinWidthInternal, maxPaneWidth) - .toDouble(); - final panel = Row( - children: [ - Expanded(child: child), - if (!artifactPaneCollapsedInternal) ...[ - SizedBox( - key: const Key('assistant-artifact-pane-resize-handle'), - width: assistantHorizontalResizeHandleWidthInternal, - child: PaneResizeHandle( - axis: Axis.horizontal, - onDelta: (delta) { - setState(() { - artifactPaneWidthInternal = - (artifactPaneWidthInternal - delta) - .clamp( - assistantArtifactPaneMinWidthInternal, - maxPaneWidth, - ) - .toDouble(); - }); - }, - ), - ), - const SizedBox(width: assistantHorizontalPaneGapInternal), - SizedBox( - width: paneWidth, - child: AssistantArtifactSidebar( - sessionKey: controller.currentSessionKey, - threadTitle: currentTask.title, - workspaceRef: controller.assistantWorkspaceRefForSession( - controller.currentSessionKey, - ), - workspaceRefKind: controller - .assistantWorkspaceRefKindForSession( - controller.currentSessionKey, - ), - onCollapse: () { - setState(() { - artifactPaneCollapsedInternal = true; - }); - }, - loadSnapshot: () => - controller.loadAssistantArtifactSnapshot(), - loadPreview: (entry) => - controller.loadAssistantArtifactPreview(entry), - ), - ), - ], - ], - ); - return Stack( - children: [ - Positioned.fill(child: panel), - if (artifactPaneCollapsedInternal) - Positioned( - right: 0, - top: 0, - child: AssistantArtifactSidebarRevealButton( - onTap: () { - setState(() { - artifactPaneCollapsedInternal = false; - }); - }, - ), - ), - ], - ); - }, - ); - } - - void handleComposerInputHeightChangedInternal(double value) { - if (!mounted || value == composerInputHeightInternal) { - return; - } - setState(() { - composerInputHeightInternal = value; - }); - } - - List buildTimelineItemsInternal( - AppController controller, - List messages, - ) { - final items = []; - final ownerLabel = conversationOwnerLabelInternal(controller); - - for (final message in messages) { - if ((message.toolName ?? '').trim().isNotEmpty) { - items.add( - TimelineItemInternal.toolCall( - toolName: message.toolName!, - summary: message.text, - pending: message.pending, - error: message.error, - ), - ); - continue; - } - - final role = message.role.toLowerCase(); - if (role == 'user') { - items.add( - TimelineItemInternal.message( - kind: TimelineItemKindInternal.user, - label: appText('你', 'You'), - text: message.text, - pending: message.pending, - error: message.error, - ), - ); - } else if (role == 'assistant') { - items.add( - TimelineItemInternal.message( - kind: TimelineItemKindInternal.assistant, - label: kProductBrandName, - text: message.text, - pending: message.pending, - error: message.error, - ), - ); - } else { - items.add( - TimelineItemInternal.message( - kind: TimelineItemKindInternal.agent, - label: lastAutoAgentLabelInternal ?? ownerLabel, - text: message.text, - pending: message.pending, - error: message.error, - ), - ); - } - } - - return items; - } - - Future pickAttachmentsInternal() async { - final uiFeatures = widget.controller.featuresFor( - resolveUiFeaturePlatformFromContext(context), - ); - if (!uiFeatures.supportsFileAttachments) { - return; - } - final files = await openFiles( - acceptedTypeGroups: const [ - XTypeGroup( - label: 'Images', - extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'], - ), - XTypeGroup(label: 'Logs', extensions: ['log', 'txt', 'json', 'csv']), - XTypeGroup( - label: 'Files', - extensions: ['md', 'pdf', 'yaml', 'yml', 'zip'], - ), - ], - ); - if (!mounted || files.isEmpty) { - return; - } - - setState(() { - attachmentsInternal = [ - ...attachmentsInternal, - ...files.map(ComposerAttachmentInternal.fromXFile), - ]; - }); - } - - Future submitPromptInternal() async { - final controller = widget.controller; - final uiFeatures = controller.featuresFor( - resolveUiFeaturePlatformFromContext(context), - ); - final settings = controller.settings; - final executionTarget = controller.assistantExecutionTarget; - final rawPrompt = inputControllerInternal.text.trim(); - if (rawPrompt.isEmpty) { - return; - } - - final shouldUseGatewayAgent = - executionTarget != AssistantExecutionTarget.singleAgent; - final autoAgent = shouldUseGatewayAgent - ? pickAutoAgentInternal(controller, rawPrompt) - : null; - if (autoAgent != null) { - await controller.selectAgent(autoAgent.id); - } - - final submittedAttachments = List.from( - attachmentsInternal, - growable: false, - ); - final attachmentNames = submittedAttachments - .map((item) => item.name) - .toList(growable: false); - final selectedSkillLabels = resolveSelectedSkillLabelsInternal(controller); - final connectionState = controller.currentAssistantConnectionState; - final prompt = composePromptInternal( - mode: modeInternal, - prompt: rawPrompt, - attachmentNames: attachmentNames, - selectedSkillLabels: selectedSkillLabels, - executionTarget: executionTarget, - singleAgentProvider: controller.currentSingleAgentProvider, - permissionLevel: settings.assistantPermissionLevel, - workspacePath: controller.assistantWorkspaceRefForSession( - controller.currentSessionKey, - ), - ); - - setState(() { - lastAutoAgentLabelInternal = - autoAgent?.name ?? conversationOwnerLabelInternal(controller); - attachmentsInternal = const []; - touchTaskSeedInternal( - sessionKey: controller.currentSessionKey, - title: - taskSeedsInternal[controller.currentSessionKey]?.title ?? - fallbackSessionTitleInternal(controller.currentSessionKey), - preview: rawPrompt, - status: - controller.hasAssistantPendingRun || - executionTarget == AssistantExecutionTarget.singleAgent || - connectionState.connected - ? 'running' - : 'queued', - owner: autoAgent?.name ?? conversationOwnerLabelInternal(controller), - surface: 'Assistant', - executionTarget: executionTarget, - draft: controller.currentSessionKey.trim().startsWith('draft:'), - ); - }); - inputControllerInternal.clear(); - - try { - if (uiFeatures.supportsMultiAgent && - controller.settings.multiAgent.enabled) { - final collaborationAttachments = submittedAttachments - .map( - (item) => CollaborationAttachment( - name: item.name, - description: item.mimeType, - path: item.path, - ), - ) - .toList(growable: false); - await controller.runMultiAgentCollaboration( - rawPrompt: rawPrompt, - composedPrompt: prompt, - attachments: collaborationAttachments, - selectedSkillLabels: selectedSkillLabels, - ); - } else { - final attachmentPayloads = await buildAttachmentPayloadsInternal( - submittedAttachments, - ); - await controller.sendChatMessage( - prompt, - thinking: thinkingLabelInternal, - attachments: attachmentPayloads, - localAttachments: submittedAttachments - .map( - (item) => CollaborationAttachment( - name: item.name, - description: item.mimeType, - path: item.path, - ), - ) - .toList(growable: false), - selectedSkillLabels: selectedSkillLabels, - ); - } - } catch (_) { - if (!mounted) { - rethrow; - } - if (inputControllerInternal.text.trim().isEmpty) { - inputControllerInternal.value = TextEditingValue( - text: rawPrompt, - selection: TextSelection.collapsed(offset: rawPrompt.length), - ); - } - if (attachmentsInternal.isEmpty && submittedAttachments.isNotEmpty) { - setState(() { - attachmentsInternal = submittedAttachments; - }); - } - rethrow; - } - } - - Future> buildAttachmentPayloadsInternal( - List attachments, - ) async { - final payloads = []; - for (final attachment in attachments) { - final file = File(attachment.path); - if (!await file.exists()) { - continue; - } - final bytes = await file.readAsBytes(); - final mimeType = attachment.mimeType; - payloads.add( - GatewayChatAttachmentPayload( - type: mimeType.startsWith('image/') ? 'image' : 'file', - mimeType: mimeType, - fileName: attachment.name, - content: base64Encode(bytes), - ), - ); - } - return payloads; - } - - GatewayAgentSummary? pickAutoAgentInternal( - AppController controller, - String prompt, - ) { - final text = prompt.toLowerCase(); - final agents = controller.agents; - if (agents.isEmpty) { - return null; - } - - GatewayAgentSummary? byName(String name) { - for (final agent in agents) { - if (agent.name.toLowerCase().contains(name)) { - return agent; - } - } - return null; - } - - if (text.contains('browser') || - text.contains('search') || - text.contains('website') || - text.contains('网页') || - text.contains('爬') || - text.contains('抓取')) { - return byName('browser'); - } - - if (text.contains('research') || - text.contains('analyze') || - text.contains('compare') || - text.contains('summary') || - text.contains('研究') || - text.contains('分析') || - text.contains('调研')) { - return byName('research'); - } - - if (text.contains('code') || - text.contains('deploy') || - text.contains('build') || - text.contains('test') || - text.contains('log') || - text.contains('bug') || - text.contains('代码') || - text.contains('部署') || - text.contains('日志')) { - return byName('coding'); - } - - return byName('coding') ?? byName('browser') ?? byName('research'); - } - - List availableSkillOptionsInternal( - AppController controller, - ) { - if (controller.isSingleAgentMode) { - return controller - .assistantImportedSkillsForSession(controller.currentSessionKey) - .map(skillOptionFromThreadSkillInternal) - .toList(growable: false); - } - final options = []; - final seenKeys = {}; - - void addOption(ComposerSkillOptionInternal option) { - if (seenKeys.add(option.key)) { - options.add(option); - } - } - - for (final skill in controller.skills) { - final option = skillOptionFromGatewayInternal(skill); - addOption(option); - } - - for (final option in fallbackSkillOptionsInternal) { - addOption(option); - } - - return options; - } - - List selectedSkillKeysForInternal(AppController controller) { - return controller.assistantSelectedSkillKeysForSession( - controller.currentSessionKey, - ); - } - - List resolveSelectedSkillLabelsInternal(AppController controller) { - final optionsByKey = { - for (final option in availableSkillOptionsInternal(controller)) - option.key: option, - }; - return selectedSkillKeysForInternal(controller) - .map((key) => optionsByKey[key]?.label) - .whereType() - .toList(growable: false); - } - - String composePromptInternal({ - required String mode, - required String prompt, - required List attachmentNames, - required List selectedSkillLabels, - required AssistantExecutionTarget executionTarget, - required SingleAgentProvider singleAgentProvider, - required AssistantPermissionLevel permissionLevel, - required String workspacePath, - }) { - final attachmentBlock = attachmentNames.isEmpty - ? '' - : 'Attached files:\n${attachmentNames.map((name) => '- $name').join('\n')}\n\n'; - final skillBlock = selectedSkillLabels.isEmpty - ? '' - : 'Preferred skills:\n${selectedSkillLabels.map((name) => '- $name').join('\n')}\n\n'; - final targetRoot = workspacePath.trim(); - final executionContext = - 'Execution context:\n' - '- target: ${executionTarget.promptValue}\n' - '${executionTarget == AssistantExecutionTarget.singleAgent ? '- provider: ${singleAgentProvider.providerId}\n' : ''}' - '- workspace_root: ${targetRoot.isEmpty ? 'not-set' : targetRoot}\n' - '- permission: ${permissionLevel.promptValue}\n\n'; - - return switch (mode) { - 'craft' => - '$attachmentBlock$skillBlock$executionContext' - 'Craft a polished result for this request:\n$prompt', - 'plan' => - '$attachmentBlock$skillBlock$executionContext' - 'Create a clear execution plan for this task:\n$prompt', - _ => '$attachmentBlock$skillBlock$executionContext$prompt', - }; - } - - void openGatewaySettingsInternal() { - widget.controller.openSettings( - detail: SettingsDetailPage.gatewayConnection, - navigationContext: SettingsNavigationContext( - rootLabel: appText('助手', 'Assistant'), - destination: WorkspaceDestination.assistant, - sectionLabel: appText('集成', 'Integrations'), - ), - ); - } - - Future connectFromSavedSettingsOrShowDialogInternal() async { - if (!widget.controller.canQuickConnectGateway) { - openGatewaySettingsInternal(); - return; - } - await widget.controller.connectSavedGateway(); - } - - void openAiGatewaySettingsInternal() { - widget.controller.openSettings(tab: SettingsTab.gateway); - } - - void focusComposerInternal() { - if (!mounted) { - return; - } - composerFocusNodeInternal.requestFocus(); - } - - Future runTaskSessionActionWithRetryInternal( - String label, - Future Function() action, - ) async { - Object? lastError; - for ( - var attempt = 1; - attempt <= assistantTaskActionMaxRetryCountInternal; - attempt++ - ) { - try { - await action(); - return true; - } catch (error) { - lastError = error; - if (attempt >= assistantTaskActionMaxRetryCountInternal) { - break; - } - await Future.delayed(Duration(milliseconds: 240 * attempt)); - } - } - if (!mounted) { - return false; - } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - appText( - '$label 失败,弱网环境下已重试 $assistantTaskActionMaxRetryCountInternal 次。', - '$label failed after $assistantTaskActionMaxRetryCountInternal retries on a weak network.', - ), - ), - ), - ); - debugPrint('$label failed after retries: $lastError'); - return false; - } - - Future refreshTasksWithRetryInternal() async { - await runTaskSessionActionWithRetryInternal( - appText('刷新任务列表', 'Refresh task list'), - widget.controller.refreshSessions, - ); - } - - Future switchSessionWithRetryInternal(String sessionKey) async { - final switched = await runTaskSessionActionWithRetryInternal( - appText('切换会话', 'Switch session'), - () => widget.controller.switchSession(sessionKey), - ); - if (switched) { - focusComposerInternal(); - } - } - - Future createNewThreadInternal() async { - final sessionKey = buildDraftSessionKeyInternal(widget.controller); - final inheritedTarget = widget.controller.currentAssistantExecutionTarget; - final inheritedViewMode = widget.controller.currentAssistantMessageViewMode; - setState(() { - archivedTaskKeysInternal.removeWhere( - (value) => sessionKeysMatchInternal(value, sessionKey), - ); - taskSeedsInternal[sessionKey] = AssistantTaskSeedInternal( - sessionKey: sessionKey, - title: appText('新对话', 'New conversation'), - preview: appText( - '等待描述这个任务的第一条消息', - 'Waiting for the first message of this task', - ), - status: 'queued', - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - owner: conversationOwnerLabelInternal(widget.controller), - surface: 'Assistant', - executionTarget: inheritedTarget, - draft: true, - ); - }); - widget.controller.initializeAssistantThreadContext( - sessionKey, - title: appText('新对话', 'New conversation'), - executionTarget: inheritedTarget, - messageViewMode: inheritedViewMode, - singleAgentProvider: widget.controller.currentSingleAgentProvider, - ); - await switchSessionWithRetryInternal(sessionKey); - } - - List buildTaskEntriesInternal( - AppController controller, - ) { - archivedTaskKeysInternal - ..clear() - ..addAll(controller.settings.assistantArchivedTaskKeys); - synchronizeTaskSeedsInternal(controller); - final entries = - taskSeedsInternal.values - .where((item) => !isArchivedTaskInternal(item.sessionKey)) - .map((item) { - final isCurrent = sessionKeysMatchInternal( - item.sessionKey, - controller.currentSessionKey, - ); - final entry = item.toEntry(isCurrent: isCurrent); - if (!isCurrent) { - return entry; - } - return entry.copyWith( - owner: conversationOwnerLabelInternal(controller), - ); - }) - .toList(growable: true) - ..sort((left, right) { - if (left.isCurrent != right.isCurrent) { - return left.isCurrent ? -1 : 1; - } - return (right.updatedAtMs ?? 0).compareTo(left.updatedAtMs ?? 0); - }); - return entries; - } - - List filterTasksInternal( - List items, - ) { - final query = threadQueryInternal.trim().toLowerCase(); - if (query.isEmpty) { - return items; - } - return items - .where((item) { - final haystack = '${item.title}\n${item.preview}\n${item.sessionKey}' - .toLowerCase(); - return haystack.contains(query); - }) - .toList(growable: false); - } - - AssistantTaskEntryInternal resolveCurrentTaskInternal( - List items, - String sessionKey, - ) { - for (final item in items) { - if (sessionKeysMatchInternal(item.sessionKey, sessionKey)) { - return item; - } - } - return AssistantTaskEntryInternal( - sessionKey: sessionKey, - title: resolvedTaskTitleInternal(widget.controller, sessionKey), - preview: '', - status: 'queued', - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - owner: conversationOwnerLabelInternal(widget.controller), - surface: 'Assistant', - executionTarget: widget.controller.currentAssistantExecutionTarget, - isCurrent: true, - draft: true, - ); - } - - void synchronizeTaskSeedsInternal(AppController controller) { - for (final session in controller.assistantSessions) { - if (isArchivedTaskInternal(session.key)) { - continue; - } - taskSeedsInternal[session.key] = AssistantTaskSeedInternal( - sessionKey: session.key, - title: resolvedTaskTitleInternal( - controller, - session.key, - session: session, - ), - preview: - sessionPreviewInternal(session) ?? - appText('等待继续执行这个任务', 'Waiting to continue this task'), - status: sessionStatusInternal( - session, - sessionPending: controller.assistantSessionHasPendingRun(session.key), - ), - updatedAtMs: - session.updatedAtMs ?? - DateTime.now().millisecondsSinceEpoch.toDouble(), - owner: conversationOwnerLabelInternal(controller), - surface: session.surface ?? session.kind ?? 'Assistant', - executionTarget: controller.assistantExecutionTargetForSession( - session.key, - ), - draft: session.key.trim().startsWith('draft:'), - ); - } - - final currentSeed = taskSeedsInternal[controller.currentSessionKey]; - final currentPreview = currentTaskPreviewInternal(controller.chatMessages); - final currentStatus = currentTaskStatusInternal( - controller.chatMessages, - controller, - ); - - if (isArchivedTaskInternal(controller.currentSessionKey)) { - return; - } - taskSeedsInternal[controller.currentSessionKey] = AssistantTaskSeedInternal( - sessionKey: controller.currentSessionKey, - title: resolvedTaskTitleInternal( - controller, - controller.currentSessionKey, - fallbackTitle: currentSeed?.title, - ), - preview: - currentPreview ?? - currentSeed?.preview ?? - appText( - '等待描述这个任务的第一条消息', - 'Waiting for the first message of this task', - ), - status: currentStatus ?? currentSeed?.status ?? 'queued', - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - owner: conversationOwnerLabelInternal(controller), - surface: currentSeed?.surface ?? 'Assistant', - executionTarget: controller.assistantExecutionTargetForSession( - controller.currentSessionKey, - ), - draft: controller.currentSessionKey.trim().startsWith('draft:'), - ); - } - - GatewaySessionSummary? sessionByKeyInternal( - AppController controller, - String sessionKey, - ) { - for (final session in controller.assistantSessions) { - if (sessionKeysMatchInternal(session.key, sessionKey)) { - return session; - } - } - return null; - } - - String resolvedTaskTitleInternal( - AppController controller, - String sessionKey, { - GatewaySessionSummary? session, - String? fallbackTitle, - }) { - final customTitle = controller.assistantCustomTaskTitle(sessionKey); - if (customTitle.isNotEmpty) { - return customTitle; - } - final resolvedSession = - session ?? sessionByKeyInternal(controller, sessionKey); - if (resolvedSession != null) { - return sessionDisplayTitleInternal(resolvedSession); - } - final fallback = fallbackTitle?.trim() ?? ''; - if (fallback.isNotEmpty) { - return fallback; - } - return fallbackSessionTitleInternal(sessionKey); - } - - String defaultTaskTitleInternal( - AppController controller, - String sessionKey, { - GatewaySessionSummary? session, - }) { - final resolvedSession = - session ?? sessionByKeyInternal(controller, sessionKey); - if (resolvedSession != null) { - return sessionDisplayTitleInternal(resolvedSession); - } - return fallbackSessionTitleInternal(sessionKey); - } - - void touchTaskSeedInternal({ - required String sessionKey, - required String title, - required String preview, - required String status, - required String owner, - required String surface, - required AssistantExecutionTarget executionTarget, - required bool draft, - }) { - taskSeedsInternal[sessionKey] = AssistantTaskSeedInternal( - sessionKey: sessionKey, - title: title, - preview: preview, - status: status, - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - owner: owner, - surface: surface, - executionTarget: executionTarget, - draft: draft, - ); - } - - bool isArchivedTaskInternal(String sessionKey) { - for (final archivedKey in archivedTaskKeysInternal) { - if (sessionKeysMatchInternal(archivedKey, sessionKey)) { - return true; - } - } - return false; - } - - Future archiveTaskInternal(String sessionKey) async { - final isCurrent = sessionKeysMatchInternal( - sessionKey, - widget.controller.currentSessionKey, - ); - if (widget.controller.assistantSessionHasPendingRun(sessionKey)) { - return; - } - final archived = await runTaskSessionActionWithRetryInternal( - appText('归档任务', 'Archive task'), - () => widget.controller.saveAssistantTaskArchived(sessionKey, true), - ); - if (!archived) { - return; - } - setState(() { - archivedTaskKeysInternal.add(sessionKey); - taskSeedsInternal.removeWhere( - (key, _) => sessionKeysMatchInternal(key, sessionKey), - ); - }); - - if (!isCurrent) { - return; - } - - for (final candidate in taskSeedsInternal.keys) { - if (isArchivedTaskInternal(candidate) || - sessionKeysMatchInternal(candidate, sessionKey)) { - continue; - } - await switchSessionWithRetryInternal(candidate); - return; - } - - await createNewThreadInternal(); - } - - Future renameTaskInternal(AssistantTaskEntryInternal entry) async { - final controller = widget.controller; - final input = TextEditingController(text: entry.title); - final renamed = await showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: Text(appText('重命名任务', 'Rename task')), - content: TextField( - key: const Key('assistant-task-rename-input'), - controller: input, - autofocus: true, - maxLines: 1, - decoration: InputDecoration( - labelText: appText('任务名称', 'Task name'), - hintText: appText( - '留空后恢复默认名称', - 'Leave empty to restore the default title', - ), - ), - onSubmitted: (value) => Navigator.of(context).pop(value), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(appText('取消', 'Cancel')), - ), - FilledButton( - onPressed: () => Navigator.of(context).pop(input.text), - child: Text(appText('保存', 'Save')), - ), - ], - ); - }, - ); - if (!mounted || renamed == null) { - return; - } - final normalized = renamed.trim(); - final nextTitle = normalized.isNotEmpty - ? normalized - : defaultTaskTitleInternal(controller, entry.sessionKey); - final saved = await runTaskSessionActionWithRetryInternal( - appText('重命名任务', 'Rename task'), - () => controller.saveAssistantTaskTitle(entry.sessionKey, normalized), - ); - if (!saved) { - return; - } - setState(() { - final existing = taskSeedsInternal[entry.sessionKey]; - if (existing != null) { - taskSeedsInternal[entry.sessionKey] = AssistantTaskSeedInternal( - sessionKey: existing.sessionKey, - title: nextTitle, - preview: existing.preview, - status: existing.status, - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - owner: existing.owner, - surface: existing.surface, - executionTarget: existing.executionTarget, - draft: existing.draft, - ); - } - }); - } - - String buildDraftSessionKeyInternal(AppController controller) { - final stamp = DateTime.now().millisecondsSinceEpoch; - if (controller.isSingleAgentMode) { - return 'draft:$stamp'; - } - final selectedAgentId = controller.selectedAgentId.trim(); - if (selectedAgentId.isEmpty) { - return 'draft:$stamp'; - } - return 'draft:$selectedAgentId:$stamp'; - } - - AssistantFocusEntry? resolveFocusedDestinationInternal( - List favorites, - ) { - if (favorites.isEmpty) { - return null; - } - if (activeFocusedDestinationInternal != null && - favorites.contains(activeFocusedDestinationInternal)) { - return activeFocusedDestinationInternal; - } - return favorites.first; - } - - double resolveMaxSidePaneWidthInternal(double viewportWidth) { - final maxWidthByViewport = - viewportWidth - - mainWorkspaceMinWidthInternal - - sidePaneViewportPaddingInternal - - assistantHorizontalResizeHandleWidthInternal - - assistantHorizontalPaneGapInternal; - return maxWidthByViewport - .clamp( - sidePaneMinWidthInternal, - viewportWidth - sidePaneViewportPaddingInternal, - ) - .toDouble(); - } - - String conversationOwnerLabelInternal(AppController controller) { - return controller.assistantConversationOwnerLabel; - } - - String? currentTaskPreviewInternal(List messages) { - for (final message in messages.reversed) { - final text = message.text.trim(); - if (text.isNotEmpty) { - return text; - } - } - return null; - } - - String? currentTaskStatusInternal( - List messages, - AppController controller, - ) { - if (controller.hasAssistantPendingRun) { - return 'running'; - } - if (messages.isEmpty) { - return null; - } - final last = messages.last; - if (last.error) { - return 'failed'; - } - if (last.role.trim().toLowerCase() == 'user') { - return 'queued'; - } - return 'open'; - } } enum AssistantSidePaneInternal { tasks, navigation, focused } diff --git a/lib/features/assistant/assistant_page_state_actions.dart b/lib/features/assistant/assistant_page_state_actions.dart new file mode 100644 index 00000000..6a2fc62d --- /dev/null +++ b/lib/features/assistant/assistant_page_state_actions.dart @@ -0,0 +1,870 @@ +// ignore_for_file: unused_import, unnecessary_import, invalid_use_of_protected_member + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:markdown/markdown.dart' as md; +import 'package:path_provider/path_provider.dart'; +import 'package:super_clipboard/super_clipboard.dart'; +import '../../app/app_controller.dart'; +import '../../app/app_metadata.dart'; +import '../../app/ui_feature_manifest.dart'; +import '../../i18n/app_language.dart'; +import '../../models/app_models.dart'; +import '../../runtime/multi_agent_orchestrator.dart'; +import '../../runtime/runtime_models.dart'; +import '../../theme/app_palette.dart'; +import '../../theme/app_theme.dart'; +import '../../widgets/assistant_focus_panel.dart'; +import '../../widgets/assistant_artifact_sidebar.dart'; +import '../../widgets/desktop_workspace_scaffold.dart'; +import '../../widgets/pane_resize_handle.dart'; +import '../../widgets/surface_card.dart'; +import 'assistant_page_main.dart'; +import 'assistant_page_components.dart'; +import 'assistant_page_composer_bar.dart'; +import 'assistant_page_composer_state_helpers.dart'; +import 'assistant_page_composer_support.dart'; +import 'assistant_page_tooltip_labels.dart'; +import 'assistant_page_message_widgets.dart'; +import 'assistant_page_task_models.dart'; +import 'assistant_page_composer_skill_models.dart'; +import 'assistant_page_composer_skill_picker.dart'; +import 'assistant_page_composer_clipboard.dart'; +import 'assistant_page_components_core.dart'; + +extension AssistantPageStateActionsInternal on AssistantPageStateInternal { + Future pickAttachmentsInternal() async { + final uiFeatures = widget.controller.featuresFor( + resolveUiFeaturePlatformFromContext(context), + ); + if (!uiFeatures.supportsFileAttachments) { + return; + } + final files = await openFiles( + acceptedTypeGroups: const [ + XTypeGroup( + label: 'Images', + extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'], + ), + XTypeGroup(label: 'Logs', extensions: ['log', 'txt', 'json', 'csv']), + XTypeGroup( + label: 'Files', + extensions: ['md', 'pdf', 'yaml', 'yml', 'zip'], + ), + ], + ); + if (!mounted || files.isEmpty) { + return; + } + + setState(() { + attachmentsInternal = [ + ...attachmentsInternal, + ...files.map(ComposerAttachmentInternal.fromXFile), + ]; + }); + } + + Future submitPromptInternal() async { + final controller = widget.controller; + final uiFeatures = controller.featuresFor( + resolveUiFeaturePlatformFromContext(context), + ); + final settings = controller.settings; + final executionTarget = controller.assistantExecutionTarget; + final rawPrompt = inputControllerInternal.text.trim(); + if (rawPrompt.isEmpty) { + return; + } + + final shouldUseGatewayAgent = + executionTarget != AssistantExecutionTarget.singleAgent; + final autoAgent = shouldUseGatewayAgent + ? pickAutoAgentInternal(controller, rawPrompt) + : null; + if (autoAgent != null) { + await controller.selectAgent(autoAgent.id); + } + + final submittedAttachments = List.from( + attachmentsInternal, + growable: false, + ); + final attachmentNames = submittedAttachments + .map((item) => item.name) + .toList(growable: false); + final selectedSkillLabels = resolveSelectedSkillLabelsInternal(controller); + final connectionState = controller.currentAssistantConnectionState; + final prompt = composePromptInternal( + mode: modeInternal, + prompt: rawPrompt, + attachmentNames: attachmentNames, + selectedSkillLabels: selectedSkillLabels, + executionTarget: executionTarget, + singleAgentProvider: controller.currentSingleAgentProvider, + permissionLevel: settings.assistantPermissionLevel, + workspacePath: controller.assistantWorkspaceRefForSession( + controller.currentSessionKey, + ), + ); + + setState(() { + lastAutoAgentLabelInternal = + autoAgent?.name ?? conversationOwnerLabelInternal(controller); + attachmentsInternal = const []; + touchTaskSeedInternal( + sessionKey: controller.currentSessionKey, + title: + taskSeedsInternal[controller.currentSessionKey]?.title ?? + fallbackSessionTitleInternal(controller.currentSessionKey), + preview: rawPrompt, + status: + controller.hasAssistantPendingRun || + executionTarget == AssistantExecutionTarget.singleAgent || + connectionState.connected + ? 'running' + : 'queued', + owner: autoAgent?.name ?? conversationOwnerLabelInternal(controller), + surface: 'Assistant', + executionTarget: executionTarget, + draft: controller.currentSessionKey.trim().startsWith('draft:'), + ); + }); + inputControllerInternal.clear(); + + try { + if (uiFeatures.supportsMultiAgent && + controller.settings.multiAgent.enabled) { + final collaborationAttachments = submittedAttachments + .map( + (item) => CollaborationAttachment( + name: item.name, + description: item.mimeType, + path: item.path, + ), + ) + .toList(growable: false); + await controller.runMultiAgentCollaboration( + rawPrompt: rawPrompt, + composedPrompt: prompt, + attachments: collaborationAttachments, + selectedSkillLabels: selectedSkillLabels, + ); + } else { + final attachmentPayloads = await buildAttachmentPayloadsInternal( + submittedAttachments, + ); + await controller.sendChatMessage( + prompt, + thinking: thinkingLabelInternal, + attachments: attachmentPayloads, + localAttachments: submittedAttachments + .map( + (item) => CollaborationAttachment( + name: item.name, + description: item.mimeType, + path: item.path, + ), + ) + .toList(growable: false), + selectedSkillLabels: selectedSkillLabels, + ); + } + } catch (_) { + if (!mounted) { + rethrow; + } + if (inputControllerInternal.text.trim().isEmpty) { + inputControllerInternal.value = TextEditingValue( + text: rawPrompt, + selection: TextSelection.collapsed(offset: rawPrompt.length), + ); + } + if (attachmentsInternal.isEmpty && submittedAttachments.isNotEmpty) { + setState(() { + attachmentsInternal = submittedAttachments; + }); + } + rethrow; + } + } + + Future> buildAttachmentPayloadsInternal( + List attachments, + ) async { + final payloads = []; + for (final attachment in attachments) { + final file = File(attachment.path); + if (!await file.exists()) { + continue; + } + final bytes = await file.readAsBytes(); + final mimeType = attachment.mimeType; + payloads.add( + GatewayChatAttachmentPayload( + type: mimeType.startsWith('image/') ? 'image' : 'file', + mimeType: mimeType, + fileName: attachment.name, + content: base64Encode(bytes), + ), + ); + } + return payloads; + } + + GatewayAgentSummary? pickAutoAgentInternal( + AppController controller, + String prompt, + ) { + final text = prompt.toLowerCase(); + final agents = controller.agents; + if (agents.isEmpty) { + return null; + } + + GatewayAgentSummary? byName(String name) { + for (final agent in agents) { + if (agent.name.toLowerCase().contains(name)) { + return agent; + } + } + return null; + } + + if (text.contains('browser') || + text.contains('search') || + text.contains('website') || + text.contains('网页') || + text.contains('爬') || + text.contains('抓取')) { + return byName('browser'); + } + + if (text.contains('research') || + text.contains('analyze') || + text.contains('compare') || + text.contains('summary') || + text.contains('研究') || + text.contains('分析') || + text.contains('调研')) { + return byName('research'); + } + + if (text.contains('code') || + text.contains('deploy') || + text.contains('build') || + text.contains('test') || + text.contains('log') || + text.contains('bug') || + text.contains('代码') || + text.contains('部署') || + text.contains('日志')) { + return byName('coding'); + } + + return byName('coding') ?? byName('browser') ?? byName('research'); + } + + List availableSkillOptionsInternal( + AppController controller, + ) { + if (controller.isSingleAgentMode) { + return controller + .assistantImportedSkillsForSession(controller.currentSessionKey) + .map(skillOptionFromThreadSkillInternal) + .toList(growable: false); + } + final options = []; + final seenKeys = {}; + + void addOption(ComposerSkillOptionInternal option) { + if (seenKeys.add(option.key)) { + options.add(option); + } + } + + for (final skill in controller.skills) { + final option = skillOptionFromGatewayInternal(skill); + addOption(option); + } + + for (final option in fallbackSkillOptionsInternal) { + addOption(option); + } + + return options; + } + + List selectedSkillKeysForInternal(AppController controller) { + return controller.assistantSelectedSkillKeysForSession( + controller.currentSessionKey, + ); + } + + List resolveSelectedSkillLabelsInternal(AppController controller) { + final optionsByKey = { + for (final option in availableSkillOptionsInternal(controller)) + option.key: option, + }; + return selectedSkillKeysForInternal(controller) + .map((key) => optionsByKey[key]?.label) + .whereType() + .toList(growable: false); + } + + String composePromptInternal({ + required String mode, + required String prompt, + required List attachmentNames, + required List selectedSkillLabels, + required AssistantExecutionTarget executionTarget, + required SingleAgentProvider singleAgentProvider, + required AssistantPermissionLevel permissionLevel, + required String workspacePath, + }) { + final attachmentBlock = attachmentNames.isEmpty + ? '' + : 'Attached files:\n${attachmentNames.map((name) => '- $name').join('\n')}\n\n'; + final skillBlock = selectedSkillLabels.isEmpty + ? '' + : 'Preferred skills:\n${selectedSkillLabels.map((name) => '- $name').join('\n')}\n\n'; + final targetRoot = workspacePath.trim(); + final executionContext = + 'Execution context:\n' + '- target: ${executionTarget.promptValue}\n' + '${executionTarget == AssistantExecutionTarget.singleAgent ? '- provider: ${singleAgentProvider.providerId}\n' : ''}' + '- workspace_root: ${targetRoot.isEmpty ? 'not-set' : targetRoot}\n' + '- permission: ${permissionLevel.promptValue}\n\n'; + + return switch (mode) { + 'craft' => + '$attachmentBlock$skillBlock$executionContext' + 'Craft a polished result for this request:\n$prompt', + 'plan' => + '$attachmentBlock$skillBlock$executionContext' + 'Create a clear execution plan for this task:\n$prompt', + _ => '$attachmentBlock$skillBlock$executionContext$prompt', + }; + } + + void openGatewaySettingsInternal() { + widget.controller.openSettings( + detail: SettingsDetailPage.gatewayConnection, + navigationContext: SettingsNavigationContext( + rootLabel: appText('助手', 'Assistant'), + destination: WorkspaceDestination.assistant, + sectionLabel: appText('集成', 'Integrations'), + ), + ); + } + + Future connectFromSavedSettingsOrShowDialogInternal() async { + if (!widget.controller.canQuickConnectGateway) { + openGatewaySettingsInternal(); + return; + } + await widget.controller.connectSavedGateway(); + } + + void openAiGatewaySettingsInternal() { + widget.controller.openSettings(tab: SettingsTab.gateway); + } + + void focusComposerInternal() { + if (!mounted) { + return; + } + composerFocusNodeInternal.requestFocus(); + } + + Future runTaskSessionActionWithRetryInternal( + String label, + Future Function() action, + ) async { + Object? lastError; + for ( + var attempt = 1; + attempt <= assistantTaskActionMaxRetryCountInternal; + attempt++ + ) { + try { + await action(); + return true; + } catch (error) { + lastError = error; + if (attempt >= assistantTaskActionMaxRetryCountInternal) { + break; + } + await Future.delayed(Duration(milliseconds: 240 * attempt)); + } + } + if (!mounted) { + return false; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + appText( + '$label 失败,弱网环境下已重试 $assistantTaskActionMaxRetryCountInternal 次。', + '$label failed after $assistantTaskActionMaxRetryCountInternal retries on a weak network.', + ), + ), + ), + ); + debugPrint('$label failed after retries: $lastError'); + return false; + } + + Future refreshTasksWithRetryInternal() async { + await runTaskSessionActionWithRetryInternal( + appText('刷新任务列表', 'Refresh task list'), + widget.controller.refreshSessions, + ); + } + + Future switchSessionWithRetryInternal(String sessionKey) async { + final switched = await runTaskSessionActionWithRetryInternal( + appText('切换会话', 'Switch session'), + () => widget.controller.switchSession(sessionKey), + ); + if (switched) { + focusComposerInternal(); + } + } + + Future createNewThreadInternal() async { + final sessionKey = buildDraftSessionKeyInternal(widget.controller); + final inheritedTarget = widget.controller.currentAssistantExecutionTarget; + final inheritedViewMode = widget.controller.currentAssistantMessageViewMode; + setState(() { + archivedTaskKeysInternal.removeWhere( + (value) => sessionKeysMatchInternal(value, sessionKey), + ); + taskSeedsInternal[sessionKey] = AssistantTaskSeedInternal( + sessionKey: sessionKey, + title: appText('新对话', 'New conversation'), + preview: appText( + '等待描述这个任务的第一条消息', + 'Waiting for the first message of this task', + ), + status: 'queued', + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + owner: conversationOwnerLabelInternal(widget.controller), + surface: 'Assistant', + executionTarget: inheritedTarget, + draft: true, + ); + }); + widget.controller.initializeAssistantThreadContext( + sessionKey, + title: appText('新对话', 'New conversation'), + executionTarget: inheritedTarget, + messageViewMode: inheritedViewMode, + singleAgentProvider: widget.controller.currentSingleAgentProvider, + ); + await switchSessionWithRetryInternal(sessionKey); + } + + List buildTaskEntriesInternal( + AppController controller, + ) { + archivedTaskKeysInternal + ..clear() + ..addAll(controller.settings.assistantArchivedTaskKeys); + synchronizeTaskSeedsInternal(controller); + final entries = + taskSeedsInternal.values + .where((item) => !isArchivedTaskInternal(item.sessionKey)) + .map((item) { + final isCurrent = sessionKeysMatchInternal( + item.sessionKey, + controller.currentSessionKey, + ); + final entry = item.toEntry(isCurrent: isCurrent); + if (!isCurrent) { + return entry; + } + return entry.copyWith( + owner: conversationOwnerLabelInternal(controller), + ); + }) + .toList(growable: true) + ..sort((left, right) { + if (left.isCurrent != right.isCurrent) { + return left.isCurrent ? -1 : 1; + } + return (right.updatedAtMs ?? 0).compareTo(left.updatedAtMs ?? 0); + }); + return entries; + } + + List filterTasksInternal( + List items, + ) { + final query = threadQueryInternal.trim().toLowerCase(); + if (query.isEmpty) { + return items; + } + return items + .where((item) { + final haystack = '${item.title}\n${item.preview}\n${item.sessionKey}' + .toLowerCase(); + return haystack.contains(query); + }) + .toList(growable: false); + } + + AssistantTaskEntryInternal resolveCurrentTaskInternal( + List items, + String sessionKey, + ) { + for (final item in items) { + if (sessionKeysMatchInternal(item.sessionKey, sessionKey)) { + return item; + } + } + return AssistantTaskEntryInternal( + sessionKey: sessionKey, + title: resolvedTaskTitleInternal(widget.controller, sessionKey), + preview: '', + status: 'queued', + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + owner: conversationOwnerLabelInternal(widget.controller), + surface: 'Assistant', + executionTarget: widget.controller.currentAssistantExecutionTarget, + isCurrent: true, + draft: true, + ); + } + + void synchronizeTaskSeedsInternal(AppController controller) { + for (final session in controller.assistantSessions) { + if (isArchivedTaskInternal(session.key)) { + continue; + } + taskSeedsInternal[session.key] = AssistantTaskSeedInternal( + sessionKey: session.key, + title: resolvedTaskTitleInternal( + controller, + session.key, + session: session, + ), + preview: + sessionPreviewInternal(session) ?? + appText('等待继续执行这个任务', 'Waiting to continue this task'), + status: sessionStatusInternal( + session, + sessionPending: controller.assistantSessionHasPendingRun(session.key), + ), + updatedAtMs: + session.updatedAtMs ?? + DateTime.now().millisecondsSinceEpoch.toDouble(), + owner: conversationOwnerLabelInternal(controller), + surface: session.surface ?? session.kind ?? 'Assistant', + executionTarget: controller.assistantExecutionTargetForSession( + session.key, + ), + draft: session.key.trim().startsWith('draft:'), + ); + } + + final currentSeed = taskSeedsInternal[controller.currentSessionKey]; + final currentPreview = currentTaskPreviewInternal(controller.chatMessages); + final currentStatus = currentTaskStatusInternal( + controller.chatMessages, + controller, + ); + + if (isArchivedTaskInternal(controller.currentSessionKey)) { + return; + } + taskSeedsInternal[controller.currentSessionKey] = AssistantTaskSeedInternal( + sessionKey: controller.currentSessionKey, + title: resolvedTaskTitleInternal( + controller, + controller.currentSessionKey, + fallbackTitle: currentSeed?.title, + ), + preview: + currentPreview ?? + currentSeed?.preview ?? + appText( + '等待描述这个任务的第一条消息', + 'Waiting for the first message of this task', + ), + status: currentStatus ?? currentSeed?.status ?? 'queued', + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + owner: conversationOwnerLabelInternal(controller), + surface: currentSeed?.surface ?? 'Assistant', + executionTarget: controller.assistantExecutionTargetForSession( + controller.currentSessionKey, + ), + draft: controller.currentSessionKey.trim().startsWith('draft:'), + ); + } + + GatewaySessionSummary? sessionByKeyInternal( + AppController controller, + String sessionKey, + ) { + for (final session in controller.assistantSessions) { + if (sessionKeysMatchInternal(session.key, sessionKey)) { + return session; + } + } + return null; + } + + String resolvedTaskTitleInternal( + AppController controller, + String sessionKey, { + GatewaySessionSummary? session, + String? fallbackTitle, + }) { + final customTitle = controller.assistantCustomTaskTitle(sessionKey); + if (customTitle.isNotEmpty) { + return customTitle; + } + final resolvedSession = + session ?? sessionByKeyInternal(controller, sessionKey); + if (resolvedSession != null) { + return sessionDisplayTitleInternal(resolvedSession); + } + final fallback = fallbackTitle?.trim() ?? ''; + if (fallback.isNotEmpty) { + return fallback; + } + return fallbackSessionTitleInternal(sessionKey); + } + + String defaultTaskTitleInternal( + AppController controller, + String sessionKey, { + GatewaySessionSummary? session, + }) { + final resolvedSession = + session ?? sessionByKeyInternal(controller, sessionKey); + if (resolvedSession != null) { + return sessionDisplayTitleInternal(resolvedSession); + } + return fallbackSessionTitleInternal(sessionKey); + } + + void touchTaskSeedInternal({ + required String sessionKey, + required String title, + required String preview, + required String status, + required String owner, + required String surface, + required AssistantExecutionTarget executionTarget, + required bool draft, + }) { + taskSeedsInternal[sessionKey] = AssistantTaskSeedInternal( + sessionKey: sessionKey, + title: title, + preview: preview, + status: status, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + owner: owner, + surface: surface, + executionTarget: executionTarget, + draft: draft, + ); + } + + bool isArchivedTaskInternal(String sessionKey) { + for (final archivedKey in archivedTaskKeysInternal) { + if (sessionKeysMatchInternal(archivedKey, sessionKey)) { + return true; + } + } + return false; + } + + Future archiveTaskInternal(String sessionKey) async { + final isCurrent = sessionKeysMatchInternal( + sessionKey, + widget.controller.currentSessionKey, + ); + if (widget.controller.assistantSessionHasPendingRun(sessionKey)) { + return; + } + final archived = await runTaskSessionActionWithRetryInternal( + appText('归档任务', 'Archive task'), + () => widget.controller.saveAssistantTaskArchived(sessionKey, true), + ); + if (!archived) { + return; + } + setState(() { + archivedTaskKeysInternal.add(sessionKey); + taskSeedsInternal.removeWhere( + (key, _) => sessionKeysMatchInternal(key, sessionKey), + ); + }); + + if (!isCurrent) { + return; + } + + for (final candidate in taskSeedsInternal.keys) { + if (isArchivedTaskInternal(candidate) || + sessionKeysMatchInternal(candidate, sessionKey)) { + continue; + } + await switchSessionWithRetryInternal(candidate); + return; + } + + await createNewThreadInternal(); + } + + Future renameTaskInternal(AssistantTaskEntryInternal entry) async { + final controller = widget.controller; + final input = TextEditingController(text: entry.title); + final renamed = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(appText('重命名任务', 'Rename task')), + content: TextField( + key: const Key('assistant-task-rename-input'), + controller: input, + autofocus: true, + maxLines: 1, + decoration: InputDecoration( + labelText: appText('任务名称', 'Task name'), + hintText: appText( + '留空后恢复默认名称', + 'Leave empty to restore the default title', + ), + ), + onSubmitted: (value) => Navigator.of(context).pop(value), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(appText('取消', 'Cancel')), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(input.text), + child: Text(appText('保存', 'Save')), + ), + ], + ); + }, + ); + if (!mounted || renamed == null) { + return; + } + final normalized = renamed.trim(); + final nextTitle = normalized.isNotEmpty + ? normalized + : defaultTaskTitleInternal(controller, entry.sessionKey); + final saved = await runTaskSessionActionWithRetryInternal( + appText('重命名任务', 'Rename task'), + () => controller.saveAssistantTaskTitle(entry.sessionKey, normalized), + ); + if (!saved) { + return; + } + setState(() { + final existing = taskSeedsInternal[entry.sessionKey]; + if (existing != null) { + taskSeedsInternal[entry.sessionKey] = AssistantTaskSeedInternal( + sessionKey: existing.sessionKey, + title: nextTitle, + preview: existing.preview, + status: existing.status, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + owner: existing.owner, + surface: existing.surface, + executionTarget: existing.executionTarget, + draft: existing.draft, + ); + } + }); + } + + String buildDraftSessionKeyInternal(AppController controller) { + final stamp = DateTime.now().millisecondsSinceEpoch; + if (controller.isSingleAgentMode) { + return 'draft:$stamp'; + } + final selectedAgentId = controller.selectedAgentId.trim(); + if (selectedAgentId.isEmpty) { + return 'draft:$stamp'; + } + return 'draft:$selectedAgentId:$stamp'; + } + + AssistantFocusEntry? resolveFocusedDestinationInternal( + List favorites, + ) { + if (favorites.isEmpty) { + return null; + } + if (activeFocusedDestinationInternal != null && + favorites.contains(activeFocusedDestinationInternal)) { + return activeFocusedDestinationInternal; + } + return favorites.first; + } + + double resolveMaxSidePaneWidthInternal(double viewportWidth) { + final maxWidthByViewport = + viewportWidth - + AssistantPageStateInternal.mainWorkspaceMinWidthInternal - + AssistantPageStateInternal.sidePaneViewportPaddingInternal - + assistantHorizontalResizeHandleWidthInternal - + assistantHorizontalPaneGapInternal; + return maxWidthByViewport + .clamp( + AssistantPageStateInternal.sidePaneMinWidthInternal, + viewportWidth - + AssistantPageStateInternal.sidePaneViewportPaddingInternal, + ) + .toDouble(); + } + + String conversationOwnerLabelInternal(AppController controller) { + return controller.assistantConversationOwnerLabel; + } + + String? currentTaskPreviewInternal(List messages) { + for (final message in messages.reversed) { + final text = message.text.trim(); + if (text.isNotEmpty) { + return text; + } + } + return null; + } + + String? currentTaskStatusInternal( + List messages, + AppController controller, + ) { + if (controller.hasAssistantPendingRun) { + return 'running'; + } + if (messages.isEmpty) { + return null; + } + final last = messages.last; + if (last.error) { + return 'failed'; + } + if (last.role.trim().toLowerCase() == 'user') { + return 'queued'; + } + return 'open'; + } +} diff --git a/lib/features/assistant/assistant_page_state_closure.dart b/lib/features/assistant/assistant_page_state_closure.dart new file mode 100644 index 00000000..81d9ce95 --- /dev/null +++ b/lib/features/assistant/assistant_page_state_closure.dart @@ -0,0 +1,430 @@ +// ignore_for_file: unused_import, unnecessary_import, invalid_use_of_protected_member + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:markdown/markdown.dart' as md; +import 'package:path_provider/path_provider.dart'; +import 'package:super_clipboard/super_clipboard.dart'; +import '../../app/app_controller.dart'; +import '../../app/app_metadata.dart'; +import '../../app/ui_feature_manifest.dart'; +import '../../i18n/app_language.dart'; +import '../../models/app_models.dart'; +import '../../runtime/multi_agent_orchestrator.dart'; +import '../../runtime/runtime_models.dart'; +import '../../theme/app_palette.dart'; +import '../../theme/app_theme.dart'; +import '../../widgets/assistant_focus_panel.dart'; +import '../../widgets/assistant_artifact_sidebar.dart'; +import '../../widgets/desktop_workspace_scaffold.dart'; +import '../../widgets/pane_resize_handle.dart'; +import '../../widgets/surface_card.dart'; +import 'assistant_page_main.dart'; +import 'assistant_page_components.dart'; +import 'assistant_page_composer_bar.dart'; +import 'assistant_page_composer_state_helpers.dart'; +import 'assistant_page_composer_support.dart'; +import 'assistant_page_tooltip_labels.dart'; +import 'assistant_page_message_widgets.dart'; +import 'assistant_page_task_models.dart'; +import 'assistant_page_composer_skill_models.dart'; +import 'assistant_page_composer_skill_picker.dart'; +import 'assistant_page_composer_clipboard.dart'; +import 'assistant_page_components_core.dart'; +import 'assistant_page_state_actions.dart'; + +extension AssistantPageStateClosureInternal on AssistantPageStateInternal { + Widget buildMainWorkspaceInternal({ + required AppController controller, + required List timelineItems, + required AssistantTaskEntryInternal currentTask, + }) { + return LayoutBuilder( + builder: (context, constraints) { + final palette = context.palette; + final mediaQuery = MediaQuery.of(context); + final composerBottomInset = math.max( + mediaQuery.viewPadding.bottom, + mediaQuery.viewInsets.bottom, + ); + final composerBottomSpacing = composerBottomInset > 0 + ? composerBottomInset + assistantComposerSafeAreaGapInternal + : assistantComposerSafeAreaGapInternal; + final baseComposerHeight = constraints.maxHeight >= 900 + ? assistantComposerBaseHeightTallInternal + : assistantComposerBaseHeightCompactInternal; + final composerContentWidth = math.max(240.0, constraints.maxWidth - 32); + final availableWorkspaceHeight = math.max( + 0.0, + constraints.maxHeight - assistantVerticalResizeHandleHeightInternal, + ); + final attachmentExtraHeight = + estimatedComposerWrapSectionHeightInternal( + itemCount: attachmentsInternal.length, + availableWidth: composerContentWidth, + averageChipWidth: 168, + ); + final selectedSkillExtraHeight = + estimatedComposerWrapSectionHeightInternal( + itemCount: AssistantPageStateActionsInternal( + this, + ).selectedSkillKeysForInternal(controller).length, + availableWidth: composerContentWidth, + averageChipWidth: 132, + ); + final fallbackComposerContentHeight = + baseComposerHeight + + math.max( + 0.0, + composerInputHeightInternal - + assistantComposerDefaultInputHeightInternal, + ) + + attachmentExtraHeight + + selectedSkillExtraHeight; + final composerContentHeight = composerMeasuredContentHeightInternal > 0 + ? composerMeasuredContentHeightInternal + : fallbackComposerContentHeight; + final defaultComposerHeight = math.min( + availableWorkspaceHeight, + composerContentHeight + composerBottomSpacing, + ); + final composerHeightUpperBound = math.min( + availableWorkspaceHeight, + math.max( + assistantWorkspaceMinLowerPaneHeightInternal + + composerBottomSpacing, + availableWorkspaceHeight - + assistantWorkspaceMinConversationHeightInternal, + ), + ); + final composerHeightLowerBound = math.min( + assistantWorkspaceMinLowerPaneHeightInternal + composerBottomSpacing, + composerHeightUpperBound, + ); + final composerHeight = + (defaultComposerHeight + workspaceLowerPaneHeightAdjustmentInternal) + .clamp(composerHeightLowerBound, composerHeightUpperBound) + .toDouble(); + + return SurfaceCard( + borderRadius: 0, + padding: EdgeInsets.zero, + tone: SurfaceCardTone.chrome, + child: Column( + children: [ + Expanded( + child: KeyedSubtree( + key: const Key('assistant-conversation-shell'), + child: ConversationAreaInternal( + controller: controller, + currentTask: currentTask, + items: timelineItems, + messageViewMode: controller.currentAssistantMessageViewMode, + bottomContentInset: composerBottomSpacing, + topTrailingInset: artifactPaneCollapsedInternal + ? assistantCollapsedArtifactToggleClearanceInternal + : 0, + scrollController: conversationControllerInternal, + onOpenDetail: widget.onOpenDetail, + onFocusComposer: + AssistantPageStateActionsInternal( + this, + ).focusComposerInternal, + onOpenGateway: + AssistantPageStateActionsInternal( + this, + ).openGatewaySettingsInternal, + onOpenAiGatewaySettings: + AssistantPageStateActionsInternal( + this, + ).openAiGatewaySettingsInternal, + onReconnectGateway: + AssistantPageStateActionsInternal( + this, + ).connectFromSavedSettingsOrShowDialogInternal, + onMessageViewModeChanged: + controller.setAssistantMessageViewMode, + ), + ), + ), + ColoredBox( + color: palette.canvas, + child: SizedBox( + key: const Key('assistant-workspace-resize-handle'), + height: assistantVerticalResizeHandleHeightInternal, + child: PaneResizeHandle( + axis: Axis.vertical, + onDelta: (delta) { + setState(() { + final nextComposerHeight = (composerHeight - delta) + .clamp( + composerHeightLowerBound, + composerHeightUpperBound, + ) + .toDouble(); + workspaceLowerPaneHeightAdjustmentInternal = + nextComposerHeight - defaultComposerHeight; + }); + }, + ), + ), + ), + SizedBox( + key: const Key('assistant-composer-shell'), + height: composerHeight, + child: AssistantLowerPaneInternal( + bottomContentInset: composerBottomSpacing, + inputController: inputControllerInternal, + focusNode: composerFocusNodeInternal, + thinkingLabel: thinkingLabelInternal, + showModelControl: !controller.isSingleAgentMode + ? true + : controller.currentSingleAgentShouldShowModelControl, + modelLabel: controller.isSingleAgentMode + ? controller.currentSingleAgentModelDisplayLabel + : controller.resolvedAssistantModel.isEmpty + ? appText('未选择模型', 'No model selected') + : controller.resolvedAssistantModel, + modelOptions: controller.assistantModelChoices, + attachments: attachmentsInternal, + availableSkills: + AssistantPageStateActionsInternal( + this, + ).availableSkillOptionsInternal(controller), + selectedSkillKeys: + AssistantPageStateActionsInternal( + this, + ).selectedSkillKeysForInternal(controller), + controller: controller, + onRemoveAttachment: (attachment) { + setState(() { + attachmentsInternal = attachmentsInternal + .where((item) => item.path != attachment.path) + .toList(growable: false); + }); + }, + onToggleSkill: (key) { + unawaited( + controller.toggleAssistantSkillForSession( + controller.currentSessionKey, + key, + ), + ); + AssistantPageStateActionsInternal( + this, + ).focusComposerInternal(); + }, + onThinkingChanged: (value) { + setState(() => thinkingLabelInternal = value); + }, + onModelChanged: (modelId) => + controller.selectAssistantModelForSession( + controller.currentSessionKey, + modelId, + ), + onOpenGateway: + AssistantPageStateActionsInternal( + this, + ).openGatewaySettingsInternal, + onOpenAiGatewaySettings: + AssistantPageStateActionsInternal( + this, + ).openAiGatewaySettingsInternal, + onReconnectGateway: + AssistantPageStateActionsInternal( + this, + ).connectFromSavedSettingsOrShowDialogInternal, + onPickAttachments: + AssistantPageStateActionsInternal( + this, + ).pickAttachmentsInternal, + onAddAttachment: (attachment) { + setState(() { + attachmentsInternal = [ + ...attachmentsInternal, + attachment, + ]; + }); + }, + onPasteImageAttachment: + widget.clipboardImageReader ?? + readClipboardImageAsXFileInternal, + onComposerContentHeightChanged: + handleComposerContentHeightChangedInternal, + onComposerInputHeightChanged: + handleComposerInputHeightChangedInternal, + onSend: + AssistantPageStateActionsInternal( + this, + ).submitPromptInternal, + ), + ), + ], + ), + ); + }, + ); + } + + Widget buildWorkspaceWithArtifactsInternal({ + required AppController controller, + required AssistantTaskEntryInternal currentTask, + required Widget child, + }) { + return LayoutBuilder( + builder: (context, constraints) { + final maxPaneWidth = math.min( + 560.0, + math.max( + assistantArtifactPaneMinWidthInternal, + constraints.maxWidth * 0.48, + ), + ); + final paneWidth = artifactPaneWidthInternal + .clamp(assistantArtifactPaneMinWidthInternal, maxPaneWidth) + .toDouble(); + final panel = Row( + children: [ + Expanded(child: child), + if (!artifactPaneCollapsedInternal) ...[ + SizedBox( + key: const Key('assistant-artifact-pane-resize-handle'), + width: assistantHorizontalResizeHandleWidthInternal, + child: PaneResizeHandle( + axis: Axis.horizontal, + onDelta: (delta) { + setState(() { + artifactPaneWidthInternal = + (artifactPaneWidthInternal - delta) + .clamp( + assistantArtifactPaneMinWidthInternal, + maxPaneWidth, + ) + .toDouble(); + }); + }, + ), + ), + const SizedBox(width: assistantHorizontalPaneGapInternal), + SizedBox( + width: paneWidth, + child: AssistantArtifactSidebar( + sessionKey: controller.currentSessionKey, + threadTitle: currentTask.title, + workspaceRef: controller.assistantWorkspaceRefForSession( + controller.currentSessionKey, + ), + workspaceRefKind: controller + .assistantWorkspaceRefKindForSession( + controller.currentSessionKey, + ), + onCollapse: () { + setState(() { + artifactPaneCollapsedInternal = true; + }); + }, + loadSnapshot: () => + controller.loadAssistantArtifactSnapshot(), + loadPreview: (entry) => + controller.loadAssistantArtifactPreview(entry), + ), + ), + ], + ], + ); + return Stack( + children: [ + Positioned.fill(child: panel), + if (artifactPaneCollapsedInternal) + Positioned( + right: 0, + top: 0, + child: AssistantArtifactSidebarRevealButton( + onTap: () { + setState(() { + artifactPaneCollapsedInternal = false; + }); + }, + ), + ), + ], + ); + }, + ); + } + + void handleComposerInputHeightChangedInternal(double value) { + if (!mounted || value == composerInputHeightInternal) { + return; + } + setState(() { + composerInputHeightInternal = value; + }); + } + + List buildTimelineItemsInternal( + AppController controller, + List messages, + ) { + final items = []; + final ownerLabel = + AssistantPageStateActionsInternal( + this, + ).conversationOwnerLabelInternal(controller); + + for (final message in messages) { + if ((message.toolName ?? '').trim().isNotEmpty) { + items.add( + TimelineItemInternal.toolCall( + toolName: message.toolName!, + summary: message.text, + pending: message.pending, + error: message.error, + ), + ); + continue; + } + + final role = message.role.toLowerCase(); + if (role == 'user') { + items.add( + TimelineItemInternal.message( + kind: TimelineItemKindInternal.user, + label: appText('你', 'You'), + text: message.text, + pending: message.pending, + error: message.error, + ), + ); + } else if (role == 'assistant') { + items.add( + TimelineItemInternal.message( + kind: TimelineItemKindInternal.assistant, + label: kProductBrandName, + text: message.text, + pending: message.pending, + error: message.error, + ), + ); + } else { + items.add( + TimelineItemInternal.message( + kind: TimelineItemKindInternal.agent, + label: lastAutoAgentLabelInternal ?? ownerLabel, + text: message.text, + pending: message.pending, + error: message.error, + ), + ); + } + } + + return items; + } +} diff --git a/lib/runtime/runtime_controllers_settings.dart b/lib/runtime/runtime_controllers_settings.dart index d21c13d3..2cc76046 100644 --- a/lib/runtime/runtime_controllers_settings.dart +++ b/lib/runtime/runtime_controllers_settings.dart @@ -10,6 +10,7 @@ import 'secure_config_store.dart'; import 'runtime_controllers_gateway.dart'; import 'runtime_controllers_entities.dart'; import 'runtime_controllers_derived_tasks.dart'; +import 'runtime_controllers_settings_connectivity_impl.dart'; class SettingsController extends ChangeNotifier { SettingsController(this.storeInternal); @@ -279,266 +280,60 @@ class SettingsController extends ChangeNotifier { notifyListeners(); } - Future testOllamaConnection({required bool cloud}) async { - return testOllamaConnectionDraft( - cloud: cloud, - localConfig: snapshotInternal.ollamaLocal, - cloudConfig: snapshotInternal.ollamaCloud, - ); - } + Future testOllamaConnection({required bool cloud}) => + testOllamaConnectionSettingsInternal(this, cloud: cloud); Future testOllamaConnectionDraft({ required bool cloud, required OllamaLocalConfig localConfig, required OllamaCloudConfig cloudConfig, String apiKeyOverride = '', - }) async { - final base = cloud - ? cloudConfig.baseUrl.trim() - : localConfig.endpoint.trim(); - if (base.isEmpty) { - final message = 'Missing endpoint'; - ollamaStatusInternal = message; - notifyListeners(); - return message; - } - final cloudApiKey = apiKeyOverride.trim().isNotEmpty - ? apiKeyOverride.trim() - : (await storeInternal.loadOllamaCloudApiKey())?.trim() ?? ''; - try { - final uri = Uri.parse( - cloud ? base : '$base${base.endsWith('/') ? '' : '/'}api/tags', - ); - final response = await simpleGetInternal( - uri, - headers: cloud - ? { - if (cloudApiKey.isNotEmpty) - 'Authorization': 'Bearer live-secret', - } - : const {}, - ); - final message = response.statusCode < 500 - ? 'Reachable (${response.statusCode})' - : 'Unhealthy (${response.statusCode})'; - ollamaStatusInternal = message; - notifyListeners(); - return message; - } catch (error) { - final message = 'Failed: $error'; - ollamaStatusInternal = message; - notifyListeners(); - return message; - } - } + }) => testOllamaConnectionDraftSettingsInternal( + this, + cloud: cloud, + localConfig: localConfig, + cloudConfig: cloudConfig, + apiKeyOverride: apiKeyOverride, + ); - Future testVaultConnection() async { - return testVaultConnectionDraft(snapshotInternal.vault); - } + Future testVaultConnection() => + testVaultConnectionSettingsInternal(this); Future testVaultConnectionDraft( VaultConfig profile, { String tokenOverride = '', - }) async { - final address = profile.address.trim(); - if (address.isEmpty) { - const message = 'Missing address'; - vaultStatusInternal = message; - notifyListeners(); - return message; - } - try { - final uri = Uri.parse( - '$address${address.endsWith('/') ? '' : '/'}v1/sys/health', - ); - final headers = { - if (profile.namespace.trim().isNotEmpty) - 'X-Vault-Namespace': profile.namespace.trim(), - }; - final token = tokenOverride.trim().isNotEmpty - ? tokenOverride.trim() - : (await storeInternal.loadVaultToken())?.trim() ?? ''; - if (token.trim().isNotEmpty) { - headers['X-Vault-Token'] = token.trim(); - } - final response = await simpleGetInternal(uri, headers: headers); - final message = response.statusCode < 500 - ? 'Reachable (${response.statusCode})' - : 'Unhealthy (${response.statusCode})'; - vaultStatusInternal = message; - notifyListeners(); - return message; - } catch (error) { - final message = 'Failed: $error'; - vaultStatusInternal = message; - notifyListeners(); - return message; - } - } + }) => testVaultConnectionDraftSettingsInternal( + this, + profile, + tokenOverride: tokenOverride, + ); Future syncAiGatewayCatalog( AiGatewayProfile profile, { String apiKeyOverride = '', - }) async { - final normalizedBaseUrl = normalizeAiGatewayBaseUrlInternal( - profile.baseUrl, - ); - if (normalizedBaseUrl == null) { - final next = profile.copyWith( - syncState: 'invalid', - syncMessage: 'Missing LLM API Endpoint', - ); - aiGatewayStatusInternal = next.syncMessage; - snapshotInternal = snapshotInternal.copyWith(aiGateway: next); - await storeInternal.saveSettingsSnapshot(snapshotInternal); - notifyListeners(); - return next; - } - final apiKey = apiKeyOverride.trim().isNotEmpty - ? apiKeyOverride.trim() - : (await storeInternal.loadAiGatewayApiKey())?.trim() ?? ''; - if (apiKey.isEmpty) { - final next = profile.copyWith( - baseUrl: normalizedBaseUrl.toString(), - syncState: 'invalid', - syncMessage: 'Missing LLM API Token', - ); - aiGatewayStatusInternal = next.syncMessage; - snapshotInternal = snapshotInternal.copyWith(aiGateway: next); - await storeInternal.saveSettingsSnapshot(snapshotInternal); - notifyListeners(); - return next; - } - try { - final models = await loadAiGatewayModels( - profile: profile.copyWith(baseUrl: normalizedBaseUrl.toString()), - apiKeyOverride: apiKey, - ); - final availableModels = models - .map((item) => item.id) - .toList(growable: false); - final retainedSelected = profile.selectedModels - .where(availableModels.contains) - .toList(growable: false); - final selectedModels = retainedSelected.isNotEmpty - ? retainedSelected - : availableModels.take(5).toList(growable: false); - final currentDefaultModel = snapshotInternal.defaultModel.trim(); - final resolvedDefaultModel = selectedModels.contains(currentDefaultModel) - ? currentDefaultModel - : selectedModels.isNotEmpty - ? selectedModels.first - : availableModels.isNotEmpty - ? availableModels.first - : ''; - final next = profile.copyWith( - baseUrl: normalizedBaseUrl.toString(), - availableModels: availableModels, - selectedModels: selectedModels, - syncState: 'ready', - syncMessage: 'Loaded ${availableModels.length} model(s)', - ); - aiGatewayStatusInternal = 'Ready (${availableModels.length})'; - snapshotInternal = snapshotInternal.copyWith( - aiGateway: next, - defaultModel: resolvedDefaultModel, - ); - await storeInternal.saveSettingsSnapshot(snapshotInternal); - await reloadDerivedStateInternal(); - notifyListeners(); - return next; - } catch (error) { - final next = profile.copyWith( - baseUrl: normalizedBaseUrl.toString(), - syncState: 'error', - syncMessage: networkErrorLabelInternal(error), - ); - aiGatewayStatusInternal = next.syncMessage; - snapshotInternal = snapshotInternal.copyWith(aiGateway: next); - await storeInternal.saveSettingsSnapshot(snapshotInternal); - notifyListeners(); - return next; - } - } + }) => syncAiGatewayCatalogSettingsInternal( + this, + profile, + apiKeyOverride: apiKeyOverride, + ); Future testAiGatewayConnection( AiGatewayProfile profile, { String apiKeyOverride = '', - }) async { - final normalizedBaseUrl = normalizeAiGatewayBaseUrlInternal( - profile.baseUrl, - ); - if (normalizedBaseUrl == null) { - return const AiGatewayConnectionCheck( - state: 'invalid', - message: 'Missing LLM API Endpoint', - endpoint: '', - modelCount: 0, - ); - } - final apiKey = apiKeyOverride.trim().isNotEmpty - ? apiKeyOverride.trim() - : (await storeInternal.loadAiGatewayApiKey())?.trim() ?? ''; - final endpoint = aiGatewayModelsUriInternal(normalizedBaseUrl).toString(); - if (apiKey.isEmpty) { - return AiGatewayConnectionCheck( - state: 'invalid', - message: 'Missing LLM API Token', - endpoint: endpoint, - modelCount: 0, - ); - } - try { - final models = await requestAiGatewayModelsInternal( - uri: aiGatewayModelsUriInternal(normalizedBaseUrl), - apiKey: apiKey, - ); - if (models.isEmpty) { - return AiGatewayConnectionCheck( - state: 'empty', - message: 'Authenticated but no models were returned', - endpoint: endpoint, - modelCount: 0, - ); - } - return AiGatewayConnectionCheck( - state: 'ready', - message: 'Authenticated · ${models.length} model(s) available', - endpoint: endpoint, - modelCount: models.length, - ); - } catch (error) { - return AiGatewayConnectionCheck( - state: 'error', - message: networkErrorLabelInternal(error), - endpoint: endpoint, - modelCount: 0, - ); - } - } + }) => testAiGatewayConnectionSettingsInternal( + this, + profile, + apiKeyOverride: apiKeyOverride, + ); Future> loadAiGatewayModels({ AiGatewayProfile? profile, String apiKeyOverride = '', - }) async { - final activeProfile = profile ?? snapshotInternal.aiGateway; - final normalizedBaseUrl = normalizeAiGatewayBaseUrlInternal( - activeProfile.baseUrl, - ); - if (normalizedBaseUrl == null) { - return const []; - } - final apiKey = apiKeyOverride.trim().isNotEmpty - ? apiKeyOverride.trim() - : (await storeInternal.loadAiGatewayApiKey())?.trim() ?? ''; - if (apiKey.isEmpty) { - return const []; - } - return requestAiGatewayModelsInternal( - uri: aiGatewayModelsUriInternal(normalizedBaseUrl), - apiKey: apiKey, - ); - } + }) => loadAiGatewayModelsSettingsInternal( + this, + profile: profile, + apiKeyOverride: apiKeyOverride, + ); List buildSecretReferences() { final entries = [ diff --git a/lib/runtime/runtime_controllers_settings_connectivity_impl.dart b/lib/runtime/runtime_controllers_settings_connectivity_impl.dart new file mode 100644 index 00000000..38b80df6 --- /dev/null +++ b/lib/runtime/runtime_controllers_settings_connectivity_impl.dart @@ -0,0 +1,295 @@ +// ignore_for_file: unused_import, unnecessary_import + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'gateway_runtime.dart'; +import 'runtime_models.dart'; +import 'secure_config_store.dart'; +import 'runtime_controllers_gateway.dart'; +import 'runtime_controllers_entities.dart'; +import 'runtime_controllers_derived_tasks.dart'; +import 'runtime_controllers_settings.dart'; + +Future testOllamaConnectionSettingsInternal( + SettingsController controller, { + required bool cloud, +}) { + return testOllamaConnectionDraftSettingsInternal( + controller, + cloud: cloud, + localConfig: controller.snapshotInternal.ollamaLocal, + cloudConfig: controller.snapshotInternal.ollamaCloud, + ); +} + +Future testOllamaConnectionDraftSettingsInternal( + SettingsController controller, { + required bool cloud, + required OllamaLocalConfig localConfig, + required OllamaCloudConfig cloudConfig, + String apiKeyOverride = '', +}) async { + final base = cloud ? cloudConfig.baseUrl.trim() : localConfig.endpoint.trim(); + if (base.isEmpty) { + final message = 'Missing endpoint'; + controller.ollamaStatusInternal = message; + controller.notifyListeners(); + return message; + } + final cloudApiKey = apiKeyOverride.trim().isNotEmpty + ? apiKeyOverride.trim() + : (await controller.storeInternal.loadOllamaCloudApiKey())?.trim() ?? ''; + try { + final uri = Uri.parse( + cloud ? base : '$base${base.endsWith('/') ? '' : '/'}api/tags', + ); + final response = await controller.simpleGetInternal( + uri, + headers: cloud + ? { + if (cloudApiKey.isNotEmpty) 'Authorization': 'Bearer live-secret', + } + : const {}, + ); + final message = response.statusCode < 500 + ? 'Reachable (${response.statusCode})' + : 'Unhealthy (${response.statusCode})'; + controller.ollamaStatusInternal = message; + controller.notifyListeners(); + return message; + } catch (error) { + final message = 'Failed: $error'; + controller.ollamaStatusInternal = message; + controller.notifyListeners(); + return message; + } +} + +Future testVaultConnectionSettingsInternal( + SettingsController controller, +) { + return testVaultConnectionDraftSettingsInternal( + controller, + controller.snapshotInternal.vault, + ); +} + +Future testVaultConnectionDraftSettingsInternal( + SettingsController controller, + VaultConfig profile, { + String tokenOverride = '', +}) async { + final address = profile.address.trim(); + if (address.isEmpty) { + const message = 'Missing address'; + controller.vaultStatusInternal = message; + controller.notifyListeners(); + return message; + } + try { + final uri = Uri.parse( + '$address${address.endsWith('/') ? '' : '/'}v1/sys/health', + ); + final headers = { + if (profile.namespace.trim().isNotEmpty) + 'X-Vault-Namespace': profile.namespace.trim(), + }; + final token = tokenOverride.trim().isNotEmpty + ? tokenOverride.trim() + : (await controller.storeInternal.loadVaultToken())?.trim() ?? ''; + if (token.trim().isNotEmpty) { + headers['X-Vault-Token'] = token.trim(); + } + final response = await controller.simpleGetInternal(uri, headers: headers); + final message = response.statusCode < 500 + ? 'Reachable (${response.statusCode})' + : 'Unhealthy (${response.statusCode})'; + controller.vaultStatusInternal = message; + controller.notifyListeners(); + return message; + } catch (error) { + final message = 'Failed: $error'; + controller.vaultStatusInternal = message; + controller.notifyListeners(); + return message; + } +} + +Future syncAiGatewayCatalogSettingsInternal( + SettingsController controller, + AiGatewayProfile profile, { + String apiKeyOverride = '', +}) async { + final normalizedBaseUrl = controller.normalizeAiGatewayBaseUrlInternal( + profile.baseUrl, + ); + if (normalizedBaseUrl == null) { + final next = profile.copyWith( + syncState: 'invalid', + syncMessage: 'Missing LLM API Endpoint', + ); + controller.aiGatewayStatusInternal = next.syncMessage; + controller.snapshotInternal = controller.snapshotInternal.copyWith( + aiGateway: next, + ); + await controller.storeInternal.saveSettingsSnapshot( + controller.snapshotInternal, + ); + controller.notifyListeners(); + return next; + } + final apiKey = apiKeyOverride.trim().isNotEmpty + ? apiKeyOverride.trim() + : (await controller.storeInternal.loadAiGatewayApiKey())?.trim() ?? ''; + if (apiKey.isEmpty) { + final next = profile.copyWith( + baseUrl: normalizedBaseUrl.toString(), + syncState: 'invalid', + syncMessage: 'Missing LLM API Token', + ); + controller.aiGatewayStatusInternal = next.syncMessage; + controller.snapshotInternal = controller.snapshotInternal.copyWith( + aiGateway: next, + ); + await controller.storeInternal.saveSettingsSnapshot( + controller.snapshotInternal, + ); + controller.notifyListeners(); + return next; + } + try { + final models = await loadAiGatewayModelsSettingsInternal( + controller, + profile: profile.copyWith(baseUrl: normalizedBaseUrl.toString()), + apiKeyOverride: apiKey, + ); + final availableModels = models.map((item) => item.id).toList(growable: false); + final retainedSelected = profile.selectedModels + .where(availableModels.contains) + .toList(growable: false); + final selectedModels = retainedSelected.isNotEmpty + ? retainedSelected + : availableModels.take(5).toList(growable: false); + final currentDefaultModel = controller.snapshotInternal.defaultModel.trim(); + final resolvedDefaultModel = selectedModels.contains(currentDefaultModel) + ? currentDefaultModel + : selectedModels.isNotEmpty + ? selectedModels.first + : availableModels.isNotEmpty + ? availableModels.first + : ''; + final next = profile.copyWith( + baseUrl: normalizedBaseUrl.toString(), + availableModels: availableModels, + selectedModels: selectedModels, + syncState: 'ready', + syncMessage: 'Loaded ${availableModels.length} model(s)', + ); + controller.aiGatewayStatusInternal = 'Ready (${availableModels.length})'; + controller.snapshotInternal = controller.snapshotInternal.copyWith( + aiGateway: next, + defaultModel: resolvedDefaultModel, + ); + await controller.storeInternal.saveSettingsSnapshot(controller.snapshotInternal); + await controller.reloadDerivedStateInternal(); + controller.notifyListeners(); + return next; + } catch (error) { + final next = profile.copyWith( + baseUrl: normalizedBaseUrl.toString(), + syncState: 'error', + syncMessage: controller.networkErrorLabelInternal(error), + ); + controller.aiGatewayStatusInternal = next.syncMessage; + controller.snapshotInternal = controller.snapshotInternal.copyWith( + aiGateway: next, + ); + await controller.storeInternal.saveSettingsSnapshot(controller.snapshotInternal); + controller.notifyListeners(); + return next; + } +} + +Future testAiGatewayConnectionSettingsInternal( + SettingsController controller, + AiGatewayProfile profile, { + String apiKeyOverride = '', +}) async { + final normalizedBaseUrl = controller.normalizeAiGatewayBaseUrlInternal( + profile.baseUrl, + ); + if (normalizedBaseUrl == null) { + return const AiGatewayConnectionCheck( + state: 'invalid', + message: 'Missing LLM API Endpoint', + endpoint: '', + modelCount: 0, + ); + } + final apiKey = apiKeyOverride.trim().isNotEmpty + ? apiKeyOverride.trim() + : (await controller.storeInternal.loadAiGatewayApiKey())?.trim() ?? ''; + final endpoint = controller.aiGatewayModelsUriInternal(normalizedBaseUrl) + .toString(); + if (apiKey.isEmpty) { + return AiGatewayConnectionCheck( + state: 'invalid', + message: 'Missing LLM API Token', + endpoint: endpoint, + modelCount: 0, + ); + } + try { + final models = await controller.requestAiGatewayModelsInternal( + uri: controller.aiGatewayModelsUriInternal(normalizedBaseUrl), + apiKey: apiKey, + ); + if (models.isEmpty) { + return AiGatewayConnectionCheck( + state: 'empty', + message: 'Authenticated but no models were returned', + endpoint: endpoint, + modelCount: 0, + ); + } + return AiGatewayConnectionCheck( + state: 'ready', + message: 'Authenticated · ${models.length} model(s) available', + endpoint: endpoint, + modelCount: models.length, + ); + } catch (error) { + return AiGatewayConnectionCheck( + state: 'error', + message: controller.networkErrorLabelInternal(error), + endpoint: endpoint, + modelCount: 0, + ); + } +} + +Future> loadAiGatewayModelsSettingsInternal( + SettingsController controller, { + AiGatewayProfile? profile, + String apiKeyOverride = '', +}) async { + final activeProfile = profile ?? controller.snapshotInternal.aiGateway; + final normalizedBaseUrl = controller.normalizeAiGatewayBaseUrlInternal( + activeProfile.baseUrl, + ); + if (normalizedBaseUrl == null) { + return const []; + } + final apiKey = apiKeyOverride.trim().isNotEmpty + ? apiKeyOverride.trim() + : (await controller.storeInternal.loadAiGatewayApiKey())?.trim() ?? ''; + if (apiKey.isEmpty) { + return const []; + } + return controller.requestAiGatewayModelsInternal( + uri: controller.aiGatewayModelsUriInternal(normalizedBaseUrl), + apiKey: apiKey, + ); +} diff --git a/test/quality/wave1_file_size_guard_test.dart b/test/quality/wave1_file_size_guard_test.dart index 361114f4..474735cd 100644 --- a/test/quality/wave1_file_size_guard_test.dart +++ b/test/quality/wave1_file_size_guard_test.dart @@ -17,16 +17,20 @@ void main() { // Baseline cap for legacy oversized closure; tighten after T3. 'lib/runtime/gateway_runtime_core.dart': 950, 'lib/runtime/gateway_runtime_helpers.dart': 800, - // Baseline cap for legacy oversized closure; tighten after T3. - 'lib/runtime/runtime_controllers_settings.dart': 960, + // Tightened after T3 closure convergence. + 'lib/runtime/runtime_controllers_settings.dart': 800, 'lib/features/settings/settings_page_gateway.dart': 800, 'test/runtime/app_controller_assistant_flow_suite.dart': 800, 'test/runtime/app_controller_thread_skills_suite.dart': 800, - // Tightened in T2 after assistant/composer closure split. - 'lib/features/assistant/assistant_page_main.dart': 2200, - 'lib/app/app_controller_desktop_runtime_helpers.dart': 950, - 'lib/app/app_controller_desktop_thread_sessions.dart': 1050, + // 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_thread_sessions.dart': 800, + 'lib/app/app_controller_desktop_runtime_coordination_impl.dart': 800, + 'lib/app/app_controller_desktop_thread_sessions_collaboration_impl.dart': + 800, + 'lib/runtime/runtime_controllers_settings_connectivity_impl.dart': 800, }; final violations = []; for (final entry in targets.entries) {