diff --git a/README.md b/README.md index 90fe1881..6c9b8bb6 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # XWorkmate -XWorkmate is a desktop-first AI workspace shell built with Flutter. -`v0.5` ships persistent assistant task threads, optional ARIS-powered multi-agent collaboration, and a bundled Go bridge runtime that travels with the app. +XWorkmate is an AI workspace shell built with Flutter. +`v0.5` ships persistent assistant task threads, optional ARIS-powered multi-agent collaboration, and a bundled Go bridge runtime that travels with the macOS app. ## v0.5 Highlights @@ -19,6 +19,7 @@ XWorkmate is a desktop-first AI workspace shell built with Flutter. - Multi-Agent orchestration with optional ARIS preset - Bundled ARIS skills, Go bridge helper, `llm-chat` reviewer, and `claude-review` - Ollama Cloud settings, task grouping, and macOS packaged delivery +- Flutter Web shell with `Assistant` + `Settings` only, supporting `Direct AI Gateway` and `Relay OpenClaw Gateway` ### Not Yet Implemented - Built-in Codex runtime through Rust FFI @@ -38,9 +39,29 @@ XWorkmate is a desktop-first AI workspace shell built with Flutter. ```bash flutter analyze flutter test +flutter test --platform chrome test/widget_test.dart test/web flutter run -d macos ``` +## Flutter Web + +Web keeps the Assistant-first entry flow, but only exposes: + +- `Assistant` +- `Settings` +- `Direct AI Gateway` +- `Relay OpenClaw Gateway` + +Web does not expose local CLI, workspace file access, native runtime orchestration, or desktop-only diagnostics. + +Build the root-site bundle with: + +```bash +flutter build web --release --base-href / +``` + +Deployment notes for `https://xworkmate.svc.plus/` are in [docs/web-deployment.md](/Users/shenlan/workspaces/cloud-neutral-toolkit/XWorkmate.svc.plus/docs/web-deployment.md). + ## macOS Packaging ```bash diff --git a/docs/web-deployment.md b/docs/web-deployment.md new file mode 100644 index 00000000..bd3902e9 --- /dev/null +++ b/docs/web-deployment.md @@ -0,0 +1,61 @@ +# XWorkmate Web Deployment + +This repo now ships a browser-safe Flutter Web variant intended to be deployed at the root site: + +- `https://xworkmate.svc.plus/` + +## Product Scope + +The Web app keeps only: + +- `Assistant` +- `Settings` +- `Direct AI Gateway` +- `Relay OpenClaw Gateway` + +The following remain desktop-only: + +- local OpenClaw gateway mode +- local CLI orchestration +- workspace file and attachment access +- native desktop integrations +- desktop diagnostics/runtime surfaces + +## Build Commands + +Use a root-site build: + +```bash +flutter build web --release --base-href / +``` + +Recommended validation before deployment: + +```bash +flutter analyze +flutter test +flutter test --platform chrome test/widget_test.dart test/web +flutter build web --release --base-href / +``` + +## Static Hosting Notes + +- Deploy the contents of `build/web/` at the site root. +- Keep `index.html` served from `/`. +- Flutter emits fingerprinted assets; publish the full directory together so `flutter_service_worker.js` and asset hashes stay aligned. +- Cache `index.html` conservatively or with revalidation so new asset manifests are picked up quickly after each release. +- Static assets under `build/web/assets/` and hashed JS files can be cached aggressively. + +## Network Requirements + +- `Direct AI Gateway` must be browser-reachable from the end user device. +- Direct gateway endpoints must allow the Web origin with correct CORS headers. +- If a provider cannot satisfy browser reachability or CORS constraints, users must use `Relay OpenClaw Gateway` instead. +- Relay endpoints should stay on TLS in production and must not silently downgrade to insecure transport for remote usage. + +## Persistence and Secrets + +- Web configuration is stored in browser-local persistent storage on the current device. +- This includes the selected execution target, direct gateway settings, relay settings, and Web conversation metadata. +- Web persistence is less secure than desktop secure storage; use trusted devices only. +- `.env` remains desktop/development prefill-only and is not auto-imported into Web runtime behavior. diff --git a/lib/app/app_capabilities.dart b/lib/app/app_capabilities.dart new file mode 100644 index 00000000..af3f5d0f --- /dev/null +++ b/lib/app/app_capabilities.dart @@ -0,0 +1,56 @@ +import '../models/app_models.dart'; + +class AppCapabilities { + const AppCapabilities({ + required this.allowedDestinations, + required this.supportsFileAttachments, + required this.supportsLocalGateway, + required this.supportsRelayGateway, + required this.supportsDesktopRuntime, + required this.supportsDiagnostics, + }); + + final Set allowedDestinations; + final bool supportsFileAttachments; + final bool supportsLocalGateway; + final bool supportsRelayGateway; + final bool supportsDesktopRuntime; + final bool supportsDiagnostics; + + bool supportsDestination(WorkspaceDestination destination) { + return allowedDestinations.contains(destination); + } + + static const desktop = AppCapabilities( + allowedDestinations: { + WorkspaceDestination.assistant, + WorkspaceDestination.tasks, + WorkspaceDestination.skills, + WorkspaceDestination.nodes, + WorkspaceDestination.agents, + WorkspaceDestination.mcpServer, + WorkspaceDestination.clawHub, + WorkspaceDestination.secrets, + WorkspaceDestination.aiGateway, + WorkspaceDestination.settings, + WorkspaceDestination.account, + }, + supportsFileAttachments: true, + supportsLocalGateway: true, + supportsRelayGateway: true, + supportsDesktopRuntime: true, + supportsDiagnostics: true, + ); + + static const web = AppCapabilities( + allowedDestinations: { + WorkspaceDestination.assistant, + WorkspaceDestination.settings, + }, + supportsFileAttachments: false, + supportsLocalGateway: false, + supportsRelayGateway: true, + supportsDesktopRuntime: false, + supportsDiagnostics: false, + ); +} diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index 4e7be25c..7035b122 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -1,3079 +1,2 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter/material.dart'; - -import 'app_metadata.dart'; -import '../i18n/app_language.dart'; -import '../models/app_models.dart'; -import '../runtime/device_identity_store.dart'; -import '../runtime/aris_bundle.dart'; -import '../runtime/aris_bridge.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/runtime_coordinator.dart'; -import '../runtime/codex_runtime.dart'; -import '../runtime/codex_config_bridge.dart'; -import '../runtime/code_agent_node_orchestrator.dart'; -import '../runtime/mode_switcher.dart'; -import '../runtime/agent_registry.dart'; -import '../runtime/multi_agent_broker.dart'; -import '../runtime/multi_agent_mounts.dart'; -import '../runtime/multi_agent_orchestrator.dart'; - -enum CodexCooperationState { notStarted, bridgeOnly, registered } - -class AppController extends ChangeNotifier { - AppController({ - SecureConfigStore? store, - RuntimeCoordinator? runtimeCoordinator, - DesktopPlatformService? desktopPlatformService, - }) { - _store = store ?? SecureConfigStore(); - - final resolvedRuntimeCoordinator = - runtimeCoordinator ?? - RuntimeCoordinator( - gateway: GatewayRuntime( - store: _store, - identityStore: DeviceIdentityStore(_store), - ), - codex: CodexRuntime(), - configBridge: CodexConfigBridge(), - ); - - _runtimeCoordinator = resolvedRuntimeCoordinator; - _codeAgentNodeOrchestrator = CodeAgentNodeOrchestrator(_runtimeCoordinator); - _codeAgentBridgeRegistry = AgentRegistry(_runtimeCoordinator.gateway); - _settingsController = SettingsController(_store); - _agentsController = GatewayAgentsController(_runtimeCoordinator.gateway); - _sessionsController = GatewaySessionsController( - _runtimeCoordinator.gateway, - ); - _chatController = GatewayChatController(_runtimeCoordinator.gateway); - _instancesController = InstancesController(_runtimeCoordinator.gateway); - _skillsController = SkillsController(_runtimeCoordinator.gateway); - _connectorsController = ConnectorsController(_runtimeCoordinator.gateway); - _modelsController = ModelsController( - _runtimeCoordinator.gateway, - _settingsController, - ); - _cronJobsController = CronJobsController(_runtimeCoordinator.gateway); - _devicesController = DevicesController(_runtimeCoordinator.gateway); - _tasksController = DerivedTasksController(); - _desktopPlatformService = - desktopPlatformService ?? createDesktopPlatformService(); - _arisBundleRepository = ArisBundleRepository(); - _arisBridgeLocator = ArisBridgeLocator(); - _multiAgentMountManager = MultiAgentMountManager( - arisBundleRepository: _arisBundleRepository, - arisBridgeLocator: _arisBridgeLocator, - ); - _multiAgentOrchestrator = MultiAgentOrchestrator( - config: _resolveMultiAgentConfig(_settingsController.snapshot), - arisBundleRepository: _arisBundleRepository, - arisBridgeLocator: _arisBridgeLocator, - ); - - _attachChildListeners(); - unawaited(_initialize()); - } - - late final SecureConfigStore _store; - - late final RuntimeCoordinator _runtimeCoordinator; - late final CodeAgentNodeOrchestrator _codeAgentNodeOrchestrator; - late final AgentRegistry _codeAgentBridgeRegistry; - late final SettingsController _settingsController; - late final GatewayAgentsController _agentsController; - late final GatewaySessionsController _sessionsController; - late final GatewayChatController _chatController; - late final InstancesController _instancesController; - late final SkillsController _skillsController; - late final ConnectorsController _connectorsController; - late final ModelsController _modelsController; - late final CronJobsController _cronJobsController; - late final DevicesController _devicesController; - late final DerivedTasksController _tasksController; - late final DesktopPlatformService _desktopPlatformService; - late final ArisBundleRepository _arisBundleRepository; - late final ArisBridgeLocator _arisBridgeLocator; - late final MultiAgentMountManager _multiAgentMountManager; - late final MultiAgentOrchestrator _multiAgentOrchestrator; - MultiAgentBrokerServer? _multiAgentBrokerServer; - MultiAgentBrokerClient? _multiAgentBrokerClient; - final Map> _assistantThreadMessages = - >{}; - final Map _assistantThreadRecords = - {}; - final Map> _localSessionMessages = - >{}; - final Map> _gatewayHistoryCache = - >{}; - final Map _aiGatewayStreamingTextBySession = - {}; - final Map _aiGatewayStreamingClients = - {}; - final Set _aiGatewayPendingSessionKeys = {}; - final Set _aiGatewayAbortedSessionKeys = {}; - final Set _activeMultiAgentBrokerSessions = {}; - bool _multiAgentRunPending = false; - int _localMessageCounter = 0; - - WorkspaceDestination _destination = WorkspaceDestination.assistant; - ThemeMode _themeMode = ThemeMode.light; - AppSidebarState _sidebarState = AppSidebarState.expanded; - ModulesTab _modulesTab = ModulesTab.gateway; - SecretsTab _secretsTab = SecretsTab.vault; - AiGatewayTab _aiGatewayTab = AiGatewayTab.models; - SettingsTab _settingsTab = SettingsTab.general; - SettingsDetailPage? _settingsDetail; - SettingsNavigationContext? _settingsNavigationContext; - DetailPanelData? _detailPanel; - bool _initializing = true; - String? _bootstrapError; - StreamSubscription? _runtimeEventsSubscription; - bool _disposed = false; - - WorkspaceDestination get destination => _destination; - ThemeMode get themeMode => _themeMode; - AppSidebarState get sidebarState => _sidebarState; - ModulesTab get modulesTab => _modulesTab; - SecretsTab get secretsTab => _secretsTab; - AiGatewayTab get aiGatewayTab => _aiGatewayTab; - SettingsTab get settingsTab => _settingsTab; - SettingsDetailPage? get settingsDetail => _settingsDetail; - SettingsNavigationContext? get settingsNavigationContext => - _settingsNavigationContext; - DetailPanelData? get detailPanel => _detailPanel; - bool get initializing => _initializing; - String? get bootstrapError => _bootstrapError; - - RuntimeCoordinator get runtimeCoordinator => _runtimeCoordinator; - GatewayRuntime get _runtime => _runtimeCoordinator.gateway; - GatewayRuntime get runtime => _runtime; - - /// Whether Codex bridge is enabled and configured - bool get isCodexBridgeEnabled => _isCodexBridgeEnabled; - bool _isCodexBridgeEnabled = false; - bool _isCodexBridgeBusy = false; - String? _codexBridgeError; - String? _codexRuntimeWarning; - String? _resolvedCodexCliPath; - CodexCooperationState _codexCooperationState = - CodexCooperationState.notStarted; - SettingsController get settingsController => _settingsController; - GatewayAgentsController get agentsController => _agentsController; - GatewaySessionsController get sessionsController => _sessionsController; - MultiAgentOrchestrator get multiAgentOrchestrator => _multiAgentOrchestrator; - GatewayChatController get chatController => _chatController; - InstancesController get instancesController => _instancesController; - SkillsController get skillsController => _skillsController; - ConnectorsController get connectorsController => _connectorsController; - ModelsController get modelsController => _modelsController; - CronJobsController get cronJobsController => _cronJobsController; - DevicesController get devicesController => _devicesController; - DerivedTasksController get tasksController => _tasksController; - DesktopIntegrationState get desktopIntegration => - _desktopPlatformService.state; - bool get supportsDesktopIntegration => desktopIntegration.isSupported; - bool get desktopPlatformBusy => _desktopPlatformBusy; - - GatewayConnectionSnapshot get connection => _runtime.snapshot; - SettingsSnapshot get settings => _settingsController.snapshot; - List get agents => _agentsController.agents; - List get sessions => isAiGatewayOnlyMode - ? _assistantSessionSummaries() - : _sessionsController.sessions; - List get assistantSessions => _assistantSessions(); - List get instances => _instancesController.items; - List get skills => _skillsController.items; - List get connectors => _connectorsController.items; - List get models => _modelsController.items; - List get cronJobs => _cronJobsController.items; - GatewayDevicePairingList get devices => _devicesController.items; - String get selectedAgentId => _agentsController.selectedAgentId; - String get activeAgentName => _agentsController.activeAgentName; - String get currentSessionKey => _sessionsController.currentSessionKey; - String? get activeRunId => _chatController.activeRunId; - AppLanguage get appLanguage => settings.appLanguage; - AssistantExecutionTarget get assistantExecutionTarget => - currentAssistantExecutionTarget; - AssistantExecutionTarget get currentAssistantExecutionTarget => - assistantExecutionTargetForSession(currentSessionKey); - AssistantMessageViewMode get currentAssistantMessageViewMode => - assistantMessageViewModeForSession(currentSessionKey); - AssistantPermissionLevel get assistantPermissionLevel => - settings.assistantPermissionLevel; - bool get hasStoredGatewayCredential => - _settingsController.secureRefs.containsKey('gateway_token') || - _settingsController.secureRefs.containsKey('gateway_password') || - _settingsController.secureRefs.containsKey( - 'gateway_device_token_operator', - ); - bool get hasStoredGatewayToken => - _settingsController.secureRefs.containsKey('gateway_token'); - String? get storedGatewayTokenMask => - _settingsController.secureRefs['gateway_token']; - String get aiGatewayUrl => settings.aiGateway.baseUrl.trim(); - bool get hasStoredAiGatewayApiKey => - _settingsController.secureRefs.containsKey('ai_gateway_api_key'); - bool get isAiGatewayOnlyMode => - currentAssistantExecutionTarget == AssistantExecutionTarget.aiGatewayOnly; - bool get isCodexBridgeBusy => _isCodexBridgeBusy; - String? get codexBridgeError => _codexBridgeError; - String? get codexRuntimeWarning => _codexRuntimeWarning; - String? get resolvedCodexCliPath => _resolvedCodexCliPath; - bool get hasDetectedCodexCli => _resolvedCodexCliPath != null; - String get configuredCodexCliPath => settings.codexCliPath.trim(); - CodeAgentRuntimeMode get configuredCodeAgentRuntimeMode => - settings.codeAgentRuntimeMode; - CodeAgentRuntimeMode get effectiveCodeAgentRuntimeMode => - configuredCodeAgentRuntimeMode; - CodexCooperationState get codexCooperationState => _codexCooperationState; - bool get isMultiAgentRunPending => _multiAgentRunPending; - bool _desktopPlatformBusy = false; - - bool get hasAssistantPendingRun => - assistantSessionHasPendingRun(currentSessionKey); - - bool get canUseAiGatewayConversation => - aiGatewayUrl.isNotEmpty && - hasStoredAiGatewayApiKey && - resolvedAiGatewayModel.isNotEmpty; - - List get aiGatewayConversationModelChoices { - final selected = settings.aiGateway.selectedModels - .map((item) => item.trim()) - .where( - (item) => - item.isNotEmpty && - settings.aiGateway.availableModels.contains(item), - ) - .toList(growable: false); - if (selected.isNotEmpty) { - return selected; - } - final available = settings.aiGateway.availableModels - .map((item) => item.trim()) - .where((item) => item.isNotEmpty) - .toList(growable: false); - if (available.isNotEmpty) { - return available; - } - return const []; - } - - String get resolvedAiGatewayModel { - final current = settings.defaultModel.trim(); - final choices = aiGatewayConversationModelChoices; - if (choices.contains(current)) { - return current; - } - if (choices.isNotEmpty) { - return choices.first; - } - return ''; - } - - String get resolvedAssistantModel { - return _resolvedAssistantModelForTarget(currentAssistantExecutionTarget); - } - - String _resolvedAssistantModelForTarget(AssistantExecutionTarget target) { - if (target == AssistantExecutionTarget.aiGatewayOnly) { - return resolvedAiGatewayModel; - } - final resolved = resolvedDefaultModel.trim(); - if (resolved.isNotEmpty) { - return resolved; - } - return ''; - } - - String get assistantConversationOwnerLabel { - if (!isAiGatewayOnlyMode) { - return activeAgentName; - } - final model = resolvedAssistantModel; - return model.isEmpty ? appText('AI Gateway', 'AI Gateway') : model; - } - - String get assistantConnectionStatusLabel => isAiGatewayOnlyMode - ? appText('仅 AI Gateway', 'AI Gateway Only') - : connection.status.label; - - String get assistantConnectionTargetLabel { - if (!isAiGatewayOnlyMode) { - return connection.remoteAddress ?? appText('未连接目标', 'No target'); - } - final model = resolvedAssistantModel; - final host = _aiGatewayHostLabel(settings.aiGateway.baseUrl); - if (model.isNotEmpty && host.isNotEmpty) { - return '$model · $host'; - } - if (model.isNotEmpty) { - return model; - } - if (host.isNotEmpty) { - return host; - } - return appText('AI Gateway 未配置', 'AI Gateway not configured'); - } - - Future loadAiGatewayApiKey() async { - return (await _store.loadAiGatewayApiKey())?.trim() ?? ''; - } - - Future saveMultiAgentConfig(MultiAgentConfig config) async { - final resolved = _resolveMultiAgentConfig( - settings.copyWith(multiAgent: config), - ); - await saveSettings( - settings.copyWith(multiAgent: resolved), - refreshAfterSave: false, - ); - await refreshMultiAgentMounts(sync: resolved.autoSync); - } - - Future refreshMultiAgentMounts({bool sync = false}) async { - if (_disposed) { - return; - } - final resolved = _resolveMultiAgentConfig(settings); - final reconciled = await _multiAgentMountManager.reconcile( - config: sync ? resolved : resolved.copyWith(autoSync: false), - aiGatewayUrl: aiGatewayUrl, - ); - if (_disposed) { - return; - } - if (jsonEncode(reconciled.toJson()) != - jsonEncode(settings.multiAgent.toJson())) { - await _settingsController.saveSnapshot( - settings.copyWith(multiAgent: reconciled), - ); - } - if (_disposed) { - return; - } - _multiAgentOrchestrator.updateConfig(reconciled); - _notifyIfActive(); - } - - Future runMultiAgentCollaboration({ - required String rawPrompt, - required String composedPrompt, - required List attachments, - required List selectedSkillLabels, - }) async { - final sessionKey = currentSessionKey.trim().isEmpty - ? 'main' - : currentSessionKey; - final client = await _ensureMultiAgentBrokerClient(); - final aiGatewayApiKey = await loadAiGatewayApiKey(); - _multiAgentRunPending = true; - _appendLocalSessionMessage( - sessionKey, - GatewayChatMessage( - id: _nextLocalMessageId(), - role: 'user', - text: rawPrompt, - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: null, - stopReason: null, - pending: false, - error: false, - ), - ); - _recomputeTasks(); - try { - final taskStream = settings.multiAgent.usesAris - ? (_activeMultiAgentBrokerSessions.contains(sessionKey) - ? client.sendSessionMessage( - sessionId: sessionKey, - taskPrompt: composedPrompt, - workingDirectory: - _resolveCodexWorkingDirectory() ?? - Directory.current.path, - attachments: attachments, - selectedSkills: selectedSkillLabels, - aiGatewayBaseUrl: aiGatewayUrl, - aiGatewayApiKey: aiGatewayApiKey, - ) - : client.startSession( - sessionId: sessionKey, - taskPrompt: composedPrompt, - workingDirectory: - _resolveCodexWorkingDirectory() ?? - Directory.current.path, - attachments: attachments, - selectedSkills: selectedSkillLabels, - aiGatewayBaseUrl: aiGatewayUrl, - aiGatewayApiKey: aiGatewayApiKey, - )) - : client.runTask( - taskPrompt: composedPrompt, - workingDirectory: - _resolveCodexWorkingDirectory() ?? Directory.current.path, - attachments: attachments, - selectedSkills: selectedSkillLabels, - aiGatewayBaseUrl: aiGatewayUrl, - aiGatewayApiKey: aiGatewayApiKey, - ); - if (settings.multiAgent.usesAris) { - _activeMultiAgentBrokerSessions.add(sessionKey); - } - 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']; - _appendLocalSessionMessage( - sessionKey, - GatewayChatMessage( - id: _nextLocalMessageId(), - 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; - } - _appendLocalSessionMessage( - sessionKey, - GatewayChatMessage( - id: _nextLocalMessageId(), - role: 'assistant', - text: event.message, - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: event.title, - stopReason: null, - pending: event.pending, - error: event.error, - ), - ); - } - } catch (error) { - _appendLocalSessionMessage( - sessionKey, - GatewayChatMessage( - id: _nextLocalMessageId(), - role: 'assistant', - text: error.toString(), - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: 'Multi-Agent', - stopReason: null, - pending: false, - error: true, - ), - ); - } finally { - _multiAgentRunPending = false; - _recomputeTasks(); - _notifyIfActive(); - } - } - - 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 _modelsController.items - .map((item) => item.id.trim()) - .where((item) => item.isNotEmpty) - .toList(growable: false); - } - - List get assistantModelChoices { - if (isAiGatewayOnlyMode) { - return aiGatewayConversationModelChoices; - } - 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 profile = settings.gateway; - 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 = GatewayConnectionProfile.defaults(); - return hasStoredGatewayCredential || - host != defaults.host || - profile.port != defaults.port || - profile.tls != defaults.tls || - profile.mode != defaults.mode; - } - - List get secretReferences => - _settingsController.buildSecretReferences(); - List get secretAuditTrail => _settingsController.auditTrail; - List get runtimeLogs => _runtime.logs; - List get assistantNavigationDestinations => - normalizeAssistantNavigationDestinations( - settings.assistantNavigationDestinations, - ); - - List get chatMessages { - final sessionKey = _normalizedAssistantSessionKey( - _sessionsController.currentSessionKey, - ); - final items = List.from( - isAiGatewayOnlyMode - ? (_gatewayHistoryCache[sessionKey] ?? const []) - : _chatController.messages, - ); - final threadItems = isAiGatewayOnlyMode - ? _assistantThreadMessages[sessionKey] - : null; - if (threadItems != null && threadItems.isNotEmpty) { - items.addAll(threadItems); - } - final localItems = _localSessionMessages[sessionKey]; - if (localItems != null && localItems.isNotEmpty) { - items.addAll(localItems); - } - final streaming = isAiGatewayOnlyMode - ? (_aiGatewayStreamingTextBySession[sessionKey]?.trim() ?? '') - : (_chatController.streamingAssistantText?.trim() ?? ''); - if (streaming.isNotEmpty) { - items.add( - GatewayChatMessage( - id: 'streaming', - role: 'assistant', - text: streaming, - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: null, - stopReason: null, - pending: true, - error: false, - ), - ); - } - return items; - } - - String _normalizedAssistantSessionKey(String sessionKey) { - final trimmed = sessionKey.trim(); - return trimmed.isEmpty ? 'main' : trimmed; - } - - AssistantExecutionTarget assistantExecutionTargetForSession( - String sessionKey, - ) { - final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); - return _assistantThreadRecords[normalizedSessionKey]?.executionTarget ?? - settings.assistantExecutionTarget; - } - - AssistantMessageViewMode assistantMessageViewModeForSession( - String sessionKey, - ) { - final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); - return _assistantThreadRecords[normalizedSessionKey]?.messageViewMode ?? - AssistantMessageViewMode.rendered; - } - - List _assistantSessions() { - final archivedKeys = settings.assistantArchivedTaskKeys - .map(_normalizedAssistantSessionKey) - .toSet(); - final byKey = {}; - - for (final session in _sessionsController.sessions) { - final normalizedSessionKey = _normalizedAssistantSessionKey(session.key); - if (archivedKeys.contains(normalizedSessionKey)) { - continue; - } - byKey[normalizedSessionKey] = session; - } - - for (final record in _assistantThreadRecords.values) { - final normalizedSessionKey = _normalizedAssistantSessionKey( - record.sessionKey, - ); - if (normalizedSessionKey.isEmpty || - archivedKeys.contains(normalizedSessionKey) || - record.archived) { - continue; - } - byKey.putIfAbsent( - normalizedSessionKey, - () => _assistantSessionSummaryFor(normalizedSessionKey, record: record), - ); - } - - final currentKey = _normalizedAssistantSessionKey(currentSessionKey); - if (!archivedKeys.contains(currentKey) && !byKey.containsKey(currentKey)) { - byKey[currentKey] = _assistantSessionSummaryFor(currentKey); - } - - final items = byKey.values.toList(growable: true) - ..sort( - (left, right) => - (right.updatedAtMs ?? 0).compareTo(left.updatedAtMs ?? 0), - ); - return items; - } - - bool assistantSessionHasPendingRun(String sessionKey) { - final normalized = _normalizedAssistantSessionKey(sessionKey); - if (assistantExecutionTargetForSession(normalized) == - AssistantExecutionTarget.aiGatewayOnly) { - return _aiGatewayPendingSessionKeys.contains(normalized); - } - return (_chatController.hasPendingRun || _multiAgentRunPending) && - matchesSessionKey(normalized, _sessionsController.currentSessionKey); - } - - void navigateTo(WorkspaceDestination destination) { - final nextModulesTab = switch (destination) { - WorkspaceDestination.nodes => ModulesTab.nodes, - WorkspaceDestination.agents => ModulesTab.agents, - _ => _modulesTab, - }; - final shouldClearSettingsDrillIn = - _settingsDetail != null || _settingsNavigationContext != null; - final changed = - _destination != destination || - _detailPanel != null || - shouldClearSettingsDrillIn || - nextModulesTab != _modulesTab; - if (!changed) { - return; - } - _destination = destination; - _modulesTab = nextModulesTab; - _settingsDetail = null; - _settingsNavigationContext = null; - _detailPanel = null; - notifyListeners(); - } - - void navigateHome() { - final mainSessionKey = - _runtime.snapshot.mainSessionKey?.trim().isNotEmpty == true - ? _runtime.snapshot.mainSessionKey!.trim() - : 'main'; - final destinationChanged = _destination != WorkspaceDestination.assistant; - final detailChanged = _detailPanel != null; - final settingsDrillInChanged = - _settingsDetail != null || _settingsNavigationContext != null; - _destination = WorkspaceDestination.assistant; - _settingsDetail = null; - _settingsNavigationContext = null; - _detailPanel = null; - if (destinationChanged || detailChanged || settingsDrillInChanged) { - notifyListeners(); - } - if (_sessionsController.currentSessionKey != mainSessionKey) { - unawaited(switchSession(mainSessionKey)); - } - } - - void openModules({ModulesTab tab = ModulesTab.gateway}) { - final destination = tab == ModulesTab.agents - ? WorkspaceDestination.agents - : WorkspaceDestination.nodes; - final changed = - _destination != destination || - _modulesTab != tab || - _detailPanel != null || - _settingsDetail != null || - _settingsNavigationContext != null; - if (!changed) { - return; - } - _destination = destination; - _modulesTab = tab; - _detailPanel = null; - _settingsDetail = null; - _settingsNavigationContext = null; - notifyListeners(); - } - - void setModulesTab(ModulesTab tab) { - if (_modulesTab == tab) { - return; - } - _modulesTab = tab; - notifyListeners(); - } - - void openSecrets({SecretsTab tab = SecretsTab.vault}) { - final changed = - _destination != WorkspaceDestination.secrets || - _secretsTab != tab || - _detailPanel != null || - _settingsDetail != null || - _settingsNavigationContext != null; - if (!changed) { - return; - } - _destination = WorkspaceDestination.secrets; - _secretsTab = tab; - _detailPanel = null; - _settingsDetail = null; - _settingsNavigationContext = null; - notifyListeners(); - } - - void setSecretsTab(SecretsTab tab) { - if (_secretsTab == tab) { - return; - } - _secretsTab = tab; - notifyListeners(); - } - - void openAiGateway({AiGatewayTab tab = AiGatewayTab.models}) { - final changed = - _destination != WorkspaceDestination.aiGateway || - _aiGatewayTab != tab || - _detailPanel != null || - _settingsDetail != null || - _settingsNavigationContext != null; - if (!changed) { - return; - } - _destination = WorkspaceDestination.aiGateway; - _aiGatewayTab = tab; - _detailPanel = null; - _settingsDetail = null; - _settingsNavigationContext = null; - notifyListeners(); - } - - void setAiGatewayTab(AiGatewayTab tab) { - if (_aiGatewayTab == tab) { - return; - } - _aiGatewayTab = tab; - notifyListeners(); - } - - void openSettings({ - SettingsTab tab = SettingsTab.general, - SettingsDetailPage? detail, - SettingsNavigationContext? navigationContext, - }) { - final resolvedTab = detail?.tab ?? tab; - final changed = - _destination != WorkspaceDestination.settings || - _settingsTab != resolvedTab || - _settingsDetail != detail || - _settingsNavigationContext != navigationContext || - _detailPanel != null; - if (!changed) { - return; - } - _destination = WorkspaceDestination.settings; - _settingsTab = resolvedTab; - _settingsDetail = detail; - _settingsNavigationContext = navigationContext; - _detailPanel = null; - notifyListeners(); - } - - void setSettingsTab(SettingsTab tab, {bool clearDetail = true}) { - final changed = - _settingsTab != tab || - (clearDetail && - (_settingsDetail != null || _settingsNavigationContext != null)); - if (!changed) { - return; - } - _settingsTab = tab; - if (clearDetail) { - _settingsDetail = null; - _settingsNavigationContext = null; - } - notifyListeners(); - } - - void closeSettingsDetail() { - if (_settingsDetail == null && _settingsNavigationContext == null) { - return; - } - _settingsDetail = null; - _settingsNavigationContext = null; - notifyListeners(); - } - - void cycleSidebarState() { - _sidebarState = switch (_sidebarState) { - AppSidebarState.expanded => AppSidebarState.collapsed, - AppSidebarState.collapsed => AppSidebarState.hidden, - AppSidebarState.hidden => AppSidebarState.expanded, - }; - notifyListeners(); - } - - void setSidebarState(AppSidebarState state) { - if (_sidebarState == state) { - return; - } - _sidebarState = state; - notifyListeners(); - } - - void setThemeMode(ThemeMode mode) { - if (_themeMode == mode) { - return; - } - _themeMode = mode; - notifyListeners(); - } - - Future toggleAppLanguage() async { - await setAppLanguage( - settings.appLanguage == AppLanguage.zh ? AppLanguage.en : AppLanguage.zh, - ); - } - - Future setAppLanguage(AppLanguage language) async { - if (settings.appLanguage == language) { - return; - } - setActiveAppLanguage(language); - await saveSettings( - settings.copyWith(appLanguage: language), - refreshAfterSave: false, - ); - } - - void openDetail(DetailPanelData detailPanel) { - _detailPanel = detailPanel; - notifyListeners(); - } - - void closeDetail() { - if (_detailPanel == null) { - return; - } - _detailPanel = null; - notifyListeners(); - } - - Future connectWithSetupCode({ - required String setupCode, - String token = '', - String password = '', - }) async { - final decoded = decodeGatewaySetupCode(setupCode); - final resolvedToken = token.trim().isNotEmpty - ? token.trim() - : (decoded?.token.trim() ?? ''); - final resolvedPassword = password.trim().isNotEmpty - ? password.trim() - : (decoded?.password.trim() ?? ''); - await _settingsController.saveGatewaySecrets( - token: resolvedToken, - password: resolvedPassword, - ); - final nextProfile = settings.gateway.copyWith( - useSetupCode: true, - setupCode: setupCode.trim(), - host: decoded?.host ?? settings.gateway.host, - port: decoded?.port ?? settings.gateway.port, - tls: decoded?.tls ?? settings.gateway.tls, - mode: _modeFromHost(decoded?.host ?? settings.gateway.host), - ); - final nextTarget = _assistantExecutionTargetForMode(nextProfile.mode); - await saveSettings( - settings.copyWith( - gateway: nextProfile, - assistantExecutionTarget: nextTarget, - ), - refreshAfterSave: false, - ); - _upsertAssistantThreadRecord( - _sessionsController.currentSessionKey, - executionTarget: nextTarget, - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); - await _connectProfile( - nextProfile, - authTokenOverride: resolvedToken, - authPasswordOverride: resolvedPassword, - ); - await _chatController.loadSession(_sessionsController.currentSessionKey); - } - - Future connectManual({ - required String host, - required int port, - required bool tls, - required RuntimeConnectionMode mode, - String token = '', - String password = '', - }) async { - await _settingsController.saveGatewaySecrets( - token: token.trim(), - password: password.trim(), - ); - final resolvedHost = - host.trim().isEmpty && mode == RuntimeConnectionMode.local - ? '127.0.0.1' - : host.trim(); - final resolvedPort = mode == RuntimeConnectionMode.local && port <= 0 - ? 18789 - : port; - final nextProfile = settings.gateway.copyWith( - mode: mode, - useSetupCode: false, - setupCode: '', - host: resolvedHost, - port: resolvedPort <= 0 ? 443 : resolvedPort, - tls: mode == RuntimeConnectionMode.local ? false : tls, - ); - final nextTarget = _assistantExecutionTargetForMode(nextProfile.mode); - await saveSettings( - settings.copyWith( - gateway: nextProfile, - assistantExecutionTarget: nextTarget, - ), - refreshAfterSave: false, - ); - _upsertAssistantThreadRecord( - _sessionsController.currentSessionKey, - executionTarget: nextTarget, - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); - await _connectProfile( - nextProfile, - authTokenOverride: token.trim(), - authPasswordOverride: password.trim(), - ); - await _chatController.loadSession(_sessionsController.currentSessionKey); - } - - Future disconnectGateway() async { - _clearCodexGatewayRegistration(); - await _runtime.disconnect(clearDesiredProfile: false); - await _settingsController.refreshDerivedState(); - await _agentsController.refresh(); - await _sessionsController.refresh(); - _chatController.clear(); - await _instancesController.refresh(); - await _skillsController.refresh(); - await _connectorsController.refresh(); - await _modelsController.refresh(); - await _cronJobsController.refresh(); - _devicesController.clear(); - _recomputeTasks(); - } - - Future connectSavedGateway() async { - await _connectProfile(settings.gateway); - } - - Future clearStoredGatewayToken() async { - await _settingsController.clearGatewaySecrets(token: true); - } - - Future refreshGatewayHealth() async { - if (!_runtime.isConnected) { - return; - } - try { - await _runtime.health(); - } catch (_) {} - try { - await _runtime.status(); - } catch (_) {} - notifyListeners(); - } - - Future refreshDevices({bool quiet = false}) async { - await _devicesController.refresh(quiet: quiet); - } - - Future approveDevicePairing(String requestId) async { - await _devicesController.approve(requestId); - await _settingsController.refreshDerivedState(); - } - - Future rejectDevicePairing(String requestId) async { - await _devicesController.reject(requestId); - } - - Future removePairedDevice(String deviceId) async { - await _devicesController.remove(deviceId); - await _settingsController.refreshDerivedState(); - } - - Future rotateDeviceRoleToken({ - required String deviceId, - required String role, - List scopes = const [], - }) async { - final token = await _devicesController.rotateToken( - deviceId: deviceId, - role: role, - scopes: scopes, - ); - await _settingsController.refreshDerivedState(); - return token; - } - - Future revokeDeviceRoleToken({ - required String deviceId, - required String role, - }) async { - await _devicesController.revokeToken(deviceId: deviceId, role: role); - await _settingsController.refreshDerivedState(); - } - - Future refreshAgents() async { - await _agentsController.refresh(); - _sessionsController.configure( - mainSessionKey: _runtime.snapshot.mainSessionKey ?? 'main', - selectedAgentId: _agentsController.selectedAgentId, - defaultAgentId: '', - ); - _recomputeTasks(); - } - - Future selectAgent(String? agentId) async { - _agentsController.selectAgent(agentId); - final nextProfile = settings.gateway.copyWith( - selectedAgentId: _agentsController.selectedAgentId, - ); - await saveSettings( - settings.copyWith(gateway: nextProfile), - refreshAfterSave: false, - ); - _sessionsController.configure( - mainSessionKey: _runtime.snapshot.mainSessionKey ?? 'main', - selectedAgentId: _agentsController.selectedAgentId, - defaultAgentId: '', - ); - await _chatController.loadSession(_sessionsController.currentSessionKey); - await _skillsController.refresh( - agentId: _agentsController.selectedAgentId.isEmpty - ? null - : _agentsController.selectedAgentId, - ); - _recomputeTasks(); - } - - Future refreshSessions() async { - _sessionsController.configure( - mainSessionKey: _runtime.snapshot.mainSessionKey ?? 'main', - selectedAgentId: _agentsController.selectedAgentId, - defaultAgentId: '', - ); - await _sessionsController.refresh(); - await _chatController.loadSession(_sessionsController.currentSessionKey); - _recomputeTasks(); - } - - Future switchSession(String sessionKey) async { - final previousSessionKey = _normalizedAssistantSessionKey( - _sessionsController.currentSessionKey, - ); - final nextSessionKey = _normalizedAssistantSessionKey(sessionKey); - final nextTarget = assistantExecutionTargetForSession(nextSessionKey); - final nextViewMode = assistantMessageViewModeForSession(nextSessionKey); - - if (!isAiGatewayOnlyMode) { - _preserveGatewayHistoryForSession(previousSessionKey); - } - - await _sessionsController.switchSession(nextSessionKey); - _upsertAssistantThreadRecord( - nextSessionKey, - executionTarget: nextTarget, - messageViewMode: nextViewMode, - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); - await _applyAssistantExecutionTarget( - nextTarget, - sessionKey: nextSessionKey, - persistDefaultSelection: false, - ); - _recomputeTasks(); - } - - Future sendChatMessage( - String message, { - String thinking = 'off', - List attachments = - const [], - }) async { - if (isAiGatewayOnlyMode) { - await _sendAiGatewayMessage( - message, - thinking: thinking, - attachments: attachments, - ); - _recomputeTasks(); - return; - } - final dispatch = _codeAgentNodeOrchestrator.buildGatewayDispatch( - _buildCodeAgentNodeState(), - ); - await _chatController.sendMessage( - sessionKey: _sessionsController.currentSessionKey, - message: message, - thinking: thinking, - attachments: attachments, - agentId: dispatch.agentId, - metadata: dispatch.metadata, - ); - _recomputeTasks(); - } - - Future abortRun() async { - if (_multiAgentRunPending) { - final sessionKey = _normalizedAssistantSessionKey( - _sessionsController.currentSessionKey, - ); - if (_activeMultiAgentBrokerSessions.contains(sessionKey)) { - await _multiAgentBrokerClient?.cancelSession(sessionKey); - } - await _multiAgentOrchestrator.abort(); - _multiAgentRunPending = false; - _recomputeTasks(); - _notifyIfActive(); - return; - } - if (isAiGatewayOnlyMode) { - await _abortAiGatewayRun(_sessionsController.currentSessionKey); - return; - } - await _chatController.abortRun(); - } - - Future setAssistantExecutionTarget( - AssistantExecutionTarget target, - ) async { - final currentTarget = assistantExecutionTargetForSession( - _sessionsController.currentSessionKey, - ); - if (currentTarget == target && - settings.assistantExecutionTarget == target) { - return; - } - _upsertAssistantThreadRecord( - _sessionsController.currentSessionKey, - executionTarget: target, - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); - await _applyAssistantExecutionTarget( - target, - sessionKey: _sessionsController.currentSessionKey, - persistDefaultSelection: true, - ); - _recomputeTasks(); - _notifyIfActive(); - } - - Future setAssistantMessageViewMode( - AssistantMessageViewMode mode, - ) async { - final sessionKey = _normalizedAssistantSessionKey( - _sessionsController.currentSessionKey, - ); - if (assistantMessageViewModeForSession(sessionKey) == mode) { - return; - } - _upsertAssistantThreadRecord( - sessionKey, - messageViewMode: mode, - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); - _recomputeTasks(); - _notifyIfActive(); - } - - Future setAssistantPermissionLevel( - AssistantPermissionLevel level, - ) async { - if (settings.assistantPermissionLevel == level) { - return; - } - await saveSettings( - settings.copyWith(assistantPermissionLevel: level), - refreshAfterSave: false, - ); - } - - Future _applyAssistantExecutionTarget( - AssistantExecutionTarget target, { - required String sessionKey, - required bool persistDefaultSelection, - }) async { - final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); - if (!matchesSessionKey( - normalizedSessionKey, - _sessionsController.currentSessionKey, - )) { - await _sessionsController.switchSession(normalizedSessionKey); - } - if (persistDefaultSelection && - settings.assistantExecutionTarget != target) { - await saveSettings( - settings.copyWith(assistantExecutionTarget: target), - refreshAfterSave: false, - ); - } - - if (target == AssistantExecutionTarget.aiGatewayOnly) { - if (_runtime.isConnected) { - _preserveGatewayHistoryForSession(normalizedSessionKey); - } - await _ensureActiveAssistantThread(); - if (_runtime.isConnected) { - try { - await disconnectGateway(); - } catch (_) { - // Preserve the selected thread-bound target even when the active - // gateway session does not close cleanly on the first attempt. - } - } else { - _chatController.clear(); - } - await _sessionsController.switchSession(normalizedSessionKey); - return; - } - - final targetProfile = _gatewayProfileForAssistantExecutionTarget(target); - try { - await _connectProfile(targetProfile); - } catch (_) { - // Keep the selected execution target even when the immediate reconnect - // fails so the user can retry or adjust gateway settings manually. - } - await _sessionsController.switchSession(normalizedSessionKey); - await _chatController.loadSession(normalizedSessionKey); - } - - Future selectDefaultModel(String modelId) async { - final trimmed = modelId.trim(); - if (trimmed.isEmpty || settings.defaultModel == trimmed) { - return; - } - await saveSettings( - settings.copyWith(defaultModel: trimmed), - refreshAfterSave: false, - ); - } - - Future selectAssistantModel(String modelId) async { - final trimmed = modelId.trim(); - if (trimmed.isEmpty) { - return; - } - final choices = assistantModelChoices; - if (choices.isNotEmpty && !choices.contains(trimmed)) { - return; - } - await selectDefaultModel(trimmed); - } - - String assistantCustomTaskTitle(String sessionKey) { - final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); - final settingsTitle = - settings.assistantCustomTaskTitles[normalizedSessionKey]?.trim() ?? ''; - if (settingsTitle.isNotEmpty) { - return settingsTitle; - } - return _assistantThreadRecords[normalizedSessionKey]?.title.trim() ?? ''; - } - - void initializeAssistantThreadContext( - String sessionKey, { - String title = '', - AssistantExecutionTarget? executionTarget, - AssistantMessageViewMode? messageViewMode, - }) { - _upsertAssistantThreadRecord( - sessionKey, - title: title.trim(), - executionTarget: - executionTarget ?? - assistantExecutionTargetForSession(currentSessionKey), - messageViewMode: - messageViewMode ?? - assistantMessageViewModeForSession(currentSessionKey), - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); - _notifyIfActive(); - } - - Future saveAssistantTaskTitle(String sessionKey, String title) async { - final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); - if (normalizedSessionKey.isEmpty) { - return; - } - final normalizedTitle = title.trim(); - final next = Map.from(settings.assistantCustomTaskTitles); - final current = next[normalizedSessionKey]?.trim() ?? ''; - if (normalizedTitle.isEmpty) { - if (current.isEmpty) { - return; - } - next.remove(normalizedSessionKey); - } else { - if (current == normalizedTitle) { - return; - } - next[normalizedSessionKey] = normalizedTitle; - } - await saveSettings( - settings.copyWith(assistantCustomTaskTitles: next), - refreshAfterSave: false, - ); - _upsertAssistantThreadRecord( - normalizedSessionKey, - title: normalizedTitle, - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); - _recomputeTasks(); - _notifyIfActive(); - } - - bool isAssistantTaskArchived(String sessionKey) { - final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); - return settings.assistantArchivedTaskKeys.any( - (item) => _normalizedAssistantSessionKey(item) == normalizedSessionKey, - ); - } - - Future saveAssistantTaskArchived( - String sessionKey, - bool archived, - ) async { - final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); - if (normalizedSessionKey.isEmpty) { - return; - } - final next = [ - ...settings.assistantArchivedTaskKeys.where( - (item) => _normalizedAssistantSessionKey(item) != normalizedSessionKey, - ), - ]; - if (archived) { - next.add(normalizedSessionKey); - } - await saveSettings( - settings.copyWith(assistantArchivedTaskKeys: next), - refreshAfterSave: false, - ); - if (archived) { - _activeMultiAgentBrokerSessions.remove(normalizedSessionKey); - unawaited(_multiAgentBrokerClient?.closeSession(normalizedSessionKey)); - } - _upsertAssistantThreadRecord( - normalizedSessionKey, - archived: archived, - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); - _recomputeTasks(); - _notifyIfActive(); - } - - Future updateAiGatewaySelection(List selectedModels) async { - final available = settings.aiGateway.availableModels; - final normalized = selectedModels - .map((item) => item.trim()) - .where((item) => item.isNotEmpty && available.contains(item)) - .toList(growable: false); - final fallbackSelection = normalized.isNotEmpty - ? normalized - : available.isNotEmpty - ? [available.first] - : const []; - final currentDefaultModel = settings.defaultModel.trim(); - final resolvedDefaultModel = fallbackSelection.contains(currentDefaultModel) - ? currentDefaultModel - : fallbackSelection.isNotEmpty - ? fallbackSelection.first - : ''; - await saveSettings( - settings.copyWith( - aiGateway: settings.aiGateway.copyWith( - selectedModels: fallbackSelection, - ), - defaultModel: resolvedDefaultModel, - ), - refreshAfterSave: false, - ); - } - - Future syncAiGatewayCatalog( - AiGatewayProfile profile, { - String apiKeyOverride = '', - }) async { - final synced = await _settingsController.syncAiGatewayCatalog( - profile, - apiKeyOverride: apiKeyOverride, - ); - _modelsController.restoreFromSettings( - _settingsController.snapshot.aiGateway, - ); - _recomputeTasks(); - return synced; - } - - Future saveSettings( - SettingsSnapshot snapshot, { - bool refreshAfterSave = true, - }) async { - final current = settings; - final sanitized = _sanitizeMultiAgentSettings( - _sanitizeOllamaCloudSettings(_sanitizeCodeAgentSettings(snapshot)), - ); - setActiveAppLanguage(sanitized.appLanguage); - await _settingsController.saveSnapshot(sanitized); - _multiAgentOrchestrator.updateConfig(sanitized.multiAgent); - _agentsController.restoreSelection(sanitized.gateway.selectedAgentId); - _modelsController.restoreFromSettings(sanitized.aiGateway); - if (current.codexCliPath != sanitized.codexCliPath || - current.codeAgentRuntimeMode != sanitized.codeAgentRuntimeMode) { - _registerCodexExternalProvider(codexPath: sanitized.codexCliPath); - await _refreshCodexCliAvailability(); - } - if (current.linuxDesktop.toJson().toString() != - sanitized.linuxDesktop.toJson().toString() || - current.launchAtLogin != sanitized.launchAtLogin) { - await _desktopPlatformService.syncConfig(sanitized.linuxDesktop); - await _desktopPlatformService.setLaunchAtLogin(sanitized.launchAtLogin); - } - if (refreshAfterSave) { - _recomputeTasks(); - } - unawaited(refreshMultiAgentMounts(sync: sanitized.multiAgent.autoSync)); - notifyListeners(); - } - - Future refreshDesktopIntegration() async { - _desktopPlatformBusy = true; - notifyListeners(); - try { - await _desktopPlatformService.refresh(); - } finally { - _desktopPlatformBusy = false; - notifyListeners(); - } - } - - Future saveLinuxDesktopConfig(LinuxDesktopConfig config) async { - await saveSettings(settings.copyWith(linuxDesktop: config)); - } - - Future setDesktopVpnMode(VpnMode mode) async { - _desktopPlatformBusy = true; - notifyListeners(); - try { - await saveSettings( - settings.copyWith( - linuxDesktop: settings.linuxDesktop.copyWith(preferredMode: mode), - ), - refreshAfterSave: false, - ); - await _desktopPlatformService.setMode(mode); - } finally { - _desktopPlatformBusy = false; - notifyListeners(); - } - } - - Future connectDesktopTunnel() async { - _desktopPlatformBusy = true; - notifyListeners(); - try { - await _desktopPlatformService.connectTunnel(); - } finally { - _desktopPlatformBusy = false; - notifyListeners(); - } - } - - Future disconnectDesktopTunnel() async { - _desktopPlatformBusy = true; - notifyListeners(); - try { - await _desktopPlatformService.disconnectTunnel(); - } finally { - _desktopPlatformBusy = false; - notifyListeners(); - } - } - - Future setLaunchAtLogin(bool enabled) async { - await saveSettings( - settings.copyWith(launchAtLogin: enabled), - refreshAfterSave: false, - ); - } - - Future toggleAssistantNavigationDestination( - WorkspaceDestination destination, - ) async { - if (!kAssistantNavigationDestinationCandidates.contains(destination)) { - return; - } - final current = assistantNavigationDestinations; - final next = current.contains(destination) - ? current.where((item) => item != destination).toList(growable: false) - : [...current, destination]; - await saveSettings( - settings.copyWith(assistantNavigationDestinations: next), - refreshAfterSave: false, - ); - } - - Future testOllamaConnection({required bool cloud}) { - return _settingsController.testOllamaConnection(cloud: cloud); - } - - Future testVaultConnection() { - return _settingsController.testVaultConnection(); - } - - void clearRuntimeLogs() { - _runtimeCoordinator.gateway.clearLogs(); - _notifyIfActive(); - } - - List taskItemsForTab(String tab) => switch (tab) { - 'Queue' => _tasksController.queue, - 'Running' => _tasksController.running, - 'History' => _tasksController.history, - 'Failed' => _tasksController.failed, - 'Scheduled' => _tasksController.scheduled, - _ => _tasksController.queue, - }; - - /// Enable Codex ↔ Gateway bridge - Future enableCodexBridge() async { - if (_isCodexBridgeEnabled || _isCodexBridgeBusy) return; - - _isCodexBridgeBusy = true; - _codexBridgeError = null; - - try { - final gatewayUrl = aiGatewayUrl; - final apiKey = await loadAiGatewayApiKey(); - - if (gatewayUrl.isEmpty) { - throw StateError( - appText('AI Gateway URL 未配置', 'AI Gateway URL not configured'), - ); - } - - final runtimeMode = effectiveCodeAgentRuntimeMode; - String? codexPath; - if (runtimeMode == CodeAgentRuntimeMode.externalCli) { - codexPath = await _resolveCodexCliPath(); - if (codexPath == null) { - throw StateError( - appText( - '未找到 Codex CLI。请先安装或填写可执行文件路径。', - 'Codex CLI not found. Install it or set a manual binary path.', - ), - ); - } - } - - await _runtimeCoordinator.configureCodexForGateway( - gatewayUrl: gatewayUrl, - apiKey: apiKey, - ); - - await _runtimeCoordinator.startCodeAgentRuntime( - runtimeMode: runtimeMode, - codexPath: codexPath, - workingDirectory: _resolveCodexWorkingDirectory(), - ); - - _registerCodexExternalProvider(codexPath: codexPath); - _isCodexBridgeEnabled = true; - _codexCooperationState = CodexCooperationState.bridgeOnly; - await _ensureCodexGatewayRegistration(); - notifyListeners(); - } catch (e) { - _codexBridgeError = e.toString(); - notifyListeners(); - rethrow; - } finally { - _isCodexBridgeBusy = false; - notifyListeners(); - } - } - - /// Disable Codex ↔ Gateway bridge - Future disableCodexBridge() async { - if (!_isCodexBridgeEnabled || _isCodexBridgeBusy) return; - - _isCodexBridgeBusy = true; - - try { - if (_runtime.isConnected && _codeAgentBridgeRegistry.isRegistered) { - await _codeAgentBridgeRegistry.unregister(); - } else { - _codeAgentBridgeRegistry.clearRegistration(); - } - await _runtimeCoordinator.stopCodeAgentRuntime(); - _isCodexBridgeEnabled = false; - _codexCooperationState = CodexCooperationState.notStarted; - _codexBridgeError = null; - notifyListeners(); - } catch (e) { - _codexBridgeError = e.toString(); - notifyListeners(); - rethrow; - } finally { - _isCodexBridgeBusy = false; - notifyListeners(); - } - } - - @override - void dispose() { - if (_disposed) { - return; - } - _disposed = true; - _runtimeEventsSubscription?.cancel(); - _detachChildListeners(); - _runtimeCoordinator.dispose(); - _settingsController.dispose(); - _agentsController.dispose(); - _sessionsController.dispose(); - _chatController.dispose(); - _instancesController.dispose(); - _skillsController.dispose(); - _connectorsController.dispose(); - _modelsController.dispose(); - _cronJobsController.dispose(); - _devicesController.dispose(); - _tasksController.dispose(); - _store.dispose(); - _desktopPlatformService.dispose(); - unawaited(_multiAgentBrokerServer?.stop() ?? Future.value()); - super.dispose(); - } - - Future _initialize() async { - try { - await _settingsController.initialize(); - _restoreAssistantThreads(await _store.loadAssistantThreadRecords()); - if (_disposed) { - return; - } - final bootstrap = await RuntimeBootstrapConfig.load( - workspacePathHint: settings.workspacePath, - cliPathHint: settings.cliPath, - ); - if (_disposed) { - return; - } - final seeded = bootstrap.mergeIntoSettings(settings); - if (seeded.toJsonString() != settings.toJsonString()) { - await _settingsController.saveSnapshot(seeded); - if (_disposed) { - return; - } - } - final normalized = _sanitizeMultiAgentSettings( - _sanitizeOllamaCloudSettings( - _sanitizeCodeAgentSettings(_settingsController.snapshot), - ), - ); - if (normalized.toJsonString() != - _settingsController.snapshot.toJsonString()) { - await _settingsController.saveSnapshot(normalized); - if (_disposed) { - return; - } - } - _modelsController.restoreFromSettings(settings.aiGateway); - _multiAgentOrchestrator.updateConfig(settings.multiAgent); - setActiveAppLanguage(settings.appLanguage); - await _desktopPlatformService.initialize(settings.linuxDesktop); - await _desktopPlatformService.setLaunchAtLogin(settings.launchAtLogin); - _registerCodexExternalProvider(); - await _refreshCodexCliAvailability(); - if (_disposed) { - return; - } - _agentsController.restoreSelection(settings.gateway.selectedAgentId); - _sessionsController.configure( - mainSessionKey: _runtime.snapshot.mainSessionKey ?? 'main', - selectedAgentId: _agentsController.selectedAgentId, - defaultAgentId: '', - ); - await _ensureActiveAssistantThread(); - _runtimeEventsSubscription = _runtimeCoordinator.gateway.events.listen( - _handleRuntimeEvent, - ); - final shouldAutoConnect = - settings.gateway.useSetupCode && - settings.gateway.setupCode.trim().isNotEmpty; - if (shouldAutoConnect) { - try { - await _connectProfile(settings.gateway); - } catch (_) { - // Keep the shell usable when auto-connect fails. - } - } - await refreshMultiAgentMounts(sync: settings.multiAgent.autoSync); - } catch (error) { - if (_disposed) { - return; - } - _bootstrapError = error.toString(); - } finally { - if (!_disposed) { - _initializing = false; - _notifyIfActive(); - } - } - } - - Future _connectProfile( - GatewayConnectionProfile profile, { - String authTokenOverride = '', - String authPasswordOverride = '', - }) async { - await _runtime.connectProfile( - profile, - authTokenOverride: authTokenOverride, - authPasswordOverride: authPasswordOverride, - ); - await refreshGatewayHealth(); - await refreshAgents(); - await refreshSessions(); - await _instancesController.refresh(); - await _skillsController.refresh( - agentId: _agentsController.selectedAgentId.isEmpty - ? null - : _agentsController.selectedAgentId, - ); - await _connectorsController.refresh(); - await _modelsController.refresh(); - await _cronJobsController.refresh(); - await _devicesController.refresh(quiet: true); - await _settingsController.refreshDerivedState(); - await _ensureCodexGatewayRegistration(); - _recomputeTasks(); - } - - Future _ensureActiveAssistantThread() async { - if (!isAiGatewayOnlyMode || - !isAssistantTaskArchived(_sessionsController.currentSessionKey)) { - return; - } - final fallback = _assistantSessionSummaries().firstWhere( - (item) => !isAssistantTaskArchived(item.key), - orElse: () => GatewaySessionSummary( - key: 'draft:${DateTime.now().millisecondsSinceEpoch}', - kind: 'assistant', - displayName: appText('新对话', 'New conversation'), - surface: 'Assistant', - subject: null, - room: null, - space: null, - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - sessionId: null, - systemSent: false, - abortedLastRun: false, - thinkingLevel: null, - verboseLevel: null, - inputTokens: null, - outputTokens: null, - totalTokens: null, - model: null, - contextTokens: null, - derivedTitle: appText('新对话', 'New conversation'), - lastMessagePreview: null, - ), - ); - await _sessionsController.switchSession(fallback.key); - } - - void _handleRuntimeEvent(GatewayPushEvent event) { - _chatController.handleEvent(event); - if (event.event == 'chat') { - final payload = asMap(event.payload); - final state = stringValue(payload['state']); - if (state == 'final' || state == 'aborted' || state == 'error') { - unawaited(refreshSessions()); - } - } - if (event.event == 'seqGap') { - unawaited(refreshSessions()); - } - if (event.event == 'device.pair.requested' || - event.event == 'device.pair.resolved') { - unawaited(refreshDevices(quiet: true)); - } - } - - SettingsSnapshot _sanitizeMultiAgentSettings(SettingsSnapshot snapshot) { - final resolved = _resolveMultiAgentConfig(snapshot); - if (jsonEncode(snapshot.multiAgent.toJson()) == - jsonEncode(resolved.toJson())) { - return snapshot; - } - return snapshot.copyWith(multiAgent: resolved); - } - - SettingsSnapshot _sanitizeOllamaCloudSettings(SettingsSnapshot snapshot) { - final rawBaseUrl = snapshot.ollamaCloud.baseUrl.trim(); - final normalized = rawBaseUrl.endsWith('/') - ? rawBaseUrl.substring(0, rawBaseUrl.length - 1) - : rawBaseUrl; - if (normalized != 'https://ollama.svc.plus') { - return snapshot; - } - return snapshot.copyWith( - ollamaCloud: snapshot.ollamaCloud.copyWith(baseUrl: 'https://ollama.com'), - ); - } - - MultiAgentConfig _resolveMultiAgentConfig(SettingsSnapshot snapshot) { - final defaults = MultiAgentConfig.defaults(); - final current = snapshot.multiAgent; - final ollamaEndpoint = snapshot.ollamaLocal.endpoint.trim().isEmpty - ? current.ollamaEndpoint - : snapshot.ollamaLocal.endpoint.trim(); - final engineerModel = current.engineer.model.trim().isNotEmpty - ? current.engineer.model.trim() - : snapshot.ollamaLocal.defaultModel.trim().isNotEmpty - ? snapshot.ollamaLocal.defaultModel.trim() - : defaults.engineer.model; - final architectModel = current.architect.model.trim().isNotEmpty - ? current.architect.model.trim() - : defaults.architect.model; - final testerModel = current.tester.model.trim().isNotEmpty - ? current.tester.model.trim() - : defaults.tester.model; - return current.copyWith( - framework: current.arisEnabled - ? MultiAgentFramework.aris - : current.framework, - arisEnabled: - current.framework == MultiAgentFramework.aris || current.arisEnabled, - ollamaEndpoint: ollamaEndpoint, - architect: current.architect.copyWith(model: architectModel), - engineer: current.engineer.copyWith(model: engineerModel), - tester: current.tester.copyWith(model: testerModel), - mountTargets: current.mountTargets.isEmpty - ? MultiAgentConfig.defaults().mountTargets - : current.mountTargets, - ); - } - - Future _ensureMultiAgentBrokerClient() async { - _multiAgentBrokerServer ??= MultiAgentBrokerServer(_multiAgentOrchestrator); - await _multiAgentBrokerServer!.start(); - final uri = _multiAgentBrokerServer!.wsUri; - if (uri == null) { - throw StateError('Multi-agent broker is unavailable'); - } - _runtimeCoordinator.registerExternalCodeAgent( - ExternalCodeAgentProvider( - id: 'aris-broker', - name: 'ARIS Broker', - command: 'xworkmate-multi-agent-broker', - transport: ExternalAgentTransport.websocketJsonRpc, - endpoint: uri.toString(), - capabilities: const [ - 'architect', - 'engineer', - 'tester', - 'multi-agent', - 'session-stream', - ], - ), - ); - _multiAgentBrokerClient = MultiAgentBrokerClient(uri); - return _multiAgentBrokerClient!; - } - - Future _sendAiGatewayMessage( - String message, { - required String thinking, - required List attachments, - }) async { - final sessionKey = _normalizedAssistantSessionKey( - _sessionsController.currentSessionKey, - ); - final trimmed = message.trim(); - if (trimmed.isEmpty && attachments.isEmpty) { - return; - } - - final baseUrl = _normalizeAiGatewayBaseUrl(settings.aiGateway.baseUrl); - if (baseUrl == null) { - _appendAssistantThreadMessage( - sessionKey, - _assistantErrorMessage( - appText( - 'AI Gateway URL 未配置,无法发送对话。', - 'AI Gateway URL is not configured, so the conversation could not be sent.', - ), - ), - ); - return; - } - - final apiKey = await loadAiGatewayApiKey(); - if (apiKey.isEmpty) { - _appendAssistantThreadMessage( - sessionKey, - _assistantErrorMessage( - appText( - 'AI Gateway API Key 未配置,无法发送对话。', - 'AI Gateway API key is not configured, so the conversation could not be sent.', - ), - ), - ); - return; - } - - final model = resolvedAiGatewayModel; - if (model.isEmpty) { - _appendAssistantThreadMessage( - sessionKey, - _assistantErrorMessage( - appText( - '当前没有可用的 AI Gateway 对话模型。请先在 AI Gateway 页面同步并选择可用模型。', - 'No AI Gateway chat model is available yet. Sync and select a supported model in AI Gateway first.', - ), - ), - ); - return; - } - - final userText = trimmed.isEmpty ? 'See attached.' : trimmed; - _appendAssistantThreadMessage( - sessionKey, - GatewayChatMessage( - id: _nextLocalMessageId(), - role: 'user', - text: userText, - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: null, - stopReason: null, - pending: false, - error: false, - ), - ); - _aiGatewayPendingSessionKeys.add(sessionKey); - _recomputeTasks(); - _notifyIfActive(); - - try { - final assistantText = await _requestAiGatewayCompletion( - baseUrl: baseUrl, - apiKey: apiKey, - model: model, - thinking: thinking, - sessionKey: sessionKey, - ); - _appendAssistantThreadMessage( - sessionKey, - GatewayChatMessage( - id: _nextLocalMessageId(), - role: 'assistant', - text: assistantText, - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: null, - stopReason: null, - pending: false, - error: false, - ), - ); - } on _AiGatewayAbortException catch (error) { - final partial = error.partialText.trim(); - if (partial.isNotEmpty) { - _appendAssistantThreadMessage( - sessionKey, - GatewayChatMessage( - id: _nextLocalMessageId(), - role: 'assistant', - text: partial, - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: null, - stopReason: 'aborted', - pending: false, - error: false, - ), - ); - } - } catch (error) { - _appendAssistantThreadMessage( - sessionKey, - _assistantErrorMessage(_aiGatewayErrorLabel(error)), - ); - } finally { - _aiGatewayPendingSessionKeys.remove(sessionKey); - _aiGatewayStreamingClients.remove(sessionKey); - _clearAiGatewayStreamingText(sessionKey); - _recomputeTasks(); - _notifyIfActive(); - } - } - - Future _requestAiGatewayCompletion({ - required Uri baseUrl, - required String apiKey, - required String model, - required String thinking, - required String sessionKey, - }) async { - final uri = _aiGatewayChatUri(baseUrl); - final client = HttpClient() - ..connectionTimeout = const Duration(seconds: 20); - _aiGatewayStreamingClients[sessionKey] = client; - try { - final request = await client - .postUrl(uri) - .timeout(const Duration(seconds: 20)); - request.headers.set( - HttpHeaders.acceptHeader, - 'text/event-stream, application/json', - ); - request.headers.set( - HttpHeaders.contentTypeHeader, - 'application/json; charset=utf-8', - ); - request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $apiKey'); - request.headers.set('x-api-key', apiKey); - final payload = { - 'model': model, - 'stream': true, - 'messages': _buildAiGatewayRequestMessages(sessionKey), - }; - final normalizedThinking = thinking.trim().toLowerCase(); - if (normalizedThinking.isNotEmpty && normalizedThinking != 'off') { - payload['reasoning_effort'] = normalizedThinking; - } - request.add(utf8.encode(jsonEncode(payload))); - final response = await request.close().timeout( - const Duration(seconds: 60), - ); - if (response.statusCode < 200 || response.statusCode >= 300) { - final body = await response.transform(utf8.decoder).join(); - throw _AiGatewayChatException( - _formatAiGatewayHttpError( - response.statusCode, - _extractAiGatewayErrorDetail(body), - ), - ); - } - final contentType = - response.headers.contentType?.mimeType.toLowerCase() ?? - response.headers - .value(HttpHeaders.contentTypeHeader) - ?.toLowerCase() ?? - ''; - if (contentType.contains('text/event-stream')) { - final streamed = await _readAiGatewayStreamingResponse( - response: response, - sessionKey: sessionKey, - ); - if (streamed.trim().isEmpty) { - throw const FormatException('Missing assistant content'); - } - return streamed.trim(); - } - return await _readAiGatewayJsonCompletion(response); - } catch (error) { - if (_consumeAiGatewayAbort(sessionKey)) { - throw _AiGatewayAbortException( - _aiGatewayStreamingTextBySession[sessionKey] ?? '', - ); - } - rethrow; - } finally { - _aiGatewayStreamingClients.remove(sessionKey); - client.close(force: true); - } - } - - List> _buildAiGatewayRequestMessages(String sessionKey) { - final history = [ - ...(_gatewayHistoryCache[sessionKey] ?? const []), - ...(_assistantThreadMessages[sessionKey] ?? const []), - ]; - return history - .where((message) { - final role = message.role.trim().toLowerCase(); - return (role == 'user' || role == 'assistant') && - (message.toolName ?? '').trim().isEmpty && - message.text.trim().isNotEmpty; - }) - .map( - (message) => { - 'role': message.role.trim().toLowerCase() == 'assistant' - ? 'assistant' - : 'user', - 'content': message.text.trim(), - }, - ) - .toList(growable: false); - } - - Future _readAiGatewayJsonCompletion( - HttpClientResponse response, - ) async { - final body = await response.transform(utf8.decoder).join(); - final decoded = jsonDecode(_extractFirstJsonDocument(body)); - final assistantText = _extractAiGatewayAssistantText(decoded); - if (assistantText.trim().isEmpty) { - throw const FormatException('Missing assistant content'); - } - return assistantText.trim(); - } - - Future _readAiGatewayStreamingResponse({ - required HttpClientResponse response, - required String sessionKey, - }) async { - final buffer = StringBuffer(); - final eventLines = []; - - void processEvent(String payload) { - final trimmed = payload.trim(); - if (trimmed.isEmpty) { - return; - } - if (trimmed == '[DONE]') { - return; - } - final deltaText = _extractAiGatewayStreamText(trimmed); - if (deltaText.isEmpty) { - return; - } - final current = buffer.toString(); - if (current.isEmpty || deltaText == current) { - buffer - ..clear() - ..write(deltaText); - } else if (deltaText.startsWith(current)) { - buffer - ..clear() - ..write(deltaText); - } else { - buffer.write(deltaText); - } - _setAiGatewayStreamingText(sessionKey, buffer.toString()); - } - - await for (final line - in response.transform(utf8.decoder).transform(const LineSplitter())) { - if (_consumeAiGatewayAbort(sessionKey)) { - throw _AiGatewayAbortException(buffer.toString()); - } - if (line.isEmpty) { - if (eventLines.isNotEmpty) { - processEvent(eventLines.join('\n')); - eventLines.clear(); - } - continue; - } - if (line.startsWith('data:')) { - eventLines.add(line.substring(5).trimLeft()); - } - } - - if (eventLines.isNotEmpty) { - processEvent(eventLines.join('\n')); - } - - return buffer.toString(); - } - - String _extractAiGatewayStreamText(String payload) { - final decoded = jsonDecode(_extractFirstJsonDocument(payload)); - final map = asMap(decoded); - final choices = asList(map['choices']); - if (choices.isNotEmpty) { - final firstChoice = asMap(choices.first); - final delta = asMap(firstChoice['delta']); - final deltaContent = _extractAiGatewayContent(delta['content']); - if (deltaContent.isNotEmpty) { - return deltaContent; - } - } - return _extractAiGatewayAssistantText(decoded); - } - - Future _abortAiGatewayRun(String sessionKey) async { - final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); - _aiGatewayAbortedSessionKeys.add(normalizedSessionKey); - final client = _aiGatewayStreamingClients.remove(normalizedSessionKey); - if (client != null) { - try { - client.close(force: true); - } catch (_) { - // Best effort only. - } - } - _aiGatewayPendingSessionKeys.remove(normalizedSessionKey); - _clearAiGatewayStreamingText(normalizedSessionKey); - _recomputeTasks(); - _notifyIfActive(); - } - - bool _consumeAiGatewayAbort(String sessionKey) { - return _aiGatewayAbortedSessionKeys.remove( - _normalizedAssistantSessionKey(sessionKey), - ); - } - - GatewayChatMessage _assistantErrorMessage(String text) { - return GatewayChatMessage( - id: _nextLocalMessageId(), - role: 'assistant', - text: text, - timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - toolCallId: null, - toolName: null, - stopReason: null, - pending: false, - error: true, - ); - } - - void _appendAssistantThreadMessage( - String sessionKey, - GatewayChatMessage message, - ) { - final key = _normalizedAssistantSessionKey(sessionKey); - final next = List.from( - _assistantThreadMessages[key] ?? const [], - )..add(message); - _assistantThreadMessages[key] = next; - _upsertAssistantThreadRecord( - key, - messages: next, - updatedAtMs: - message.timestampMs ?? - DateTime.now().millisecondsSinceEpoch.toDouble(), - ); - _notifyIfActive(); - } - - void _appendLocalSessionMessage( - String sessionKey, - GatewayChatMessage message, - ) { - final key = _normalizedAssistantSessionKey(sessionKey); - final next = List.from( - _localSessionMessages[key] ?? const [], - )..add(message); - _localSessionMessages[key] = next; - _notifyIfActive(); - } - - void _preserveGatewayHistoryForSession(String sessionKey) { - final key = _normalizedAssistantSessionKey(sessionKey); - if (_chatController.messages.isEmpty) { - return; - } - _gatewayHistoryCache[key] = List.from( - _chatController.messages, - ); - } - - List _assistantSessionSummaries() { - final archivedKeys = settings.assistantArchivedTaskKeys - .map(_normalizedAssistantSessionKey) - .toSet(); - final items = []; - - for (final record in _assistantThreadRecords.values) { - final sessionKey = _normalizedAssistantSessionKey(record.sessionKey); - if (archivedKeys.contains(sessionKey) || record.archived) { - continue; - } - items.add(_assistantSessionSummaryFor(sessionKey, record: record)); - } - - final currentSessionKey = _normalizedAssistantSessionKey( - _sessionsController.currentSessionKey, - ); - final hasCurrent = items.any( - (item) => matchesSessionKey(item.key, currentSessionKey), - ); - if (!hasCurrent && !archivedKeys.contains(currentSessionKey)) { - items.add(_assistantSessionSummaryFor(currentSessionKey)); - } - - items.sort((left, right) { - return (right.updatedAtMs ?? 0).compareTo(left.updatedAtMs ?? 0); - }); - return items; - } - - GatewaySessionSummary _assistantSessionSummaryFor( - String sessionKey, { - AssistantThreadRecord? record, - }) { - final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); - final resolvedRecord = - record ?? _assistantThreadRecords[normalizedSessionKey]; - final messages = - resolvedRecord?.messages ?? - _assistantThreadMessages[normalizedSessionKey] ?? - const []; - final preview = _assistantThreadPreview(messages); - final title = assistantCustomTaskTitle(normalizedSessionKey); - final lastMessage = messages.isNotEmpty ? messages.last : null; - final updatedAtMs = - resolvedRecord?.updatedAtMs ?? - lastMessage?.timestampMs ?? - DateTime.now().millisecondsSinceEpoch.toDouble(); - return GatewaySessionSummary( - key: normalizedSessionKey, - kind: 'assistant', - displayName: title.isEmpty ? null : title, - surface: 'Assistant', - subject: preview, - room: null, - space: null, - updatedAtMs: updatedAtMs, - sessionId: normalizedSessionKey, - systemSent: false, - abortedLastRun: lastMessage?.error == true, - thinkingLevel: null, - verboseLevel: null, - inputTokens: null, - outputTokens: null, - totalTokens: null, - model: _resolvedAssistantModelForTarget( - assistantExecutionTargetForSession(normalizedSessionKey), - ), - contextTokens: null, - derivedTitle: title.isEmpty ? null : title, - lastMessagePreview: preview, - ); - } - - String? _assistantThreadPreview(List messages) { - for (final message in messages.reversed) { - final role = message.role.trim().toLowerCase(); - if (role != 'user' && role != 'assistant') { - continue; - } - final text = message.text.trim(); - if (text.isNotEmpty) { - return text; - } - } - return null; - } - - void _restoreAssistantThreads(List records) { - _assistantThreadRecords.clear(); - _assistantThreadMessages.clear(); - final archivedKeys = settings.assistantArchivedTaskKeys - .map(_normalizedAssistantSessionKey) - .toSet(); - for (final record in records) { - final sessionKey = _normalizedAssistantSessionKey(record.sessionKey); - if (sessionKey.isEmpty) { - continue; - } - final titleFromSettings = assistantCustomTaskTitle(sessionKey); - final normalizedRecord = record.copyWith( - sessionKey: sessionKey, - title: titleFromSettings.isEmpty - ? record.title.trim() - : titleFromSettings, - archived: record.archived || archivedKeys.contains(sessionKey), - executionTarget: - record.executionTarget ?? settings.assistantExecutionTarget, - messageViewMode: record.messageViewMode, - ); - _assistantThreadRecords[sessionKey] = normalizedRecord; - if (normalizedRecord.messages.isNotEmpty) { - _assistantThreadMessages[sessionKey] = List.from( - normalizedRecord.messages, - ); - } - } - } - - void _upsertAssistantThreadRecord( - String sessionKey, { - List? messages, - double? updatedAtMs, - String? title, - bool? archived, - AssistantExecutionTarget? executionTarget, - AssistantMessageViewMode? messageViewMode, - }) { - final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); - final existing = _assistantThreadRecords[normalizedSessionKey]; - final nextMessages = - messages ?? - existing?.messages ?? - _assistantThreadMessages[normalizedSessionKey] ?? - const []; - final nextRecord = AssistantThreadRecord( - sessionKey: normalizedSessionKey, - messages: nextMessages, - updatedAtMs: - updatedAtMs ?? - existing?.updatedAtMs ?? - (nextMessages.isNotEmpty ? nextMessages.last.timestampMs : null), - title: title ?? existing?.title ?? '', - archived: - archived ?? - existing?.archived ?? - isAssistantTaskArchived(normalizedSessionKey), - executionTarget: - executionTarget ?? - existing?.executionTarget ?? - settings.assistantExecutionTarget, - messageViewMode: - messageViewMode ?? - existing?.messageViewMode ?? - AssistantMessageViewMode.rendered, - ); - _assistantThreadRecords[normalizedSessionKey] = nextRecord; - if (messages != null) { - _assistantThreadMessages[normalizedSessionKey] = - List.from(messages); - } - unawaited( - _store.saveAssistantThreadRecords( - _assistantThreadRecords.values.toList(growable: false), - ), - ); - } - - void _setAiGatewayStreamingText(String sessionKey, String text) { - final key = _normalizedAssistantSessionKey(sessionKey); - if (text.trim().isEmpty) { - _aiGatewayStreamingTextBySession.remove(key); - } else { - _aiGatewayStreamingTextBySession[key] = text; - } - _notifyIfActive(); - } - - void _clearAiGatewayStreamingText(String sessionKey) { - final key = _normalizedAssistantSessionKey(sessionKey); - if (_aiGatewayStreamingTextBySession.remove(key) != null) { - _notifyIfActive(); - } - } - - String _nextLocalMessageId() { - _localMessageCounter += 1; - return 'local-${DateTime.now().microsecondsSinceEpoch}-$_localMessageCounter'; - } - - Uri? _normalizeAiGatewayBaseUrl(String raw) { - final trimmed = raw.trim(); - if (trimmed.isEmpty) { - return null; - } - final candidate = trimmed.contains('://') ? trimmed : 'https://$trimmed'; - final uri = Uri.tryParse(candidate); - if (uri == null || uri.host.trim().isEmpty) { - return null; - } - final pathSegments = uri.pathSegments.where((item) => item.isNotEmpty); - return uri.replace( - pathSegments: pathSegments.isEmpty ? const ['v1'] : pathSegments, - query: null, - fragment: null, - ); - } - - Uri _aiGatewayChatUri(Uri baseUrl) { - final pathSegments = baseUrl.pathSegments - .where((item) => item.isNotEmpty) - .toList(growable: true); - if (pathSegments.isEmpty) { - pathSegments.add('v1'); - } - if (pathSegments.length >= 2 && - pathSegments[pathSegments.length - 2] == 'chat' && - pathSegments.last == 'completions') { - return baseUrl.replace(query: null, fragment: null); - } - if (pathSegments.last == 'models') { - pathSegments.removeLast(); - } - if (pathSegments.last != 'chat') { - pathSegments.add('chat'); - } - pathSegments.add('completions'); - return baseUrl.replace( - pathSegments: pathSegments, - query: null, - fragment: null, - ); - } - - String _aiGatewayHostLabel(String raw) { - final uri = _normalizeAiGatewayBaseUrl(raw); - if (uri == null) { - return ''; - } - if (uri.hasPort) { - return '${uri.host}:${uri.port}'; - } - return uri.host; - } - - String _aiGatewayErrorLabel(Object error) { - if (error is _AiGatewayChatException) { - return error.message; - } - if (error is SocketException) { - return appText('无法连接到 AI Gateway。', 'Unable to reach the AI Gateway.'); - } - if (error is HandshakeException) { - return appText( - 'AI Gateway TLS 握手失败。', - 'AI Gateway TLS handshake failed.', - ); - } - if (error is TimeoutException) { - return appText('AI Gateway 请求超时。', 'AI Gateway request timed out.'); - } - if (error is FormatException) { - return appText( - 'AI Gateway 返回了无法解析的响应。', - 'AI Gateway returned an invalid response.', - ); - } - return error.toString(); - } - - String _formatAiGatewayHttpError(int statusCode, String detail) { - final base = switch (statusCode) { - 400 => appText( - 'AI Gateway 请求无效 (400)', - 'AI Gateway rejected the request (400)', - ), - 401 => appText( - 'AI Gateway 鉴权失败 (401)', - 'AI Gateway authentication failed (401)', - ), - 403 => appText('AI Gateway 拒绝访问 (403)', 'AI Gateway denied access (403)'), - 404 => appText( - 'AI Gateway chat 接口不存在 (404)', - 'AI Gateway chat endpoint was not found (404)', - ), - 429 => appText( - 'AI Gateway 限流 (429)', - 'AI Gateway rate limited the request (429)', - ), - >= 500 => appText( - 'AI Gateway 当前不可用 ($statusCode)', - 'AI Gateway is unavailable right now ($statusCode)', - ), - _ => appText( - 'AI Gateway 返回状态码 $statusCode', - 'AI Gateway responded with status $statusCode', - ), - }; - final trimmed = detail.trim(); - return trimmed.isEmpty ? base : '$base · $trimmed'; - } - - String _extractAiGatewayErrorDetail(String body) { - if (body.trim().isEmpty) { - return ''; - } - try { - final decoded = jsonDecode(_extractFirstJsonDocument(body)); - final map = asMap(decoded); - final error = asMap(map['error']); - return (stringValue(error['message']) ?? - stringValue(map['message']) ?? - stringValue(map['detail']) ?? - '') - .trim(); - } on FormatException { - return ''; - } - } - - String _extractAiGatewayAssistantText(Object? decoded) { - final map = asMap(decoded); - final choices = asList(map['choices']); - if (choices.isNotEmpty) { - final firstChoice = asMap(choices.first); - final message = asMap(firstChoice['message']); - final content = _extractAiGatewayContent(message['content']); - if (content.isNotEmpty) { - return content; - } - } - - final output = asList(map['output']); - for (final item in output) { - final entry = asMap(item); - final content = _extractAiGatewayContent(entry['content']); - if (content.isNotEmpty) { - return content; - } - } - - final direct = _extractAiGatewayContent(map['content']); - if (direct.isNotEmpty) { - return direct; - } - return stringValue(map['output_text'])?.trim() ?? ''; - } - - String _extractAiGatewayContent(Object? content) { - if (content is String) { - return content.trim(); - } - final parts = []; - for (final item in asList(content)) { - final map = asMap(item); - final nestedText = stringValue(map['text']); - if (nestedText != null && nestedText.trim().isNotEmpty) { - parts.add(nestedText.trim()); - continue; - } - final type = stringValue(map['type']) ?? ''; - if (type == 'output_text') { - final text = stringValue(map['text']) ?? stringValue(map['value']); - if (text != null && text.trim().isNotEmpty) { - parts.add(text.trim()); - } - } - } - return parts.join('\n').trim(); - } - - String _extractFirstJsonDocument(String body) { - final trimmed = body.trimLeft(); - if (trimmed.isEmpty) { - throw const FormatException('Empty response body'); - } - final start = trimmed.indexOf(RegExp(r'[\{\[]')); - if (start < 0) { - throw const FormatException('Missing JSON document'); - } - var depth = 0; - var inString = false; - var escaped = false; - for (var index = start; index < trimmed.length; index++) { - final char = trimmed[index]; - if (escaped) { - escaped = false; - continue; - } - if (char == r'\') { - escaped = true; - continue; - } - if (char == '"') { - inString = !inString; - continue; - } - if (inString) { - continue; - } - if (char == '{' || char == '[') { - depth += 1; - } else if (char == '}' || char == ']') { - depth -= 1; - if (depth == 0) { - return trimmed.substring(start, index + 1); - } - } - } - throw const FormatException('Unterminated JSON document'); - } - - SettingsSnapshot _sanitizeCodeAgentSettings(SettingsSnapshot snapshot) { - _codexRuntimeWarning = - snapshot.codeAgentRuntimeMode == CodeAgentRuntimeMode.builtIn - ? appText( - '内置 Codex 仍处于实验阶段;建议优先使用 External Codex CLI。', - 'Built-in Codex is still experimental; External Codex CLI is recommended.', - ) - : null; - final normalizedPath = snapshot.codexCliPath.trim(); - if (normalizedPath == snapshot.codexCliPath) { - return snapshot; - } - return snapshot.copyWith(codexCliPath: normalizedPath); - } - - Future _refreshCodexCliAvailability() async { - _resolvedCodexCliPath = await _runtimeCoordinator.resolveCodexPath( - codexPath: settings.codexCliPath, - ); - _notifyIfActive(); - } - - Future _resolveCodexCliPath() async { - if (_resolvedCodexCliPath != null) { - return _resolvedCodexCliPath; - } - await _refreshCodexCliAvailability(); - return _resolvedCodexCliPath; - } - - String? _resolveCodexWorkingDirectory() { - final candidate = settings.workspacePath.trim(); - if (candidate.isEmpty) { - return null; - } - final directory = Directory(candidate); - return directory.existsSync() ? directory.path : null; - } - - void _registerCodexExternalProvider({String? codexPath}) { - _runtimeCoordinator.registerExternalCodeAgent( - ExternalCodeAgentProvider( - id: 'codex', - name: 'Codex CLI', - command: (codexPath?.trim().isNotEmpty ?? false) - ? codexPath!.trim() - : 'codex', - defaultArgs: const ['app-server', '--listen', 'stdio://'], - capabilities: const [ - 'chat', - 'code-edit', - 'gateway-bridge', - 'memory-sync', - ], - ), - ); - } - - CodeAgentNodeState _buildCodeAgentNodeState() { - return CodeAgentNodeState( - selectedAgentId: _agentsController.selectedAgentId, - gatewayConnected: _runtime.isConnected, - executionTarget: currentAssistantExecutionTarget, - runtimeMode: effectiveCodeAgentRuntimeMode, - bridgeEnabled: _isCodexBridgeEnabled, - bridgeState: _codexCooperationState.name, - preferredProviderId: 'codex', - resolvedCodexCliPath: _resolvedCodexCliPath, - configuredCodexCliPath: configuredCodexCliPath, - ); - } - - GatewayMode _bridgeGatewayMode() { - return switch (settings.gateway.mode) { - RuntimeConnectionMode.local => GatewayMode.local, - RuntimeConnectionMode.remote => GatewayMode.remote, - RuntimeConnectionMode.unconfigured => GatewayMode.offline, - }; - } - - Future _ensureCodexGatewayRegistration() async { - if (!_isCodexBridgeEnabled) { - return; - } - - if (!_runtime.isConnected) { - _codexCooperationState = CodexCooperationState.bridgeOnly; - _codeAgentBridgeRegistry.clearRegistration(); - notifyListeners(); - return; - } - - if (_codeAgentBridgeRegistry.isRegistered) { - _codexCooperationState = CodexCooperationState.registered; - notifyListeners(); - return; - } - - try { - final dispatch = _codeAgentNodeOrchestrator.buildGatewayDispatch( - _buildCodeAgentNodeState(), - ); - await _codeAgentBridgeRegistry.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': _bridgeGatewayMode().name, - 'binaryConfigured': (resolvedCodexCliPath ?? configuredCodexCliPath) - .trim() - .isNotEmpty, - 'capabilities': const [ - 'chat', - 'code-edit', - 'gateway-bridge', - 'memory-sync', - ], - }, - ); - _codexCooperationState = CodexCooperationState.registered; - _codexBridgeError = null; - } catch (error) { - _codexCooperationState = CodexCooperationState.bridgeOnly; - _codexBridgeError = error.toString(); - } - - notifyListeners(); - } - - void _clearCodexGatewayRegistration() { - _codeAgentBridgeRegistry.clearRegistration(); - if (_isCodexBridgeEnabled) { - _codexCooperationState = CodexCooperationState.bridgeOnly; - } else { - _codexCooperationState = CodexCooperationState.notStarted; - } - notifyListeners(); - } - - void _recomputeTasks() { - _tasksController.recompute( - sessions: sessions, - cronJobs: _cronJobsController.items, - currentSessionKey: _sessionsController.currentSessionKey, - hasPendingRun: hasAssistantPendingRun, - activeAgentName: _agentsController.activeAgentName, - ); - } - - void _attachChildListeners() { - _runtimeCoordinator.addListener(_relayChildChange); - _settingsController.addListener(_relayChildChange); - _agentsController.addListener(_relayChildChange); - _sessionsController.addListener(_relayChildChange); - _chatController.addListener(_relayChildChange); - _instancesController.addListener(_relayChildChange); - _skillsController.addListener(_relayChildChange); - _connectorsController.addListener(_relayChildChange); - _modelsController.addListener(_relayChildChange); - _cronJobsController.addListener(_relayChildChange); - _devicesController.addListener(_relayChildChange); - _tasksController.addListener(_relayChildChange); - _multiAgentOrchestrator.addListener(_relayChildChange); - } - - void _detachChildListeners() { - _runtimeCoordinator.removeListener(_relayChildChange); - _settingsController.removeListener(_relayChildChange); - _agentsController.removeListener(_relayChildChange); - _sessionsController.removeListener(_relayChildChange); - _chatController.removeListener(_relayChildChange); - _instancesController.removeListener(_relayChildChange); - _skillsController.removeListener(_relayChildChange); - _connectorsController.removeListener(_relayChildChange); - _modelsController.removeListener(_relayChildChange); - _cronJobsController.removeListener(_relayChildChange); - _devicesController.removeListener(_relayChildChange); - _tasksController.removeListener(_relayChildChange); - _multiAgentOrchestrator.removeListener(_relayChildChange); - } - - void _relayChildChange() { - _notifyIfActive(); - } - - void _notifyIfActive() { - if (_disposed) { - return; - } - notifyListeners(); - } - - RuntimeConnectionMode _modeFromHost(String host) { - final trimmed = host.trim().toLowerCase(); - if (trimmed == '127.0.0.1' || trimmed == 'localhost') { - return RuntimeConnectionMode.local; - } - return RuntimeConnectionMode.remote; - } - - AssistantExecutionTarget _assistantExecutionTargetForMode( - RuntimeConnectionMode mode, - ) { - return switch (mode) { - RuntimeConnectionMode.unconfigured => - AssistantExecutionTarget.aiGatewayOnly, - RuntimeConnectionMode.local => AssistantExecutionTarget.local, - RuntimeConnectionMode.remote => AssistantExecutionTarget.remote, - }; - } - - GatewayConnectionProfile _gatewayProfileForAssistantExecutionTarget( - AssistantExecutionTarget target, - ) { - if (target == AssistantExecutionTarget.aiGatewayOnly) { - return settings.gateway.copyWith( - mode: RuntimeConnectionMode.unconfigured, - useSetupCode: false, - setupCode: '', - ); - } - - final desiredMode = switch (target) { - AssistantExecutionTarget.aiGatewayOnly => - RuntimeConnectionMode.unconfigured, - AssistantExecutionTarget.local => RuntimeConnectionMode.local, - AssistantExecutionTarget.remote => RuntimeConnectionMode.remote, - }; - final savedProfile = settings.gateway; - if (savedProfile.mode == desiredMode) { - return savedProfile; - } - - if (desiredMode == RuntimeConnectionMode.local) { - return savedProfile.copyWith( - mode: RuntimeConnectionMode.local, - useSetupCode: false, - setupCode: '', - host: '127.0.0.1', - port: 18789, - tls: false, - ); - } - - final defaults = GatewayConnectionProfile.defaults(); - final savedHost = savedProfile.host.trim().isEmpty - ? defaults.host - : savedProfile.host.trim(); - final savedPort = savedProfile.port <= 0 - ? defaults.port - : savedProfile.port; - return savedProfile.copyWith( - mode: RuntimeConnectionMode.remote, - useSetupCode: false, - setupCode: '', - host: savedHost, - port: savedPort, - tls: savedProfile.tls, - ); - } -} - -class _AiGatewayChatException implements Exception { - const _AiGatewayChatException(this.message); - - final String message; - - @override - String toString() => message; -} - -class _AiGatewayAbortException implements Exception { - const _AiGatewayAbortException(this.partialText); - - final String partialText; -} +export 'app_controller_desktop.dart' + if (dart.library.html) 'app_controller_web.dart'; diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart new file mode 100644 index 00000000..cdebe35f --- /dev/null +++ b/lib/app/app_controller_desktop.dart @@ -0,0 +1,3081 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'app_metadata.dart'; +import 'app_capabilities.dart'; +import '../i18n/app_language.dart'; +import '../models/app_models.dart'; +import '../runtime/device_identity_store.dart'; +import '../runtime/aris_bundle.dart'; +import '../runtime/aris_bridge.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/runtime_coordinator.dart'; +import '../runtime/codex_runtime.dart'; +import '../runtime/codex_config_bridge.dart'; +import '../runtime/code_agent_node_orchestrator.dart'; +import '../runtime/mode_switcher.dart'; +import '../runtime/agent_registry.dart'; +import '../runtime/multi_agent_broker.dart'; +import '../runtime/multi_agent_mounts.dart'; +import '../runtime/multi_agent_orchestrator.dart'; + +enum CodexCooperationState { notStarted, bridgeOnly, registered } + +class AppController extends ChangeNotifier { + AppController({ + SecureConfigStore? store, + RuntimeCoordinator? runtimeCoordinator, + DesktopPlatformService? desktopPlatformService, + }) { + _store = store ?? SecureConfigStore(); + + final resolvedRuntimeCoordinator = + runtimeCoordinator ?? + RuntimeCoordinator( + gateway: GatewayRuntime( + store: _store, + identityStore: DeviceIdentityStore(_store), + ), + codex: CodexRuntime(), + configBridge: CodexConfigBridge(), + ); + + _runtimeCoordinator = resolvedRuntimeCoordinator; + _codeAgentNodeOrchestrator = CodeAgentNodeOrchestrator(_runtimeCoordinator); + _codeAgentBridgeRegistry = AgentRegistry(_runtimeCoordinator.gateway); + _settingsController = SettingsController(_store); + _agentsController = GatewayAgentsController(_runtimeCoordinator.gateway); + _sessionsController = GatewaySessionsController( + _runtimeCoordinator.gateway, + ); + _chatController = GatewayChatController(_runtimeCoordinator.gateway); + _instancesController = InstancesController(_runtimeCoordinator.gateway); + _skillsController = SkillsController(_runtimeCoordinator.gateway); + _connectorsController = ConnectorsController(_runtimeCoordinator.gateway); + _modelsController = ModelsController( + _runtimeCoordinator.gateway, + _settingsController, + ); + _cronJobsController = CronJobsController(_runtimeCoordinator.gateway); + _devicesController = DevicesController(_runtimeCoordinator.gateway); + _tasksController = DerivedTasksController(); + _desktopPlatformService = + desktopPlatformService ?? createDesktopPlatformService(); + _arisBundleRepository = ArisBundleRepository(); + _arisBridgeLocator = ArisBridgeLocator(); + _multiAgentMountManager = MultiAgentMountManager( + arisBundleRepository: _arisBundleRepository, + arisBridgeLocator: _arisBridgeLocator, + ); + _multiAgentOrchestrator = MultiAgentOrchestrator( + config: _resolveMultiAgentConfig(_settingsController.snapshot), + arisBundleRepository: _arisBundleRepository, + arisBridgeLocator: _arisBridgeLocator, + ); + + _attachChildListeners(); + unawaited(_initialize()); + } + + late final SecureConfigStore _store; + + late final RuntimeCoordinator _runtimeCoordinator; + late final CodeAgentNodeOrchestrator _codeAgentNodeOrchestrator; + late final AgentRegistry _codeAgentBridgeRegistry; + late final SettingsController _settingsController; + late final GatewayAgentsController _agentsController; + late final GatewaySessionsController _sessionsController; + late final GatewayChatController _chatController; + late final InstancesController _instancesController; + late final SkillsController _skillsController; + late final ConnectorsController _connectorsController; + late final ModelsController _modelsController; + late final CronJobsController _cronJobsController; + late final DevicesController _devicesController; + late final DerivedTasksController _tasksController; + late final DesktopPlatformService _desktopPlatformService; + late final ArisBundleRepository _arisBundleRepository; + late final ArisBridgeLocator _arisBridgeLocator; + late final MultiAgentMountManager _multiAgentMountManager; + late final MultiAgentOrchestrator _multiAgentOrchestrator; + MultiAgentBrokerServer? _multiAgentBrokerServer; + MultiAgentBrokerClient? _multiAgentBrokerClient; + final Map> _assistantThreadMessages = + >{}; + final Map _assistantThreadRecords = + {}; + final Map> _localSessionMessages = + >{}; + final Map> _gatewayHistoryCache = + >{}; + final Map _aiGatewayStreamingTextBySession = + {}; + final Map _aiGatewayStreamingClients = + {}; + final Set _aiGatewayPendingSessionKeys = {}; + final Set _aiGatewayAbortedSessionKeys = {}; + final Set _activeMultiAgentBrokerSessions = {}; + bool _multiAgentRunPending = false; + int _localMessageCounter = 0; + + WorkspaceDestination _destination = WorkspaceDestination.assistant; + ThemeMode _themeMode = ThemeMode.light; + AppSidebarState _sidebarState = AppSidebarState.expanded; + ModulesTab _modulesTab = ModulesTab.gateway; + SecretsTab _secretsTab = SecretsTab.vault; + AiGatewayTab _aiGatewayTab = AiGatewayTab.models; + SettingsTab _settingsTab = SettingsTab.general; + SettingsDetailPage? _settingsDetail; + SettingsNavigationContext? _settingsNavigationContext; + DetailPanelData? _detailPanel; + bool _initializing = true; + String? _bootstrapError; + StreamSubscription? _runtimeEventsSubscription; + bool _disposed = false; + + WorkspaceDestination get destination => _destination; + AppCapabilities get capabilities => AppCapabilities.desktop; + ThemeMode get themeMode => _themeMode; + AppSidebarState get sidebarState => _sidebarState; + ModulesTab get modulesTab => _modulesTab; + SecretsTab get secretsTab => _secretsTab; + AiGatewayTab get aiGatewayTab => _aiGatewayTab; + SettingsTab get settingsTab => _settingsTab; + SettingsDetailPage? get settingsDetail => _settingsDetail; + SettingsNavigationContext? get settingsNavigationContext => + _settingsNavigationContext; + DetailPanelData? get detailPanel => _detailPanel; + bool get initializing => _initializing; + String? get bootstrapError => _bootstrapError; + + RuntimeCoordinator get runtimeCoordinator => _runtimeCoordinator; + GatewayRuntime get _runtime => _runtimeCoordinator.gateway; + GatewayRuntime get runtime => _runtime; + + /// Whether Codex bridge is enabled and configured + bool get isCodexBridgeEnabled => _isCodexBridgeEnabled; + bool _isCodexBridgeEnabled = false; + bool _isCodexBridgeBusy = false; + String? _codexBridgeError; + String? _codexRuntimeWarning; + String? _resolvedCodexCliPath; + CodexCooperationState _codexCooperationState = + CodexCooperationState.notStarted; + SettingsController get settingsController => _settingsController; + GatewayAgentsController get agentsController => _agentsController; + GatewaySessionsController get sessionsController => _sessionsController; + MultiAgentOrchestrator get multiAgentOrchestrator => _multiAgentOrchestrator; + GatewayChatController get chatController => _chatController; + InstancesController get instancesController => _instancesController; + SkillsController get skillsController => _skillsController; + ConnectorsController get connectorsController => _connectorsController; + ModelsController get modelsController => _modelsController; + CronJobsController get cronJobsController => _cronJobsController; + DevicesController get devicesController => _devicesController; + DerivedTasksController get tasksController => _tasksController; + DesktopIntegrationState get desktopIntegration => + _desktopPlatformService.state; + bool get supportsDesktopIntegration => desktopIntegration.isSupported; + bool get desktopPlatformBusy => _desktopPlatformBusy; + + GatewayConnectionSnapshot get connection => _runtime.snapshot; + SettingsSnapshot get settings => _settingsController.snapshot; + List get agents => _agentsController.agents; + List get sessions => isAiGatewayOnlyMode + ? _assistantSessionSummaries() + : _sessionsController.sessions; + List get assistantSessions => _assistantSessions(); + List get instances => _instancesController.items; + List get skills => _skillsController.items; + List get connectors => _connectorsController.items; + List get models => _modelsController.items; + List get cronJobs => _cronJobsController.items; + GatewayDevicePairingList get devices => _devicesController.items; + String get selectedAgentId => _agentsController.selectedAgentId; + String get activeAgentName => _agentsController.activeAgentName; + String get currentSessionKey => _sessionsController.currentSessionKey; + String? get activeRunId => _chatController.activeRunId; + AppLanguage get appLanguage => settings.appLanguage; + AssistantExecutionTarget get assistantExecutionTarget => + currentAssistantExecutionTarget; + AssistantExecutionTarget get currentAssistantExecutionTarget => + assistantExecutionTargetForSession(currentSessionKey); + AssistantMessageViewMode get currentAssistantMessageViewMode => + assistantMessageViewModeForSession(currentSessionKey); + AssistantPermissionLevel get assistantPermissionLevel => + settings.assistantPermissionLevel; + bool get hasStoredGatewayCredential => + _settingsController.secureRefs.containsKey('gateway_token') || + _settingsController.secureRefs.containsKey('gateway_password') || + _settingsController.secureRefs.containsKey( + 'gateway_device_token_operator', + ); + bool get hasStoredGatewayToken => + _settingsController.secureRefs.containsKey('gateway_token'); + String? get storedGatewayTokenMask => + _settingsController.secureRefs['gateway_token']; + String get aiGatewayUrl => settings.aiGateway.baseUrl.trim(); + bool get hasStoredAiGatewayApiKey => + _settingsController.secureRefs.containsKey('ai_gateway_api_key'); + bool get isAiGatewayOnlyMode => + currentAssistantExecutionTarget == AssistantExecutionTarget.aiGatewayOnly; + bool get isCodexBridgeBusy => _isCodexBridgeBusy; + String? get codexBridgeError => _codexBridgeError; + String? get codexRuntimeWarning => _codexRuntimeWarning; + String? get resolvedCodexCliPath => _resolvedCodexCliPath; + bool get hasDetectedCodexCli => _resolvedCodexCliPath != null; + String get configuredCodexCliPath => settings.codexCliPath.trim(); + CodeAgentRuntimeMode get configuredCodeAgentRuntimeMode => + settings.codeAgentRuntimeMode; + CodeAgentRuntimeMode get effectiveCodeAgentRuntimeMode => + configuredCodeAgentRuntimeMode; + CodexCooperationState get codexCooperationState => _codexCooperationState; + bool get isMultiAgentRunPending => _multiAgentRunPending; + bool _desktopPlatformBusy = false; + + bool get hasAssistantPendingRun => + assistantSessionHasPendingRun(currentSessionKey); + + bool get canUseAiGatewayConversation => + aiGatewayUrl.isNotEmpty && + hasStoredAiGatewayApiKey && + resolvedAiGatewayModel.isNotEmpty; + + List get aiGatewayConversationModelChoices { + final selected = settings.aiGateway.selectedModels + .map((item) => item.trim()) + .where( + (item) => + item.isNotEmpty && + settings.aiGateway.availableModels.contains(item), + ) + .toList(growable: false); + if (selected.isNotEmpty) { + return selected; + } + final available = settings.aiGateway.availableModels + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) + .toList(growable: false); + if (available.isNotEmpty) { + return available; + } + return const []; + } + + String get resolvedAiGatewayModel { + final current = settings.defaultModel.trim(); + final choices = aiGatewayConversationModelChoices; + if (choices.contains(current)) { + return current; + } + if (choices.isNotEmpty) { + return choices.first; + } + return ''; + } + + String get resolvedAssistantModel { + return _resolvedAssistantModelForTarget(currentAssistantExecutionTarget); + } + + String _resolvedAssistantModelForTarget(AssistantExecutionTarget target) { + if (target == AssistantExecutionTarget.aiGatewayOnly) { + return resolvedAiGatewayModel; + } + final resolved = resolvedDefaultModel.trim(); + if (resolved.isNotEmpty) { + return resolved; + } + return ''; + } + + String get assistantConversationOwnerLabel { + if (!isAiGatewayOnlyMode) { + return activeAgentName; + } + final model = resolvedAssistantModel; + return model.isEmpty ? appText('AI Gateway', 'AI Gateway') : model; + } + + String get assistantConnectionStatusLabel => isAiGatewayOnlyMode + ? appText('仅 AI Gateway', 'AI Gateway Only') + : connection.status.label; + + String get assistantConnectionTargetLabel { + if (!isAiGatewayOnlyMode) { + return connection.remoteAddress ?? appText('未连接目标', 'No target'); + } + final model = resolvedAssistantModel; + final host = _aiGatewayHostLabel(settings.aiGateway.baseUrl); + if (model.isNotEmpty && host.isNotEmpty) { + return '$model · $host'; + } + if (model.isNotEmpty) { + return model; + } + if (host.isNotEmpty) { + return host; + } + return appText('AI Gateway 未配置', 'AI Gateway not configured'); + } + + Future loadAiGatewayApiKey() async { + return (await _store.loadAiGatewayApiKey())?.trim() ?? ''; + } + + Future saveMultiAgentConfig(MultiAgentConfig config) async { + final resolved = _resolveMultiAgentConfig( + settings.copyWith(multiAgent: config), + ); + await saveSettings( + settings.copyWith(multiAgent: resolved), + refreshAfterSave: false, + ); + await refreshMultiAgentMounts(sync: resolved.autoSync); + } + + Future refreshMultiAgentMounts({bool sync = false}) async { + if (_disposed) { + return; + } + final resolved = _resolveMultiAgentConfig(settings); + final reconciled = await _multiAgentMountManager.reconcile( + config: sync ? resolved : resolved.copyWith(autoSync: false), + aiGatewayUrl: aiGatewayUrl, + ); + if (_disposed) { + return; + } + if (jsonEncode(reconciled.toJson()) != + jsonEncode(settings.multiAgent.toJson())) { + await _settingsController.saveSnapshot( + settings.copyWith(multiAgent: reconciled), + ); + } + if (_disposed) { + return; + } + _multiAgentOrchestrator.updateConfig(reconciled); + _notifyIfActive(); + } + + Future runMultiAgentCollaboration({ + required String rawPrompt, + required String composedPrompt, + required List attachments, + required List selectedSkillLabels, + }) async { + final sessionKey = currentSessionKey.trim().isEmpty + ? 'main' + : currentSessionKey; + final client = await _ensureMultiAgentBrokerClient(); + final aiGatewayApiKey = await loadAiGatewayApiKey(); + _multiAgentRunPending = true; + _appendLocalSessionMessage( + sessionKey, + GatewayChatMessage( + id: _nextLocalMessageId(), + role: 'user', + text: rawPrompt, + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + ); + _recomputeTasks(); + try { + final taskStream = settings.multiAgent.usesAris + ? (_activeMultiAgentBrokerSessions.contains(sessionKey) + ? client.sendSessionMessage( + sessionId: sessionKey, + taskPrompt: composedPrompt, + workingDirectory: + _resolveCodexWorkingDirectory() ?? + Directory.current.path, + attachments: attachments, + selectedSkills: selectedSkillLabels, + aiGatewayBaseUrl: aiGatewayUrl, + aiGatewayApiKey: aiGatewayApiKey, + ) + : client.startSession( + sessionId: sessionKey, + taskPrompt: composedPrompt, + workingDirectory: + _resolveCodexWorkingDirectory() ?? + Directory.current.path, + attachments: attachments, + selectedSkills: selectedSkillLabels, + aiGatewayBaseUrl: aiGatewayUrl, + aiGatewayApiKey: aiGatewayApiKey, + )) + : client.runTask( + taskPrompt: composedPrompt, + workingDirectory: + _resolveCodexWorkingDirectory() ?? Directory.current.path, + attachments: attachments, + selectedSkills: selectedSkillLabels, + aiGatewayBaseUrl: aiGatewayUrl, + aiGatewayApiKey: aiGatewayApiKey, + ); + if (settings.multiAgent.usesAris) { + _activeMultiAgentBrokerSessions.add(sessionKey); + } + 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']; + _appendLocalSessionMessage( + sessionKey, + GatewayChatMessage( + id: _nextLocalMessageId(), + 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; + } + _appendLocalSessionMessage( + sessionKey, + GatewayChatMessage( + id: _nextLocalMessageId(), + role: 'assistant', + text: event.message, + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: event.title, + stopReason: null, + pending: event.pending, + error: event.error, + ), + ); + } + } catch (error) { + _appendLocalSessionMessage( + sessionKey, + GatewayChatMessage( + id: _nextLocalMessageId(), + role: 'assistant', + text: error.toString(), + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: 'Multi-Agent', + stopReason: null, + pending: false, + error: true, + ), + ); + } finally { + _multiAgentRunPending = false; + _recomputeTasks(); + _notifyIfActive(); + } + } + + 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 _modelsController.items + .map((item) => item.id.trim()) + .where((item) => item.isNotEmpty) + .toList(growable: false); + } + + List get assistantModelChoices { + if (isAiGatewayOnlyMode) { + return aiGatewayConversationModelChoices; + } + 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 profile = settings.gateway; + 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 = GatewayConnectionProfile.defaults(); + return hasStoredGatewayCredential || + host != defaults.host || + profile.port != defaults.port || + profile.tls != defaults.tls || + profile.mode != defaults.mode; + } + + List get secretReferences => + _settingsController.buildSecretReferences(); + List get secretAuditTrail => _settingsController.auditTrail; + List get runtimeLogs => _runtime.logs; + List get assistantNavigationDestinations => + normalizeAssistantNavigationDestinations( + settings.assistantNavigationDestinations, + ); + + List get chatMessages { + final sessionKey = _normalizedAssistantSessionKey( + _sessionsController.currentSessionKey, + ); + final items = List.from( + isAiGatewayOnlyMode + ? (_gatewayHistoryCache[sessionKey] ?? const []) + : _chatController.messages, + ); + final threadItems = isAiGatewayOnlyMode + ? _assistantThreadMessages[sessionKey] + : null; + if (threadItems != null && threadItems.isNotEmpty) { + items.addAll(threadItems); + } + final localItems = _localSessionMessages[sessionKey]; + if (localItems != null && localItems.isNotEmpty) { + items.addAll(localItems); + } + final streaming = isAiGatewayOnlyMode + ? (_aiGatewayStreamingTextBySession[sessionKey]?.trim() ?? '') + : (_chatController.streamingAssistantText?.trim() ?? ''); + if (streaming.isNotEmpty) { + items.add( + GatewayChatMessage( + id: 'streaming', + role: 'assistant', + text: streaming, + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: null, + stopReason: null, + pending: true, + error: false, + ), + ); + } + return items; + } + + String _normalizedAssistantSessionKey(String sessionKey) { + final trimmed = sessionKey.trim(); + return trimmed.isEmpty ? 'main' : trimmed; + } + + AssistantExecutionTarget assistantExecutionTargetForSession( + String sessionKey, + ) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + return _assistantThreadRecords[normalizedSessionKey]?.executionTarget ?? + settings.assistantExecutionTarget; + } + + AssistantMessageViewMode assistantMessageViewModeForSession( + String sessionKey, + ) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + return _assistantThreadRecords[normalizedSessionKey]?.messageViewMode ?? + AssistantMessageViewMode.rendered; + } + + List _assistantSessions() { + final archivedKeys = settings.assistantArchivedTaskKeys + .map(_normalizedAssistantSessionKey) + .toSet(); + final byKey = {}; + + for (final session in _sessionsController.sessions) { + final normalizedSessionKey = _normalizedAssistantSessionKey(session.key); + if (archivedKeys.contains(normalizedSessionKey)) { + continue; + } + byKey[normalizedSessionKey] = session; + } + + for (final record in _assistantThreadRecords.values) { + final normalizedSessionKey = _normalizedAssistantSessionKey( + record.sessionKey, + ); + if (normalizedSessionKey.isEmpty || + archivedKeys.contains(normalizedSessionKey) || + record.archived) { + continue; + } + byKey.putIfAbsent( + normalizedSessionKey, + () => _assistantSessionSummaryFor(normalizedSessionKey, record: record), + ); + } + + final currentKey = _normalizedAssistantSessionKey(currentSessionKey); + if (!archivedKeys.contains(currentKey) && !byKey.containsKey(currentKey)) { + byKey[currentKey] = _assistantSessionSummaryFor(currentKey); + } + + final items = byKey.values.toList(growable: true) + ..sort( + (left, right) => + (right.updatedAtMs ?? 0).compareTo(left.updatedAtMs ?? 0), + ); + return items; + } + + bool assistantSessionHasPendingRun(String sessionKey) { + final normalized = _normalizedAssistantSessionKey(sessionKey); + if (assistantExecutionTargetForSession(normalized) == + AssistantExecutionTarget.aiGatewayOnly) { + return _aiGatewayPendingSessionKeys.contains(normalized); + } + return (_chatController.hasPendingRun || _multiAgentRunPending) && + matchesSessionKey(normalized, _sessionsController.currentSessionKey); + } + + void navigateTo(WorkspaceDestination destination) { + final nextModulesTab = switch (destination) { + WorkspaceDestination.nodes => ModulesTab.nodes, + WorkspaceDestination.agents => ModulesTab.agents, + _ => _modulesTab, + }; + final shouldClearSettingsDrillIn = + _settingsDetail != null || _settingsNavigationContext != null; + final changed = + _destination != destination || + _detailPanel != null || + shouldClearSettingsDrillIn || + nextModulesTab != _modulesTab; + if (!changed) { + return; + } + _destination = destination; + _modulesTab = nextModulesTab; + _settingsDetail = null; + _settingsNavigationContext = null; + _detailPanel = null; + notifyListeners(); + } + + void navigateHome() { + final mainSessionKey = + _runtime.snapshot.mainSessionKey?.trim().isNotEmpty == true + ? _runtime.snapshot.mainSessionKey!.trim() + : 'main'; + final destinationChanged = _destination != WorkspaceDestination.assistant; + final detailChanged = _detailPanel != null; + final settingsDrillInChanged = + _settingsDetail != null || _settingsNavigationContext != null; + _destination = WorkspaceDestination.assistant; + _settingsDetail = null; + _settingsNavigationContext = null; + _detailPanel = null; + if (destinationChanged || detailChanged || settingsDrillInChanged) { + notifyListeners(); + } + if (_sessionsController.currentSessionKey != mainSessionKey) { + unawaited(switchSession(mainSessionKey)); + } + } + + void openModules({ModulesTab tab = ModulesTab.gateway}) { + final destination = tab == ModulesTab.agents + ? WorkspaceDestination.agents + : WorkspaceDestination.nodes; + final changed = + _destination != destination || + _modulesTab != tab || + _detailPanel != null || + _settingsDetail != null || + _settingsNavigationContext != null; + if (!changed) { + return; + } + _destination = destination; + _modulesTab = tab; + _detailPanel = null; + _settingsDetail = null; + _settingsNavigationContext = null; + notifyListeners(); + } + + void setModulesTab(ModulesTab tab) { + if (_modulesTab == tab) { + return; + } + _modulesTab = tab; + notifyListeners(); + } + + void openSecrets({SecretsTab tab = SecretsTab.vault}) { + final changed = + _destination != WorkspaceDestination.secrets || + _secretsTab != tab || + _detailPanel != null || + _settingsDetail != null || + _settingsNavigationContext != null; + if (!changed) { + return; + } + _destination = WorkspaceDestination.secrets; + _secretsTab = tab; + _detailPanel = null; + _settingsDetail = null; + _settingsNavigationContext = null; + notifyListeners(); + } + + void setSecretsTab(SecretsTab tab) { + if (_secretsTab == tab) { + return; + } + _secretsTab = tab; + notifyListeners(); + } + + void openAiGateway({AiGatewayTab tab = AiGatewayTab.models}) { + final changed = + _destination != WorkspaceDestination.aiGateway || + _aiGatewayTab != tab || + _detailPanel != null || + _settingsDetail != null || + _settingsNavigationContext != null; + if (!changed) { + return; + } + _destination = WorkspaceDestination.aiGateway; + _aiGatewayTab = tab; + _detailPanel = null; + _settingsDetail = null; + _settingsNavigationContext = null; + notifyListeners(); + } + + void setAiGatewayTab(AiGatewayTab tab) { + if (_aiGatewayTab == tab) { + return; + } + _aiGatewayTab = tab; + notifyListeners(); + } + + void openSettings({ + SettingsTab tab = SettingsTab.general, + SettingsDetailPage? detail, + SettingsNavigationContext? navigationContext, + }) { + final resolvedTab = detail?.tab ?? tab; + final changed = + _destination != WorkspaceDestination.settings || + _settingsTab != resolvedTab || + _settingsDetail != detail || + _settingsNavigationContext != navigationContext || + _detailPanel != null; + if (!changed) { + return; + } + _destination = WorkspaceDestination.settings; + _settingsTab = resolvedTab; + _settingsDetail = detail; + _settingsNavigationContext = navigationContext; + _detailPanel = null; + notifyListeners(); + } + + void setSettingsTab(SettingsTab tab, {bool clearDetail = true}) { + final changed = + _settingsTab != tab || + (clearDetail && + (_settingsDetail != null || _settingsNavigationContext != null)); + if (!changed) { + return; + } + _settingsTab = tab; + if (clearDetail) { + _settingsDetail = null; + _settingsNavigationContext = null; + } + notifyListeners(); + } + + void closeSettingsDetail() { + if (_settingsDetail == null && _settingsNavigationContext == null) { + return; + } + _settingsDetail = null; + _settingsNavigationContext = null; + notifyListeners(); + } + + void cycleSidebarState() { + _sidebarState = switch (_sidebarState) { + AppSidebarState.expanded => AppSidebarState.collapsed, + AppSidebarState.collapsed => AppSidebarState.hidden, + AppSidebarState.hidden => AppSidebarState.expanded, + }; + notifyListeners(); + } + + void setSidebarState(AppSidebarState state) { + if (_sidebarState == state) { + return; + } + _sidebarState = state; + notifyListeners(); + } + + void setThemeMode(ThemeMode mode) { + if (_themeMode == mode) { + return; + } + _themeMode = mode; + notifyListeners(); + } + + Future toggleAppLanguage() async { + await setAppLanguage( + settings.appLanguage == AppLanguage.zh ? AppLanguage.en : AppLanguage.zh, + ); + } + + Future setAppLanguage(AppLanguage language) async { + if (settings.appLanguage == language) { + return; + } + setActiveAppLanguage(language); + await saveSettings( + settings.copyWith(appLanguage: language), + refreshAfterSave: false, + ); + } + + void openDetail(DetailPanelData detailPanel) { + _detailPanel = detailPanel; + notifyListeners(); + } + + void closeDetail() { + if (_detailPanel == null) { + return; + } + _detailPanel = null; + notifyListeners(); + } + + Future connectWithSetupCode({ + required String setupCode, + String token = '', + String password = '', + }) async { + final decoded = decodeGatewaySetupCode(setupCode); + final resolvedToken = token.trim().isNotEmpty + ? token.trim() + : (decoded?.token.trim() ?? ''); + final resolvedPassword = password.trim().isNotEmpty + ? password.trim() + : (decoded?.password.trim() ?? ''); + await _settingsController.saveGatewaySecrets( + token: resolvedToken, + password: resolvedPassword, + ); + final nextProfile = settings.gateway.copyWith( + useSetupCode: true, + setupCode: setupCode.trim(), + host: decoded?.host ?? settings.gateway.host, + port: decoded?.port ?? settings.gateway.port, + tls: decoded?.tls ?? settings.gateway.tls, + mode: _modeFromHost(decoded?.host ?? settings.gateway.host), + ); + final nextTarget = _assistantExecutionTargetForMode(nextProfile.mode); + await saveSettings( + settings.copyWith( + gateway: nextProfile, + assistantExecutionTarget: nextTarget, + ), + refreshAfterSave: false, + ); + _upsertAssistantThreadRecord( + _sessionsController.currentSessionKey, + executionTarget: nextTarget, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + await _connectProfile( + nextProfile, + authTokenOverride: resolvedToken, + authPasswordOverride: resolvedPassword, + ); + await _chatController.loadSession(_sessionsController.currentSessionKey); + } + + Future connectManual({ + required String host, + required int port, + required bool tls, + required RuntimeConnectionMode mode, + String token = '', + String password = '', + }) async { + await _settingsController.saveGatewaySecrets( + token: token.trim(), + password: password.trim(), + ); + final resolvedHost = + host.trim().isEmpty && mode == RuntimeConnectionMode.local + ? '127.0.0.1' + : host.trim(); + final resolvedPort = mode == RuntimeConnectionMode.local && port <= 0 + ? 18789 + : port; + final nextProfile = settings.gateway.copyWith( + mode: mode, + useSetupCode: false, + setupCode: '', + host: resolvedHost, + port: resolvedPort <= 0 ? 443 : resolvedPort, + tls: mode == RuntimeConnectionMode.local ? false : tls, + ); + final nextTarget = _assistantExecutionTargetForMode(nextProfile.mode); + await saveSettings( + settings.copyWith( + gateway: nextProfile, + assistantExecutionTarget: nextTarget, + ), + refreshAfterSave: false, + ); + _upsertAssistantThreadRecord( + _sessionsController.currentSessionKey, + executionTarget: nextTarget, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + await _connectProfile( + nextProfile, + authTokenOverride: token.trim(), + authPasswordOverride: password.trim(), + ); + await _chatController.loadSession(_sessionsController.currentSessionKey); + } + + Future disconnectGateway() async { + _clearCodexGatewayRegistration(); + await _runtime.disconnect(clearDesiredProfile: false); + await _settingsController.refreshDerivedState(); + await _agentsController.refresh(); + await _sessionsController.refresh(); + _chatController.clear(); + await _instancesController.refresh(); + await _skillsController.refresh(); + await _connectorsController.refresh(); + await _modelsController.refresh(); + await _cronJobsController.refresh(); + _devicesController.clear(); + _recomputeTasks(); + } + + Future connectSavedGateway() async { + await _connectProfile(settings.gateway); + } + + Future clearStoredGatewayToken() async { + await _settingsController.clearGatewaySecrets(token: true); + } + + Future refreshGatewayHealth() async { + if (!_runtime.isConnected) { + return; + } + try { + await _runtime.health(); + } catch (_) {} + try { + await _runtime.status(); + } catch (_) {} + notifyListeners(); + } + + Future refreshDevices({bool quiet = false}) async { + await _devicesController.refresh(quiet: quiet); + } + + Future approveDevicePairing(String requestId) async { + await _devicesController.approve(requestId); + await _settingsController.refreshDerivedState(); + } + + Future rejectDevicePairing(String requestId) async { + await _devicesController.reject(requestId); + } + + Future removePairedDevice(String deviceId) async { + await _devicesController.remove(deviceId); + await _settingsController.refreshDerivedState(); + } + + Future rotateDeviceRoleToken({ + required String deviceId, + required String role, + List scopes = const [], + }) async { + final token = await _devicesController.rotateToken( + deviceId: deviceId, + role: role, + scopes: scopes, + ); + await _settingsController.refreshDerivedState(); + return token; + } + + Future revokeDeviceRoleToken({ + required String deviceId, + required String role, + }) async { + await _devicesController.revokeToken(deviceId: deviceId, role: role); + await _settingsController.refreshDerivedState(); + } + + Future refreshAgents() async { + await _agentsController.refresh(); + _sessionsController.configure( + mainSessionKey: _runtime.snapshot.mainSessionKey ?? 'main', + selectedAgentId: _agentsController.selectedAgentId, + defaultAgentId: '', + ); + _recomputeTasks(); + } + + Future selectAgent(String? agentId) async { + _agentsController.selectAgent(agentId); + final nextProfile = settings.gateway.copyWith( + selectedAgentId: _agentsController.selectedAgentId, + ); + await saveSettings( + settings.copyWith(gateway: nextProfile), + refreshAfterSave: false, + ); + _sessionsController.configure( + mainSessionKey: _runtime.snapshot.mainSessionKey ?? 'main', + selectedAgentId: _agentsController.selectedAgentId, + defaultAgentId: '', + ); + await _chatController.loadSession(_sessionsController.currentSessionKey); + await _skillsController.refresh( + agentId: _agentsController.selectedAgentId.isEmpty + ? null + : _agentsController.selectedAgentId, + ); + _recomputeTasks(); + } + + Future refreshSessions() async { + _sessionsController.configure( + mainSessionKey: _runtime.snapshot.mainSessionKey ?? 'main', + selectedAgentId: _agentsController.selectedAgentId, + defaultAgentId: '', + ); + await _sessionsController.refresh(); + await _chatController.loadSession(_sessionsController.currentSessionKey); + _recomputeTasks(); + } + + Future switchSession(String sessionKey) async { + final previousSessionKey = _normalizedAssistantSessionKey( + _sessionsController.currentSessionKey, + ); + final nextSessionKey = _normalizedAssistantSessionKey(sessionKey); + final nextTarget = assistantExecutionTargetForSession(nextSessionKey); + final nextViewMode = assistantMessageViewModeForSession(nextSessionKey); + + if (!isAiGatewayOnlyMode) { + _preserveGatewayHistoryForSession(previousSessionKey); + } + + await _sessionsController.switchSession(nextSessionKey); + _upsertAssistantThreadRecord( + nextSessionKey, + executionTarget: nextTarget, + messageViewMode: nextViewMode, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + await _applyAssistantExecutionTarget( + nextTarget, + sessionKey: nextSessionKey, + persistDefaultSelection: false, + ); + _recomputeTasks(); + } + + Future sendChatMessage( + String message, { + String thinking = 'off', + List attachments = + const [], + }) async { + if (isAiGatewayOnlyMode) { + await _sendAiGatewayMessage( + message, + thinking: thinking, + attachments: attachments, + ); + _recomputeTasks(); + return; + } + final dispatch = _codeAgentNodeOrchestrator.buildGatewayDispatch( + _buildCodeAgentNodeState(), + ); + await _chatController.sendMessage( + sessionKey: _sessionsController.currentSessionKey, + message: message, + thinking: thinking, + attachments: attachments, + agentId: dispatch.agentId, + metadata: dispatch.metadata, + ); + _recomputeTasks(); + } + + Future abortRun() async { + if (_multiAgentRunPending) { + final sessionKey = _normalizedAssistantSessionKey( + _sessionsController.currentSessionKey, + ); + if (_activeMultiAgentBrokerSessions.contains(sessionKey)) { + await _multiAgentBrokerClient?.cancelSession(sessionKey); + } + await _multiAgentOrchestrator.abort(); + _multiAgentRunPending = false; + _recomputeTasks(); + _notifyIfActive(); + return; + } + if (isAiGatewayOnlyMode) { + await _abortAiGatewayRun(_sessionsController.currentSessionKey); + return; + } + await _chatController.abortRun(); + } + + Future setAssistantExecutionTarget( + AssistantExecutionTarget target, + ) async { + final currentTarget = assistantExecutionTargetForSession( + _sessionsController.currentSessionKey, + ); + if (currentTarget == target && + settings.assistantExecutionTarget == target) { + return; + } + _upsertAssistantThreadRecord( + _sessionsController.currentSessionKey, + executionTarget: target, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + await _applyAssistantExecutionTarget( + target, + sessionKey: _sessionsController.currentSessionKey, + persistDefaultSelection: true, + ); + _recomputeTasks(); + _notifyIfActive(); + } + + Future setAssistantMessageViewMode( + AssistantMessageViewMode mode, + ) async { + final sessionKey = _normalizedAssistantSessionKey( + _sessionsController.currentSessionKey, + ); + if (assistantMessageViewModeForSession(sessionKey) == mode) { + return; + } + _upsertAssistantThreadRecord( + sessionKey, + messageViewMode: mode, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + _recomputeTasks(); + _notifyIfActive(); + } + + Future setAssistantPermissionLevel( + AssistantPermissionLevel level, + ) async { + if (settings.assistantPermissionLevel == level) { + return; + } + await saveSettings( + settings.copyWith(assistantPermissionLevel: level), + refreshAfterSave: false, + ); + } + + Future _applyAssistantExecutionTarget( + AssistantExecutionTarget target, { + required String sessionKey, + required bool persistDefaultSelection, + }) async { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + if (!matchesSessionKey( + normalizedSessionKey, + _sessionsController.currentSessionKey, + )) { + await _sessionsController.switchSession(normalizedSessionKey); + } + if (persistDefaultSelection && + settings.assistantExecutionTarget != target) { + await saveSettings( + settings.copyWith(assistantExecutionTarget: target), + refreshAfterSave: false, + ); + } + + if (target == AssistantExecutionTarget.aiGatewayOnly) { + if (_runtime.isConnected) { + _preserveGatewayHistoryForSession(normalizedSessionKey); + } + await _ensureActiveAssistantThread(); + if (_runtime.isConnected) { + try { + await disconnectGateway(); + } catch (_) { + // Preserve the selected thread-bound target even when the active + // gateway session does not close cleanly on the first attempt. + } + } else { + _chatController.clear(); + } + await _sessionsController.switchSession(normalizedSessionKey); + return; + } + + final targetProfile = _gatewayProfileForAssistantExecutionTarget(target); + try { + await _connectProfile(targetProfile); + } catch (_) { + // Keep the selected execution target even when the immediate reconnect + // fails so the user can retry or adjust gateway settings manually. + } + await _sessionsController.switchSession(normalizedSessionKey); + await _chatController.loadSession(normalizedSessionKey); + } + + Future selectDefaultModel(String modelId) async { + final trimmed = modelId.trim(); + if (trimmed.isEmpty || settings.defaultModel == trimmed) { + return; + } + await saveSettings( + settings.copyWith(defaultModel: trimmed), + refreshAfterSave: false, + ); + } + + Future selectAssistantModel(String modelId) async { + final trimmed = modelId.trim(); + if (trimmed.isEmpty) { + return; + } + final choices = assistantModelChoices; + if (choices.isNotEmpty && !choices.contains(trimmed)) { + return; + } + await selectDefaultModel(trimmed); + } + + String assistantCustomTaskTitle(String sessionKey) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + final settingsTitle = + settings.assistantCustomTaskTitles[normalizedSessionKey]?.trim() ?? ''; + if (settingsTitle.isNotEmpty) { + return settingsTitle; + } + return _assistantThreadRecords[normalizedSessionKey]?.title.trim() ?? ''; + } + + void initializeAssistantThreadContext( + String sessionKey, { + String title = '', + AssistantExecutionTarget? executionTarget, + AssistantMessageViewMode? messageViewMode, + }) { + _upsertAssistantThreadRecord( + sessionKey, + title: title.trim(), + executionTarget: + executionTarget ?? + assistantExecutionTargetForSession(currentSessionKey), + messageViewMode: + messageViewMode ?? + assistantMessageViewModeForSession(currentSessionKey), + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + _notifyIfActive(); + } + + Future saveAssistantTaskTitle(String sessionKey, String title) async { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + if (normalizedSessionKey.isEmpty) { + return; + } + final normalizedTitle = title.trim(); + final next = Map.from(settings.assistantCustomTaskTitles); + final current = next[normalizedSessionKey]?.trim() ?? ''; + if (normalizedTitle.isEmpty) { + if (current.isEmpty) { + return; + } + next.remove(normalizedSessionKey); + } else { + if (current == normalizedTitle) { + return; + } + next[normalizedSessionKey] = normalizedTitle; + } + await saveSettings( + settings.copyWith(assistantCustomTaskTitles: next), + refreshAfterSave: false, + ); + _upsertAssistantThreadRecord( + normalizedSessionKey, + title: normalizedTitle, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + _recomputeTasks(); + _notifyIfActive(); + } + + bool isAssistantTaskArchived(String sessionKey) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + return settings.assistantArchivedTaskKeys.any( + (item) => _normalizedAssistantSessionKey(item) == normalizedSessionKey, + ); + } + + Future saveAssistantTaskArchived( + String sessionKey, + bool archived, + ) async { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + if (normalizedSessionKey.isEmpty) { + return; + } + final next = [ + ...settings.assistantArchivedTaskKeys.where( + (item) => _normalizedAssistantSessionKey(item) != normalizedSessionKey, + ), + ]; + if (archived) { + next.add(normalizedSessionKey); + } + await saveSettings( + settings.copyWith(assistantArchivedTaskKeys: next), + refreshAfterSave: false, + ); + if (archived) { + _activeMultiAgentBrokerSessions.remove(normalizedSessionKey); + unawaited(_multiAgentBrokerClient?.closeSession(normalizedSessionKey)); + } + _upsertAssistantThreadRecord( + normalizedSessionKey, + archived: archived, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + _recomputeTasks(); + _notifyIfActive(); + } + + Future updateAiGatewaySelection(List selectedModels) async { + final available = settings.aiGateway.availableModels; + final normalized = selectedModels + .map((item) => item.trim()) + .where((item) => item.isNotEmpty && available.contains(item)) + .toList(growable: false); + final fallbackSelection = normalized.isNotEmpty + ? normalized + : available.isNotEmpty + ? [available.first] + : const []; + final currentDefaultModel = settings.defaultModel.trim(); + final resolvedDefaultModel = fallbackSelection.contains(currentDefaultModel) + ? currentDefaultModel + : fallbackSelection.isNotEmpty + ? fallbackSelection.first + : ''; + await saveSettings( + settings.copyWith( + aiGateway: settings.aiGateway.copyWith( + selectedModels: fallbackSelection, + ), + defaultModel: resolvedDefaultModel, + ), + refreshAfterSave: false, + ); + } + + Future syncAiGatewayCatalog( + AiGatewayProfile profile, { + String apiKeyOverride = '', + }) async { + final synced = await _settingsController.syncAiGatewayCatalog( + profile, + apiKeyOverride: apiKeyOverride, + ); + _modelsController.restoreFromSettings( + _settingsController.snapshot.aiGateway, + ); + _recomputeTasks(); + return synced; + } + + Future saveSettings( + SettingsSnapshot snapshot, { + bool refreshAfterSave = true, + }) async { + final current = settings; + final sanitized = _sanitizeMultiAgentSettings( + _sanitizeOllamaCloudSettings(_sanitizeCodeAgentSettings(snapshot)), + ); + setActiveAppLanguage(sanitized.appLanguage); + await _settingsController.saveSnapshot(sanitized); + _multiAgentOrchestrator.updateConfig(sanitized.multiAgent); + _agentsController.restoreSelection(sanitized.gateway.selectedAgentId); + _modelsController.restoreFromSettings(sanitized.aiGateway); + if (current.codexCliPath != sanitized.codexCliPath || + current.codeAgentRuntimeMode != sanitized.codeAgentRuntimeMode) { + _registerCodexExternalProvider(codexPath: sanitized.codexCliPath); + await _refreshCodexCliAvailability(); + } + if (current.linuxDesktop.toJson().toString() != + sanitized.linuxDesktop.toJson().toString() || + current.launchAtLogin != sanitized.launchAtLogin) { + await _desktopPlatformService.syncConfig(sanitized.linuxDesktop); + await _desktopPlatformService.setLaunchAtLogin(sanitized.launchAtLogin); + } + if (refreshAfterSave) { + _recomputeTasks(); + } + unawaited(refreshMultiAgentMounts(sync: sanitized.multiAgent.autoSync)); + notifyListeners(); + } + + Future refreshDesktopIntegration() async { + _desktopPlatformBusy = true; + notifyListeners(); + try { + await _desktopPlatformService.refresh(); + } finally { + _desktopPlatformBusy = false; + notifyListeners(); + } + } + + Future saveLinuxDesktopConfig(LinuxDesktopConfig config) async { + await saveSettings(settings.copyWith(linuxDesktop: config)); + } + + Future setDesktopVpnMode(VpnMode mode) async { + _desktopPlatformBusy = true; + notifyListeners(); + try { + await saveSettings( + settings.copyWith( + linuxDesktop: settings.linuxDesktop.copyWith(preferredMode: mode), + ), + refreshAfterSave: false, + ); + await _desktopPlatformService.setMode(mode); + } finally { + _desktopPlatformBusy = false; + notifyListeners(); + } + } + + Future connectDesktopTunnel() async { + _desktopPlatformBusy = true; + notifyListeners(); + try { + await _desktopPlatformService.connectTunnel(); + } finally { + _desktopPlatformBusy = false; + notifyListeners(); + } + } + + Future disconnectDesktopTunnel() async { + _desktopPlatformBusy = true; + notifyListeners(); + try { + await _desktopPlatformService.disconnectTunnel(); + } finally { + _desktopPlatformBusy = false; + notifyListeners(); + } + } + + Future setLaunchAtLogin(bool enabled) async { + await saveSettings( + settings.copyWith(launchAtLogin: enabled), + refreshAfterSave: false, + ); + } + + Future toggleAssistantNavigationDestination( + WorkspaceDestination destination, + ) async { + if (!kAssistantNavigationDestinationCandidates.contains(destination)) { + return; + } + final current = assistantNavigationDestinations; + final next = current.contains(destination) + ? current.where((item) => item != destination).toList(growable: false) + : [...current, destination]; + await saveSettings( + settings.copyWith(assistantNavigationDestinations: next), + refreshAfterSave: false, + ); + } + + Future testOllamaConnection({required bool cloud}) { + return _settingsController.testOllamaConnection(cloud: cloud); + } + + Future testVaultConnection() { + return _settingsController.testVaultConnection(); + } + + void clearRuntimeLogs() { + _runtimeCoordinator.gateway.clearLogs(); + _notifyIfActive(); + } + + List taskItemsForTab(String tab) => switch (tab) { + 'Queue' => _tasksController.queue, + 'Running' => _tasksController.running, + 'History' => _tasksController.history, + 'Failed' => _tasksController.failed, + 'Scheduled' => _tasksController.scheduled, + _ => _tasksController.queue, + }; + + /// Enable Codex ↔ Gateway bridge + Future enableCodexBridge() async { + if (_isCodexBridgeEnabled || _isCodexBridgeBusy) return; + + _isCodexBridgeBusy = true; + _codexBridgeError = null; + + try { + final gatewayUrl = aiGatewayUrl; + final apiKey = await loadAiGatewayApiKey(); + + if (gatewayUrl.isEmpty) { + throw StateError( + appText('AI Gateway URL 未配置', 'AI Gateway URL not configured'), + ); + } + + final runtimeMode = effectiveCodeAgentRuntimeMode; + String? codexPath; + if (runtimeMode == CodeAgentRuntimeMode.externalCli) { + codexPath = await _resolveCodexCliPath(); + if (codexPath == null) { + throw StateError( + appText( + '未找到 Codex CLI。请先安装或填写可执行文件路径。', + 'Codex CLI not found. Install it or set a manual binary path.', + ), + ); + } + } + + await _runtimeCoordinator.configureCodexForGateway( + gatewayUrl: gatewayUrl, + apiKey: apiKey, + ); + + await _runtimeCoordinator.startCodeAgentRuntime( + runtimeMode: runtimeMode, + codexPath: codexPath, + workingDirectory: _resolveCodexWorkingDirectory(), + ); + + _registerCodexExternalProvider(codexPath: codexPath); + _isCodexBridgeEnabled = true; + _codexCooperationState = CodexCooperationState.bridgeOnly; + await _ensureCodexGatewayRegistration(); + notifyListeners(); + } catch (e) { + _codexBridgeError = e.toString(); + notifyListeners(); + rethrow; + } finally { + _isCodexBridgeBusy = false; + notifyListeners(); + } + } + + /// Disable Codex ↔ Gateway bridge + Future disableCodexBridge() async { + if (!_isCodexBridgeEnabled || _isCodexBridgeBusy) return; + + _isCodexBridgeBusy = true; + + try { + if (_runtime.isConnected && _codeAgentBridgeRegistry.isRegistered) { + await _codeAgentBridgeRegistry.unregister(); + } else { + _codeAgentBridgeRegistry.clearRegistration(); + } + await _runtimeCoordinator.stopCodeAgentRuntime(); + _isCodexBridgeEnabled = false; + _codexCooperationState = CodexCooperationState.notStarted; + _codexBridgeError = null; + notifyListeners(); + } catch (e) { + _codexBridgeError = e.toString(); + notifyListeners(); + rethrow; + } finally { + _isCodexBridgeBusy = false; + notifyListeners(); + } + } + + @override + void dispose() { + if (_disposed) { + return; + } + _disposed = true; + _runtimeEventsSubscription?.cancel(); + _detachChildListeners(); + _runtimeCoordinator.dispose(); + _settingsController.dispose(); + _agentsController.dispose(); + _sessionsController.dispose(); + _chatController.dispose(); + _instancesController.dispose(); + _skillsController.dispose(); + _connectorsController.dispose(); + _modelsController.dispose(); + _cronJobsController.dispose(); + _devicesController.dispose(); + _tasksController.dispose(); + _store.dispose(); + _desktopPlatformService.dispose(); + unawaited(_multiAgentBrokerServer?.stop() ?? Future.value()); + super.dispose(); + } + + Future _initialize() async { + try { + await _settingsController.initialize(); + _restoreAssistantThreads(await _store.loadAssistantThreadRecords()); + if (_disposed) { + return; + } + final bootstrap = await RuntimeBootstrapConfig.load( + workspacePathHint: settings.workspacePath, + cliPathHint: settings.cliPath, + ); + if (_disposed) { + return; + } + final seeded = bootstrap.mergeIntoSettings(settings); + if (seeded.toJsonString() != settings.toJsonString()) { + await _settingsController.saveSnapshot(seeded); + if (_disposed) { + return; + } + } + final normalized = _sanitizeMultiAgentSettings( + _sanitizeOllamaCloudSettings( + _sanitizeCodeAgentSettings(_settingsController.snapshot), + ), + ); + if (normalized.toJsonString() != + _settingsController.snapshot.toJsonString()) { + await _settingsController.saveSnapshot(normalized); + if (_disposed) { + return; + } + } + _modelsController.restoreFromSettings(settings.aiGateway); + _multiAgentOrchestrator.updateConfig(settings.multiAgent); + setActiveAppLanguage(settings.appLanguage); + await _desktopPlatformService.initialize(settings.linuxDesktop); + await _desktopPlatformService.setLaunchAtLogin(settings.launchAtLogin); + _registerCodexExternalProvider(); + await _refreshCodexCliAvailability(); + if (_disposed) { + return; + } + _agentsController.restoreSelection(settings.gateway.selectedAgentId); + _sessionsController.configure( + mainSessionKey: _runtime.snapshot.mainSessionKey ?? 'main', + selectedAgentId: _agentsController.selectedAgentId, + defaultAgentId: '', + ); + await _ensureActiveAssistantThread(); + _runtimeEventsSubscription = _runtimeCoordinator.gateway.events.listen( + _handleRuntimeEvent, + ); + final shouldAutoConnect = + settings.gateway.useSetupCode && + settings.gateway.setupCode.trim().isNotEmpty; + if (shouldAutoConnect) { + try { + await _connectProfile(settings.gateway); + } catch (_) { + // Keep the shell usable when auto-connect fails. + } + } + await refreshMultiAgentMounts(sync: settings.multiAgent.autoSync); + } catch (error) { + if (_disposed) { + return; + } + _bootstrapError = error.toString(); + } finally { + if (!_disposed) { + _initializing = false; + _notifyIfActive(); + } + } + } + + Future _connectProfile( + GatewayConnectionProfile profile, { + String authTokenOverride = '', + String authPasswordOverride = '', + }) async { + await _runtime.connectProfile( + profile, + authTokenOverride: authTokenOverride, + authPasswordOverride: authPasswordOverride, + ); + await refreshGatewayHealth(); + await refreshAgents(); + await refreshSessions(); + await _instancesController.refresh(); + await _skillsController.refresh( + agentId: _agentsController.selectedAgentId.isEmpty + ? null + : _agentsController.selectedAgentId, + ); + await _connectorsController.refresh(); + await _modelsController.refresh(); + await _cronJobsController.refresh(); + await _devicesController.refresh(quiet: true); + await _settingsController.refreshDerivedState(); + await _ensureCodexGatewayRegistration(); + _recomputeTasks(); + } + + Future _ensureActiveAssistantThread() async { + if (!isAiGatewayOnlyMode || + !isAssistantTaskArchived(_sessionsController.currentSessionKey)) { + return; + } + final fallback = _assistantSessionSummaries().firstWhere( + (item) => !isAssistantTaskArchived(item.key), + orElse: () => GatewaySessionSummary( + key: 'draft:${DateTime.now().millisecondsSinceEpoch}', + kind: 'assistant', + displayName: appText('新对话', 'New conversation'), + surface: 'Assistant', + subject: null, + room: null, + space: null, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + sessionId: null, + systemSent: false, + abortedLastRun: false, + thinkingLevel: null, + verboseLevel: null, + inputTokens: null, + outputTokens: null, + totalTokens: null, + model: null, + contextTokens: null, + derivedTitle: appText('新对话', 'New conversation'), + lastMessagePreview: null, + ), + ); + await _sessionsController.switchSession(fallback.key); + } + + void _handleRuntimeEvent(GatewayPushEvent event) { + _chatController.handleEvent(event); + if (event.event == 'chat') { + final payload = asMap(event.payload); + final state = stringValue(payload['state']); + if (state == 'final' || state == 'aborted' || state == 'error') { + unawaited(refreshSessions()); + } + } + if (event.event == 'seqGap') { + unawaited(refreshSessions()); + } + if (event.event == 'device.pair.requested' || + event.event == 'device.pair.resolved') { + unawaited(refreshDevices(quiet: true)); + } + } + + SettingsSnapshot _sanitizeMultiAgentSettings(SettingsSnapshot snapshot) { + final resolved = _resolveMultiAgentConfig(snapshot); + if (jsonEncode(snapshot.multiAgent.toJson()) == + jsonEncode(resolved.toJson())) { + return snapshot; + } + return snapshot.copyWith(multiAgent: resolved); + } + + SettingsSnapshot _sanitizeOllamaCloudSettings(SettingsSnapshot snapshot) { + final rawBaseUrl = snapshot.ollamaCloud.baseUrl.trim(); + final normalized = rawBaseUrl.endsWith('/') + ? rawBaseUrl.substring(0, rawBaseUrl.length - 1) + : rawBaseUrl; + if (normalized != 'https://ollama.svc.plus') { + return snapshot; + } + return snapshot.copyWith( + ollamaCloud: snapshot.ollamaCloud.copyWith(baseUrl: 'https://ollama.com'), + ); + } + + MultiAgentConfig _resolveMultiAgentConfig(SettingsSnapshot snapshot) { + final defaults = MultiAgentConfig.defaults(); + final current = snapshot.multiAgent; + final ollamaEndpoint = snapshot.ollamaLocal.endpoint.trim().isEmpty + ? current.ollamaEndpoint + : snapshot.ollamaLocal.endpoint.trim(); + final engineerModel = current.engineer.model.trim().isNotEmpty + ? current.engineer.model.trim() + : snapshot.ollamaLocal.defaultModel.trim().isNotEmpty + ? snapshot.ollamaLocal.defaultModel.trim() + : defaults.engineer.model; + final architectModel = current.architect.model.trim().isNotEmpty + ? current.architect.model.trim() + : defaults.architect.model; + final testerModel = current.tester.model.trim().isNotEmpty + ? current.tester.model.trim() + : defaults.tester.model; + return current.copyWith( + framework: current.arisEnabled + ? MultiAgentFramework.aris + : current.framework, + arisEnabled: + current.framework == MultiAgentFramework.aris || current.arisEnabled, + ollamaEndpoint: ollamaEndpoint, + architect: current.architect.copyWith(model: architectModel), + engineer: current.engineer.copyWith(model: engineerModel), + tester: current.tester.copyWith(model: testerModel), + mountTargets: current.mountTargets.isEmpty + ? MultiAgentConfig.defaults().mountTargets + : current.mountTargets, + ); + } + + Future _ensureMultiAgentBrokerClient() async { + _multiAgentBrokerServer ??= MultiAgentBrokerServer(_multiAgentOrchestrator); + await _multiAgentBrokerServer!.start(); + final uri = _multiAgentBrokerServer!.wsUri; + if (uri == null) { + throw StateError('Multi-agent broker is unavailable'); + } + _runtimeCoordinator.registerExternalCodeAgent( + ExternalCodeAgentProvider( + id: 'aris-broker', + name: 'ARIS Broker', + command: 'xworkmate-multi-agent-broker', + transport: ExternalAgentTransport.websocketJsonRpc, + endpoint: uri.toString(), + capabilities: const [ + 'architect', + 'engineer', + 'tester', + 'multi-agent', + 'session-stream', + ], + ), + ); + _multiAgentBrokerClient = MultiAgentBrokerClient(uri); + return _multiAgentBrokerClient!; + } + + Future _sendAiGatewayMessage( + String message, { + required String thinking, + required List attachments, + }) async { + final sessionKey = _normalizedAssistantSessionKey( + _sessionsController.currentSessionKey, + ); + final trimmed = message.trim(); + if (trimmed.isEmpty && attachments.isEmpty) { + return; + } + + final baseUrl = _normalizeAiGatewayBaseUrl(settings.aiGateway.baseUrl); + if (baseUrl == null) { + _appendAssistantThreadMessage( + sessionKey, + _assistantErrorMessage( + appText( + 'AI Gateway URL 未配置,无法发送对话。', + 'AI Gateway URL is not configured, so the conversation could not be sent.', + ), + ), + ); + return; + } + + final apiKey = await loadAiGatewayApiKey(); + if (apiKey.isEmpty) { + _appendAssistantThreadMessage( + sessionKey, + _assistantErrorMessage( + appText( + 'AI Gateway API Key 未配置,无法发送对话。', + 'AI Gateway API key is not configured, so the conversation could not be sent.', + ), + ), + ); + return; + } + + final model = resolvedAiGatewayModel; + if (model.isEmpty) { + _appendAssistantThreadMessage( + sessionKey, + _assistantErrorMessage( + appText( + '当前没有可用的 AI Gateway 对话模型。请先在 AI Gateway 页面同步并选择可用模型。', + 'No AI Gateway chat model is available yet. Sync and select a supported model in AI Gateway first.', + ), + ), + ); + return; + } + + final userText = trimmed.isEmpty ? 'See attached.' : trimmed; + _appendAssistantThreadMessage( + sessionKey, + GatewayChatMessage( + id: _nextLocalMessageId(), + role: 'user', + text: userText, + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + ); + _aiGatewayPendingSessionKeys.add(sessionKey); + _recomputeTasks(); + _notifyIfActive(); + + try { + final assistantText = await _requestAiGatewayCompletion( + baseUrl: baseUrl, + apiKey: apiKey, + model: model, + thinking: thinking, + sessionKey: sessionKey, + ); + _appendAssistantThreadMessage( + sessionKey, + GatewayChatMessage( + id: _nextLocalMessageId(), + role: 'assistant', + text: assistantText, + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + ); + } on _AiGatewayAbortException catch (error) { + final partial = error.partialText.trim(); + if (partial.isNotEmpty) { + _appendAssistantThreadMessage( + sessionKey, + GatewayChatMessage( + id: _nextLocalMessageId(), + role: 'assistant', + text: partial, + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: null, + stopReason: 'aborted', + pending: false, + error: false, + ), + ); + } + } catch (error) { + _appendAssistantThreadMessage( + sessionKey, + _assistantErrorMessage(_aiGatewayErrorLabel(error)), + ); + } finally { + _aiGatewayPendingSessionKeys.remove(sessionKey); + _aiGatewayStreamingClients.remove(sessionKey); + _clearAiGatewayStreamingText(sessionKey); + _recomputeTasks(); + _notifyIfActive(); + } + } + + Future _requestAiGatewayCompletion({ + required Uri baseUrl, + required String apiKey, + required String model, + required String thinking, + required String sessionKey, + }) async { + final uri = _aiGatewayChatUri(baseUrl); + final client = HttpClient() + ..connectionTimeout = const Duration(seconds: 20); + _aiGatewayStreamingClients[sessionKey] = client; + try { + final request = await client + .postUrl(uri) + .timeout(const Duration(seconds: 20)); + request.headers.set( + HttpHeaders.acceptHeader, + 'text/event-stream, application/json', + ); + request.headers.set( + HttpHeaders.contentTypeHeader, + 'application/json; charset=utf-8', + ); + request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $apiKey'); + request.headers.set('x-api-key', apiKey); + final payload = { + 'model': model, + 'stream': true, + 'messages': _buildAiGatewayRequestMessages(sessionKey), + }; + final normalizedThinking = thinking.trim().toLowerCase(); + if (normalizedThinking.isNotEmpty && normalizedThinking != 'off') { + payload['reasoning_effort'] = normalizedThinking; + } + request.add(utf8.encode(jsonEncode(payload))); + final response = await request.close().timeout( + const Duration(seconds: 60), + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + final body = await response.transform(utf8.decoder).join(); + throw _AiGatewayChatException( + _formatAiGatewayHttpError( + response.statusCode, + _extractAiGatewayErrorDetail(body), + ), + ); + } + final contentType = + response.headers.contentType?.mimeType.toLowerCase() ?? + response.headers + .value(HttpHeaders.contentTypeHeader) + ?.toLowerCase() ?? + ''; + if (contentType.contains('text/event-stream')) { + final streamed = await _readAiGatewayStreamingResponse( + response: response, + sessionKey: sessionKey, + ); + if (streamed.trim().isEmpty) { + throw const FormatException('Missing assistant content'); + } + return streamed.trim(); + } + return await _readAiGatewayJsonCompletion(response); + } catch (error) { + if (_consumeAiGatewayAbort(sessionKey)) { + throw _AiGatewayAbortException( + _aiGatewayStreamingTextBySession[sessionKey] ?? '', + ); + } + rethrow; + } finally { + _aiGatewayStreamingClients.remove(sessionKey); + client.close(force: true); + } + } + + List> _buildAiGatewayRequestMessages(String sessionKey) { + final history = [ + ...(_gatewayHistoryCache[sessionKey] ?? const []), + ...(_assistantThreadMessages[sessionKey] ?? const []), + ]; + return history + .where((message) { + final role = message.role.trim().toLowerCase(); + return (role == 'user' || role == 'assistant') && + (message.toolName ?? '').trim().isEmpty && + message.text.trim().isNotEmpty; + }) + .map( + (message) => { + 'role': message.role.trim().toLowerCase() == 'assistant' + ? 'assistant' + : 'user', + 'content': message.text.trim(), + }, + ) + .toList(growable: false); + } + + Future _readAiGatewayJsonCompletion( + HttpClientResponse response, + ) async { + final body = await response.transform(utf8.decoder).join(); + final decoded = jsonDecode(_extractFirstJsonDocument(body)); + final assistantText = _extractAiGatewayAssistantText(decoded); + if (assistantText.trim().isEmpty) { + throw const FormatException('Missing assistant content'); + } + return assistantText.trim(); + } + + Future _readAiGatewayStreamingResponse({ + required HttpClientResponse response, + required String sessionKey, + }) async { + final buffer = StringBuffer(); + final eventLines = []; + + void processEvent(String payload) { + final trimmed = payload.trim(); + if (trimmed.isEmpty) { + return; + } + if (trimmed == '[DONE]') { + return; + } + final deltaText = _extractAiGatewayStreamText(trimmed); + if (deltaText.isEmpty) { + return; + } + final current = buffer.toString(); + if (current.isEmpty || deltaText == current) { + buffer + ..clear() + ..write(deltaText); + } else if (deltaText.startsWith(current)) { + buffer + ..clear() + ..write(deltaText); + } else { + buffer.write(deltaText); + } + _setAiGatewayStreamingText(sessionKey, buffer.toString()); + } + + await for (final line + in response.transform(utf8.decoder).transform(const LineSplitter())) { + if (_consumeAiGatewayAbort(sessionKey)) { + throw _AiGatewayAbortException(buffer.toString()); + } + if (line.isEmpty) { + if (eventLines.isNotEmpty) { + processEvent(eventLines.join('\n')); + eventLines.clear(); + } + continue; + } + if (line.startsWith('data:')) { + eventLines.add(line.substring(5).trimLeft()); + } + } + + if (eventLines.isNotEmpty) { + processEvent(eventLines.join('\n')); + } + + return buffer.toString(); + } + + String _extractAiGatewayStreamText(String payload) { + final decoded = jsonDecode(_extractFirstJsonDocument(payload)); + final map = asMap(decoded); + final choices = asList(map['choices']); + if (choices.isNotEmpty) { + final firstChoice = asMap(choices.first); + final delta = asMap(firstChoice['delta']); + final deltaContent = _extractAiGatewayContent(delta['content']); + if (deltaContent.isNotEmpty) { + return deltaContent; + } + } + return _extractAiGatewayAssistantText(decoded); + } + + Future _abortAiGatewayRun(String sessionKey) async { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + _aiGatewayAbortedSessionKeys.add(normalizedSessionKey); + final client = _aiGatewayStreamingClients.remove(normalizedSessionKey); + if (client != null) { + try { + client.close(force: true); + } catch (_) { + // Best effort only. + } + } + _aiGatewayPendingSessionKeys.remove(normalizedSessionKey); + _clearAiGatewayStreamingText(normalizedSessionKey); + _recomputeTasks(); + _notifyIfActive(); + } + + bool _consumeAiGatewayAbort(String sessionKey) { + return _aiGatewayAbortedSessionKeys.remove( + _normalizedAssistantSessionKey(sessionKey), + ); + } + + GatewayChatMessage _assistantErrorMessage(String text) { + return GatewayChatMessage( + id: _nextLocalMessageId(), + role: 'assistant', + text: text, + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: true, + ); + } + + void _appendAssistantThreadMessage( + String sessionKey, + GatewayChatMessage message, + ) { + final key = _normalizedAssistantSessionKey(sessionKey); + final next = List.from( + _assistantThreadMessages[key] ?? const [], + )..add(message); + _assistantThreadMessages[key] = next; + _upsertAssistantThreadRecord( + key, + messages: next, + updatedAtMs: + message.timestampMs ?? + DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + _notifyIfActive(); + } + + void _appendLocalSessionMessage( + String sessionKey, + GatewayChatMessage message, + ) { + final key = _normalizedAssistantSessionKey(sessionKey); + final next = List.from( + _localSessionMessages[key] ?? const [], + )..add(message); + _localSessionMessages[key] = next; + _notifyIfActive(); + } + + void _preserveGatewayHistoryForSession(String sessionKey) { + final key = _normalizedAssistantSessionKey(sessionKey); + if (_chatController.messages.isEmpty) { + return; + } + _gatewayHistoryCache[key] = List.from( + _chatController.messages, + ); + } + + List _assistantSessionSummaries() { + final archivedKeys = settings.assistantArchivedTaskKeys + .map(_normalizedAssistantSessionKey) + .toSet(); + final items = []; + + for (final record in _assistantThreadRecords.values) { + final sessionKey = _normalizedAssistantSessionKey(record.sessionKey); + if (archivedKeys.contains(sessionKey) || record.archived) { + continue; + } + items.add(_assistantSessionSummaryFor(sessionKey, record: record)); + } + + final currentSessionKey = _normalizedAssistantSessionKey( + _sessionsController.currentSessionKey, + ); + final hasCurrent = items.any( + (item) => matchesSessionKey(item.key, currentSessionKey), + ); + if (!hasCurrent && !archivedKeys.contains(currentSessionKey)) { + items.add(_assistantSessionSummaryFor(currentSessionKey)); + } + + items.sort((left, right) { + return (right.updatedAtMs ?? 0).compareTo(left.updatedAtMs ?? 0); + }); + return items; + } + + GatewaySessionSummary _assistantSessionSummaryFor( + String sessionKey, { + AssistantThreadRecord? record, + }) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + final resolvedRecord = + record ?? _assistantThreadRecords[normalizedSessionKey]; + final messages = + resolvedRecord?.messages ?? + _assistantThreadMessages[normalizedSessionKey] ?? + const []; + final preview = _assistantThreadPreview(messages); + final title = assistantCustomTaskTitle(normalizedSessionKey); + final lastMessage = messages.isNotEmpty ? messages.last : null; + final updatedAtMs = + resolvedRecord?.updatedAtMs ?? + lastMessage?.timestampMs ?? + DateTime.now().millisecondsSinceEpoch.toDouble(); + return GatewaySessionSummary( + key: normalizedSessionKey, + kind: 'assistant', + displayName: title.isEmpty ? null : title, + surface: 'Assistant', + subject: preview, + room: null, + space: null, + updatedAtMs: updatedAtMs, + sessionId: normalizedSessionKey, + systemSent: false, + abortedLastRun: lastMessage?.error == true, + thinkingLevel: null, + verboseLevel: null, + inputTokens: null, + outputTokens: null, + totalTokens: null, + model: _resolvedAssistantModelForTarget( + assistantExecutionTargetForSession(normalizedSessionKey), + ), + contextTokens: null, + derivedTitle: title.isEmpty ? null : title, + lastMessagePreview: preview, + ); + } + + String? _assistantThreadPreview(List messages) { + for (final message in messages.reversed) { + final role = message.role.trim().toLowerCase(); + if (role != 'user' && role != 'assistant') { + continue; + } + final text = message.text.trim(); + if (text.isNotEmpty) { + return text; + } + } + return null; + } + + void _restoreAssistantThreads(List records) { + _assistantThreadRecords.clear(); + _assistantThreadMessages.clear(); + final archivedKeys = settings.assistantArchivedTaskKeys + .map(_normalizedAssistantSessionKey) + .toSet(); + for (final record in records) { + final sessionKey = _normalizedAssistantSessionKey(record.sessionKey); + if (sessionKey.isEmpty) { + continue; + } + final titleFromSettings = assistantCustomTaskTitle(sessionKey); + final normalizedRecord = record.copyWith( + sessionKey: sessionKey, + title: titleFromSettings.isEmpty + ? record.title.trim() + : titleFromSettings, + archived: record.archived || archivedKeys.contains(sessionKey), + executionTarget: + record.executionTarget ?? settings.assistantExecutionTarget, + messageViewMode: record.messageViewMode, + ); + _assistantThreadRecords[sessionKey] = normalizedRecord; + if (normalizedRecord.messages.isNotEmpty) { + _assistantThreadMessages[sessionKey] = List.from( + normalizedRecord.messages, + ); + } + } + } + + void _upsertAssistantThreadRecord( + String sessionKey, { + List? messages, + double? updatedAtMs, + String? title, + bool? archived, + AssistantExecutionTarget? executionTarget, + AssistantMessageViewMode? messageViewMode, + }) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + final existing = _assistantThreadRecords[normalizedSessionKey]; + final nextMessages = + messages ?? + existing?.messages ?? + _assistantThreadMessages[normalizedSessionKey] ?? + const []; + final nextRecord = AssistantThreadRecord( + sessionKey: normalizedSessionKey, + messages: nextMessages, + updatedAtMs: + updatedAtMs ?? + existing?.updatedAtMs ?? + (nextMessages.isNotEmpty ? nextMessages.last.timestampMs : null), + title: title ?? existing?.title ?? '', + archived: + archived ?? + existing?.archived ?? + isAssistantTaskArchived(normalizedSessionKey), + executionTarget: + executionTarget ?? + existing?.executionTarget ?? + settings.assistantExecutionTarget, + messageViewMode: + messageViewMode ?? + existing?.messageViewMode ?? + AssistantMessageViewMode.rendered, + ); + _assistantThreadRecords[normalizedSessionKey] = nextRecord; + if (messages != null) { + _assistantThreadMessages[normalizedSessionKey] = + List.from(messages); + } + unawaited( + _store.saveAssistantThreadRecords( + _assistantThreadRecords.values.toList(growable: false), + ), + ); + } + + void _setAiGatewayStreamingText(String sessionKey, String text) { + final key = _normalizedAssistantSessionKey(sessionKey); + if (text.trim().isEmpty) { + _aiGatewayStreamingTextBySession.remove(key); + } else { + _aiGatewayStreamingTextBySession[key] = text; + } + _notifyIfActive(); + } + + void _clearAiGatewayStreamingText(String sessionKey) { + final key = _normalizedAssistantSessionKey(sessionKey); + if (_aiGatewayStreamingTextBySession.remove(key) != null) { + _notifyIfActive(); + } + } + + String _nextLocalMessageId() { + _localMessageCounter += 1; + return 'local-${DateTime.now().microsecondsSinceEpoch}-$_localMessageCounter'; + } + + Uri? _normalizeAiGatewayBaseUrl(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) { + return null; + } + final candidate = trimmed.contains('://') ? trimmed : 'https://$trimmed'; + final uri = Uri.tryParse(candidate); + if (uri == null || uri.host.trim().isEmpty) { + return null; + } + final pathSegments = uri.pathSegments.where((item) => item.isNotEmpty); + return uri.replace( + pathSegments: pathSegments.isEmpty ? const ['v1'] : pathSegments, + query: null, + fragment: null, + ); + } + + Uri _aiGatewayChatUri(Uri baseUrl) { + final pathSegments = baseUrl.pathSegments + .where((item) => item.isNotEmpty) + .toList(growable: true); + if (pathSegments.isEmpty) { + pathSegments.add('v1'); + } + if (pathSegments.length >= 2 && + pathSegments[pathSegments.length - 2] == 'chat' && + pathSegments.last == 'completions') { + return baseUrl.replace(query: null, fragment: null); + } + if (pathSegments.last == 'models') { + pathSegments.removeLast(); + } + if (pathSegments.last != 'chat') { + pathSegments.add('chat'); + } + pathSegments.add('completions'); + return baseUrl.replace( + pathSegments: pathSegments, + query: null, + fragment: null, + ); + } + + String _aiGatewayHostLabel(String raw) { + final uri = _normalizeAiGatewayBaseUrl(raw); + if (uri == null) { + return ''; + } + if (uri.hasPort) { + return '${uri.host}:${uri.port}'; + } + return uri.host; + } + + String _aiGatewayErrorLabel(Object error) { + if (error is _AiGatewayChatException) { + return error.message; + } + if (error is SocketException) { + return appText('无法连接到 AI Gateway。', 'Unable to reach the AI Gateway.'); + } + if (error is HandshakeException) { + return appText( + 'AI Gateway TLS 握手失败。', + 'AI Gateway TLS handshake failed.', + ); + } + if (error is TimeoutException) { + return appText('AI Gateway 请求超时。', 'AI Gateway request timed out.'); + } + if (error is FormatException) { + return appText( + 'AI Gateway 返回了无法解析的响应。', + 'AI Gateway returned an invalid response.', + ); + } + return error.toString(); + } + + String _formatAiGatewayHttpError(int statusCode, String detail) { + final base = switch (statusCode) { + 400 => appText( + 'AI Gateway 请求无效 (400)', + 'AI Gateway rejected the request (400)', + ), + 401 => appText( + 'AI Gateway 鉴权失败 (401)', + 'AI Gateway authentication failed (401)', + ), + 403 => appText('AI Gateway 拒绝访问 (403)', 'AI Gateway denied access (403)'), + 404 => appText( + 'AI Gateway chat 接口不存在 (404)', + 'AI Gateway chat endpoint was not found (404)', + ), + 429 => appText( + 'AI Gateway 限流 (429)', + 'AI Gateway rate limited the request (429)', + ), + >= 500 => appText( + 'AI Gateway 当前不可用 ($statusCode)', + 'AI Gateway is unavailable right now ($statusCode)', + ), + _ => appText( + 'AI Gateway 返回状态码 $statusCode', + 'AI Gateway responded with status $statusCode', + ), + }; + final trimmed = detail.trim(); + return trimmed.isEmpty ? base : '$base · $trimmed'; + } + + String _extractAiGatewayErrorDetail(String body) { + if (body.trim().isEmpty) { + return ''; + } + try { + final decoded = jsonDecode(_extractFirstJsonDocument(body)); + final map = asMap(decoded); + final error = asMap(map['error']); + return (stringValue(error['message']) ?? + stringValue(map['message']) ?? + stringValue(map['detail']) ?? + '') + .trim(); + } on FormatException { + return ''; + } + } + + String _extractAiGatewayAssistantText(Object? decoded) { + final map = asMap(decoded); + final choices = asList(map['choices']); + if (choices.isNotEmpty) { + final firstChoice = asMap(choices.first); + final message = asMap(firstChoice['message']); + final content = _extractAiGatewayContent(message['content']); + if (content.isNotEmpty) { + return content; + } + } + + final output = asList(map['output']); + for (final item in output) { + final entry = asMap(item); + final content = _extractAiGatewayContent(entry['content']); + if (content.isNotEmpty) { + return content; + } + } + + final direct = _extractAiGatewayContent(map['content']); + if (direct.isNotEmpty) { + return direct; + } + return stringValue(map['output_text'])?.trim() ?? ''; + } + + String _extractAiGatewayContent(Object? content) { + if (content is String) { + return content.trim(); + } + final parts = []; + for (final item in asList(content)) { + final map = asMap(item); + final nestedText = stringValue(map['text']); + if (nestedText != null && nestedText.trim().isNotEmpty) { + parts.add(nestedText.trim()); + continue; + } + final type = stringValue(map['type']) ?? ''; + if (type == 'output_text') { + final text = stringValue(map['text']) ?? stringValue(map['value']); + if (text != null && text.trim().isNotEmpty) { + parts.add(text.trim()); + } + } + } + return parts.join('\n').trim(); + } + + String _extractFirstJsonDocument(String body) { + final trimmed = body.trimLeft(); + if (trimmed.isEmpty) { + throw const FormatException('Empty response body'); + } + final start = trimmed.indexOf(RegExp(r'[\{\[]')); + if (start < 0) { + throw const FormatException('Missing JSON document'); + } + var depth = 0; + var inString = false; + var escaped = false; + for (var index = start; index < trimmed.length; index++) { + final char = trimmed[index]; + if (escaped) { + escaped = false; + continue; + } + if (char == r'\') { + escaped = true; + continue; + } + if (char == '"') { + inString = !inString; + continue; + } + if (inString) { + continue; + } + if (char == '{' || char == '[') { + depth += 1; + } else if (char == '}' || char == ']') { + depth -= 1; + if (depth == 0) { + return trimmed.substring(start, index + 1); + } + } + } + throw const FormatException('Unterminated JSON document'); + } + + SettingsSnapshot _sanitizeCodeAgentSettings(SettingsSnapshot snapshot) { + _codexRuntimeWarning = + snapshot.codeAgentRuntimeMode == CodeAgentRuntimeMode.builtIn + ? appText( + '内置 Codex 仍处于实验阶段;建议优先使用 External Codex CLI。', + 'Built-in Codex is still experimental; External Codex CLI is recommended.', + ) + : null; + final normalizedPath = snapshot.codexCliPath.trim(); + if (normalizedPath == snapshot.codexCliPath) { + return snapshot; + } + return snapshot.copyWith(codexCliPath: normalizedPath); + } + + Future _refreshCodexCliAvailability() async { + _resolvedCodexCliPath = await _runtimeCoordinator.resolveCodexPath( + codexPath: settings.codexCliPath, + ); + _notifyIfActive(); + } + + Future _resolveCodexCliPath() async { + if (_resolvedCodexCliPath != null) { + return _resolvedCodexCliPath; + } + await _refreshCodexCliAvailability(); + return _resolvedCodexCliPath; + } + + String? _resolveCodexWorkingDirectory() { + final candidate = settings.workspacePath.trim(); + if (candidate.isEmpty) { + return null; + } + final directory = Directory(candidate); + return directory.existsSync() ? directory.path : null; + } + + void _registerCodexExternalProvider({String? codexPath}) { + _runtimeCoordinator.registerExternalCodeAgent( + ExternalCodeAgentProvider( + id: 'codex', + name: 'Codex CLI', + command: (codexPath?.trim().isNotEmpty ?? false) + ? codexPath!.trim() + : 'codex', + defaultArgs: const ['app-server', '--listen', 'stdio://'], + capabilities: const [ + 'chat', + 'code-edit', + 'gateway-bridge', + 'memory-sync', + ], + ), + ); + } + + CodeAgentNodeState _buildCodeAgentNodeState() { + return CodeAgentNodeState( + selectedAgentId: _agentsController.selectedAgentId, + gatewayConnected: _runtime.isConnected, + executionTarget: currentAssistantExecutionTarget, + runtimeMode: effectiveCodeAgentRuntimeMode, + bridgeEnabled: _isCodexBridgeEnabled, + bridgeState: _codexCooperationState.name, + preferredProviderId: 'codex', + resolvedCodexCliPath: _resolvedCodexCliPath, + configuredCodexCliPath: configuredCodexCliPath, + ); + } + + GatewayMode _bridgeGatewayMode() { + return switch (settings.gateway.mode) { + RuntimeConnectionMode.local => GatewayMode.local, + RuntimeConnectionMode.remote => GatewayMode.remote, + RuntimeConnectionMode.unconfigured => GatewayMode.offline, + }; + } + + Future _ensureCodexGatewayRegistration() async { + if (!_isCodexBridgeEnabled) { + return; + } + + if (!_runtime.isConnected) { + _codexCooperationState = CodexCooperationState.bridgeOnly; + _codeAgentBridgeRegistry.clearRegistration(); + notifyListeners(); + return; + } + + if (_codeAgentBridgeRegistry.isRegistered) { + _codexCooperationState = CodexCooperationState.registered; + notifyListeners(); + return; + } + + try { + final dispatch = _codeAgentNodeOrchestrator.buildGatewayDispatch( + _buildCodeAgentNodeState(), + ); + await _codeAgentBridgeRegistry.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': _bridgeGatewayMode().name, + 'binaryConfigured': (resolvedCodexCliPath ?? configuredCodexCliPath) + .trim() + .isNotEmpty, + 'capabilities': const [ + 'chat', + 'code-edit', + 'gateway-bridge', + 'memory-sync', + ], + }, + ); + _codexCooperationState = CodexCooperationState.registered; + _codexBridgeError = null; + } catch (error) { + _codexCooperationState = CodexCooperationState.bridgeOnly; + _codexBridgeError = error.toString(); + } + + notifyListeners(); + } + + void _clearCodexGatewayRegistration() { + _codeAgentBridgeRegistry.clearRegistration(); + if (_isCodexBridgeEnabled) { + _codexCooperationState = CodexCooperationState.bridgeOnly; + } else { + _codexCooperationState = CodexCooperationState.notStarted; + } + notifyListeners(); + } + + void _recomputeTasks() { + _tasksController.recompute( + sessions: sessions, + cronJobs: _cronJobsController.items, + currentSessionKey: _sessionsController.currentSessionKey, + hasPendingRun: hasAssistantPendingRun, + activeAgentName: _agentsController.activeAgentName, + ); + } + + void _attachChildListeners() { + _runtimeCoordinator.addListener(_relayChildChange); + _settingsController.addListener(_relayChildChange); + _agentsController.addListener(_relayChildChange); + _sessionsController.addListener(_relayChildChange); + _chatController.addListener(_relayChildChange); + _instancesController.addListener(_relayChildChange); + _skillsController.addListener(_relayChildChange); + _connectorsController.addListener(_relayChildChange); + _modelsController.addListener(_relayChildChange); + _cronJobsController.addListener(_relayChildChange); + _devicesController.addListener(_relayChildChange); + _tasksController.addListener(_relayChildChange); + _multiAgentOrchestrator.addListener(_relayChildChange); + } + + void _detachChildListeners() { + _runtimeCoordinator.removeListener(_relayChildChange); + _settingsController.removeListener(_relayChildChange); + _agentsController.removeListener(_relayChildChange); + _sessionsController.removeListener(_relayChildChange); + _chatController.removeListener(_relayChildChange); + _instancesController.removeListener(_relayChildChange); + _skillsController.removeListener(_relayChildChange); + _connectorsController.removeListener(_relayChildChange); + _modelsController.removeListener(_relayChildChange); + _cronJobsController.removeListener(_relayChildChange); + _devicesController.removeListener(_relayChildChange); + _tasksController.removeListener(_relayChildChange); + _multiAgentOrchestrator.removeListener(_relayChildChange); + } + + void _relayChildChange() { + _notifyIfActive(); + } + + void _notifyIfActive() { + if (_disposed) { + return; + } + notifyListeners(); + } + + RuntimeConnectionMode _modeFromHost(String host) { + final trimmed = host.trim().toLowerCase(); + if (trimmed == '127.0.0.1' || trimmed == 'localhost') { + return RuntimeConnectionMode.local; + } + return RuntimeConnectionMode.remote; + } + + AssistantExecutionTarget _assistantExecutionTargetForMode( + RuntimeConnectionMode mode, + ) { + return switch (mode) { + RuntimeConnectionMode.unconfigured => + AssistantExecutionTarget.aiGatewayOnly, + RuntimeConnectionMode.local => AssistantExecutionTarget.local, + RuntimeConnectionMode.remote => AssistantExecutionTarget.remote, + }; + } + + GatewayConnectionProfile _gatewayProfileForAssistantExecutionTarget( + AssistantExecutionTarget target, + ) { + if (target == AssistantExecutionTarget.aiGatewayOnly) { + return settings.gateway.copyWith( + mode: RuntimeConnectionMode.unconfigured, + useSetupCode: false, + setupCode: '', + ); + } + + final desiredMode = switch (target) { + AssistantExecutionTarget.aiGatewayOnly => + RuntimeConnectionMode.unconfigured, + AssistantExecutionTarget.local => RuntimeConnectionMode.local, + AssistantExecutionTarget.remote => RuntimeConnectionMode.remote, + }; + final savedProfile = settings.gateway; + if (savedProfile.mode == desiredMode) { + return savedProfile; + } + + if (desiredMode == RuntimeConnectionMode.local) { + return savedProfile.copyWith( + mode: RuntimeConnectionMode.local, + useSetupCode: false, + setupCode: '', + host: '127.0.0.1', + port: 18789, + tls: false, + ); + } + + final defaults = GatewayConnectionProfile.defaults(); + final savedHost = savedProfile.host.trim().isEmpty + ? defaults.host + : savedProfile.host.trim(); + final savedPort = savedProfile.port <= 0 + ? defaults.port + : savedProfile.port; + return savedProfile.copyWith( + mode: RuntimeConnectionMode.remote, + useSetupCode: false, + setupCode: '', + host: savedHost, + port: savedPort, + tls: savedProfile.tls, + ); + } +} + +class _AiGatewayChatException implements Exception { + const _AiGatewayChatException(this.message); + + final String message; + + @override + String toString() => message; +} + +class _AiGatewayAbortException implements Exception { + const _AiGatewayAbortException(this.partialText); + + final String partialText; +} diff --git a/lib/app/app_controller_web.dart b/lib/app/app_controller_web.dart new file mode 100644 index 00000000..217f0db5 --- /dev/null +++ b/lib/app/app_controller_web.dart @@ -0,0 +1,894 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../i18n/app_language.dart'; +import '../models/app_models.dart'; +import '../runtime/runtime_models.dart'; +import '../web/web_ai_gateway_client.dart'; +import '../web/web_relay_gateway_client.dart'; +import '../web/web_store.dart'; +import 'app_capabilities.dart'; + +class AppController extends ChangeNotifier { + AppController({ + WebStore? store, + WebAiGatewayClient? aiGatewayClient, + WebRelayGatewayClient? relayClient, + }) : _store = store ?? WebStore(), + _aiGatewayClient = aiGatewayClient ?? const WebAiGatewayClient() { + _relayClient = relayClient ?? WebRelayGatewayClient(_store); + _relayEventsSubscription = _relayClient.events.listen(_handleRelayEvent); + unawaited(_initialize()); + } + + final WebStore _store; + final WebAiGatewayClient _aiGatewayClient; + late final WebRelayGatewayClient _relayClient; + + late final StreamSubscription _relayEventsSubscription; + + SettingsSnapshot _settings = SettingsSnapshot.defaults(); + ThemeMode _themeMode = ThemeMode.light; + WorkspaceDestination _destination = WorkspaceDestination.assistant; + SettingsTab _settingsTab = SettingsTab.general; + bool _initializing = true; + String? _bootstrapError; + bool _relayBusy = false; + bool _aiGatewayBusy = false; + final Map _threadRecords = + {}; + final Set _pendingSessionKeys = {}; + final Map _streamingTextBySession = {}; + String _currentSessionKey = ''; + String? _lastAssistantError; + + AppCapabilities get capabilities => AppCapabilities.web; + WorkspaceDestination get destination => _destination; + SettingsTab get settingsTab => _settingsTab; + ThemeMode get themeMode => _themeMode; + bool get initializing => _initializing; + String? get bootstrapError => _bootstrapError; + SettingsSnapshot get settings => _settings; + AppLanguage get appLanguage => _settings.appLanguage; + GatewayConnectionSnapshot get connection => _relayClient.snapshot; + bool get relayBusy => _relayBusy; + bool get aiGatewayBusy => _aiGatewayBusy; + String? get lastAssistantError => _lastAssistantError; + String get currentSessionKey => _currentSessionKey; + bool get supportsDesktopIntegration => false; + bool get hasStoredGatewayToken => storedRelayTokenMask != null; + bool get hasStoredAiGatewayApiKey => storedAiGatewayApiKeyMask != null; + String? get storedGatewayTokenMask => storedRelayTokenMask; + String? get storedRelayTokenMask => WebStore.maskValue( + _relayTokenCache.trim().isEmpty ? '' : _relayTokenCache, + ); + String? get storedRelayPasswordMask => WebStore.maskValue( + _relayPasswordCache.trim().isEmpty ? '' : _relayPasswordCache, + ); + String? get storedAiGatewayApiKeyMask => WebStore.maskValue( + _aiGatewayApiKeyCache.trim().isEmpty ? '' : _aiGatewayApiKeyCache, + ); + + String _relayTokenCache = ''; + String _relayPasswordCache = ''; + String _aiGatewayApiKeyCache = ''; + + AssistantExecutionTarget get assistantExecutionTarget => + _currentRecord.executionTarget ?? _settings.assistantExecutionTarget; + AssistantExecutionTarget get currentAssistantExecutionTarget => + assistantExecutionTarget; + bool get isAiGatewayOnlyMode => + assistantExecutionTarget == AssistantExecutionTarget.aiGatewayOnly; + List get chatMessages { + final base = List.from(_currentRecord.messages); + final streaming = _streamingTextBySession[_currentSessionKey]?.trim() ?? ''; + if (streaming.isNotEmpty) { + base.add( + GatewayChatMessage( + id: 'streaming', + role: 'assistant', + text: streaming, + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: null, + stopReason: null, + pending: true, + error: false, + ), + ); + } + return base; + } + + List get conversations { + final entries = + _threadRecords.values + .map( + (record) => WebConversationSummary( + sessionKey: record.sessionKey, + title: _titleForRecord(record), + preview: _previewForRecord(record), + updatedAtMs: + record.updatedAtMs ?? + DateTime.now().millisecondsSinceEpoch.toDouble(), + executionTarget: + _sanitizeTarget(record.executionTarget) ?? + AssistantExecutionTarget.aiGatewayOnly, + pending: _pendingSessionKeys.contains(record.sessionKey), + current: record.sessionKey == _currentSessionKey, + ), + ) + .toList(growable: true) + ..sort((left, right) { + if (left.current != right.current) { + return left.current ? -1 : 1; + } + return right.updatedAtMs.compareTo(left.updatedAtMs); + }); + return entries; + } + + List conversationsForTarget( + AssistantExecutionTarget target, + ) { + return conversations + .where((item) => item.executionTarget == target) + .toList(growable: false); + } + + String get aiGatewayUrl => _settings.aiGateway.baseUrl.trim(); + String get resolvedAiGatewayModel { + final current = _settings.defaultModel.trim(); + final choices = aiGatewayConversationModelChoices; + if (choices.contains(current)) { + return current; + } + if (choices.isNotEmpty) { + return choices.first; + } + return ''; + } + + List get aiGatewayConversationModelChoices { + final selected = _settings.aiGateway.selectedModels + .map((item) => item.trim()) + .where( + (item) => + item.isNotEmpty && + _settings.aiGateway.availableModels.contains(item), + ) + .toList(growable: false); + if (selected.isNotEmpty) { + return selected; + } + return _settings.aiGateway.availableModels + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) + .toList(growable: false); + } + + bool get canUseAiGatewayConversation => + aiGatewayUrl.isNotEmpty && + _aiGatewayApiKeyCache.trim().isNotEmpty && + resolvedAiGatewayModel.isNotEmpty; + + String get assistantConnectionStatusLabel => isAiGatewayOnlyMode + ? (canUseAiGatewayConversation + ? appText('可用', 'Ready') + : appText('未配置', 'Not configured')) + : connection.status.label; + + String get assistantConnectionTargetLabel { + if (!isAiGatewayOnlyMode) { + return connection.remoteAddress ?? appText('Relay 未连接', 'Relay offline'); + } + final host = _hostLabel(_settings.aiGateway.baseUrl); + final model = resolvedAiGatewayModel; + if (host.isEmpty && model.isEmpty) { + return appText('Direct AI 未配置', 'Direct AI not configured'); + } + if (host.isNotEmpty && model.isNotEmpty) { + return '$model · $host'; + } + return host.isNotEmpty ? host : model; + } + + String get currentConversationTitle => _titleForRecord(_currentRecord); + + AssistantThreadRecord get _currentRecord { + final existing = _threadRecords[_currentSessionKey]; + if (existing != null) { + return existing; + } + final target = + _sanitizeTarget(_settings.assistantExecutionTarget) ?? + AssistantExecutionTarget.aiGatewayOnly; + final record = _newRecord(target: target); + _threadRecords[record.sessionKey] = record; + _currentSessionKey = record.sessionKey; + return record; + } + + Future _initialize() async { + try { + await _store.initialize(); + _themeMode = await _store.loadThemeMode(); + _settings = _sanitizeSettings(await _store.loadSettingsSnapshot()); + _aiGatewayApiKeyCache = await _store.loadAiGatewayApiKey(); + _relayTokenCache = await _store.loadRelayToken(); + _relayPasswordCache = await _store.loadRelayPassword(); + final records = await _store.loadAssistantThreadRecords(); + for (final record in records) { + final sanitized = _sanitizeRecord(record); + _threadRecords[sanitized.sessionKey] = sanitized; + } + if (_threadRecords.isEmpty) { + final record = _newRecord( + target: _settings.assistantExecutionTarget, + title: appText('新对话', 'New conversation'), + ); + _threadRecords[record.sessionKey] = record; + } + _currentSessionKey = conversations.first.sessionKey; + } catch (error) { + _bootstrapError = '$error'; + } finally { + _initializing = false; + notifyListeners(); + } + } + + void navigateTo(WorkspaceDestination destination) { + if (!capabilities.supportsDestination(destination)) { + return; + } + _destination = destination; + notifyListeners(); + } + + void navigateHome() { + navigateTo(WorkspaceDestination.assistant); + } + + void openSettings({SettingsTab tab = SettingsTab.general}) { + _destination = WorkspaceDestination.settings; + _settingsTab = _sanitizeSettingsTab(tab); + notifyListeners(); + } + + void setSettingsTab(SettingsTab tab) { + _settingsTab = _sanitizeSettingsTab(tab); + notifyListeners(); + } + + Future setThemeMode(ThemeMode mode) async { + if (_themeMode == mode) { + return; + } + _themeMode = mode; + await _store.saveThemeMode(mode); + notifyListeners(); + } + + Future toggleAppLanguage() async { + final next = _settings.appLanguage == AppLanguage.zh + ? AppLanguage.en + : AppLanguage.zh; + _settings = _settings.copyWith(appLanguage: next); + await _persistSettings(); + notifyListeners(); + } + + Future createConversation({AssistantExecutionTarget? target}) async { + final resolvedTarget = + _sanitizeTarget(target) ?? _settings.assistantExecutionTarget; + final record = _newRecord(target: resolvedTarget); + _threadRecords[record.sessionKey] = record; + _currentSessionKey = record.sessionKey; + _lastAssistantError = null; + await _persistThreads(); + notifyListeners(); + } + + Future switchConversation(String sessionKey) async { + if (!_threadRecords.containsKey(sessionKey)) { + return; + } + _currentSessionKey = sessionKey; + _lastAssistantError = null; + notifyListeners(); + final record = _threadRecords[sessionKey]!; + if (_sanitizeTarget(record.executionTarget) == + AssistantExecutionTarget.remote && + connection.status == RuntimeConnectionStatus.connected) { + await refreshRelayHistory(sessionKey: sessionKey); + } + } + + Future setAssistantExecutionTarget( + AssistantExecutionTarget target, + ) async { + final resolvedTarget = + _sanitizeTarget(target) ?? AssistantExecutionTarget.aiGatewayOnly; + _settings = _settings.copyWith(assistantExecutionTarget: resolvedTarget); + _replaceCurrentRecord( + _currentRecord.copyWith(executionTarget: resolvedTarget), + ); + await _persistSettings(); + await _persistThreads(); + notifyListeners(); + } + + Future saveAiGatewayConfiguration({ + required String name, + required String baseUrl, + required String provider, + required String apiKey, + required String defaultModel, + }) async { + final normalizedBaseUrl = _aiGatewayClient.normalizeBaseUrl(baseUrl); + _settings = _settings.copyWith( + defaultProvider: provider.trim().isEmpty ? 'gateway' : provider.trim(), + defaultModel: defaultModel.trim(), + aiGateway: _settings.aiGateway.copyWith( + name: name.trim().isEmpty ? 'Direct AI' : name.trim(), + baseUrl: normalizedBaseUrl?.toString() ?? baseUrl.trim(), + ), + ); + _aiGatewayApiKeyCache = apiKey.trim(); + await _store.saveAiGatewayApiKey(_aiGatewayApiKeyCache); + await _persistSettings(); + notifyListeners(); + } + + Future testAiGatewayConnection({ + required String baseUrl, + required String apiKey, + }) async { + _aiGatewayBusy = true; + notifyListeners(); + try { + return await _aiGatewayClient.testConnection( + baseUrl: baseUrl, + apiKey: apiKey, + ); + } finally { + _aiGatewayBusy = false; + notifyListeners(); + } + } + + Future syncAiGatewayModels({ + required String name, + required String baseUrl, + required String provider, + required String apiKey, + }) async { + _aiGatewayBusy = true; + notifyListeners(); + try { + final models = await _aiGatewayClient.loadModels( + baseUrl: baseUrl, + apiKey: apiKey, + ); + final availableModels = models + .map((item) => item.id) + .toList(growable: false); + final selectedModels = availableModels.take(5).toList(growable: false); + final resolvedDefaultModel = + _settings.defaultModel.trim().isNotEmpty && + availableModels.contains(_settings.defaultModel.trim()) + ? _settings.defaultModel.trim() + : selectedModels.isNotEmpty + ? selectedModels.first + : ''; + _settings = _settings.copyWith( + defaultProvider: provider.trim().isEmpty ? 'gateway' : provider.trim(), + defaultModel: resolvedDefaultModel, + aiGateway: _settings.aiGateway.copyWith( + name: name.trim().isEmpty ? 'Direct AI' : name.trim(), + baseUrl: + _aiGatewayClient.normalizeBaseUrl(baseUrl)?.toString() ?? + baseUrl.trim(), + availableModels: availableModels, + selectedModels: selectedModels, + syncState: 'ready', + syncMessage: 'Loaded ${availableModels.length} model(s)', + ), + ); + _aiGatewayApiKeyCache = apiKey.trim(); + await _store.saveAiGatewayApiKey(_aiGatewayApiKeyCache); + await _persistSettings(); + } catch (error) { + _settings = _settings.copyWith( + aiGateway: _settings.aiGateway.copyWith( + syncState: 'error', + syncMessage: _aiGatewayClient.networkErrorLabel(error), + ), + ); + await _persistSettings(); + rethrow; + } finally { + _aiGatewayBusy = false; + notifyListeners(); + } + } + + Future saveRelayConfiguration({ + required String host, + required int port, + required bool tls, + required String token, + required String password, + }) async { + _settings = _settings.copyWith( + gateway: _settings.gateway.copyWith( + mode: RuntimeConnectionMode.remote, + useSetupCode: false, + host: host.trim(), + port: port, + tls: tls, + ), + ); + _relayTokenCache = token.trim(); + _relayPasswordCache = password.trim(); + await _store.saveRelayToken(_relayTokenCache); + await _store.saveRelayPassword(_relayPasswordCache); + await _persistSettings(); + notifyListeners(); + } + + Future connectRelay() async { + _relayBusy = true; + notifyListeners(); + try { + await _relayClient.connect( + profile: _settings.gateway.copyWith( + mode: RuntimeConnectionMode.remote, + useSetupCode: false, + ), + authToken: _relayTokenCache, + authPassword: _relayPasswordCache, + ); + await refreshRelaySessions(); + await refreshRelayModels(); + if (_sanitizeTarget(_currentRecord.executionTarget) == + AssistantExecutionTarget.remote) { + await refreshRelayHistory(sessionKey: _currentSessionKey); + } + } finally { + _relayBusy = false; + notifyListeners(); + } + } + + Future disconnectRelay() async { + _relayBusy = true; + notifyListeners(); + try { + await _relayClient.disconnect(); + } finally { + _relayBusy = false; + notifyListeners(); + } + } + + Future refreshRelaySessions() async { + if (connection.status != RuntimeConnectionStatus.connected) { + return; + } + final sessions = await _relayClient.listSessions(limit: 50); + for (final session in sessions) { + final existing = _threadRecords[session.key]; + final next = AssistantThreadRecord( + sessionKey: session.key, + messages: existing?.messages ?? const [], + updatedAtMs: + session.updatedAtMs ?? + existing?.updatedAtMs ?? + DateTime.now().millisecondsSinceEpoch.toDouble(), + title: (session.derivedTitle ?? session.displayName ?? session.key) + .trim(), + archived: false, + executionTarget: AssistantExecutionTarget.remote, + messageViewMode: + existing?.messageViewMode ?? AssistantMessageViewMode.rendered, + ); + _threadRecords[session.key] = next; + } + await _persistThreads(); + notifyListeners(); + } + + Future refreshRelayModels() async { + if (connection.status != RuntimeConnectionStatus.connected) { + return; + } + final models = await _relayClient.listModels(); + final availableModels = models + .map((item) => item.id.trim()) + .where((item) => item.isNotEmpty) + .toList(growable: false); + if (availableModels.isEmpty) { + return; + } + final defaultModel = _settings.defaultModel.trim().isNotEmpty + ? _settings.defaultModel.trim() + : availableModels.first; + _settings = _settings.copyWith( + defaultModel: defaultModel, + aiGateway: _settings.aiGateway.copyWith( + availableModels: _settings.aiGateway.availableModels.isEmpty + ? availableModels + : _settings.aiGateway.availableModels, + ), + ); + await _persistSettings(); + notifyListeners(); + } + + Future refreshRelayHistory({String? sessionKey}) async { + final resolvedKey = (sessionKey ?? _currentSessionKey).trim(); + if (resolvedKey.isEmpty || + connection.status != RuntimeConnectionStatus.connected) { + return; + } + final messages = await _relayClient.loadHistory(resolvedKey, limit: 120); + final existing = _threadRecords[resolvedKey]; + final next = + (existing ?? _newRecord(target: AssistantExecutionTarget.remote)) + .copyWith( + sessionKey: resolvedKey, + messages: messages, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + title: _deriveThreadTitle( + existing?.title ?? '', + messages, + fallback: resolvedKey, + ), + executionTarget: AssistantExecutionTarget.remote, + ); + _threadRecords[resolvedKey] = next; + _streamingTextBySession.remove(resolvedKey); + await _persistThreads(); + notifyListeners(); + } + + Future sendMessage(String rawMessage) async { + final trimmed = rawMessage.trim(); + if (trimmed.isEmpty) { + return; + } + _lastAssistantError = null; + final target = assistantExecutionTarget; + final current = _currentRecord; + final updatedMessages = [ + ...current.messages, + GatewayChatMessage( + id: _messageId(), + role: 'user', + text: trimmed, + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + ]; + _replaceCurrentRecord( + current.copyWith( + messages: updatedMessages, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + title: _deriveThreadTitle(current.title, updatedMessages), + executionTarget: target, + ), + ); + _pendingSessionKeys.add(_currentSessionKey); + await _persistThreads(); + notifyListeners(); + + try { + if (target == AssistantExecutionTarget.aiGatewayOnly) { + if (!canUseAiGatewayConversation) { + throw Exception( + appText( + '请先在 Settings 配置 Direct AI 的地址、API Key 和默认模型。', + 'Configure Direct AI endpoint, API key, and default model first.', + ), + ); + } + final reply = await _aiGatewayClient.completeChat( + baseUrl: _settings.aiGateway.baseUrl, + apiKey: _aiGatewayApiKeyCache, + model: resolvedAiGatewayModel, + history: updatedMessages, + ); + _appendAssistantMessage( + sessionKey: _currentSessionKey, + text: reply, + error: false, + ); + } else { + if (connection.status != RuntimeConnectionStatus.connected) { + throw Exception( + appText( + 'Relay OpenClaw Gateway 尚未连接。', + 'Relay OpenClaw Gateway is not connected.', + ), + ); + } + await _relayClient.sendChat( + sessionKey: _currentSessionKey, + message: trimmed, + thinking: 'medium', + ); + } + } catch (error) { + _appendAssistantMessage( + sessionKey: _currentSessionKey, + text: error.toString(), + error: true, + ); + _lastAssistantError = error.toString(); + _pendingSessionKeys.remove(_currentSessionKey); + _streamingTextBySession.remove(_currentSessionKey); + await _persistThreads(); + notifyListeners(); + } + } + + Future selectDirectModel(String model) async { + final trimmed = model.trim(); + if (trimmed.isEmpty) { + return; + } + _settings = _settings.copyWith(defaultModel: trimmed); + await _persistSettings(); + notifyListeners(); + } + + @override + void dispose() { + unawaited(_relayEventsSubscription.cancel()); + unawaited(_relayClient.dispose()); + super.dispose(); + } + + SettingsTab _sanitizeSettingsTab(SettingsTab tab) { + return switch (tab) { + SettingsTab.workspace || + SettingsTab.agents || + SettingsTab.diagnostics || + SettingsTab.experimental => SettingsTab.gateway, + _ => tab, + }; + } + + SettingsSnapshot _sanitizeSettings(SettingsSnapshot snapshot) { + final target = + _sanitizeTarget(snapshot.assistantExecutionTarget) ?? + AssistantExecutionTarget.aiGatewayOnly; + return snapshot.copyWith( + assistantExecutionTarget: target, + gateway: snapshot.gateway.copyWith( + mode: target == AssistantExecutionTarget.remote + ? RuntimeConnectionMode.remote + : RuntimeConnectionMode.unconfigured, + useSetupCode: false, + ), + assistantNavigationDestinations: const [], + ); + } + + AssistantThreadRecord _sanitizeRecord(AssistantThreadRecord record) { + final target = + _sanitizeTarget(record.executionTarget) ?? + AssistantExecutionTarget.aiGatewayOnly; + return record.copyWith( + executionTarget: target, + title: record.title.trim().isEmpty + ? appText('新对话', 'New conversation') + : record.title.trim(), + ); + } + + AssistantExecutionTarget? _sanitizeTarget(AssistantExecutionTarget? target) { + return switch (target) { + AssistantExecutionTarget.remote => AssistantExecutionTarget.remote, + AssistantExecutionTarget.aiGatewayOnly => + AssistantExecutionTarget.aiGatewayOnly, + _ => AssistantExecutionTarget.aiGatewayOnly, + }; + } + + AssistantThreadRecord _newRecord({ + required AssistantExecutionTarget target, + String? title, + }) { + final timestamp = DateTime.now().millisecondsSinceEpoch; + final prefix = target == AssistantExecutionTarget.remote + ? 'relay' + : 'direct'; + return AssistantThreadRecord( + sessionKey: '$prefix:$timestamp', + messages: const [], + updatedAtMs: timestamp.toDouble(), + title: title ?? appText('新对话', 'New conversation'), + archived: false, + executionTarget: target, + messageViewMode: AssistantMessageViewMode.rendered, + ); + } + + void _replaceCurrentRecord(AssistantThreadRecord record) { + _threadRecords[record.sessionKey] = record; + _currentSessionKey = record.sessionKey; + } + + void _appendAssistantMessage({ + required String sessionKey, + required String text, + required bool error, + }) { + final existing = + _threadRecords[sessionKey] ?? + _newRecord(target: assistantExecutionTarget); + final messages = [ + ...existing.messages, + GatewayChatMessage( + id: _messageId(), + role: 'assistant', + text: text, + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: null, + stopReason: error ? 'error' : null, + pending: false, + error: error, + ), + ]; + _threadRecords[sessionKey] = existing.copyWith( + messages: messages, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + title: _deriveThreadTitle(existing.title, messages, fallback: sessionKey), + ); + _pendingSessionKeys.remove(sessionKey); + _streamingTextBySession.remove(sessionKey); + } + + void _handleRelayEvent(GatewayPushEvent event) { + if (event.event != 'chat') { + return; + } + final payload = _castMap(event.payload); + final sessionKey = (payload['sessionKey']?.toString().trim() ?? '').trim(); + if (sessionKey.isEmpty) { + return; + } + final state = payload['state']?.toString().trim() ?? ''; + final message = _castMap(payload['message']); + final text = _extractMessageText(message); + if (text.isNotEmpty && (state == 'delta' || state == 'final')) { + _streamingTextBySession[sessionKey] = text; + } + if (state == 'final' || state == 'aborted' || state == 'error') { + _pendingSessionKeys.remove(sessionKey); + unawaited(refreshRelaySessions()); + unawaited(refreshRelayHistory(sessionKey: sessionKey)); + } + notifyListeners(); + } + + Future _persistSettings() async { + await _store.saveSettingsSnapshot(_settings); + } + + Future _persistThreads() async { + await _store.saveAssistantThreadRecords( + _threadRecords.values.toList(growable: false), + ); + } + + String _titleForRecord(AssistantThreadRecord record) { + final title = record.title.trim(); + if (title.isNotEmpty) { + return title; + } + return _deriveThreadTitle('', record.messages, fallback: record.sessionKey); + } + + String _previewForRecord(AssistantThreadRecord record) { + for (final message in record.messages.reversed) { + final text = message.text.trim(); + if (text.isNotEmpty) { + return text; + } + } + return appText( + '等待描述这个任务的第一条消息', + 'Waiting for the first message of this task', + ); + } + + String _deriveThreadTitle( + String currentTitle, + List messages, { + String fallback = '', + }) { + final trimmedCurrent = currentTitle.trim(); + if (trimmedCurrent.isNotEmpty && + trimmedCurrent != appText('新对话', 'New conversation')) { + return trimmedCurrent; + } + for (final message in messages) { + if (message.role.trim().toLowerCase() != 'user') { + continue; + } + final text = message.text.trim(); + if (text.isEmpty) { + continue; + } + return text.length <= 32 ? text : '${text.substring(0, 32)}...'; + } + return fallback.isEmpty ? appText('新对话', 'New conversation') : fallback; + } + + String _hostLabel(String rawUrl) { + final normalized = _aiGatewayClient.normalizeBaseUrl(rawUrl); + return normalized?.host.trim() ?? ''; + } + + String _messageId() { + return DateTime.now().microsecondsSinceEpoch.toString(); + } + + Map _castMap(Object? value) { + if (value is Map) { + return value; + } + if (value is Map) { + return value.cast(); + } + return const {}; + } + + String _extractMessageText(Map message) { + final directContent = message['content']; + if (directContent is String) { + return directContent; + } + final parts = []; + if (directContent is List) { + for (final part in directContent) { + final map = _castMap(part); + final text = map['text']?.toString().trim(); + if (text != null && text.isNotEmpty) { + parts.add(text); + } + } + } + return parts.join('\n').trim(); + } +} + +class WebConversationSummary { + const WebConversationSummary({ + required this.sessionKey, + required this.title, + required this.preview, + required this.updatedAtMs, + required this.executionTarget, + required this.pending, + required this.current, + }); + + final String sessionKey; + final String title; + final String preview; + final double updatedAtMs; + final AssistantExecutionTarget executionTarget; + final bool pending; + final bool current; +} diff --git a/lib/app/app_shell.dart b/lib/app/app_shell.dart index 1c69bca9..399d5d48 100644 --- a/lib/app/app_shell.dart +++ b/lib/app/app_shell.dart @@ -1,463 +1 @@ -import 'package:flutter/material.dart'; - -import '../features/account/account_page.dart'; -import '../features/mobile/mobile_shell.dart'; -import '../i18n/app_language.dart'; -import '../models/app_models.dart'; -import '../theme/app_palette.dart'; -import '../theme/app_theme.dart'; -import '../widgets/detail_drawer.dart'; -import '../widgets/pane_resize_handle.dart'; -import '../widgets/sidebar_navigation.dart'; -import 'app_controller.dart'; -import 'workspace_page_registry.dart'; - -class AppShell extends StatefulWidget { - const AppShell({super.key, required this.controller}); - - final AppController controller; - @override - State createState() => _AppShellState(); -} - -class _AppShellState extends State { - static const _sidebarMinWidth = 56.0; - static const _sidebarViewportPadding = 72.0; - static const _mainContentMinWidth = 640.0; - double? _sidebarExpandedWidth; - - static const _mobileDestinations = [ - WorkspaceDestination.assistant, - WorkspaceDestination.tasks, - WorkspaceDestination.skills, - WorkspaceDestination.secrets, - WorkspaceDestination.settings, - ]; - - double _clampSidebarWidth(double value, double viewportWidth) { - final responsiveMax = - (viewportWidth - _mainContentMinWidth - _sidebarViewportPadding).clamp( - _sidebarMinWidth, - viewportWidth - _sidebarViewportPadding, - ); - return value.clamp(_sidebarMinWidth, responsiveMax).toDouble(); - } - - double _defaultSidebarWidth(AppLanguage language, double viewportWidth) { - final baseWidth = language == AppLanguage.zh - ? AppSizes.sidebarExpandedWidthZh - : AppSizes.sidebarExpandedWidthEn; - return _clampSidebarWidth(baseWidth, viewportWidth); - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: widget.controller, - builder: (context, _) { - final controller = widget.controller; - return Scaffold( - body: SafeArea( - bottom: false, - child: LayoutBuilder( - builder: (context, constraints) { - final palette = context.palette; - final platform = Theme.of(context).platform; - final brightness = Theme.of(context).brightness; - final isCompactMobile = - (platform == TargetPlatform.iOS || - platform == TargetPlatform.android) && - constraints.maxWidth < 900; - final isMobile = constraints.maxWidth < 900; - final sidebarState = controller.sidebarState; - final showSidebar = sidebarState != AppSidebarState.hidden; - final embedSidebarIntoAssistant = - controller.destination == WorkspaceDestination.assistant && - showSidebar; - final expandedSidebarWidth = _clampSidebarWidth( - _sidebarExpandedWidth ?? - _defaultSidebarWidth( - controller.appLanguage, - constraints.maxWidth, - ), - constraints.maxWidth, - ); - final showPinnedDetail = - controller.detailPanel != null && - constraints.maxWidth > 1280; - final mobileDestination = - controller.destination == WorkspaceDestination.account - ? WorkspaceDestination.assistant - : controller.destination; - - void openMobileDetail(DetailPanelData detail) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (sheetContext) { - return FractionallySizedBox( - heightFactor: 0.92, - child: DetailSheet( - data: detail, - onClose: () => Navigator.of(sheetContext).pop(), - ), - ); - }, - ); - } - - void openAccountSheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (sheetContext) { - return Container( - margin: EdgeInsets.fromLTRB( - 12, - MediaQuery.of(sheetContext).padding.top + 12, - 12, - 12, - ), - decoration: BoxDecoration( - color: palette.surfacePrimary, - borderRadius: BorderRadius.circular(28), - border: Border.all(color: palette.strokeSoft), - ), - child: SafeArea( - top: false, - child: AccountPage(controller: controller), - ), - ); - }, - ); - } - - if (isCompactMobile) { - return MobileShell(controller: controller); - } - - if (isMobile) { - return Stack( - children: [ - Column( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), - child: ClipRRect( - borderRadius: BorderRadius.circular(24), - child: Container( - color: palette.canvas.withValues(alpha: 0.18), - child: _pageForDestination( - mobileDestination, - openMobileDetail, - ), - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(12, 10, 12, 12), - child: ClipRRect( - borderRadius: BorderRadius.circular(24), - child: NavigationBar( - selectedIndex: _mobileDestinations.indexOf( - mobileDestination, - ), - onDestinationSelected: (index) { - controller.navigateTo( - _mobileDestinations[index], - ); - }, - destinations: _mobileDestinations - .map( - (destination) => NavigationDestination( - icon: Icon(destination.icon), - label: destination.label, - ), - ) - .toList(), - ), - ), - ), - ], - ), - Positioned( - right: 24, - bottom: 96, - child: FloatingActionButton.small( - onPressed: openAccountSheet, - child: const Icon(Icons.account_circle_rounded), - ), - ), - ], - ); - } - - return Stack( - children: [ - Row( - children: [ - if (showSidebar && !embedSidebarIntoAssistant) - SidebarNavigation( - currentSection: controller.destination, - sidebarState: sidebarState, - appLanguage: controller.appLanguage, - themeMode: controller.themeMode, - onSectionChanged: controller.navigateTo, - onToggleLanguage: controller.toggleAppLanguage, - onCycleSidebarState: controller.cycleSidebarState, - onExpandFromCollapsed: () => controller - .setSidebarState(AppSidebarState.expanded), - onOpenAccount: () => controller.navigateTo( - WorkspaceDestination.account, - ), - onOpenThemeToggle: () => controller.setThemeMode( - controller.themeMode == ThemeMode.dark - ? ThemeMode.light - : ThemeMode.dark, - ), - accountName: - controller.settings.accountUsername - .trim() - .isEmpty - ? appText('本地操作员', 'Local Operator') - : controller.settings.accountUsername, - accountSubtitle: - controller.settings.accountWorkspace - .trim() - .isEmpty - ? appText('账号', 'Account') - : controller.settings.accountWorkspace, - onOpenOnlineWorkspace: - controller.openOnlineWorkspace, - expandedWidthOverride: - sidebarState == AppSidebarState.expanded - ? expandedSidebarWidth - : null, - favoriteDestinations: controller - .assistantNavigationDestinations - .toSet(), - onToggleFavorite: - controller.toggleAssistantNavigationDestination, - ), - if (sidebarState == AppSidebarState.expanded && - !embedSidebarIntoAssistant) - PaneResizeHandle( - axis: Axis.horizontal, - onDelta: (delta) { - setState(() { - _sidebarExpandedWidth = _clampSidebarWidth( - expandedSidebarWidth + delta, - constraints.maxWidth, - ); - }); - }, - ), - Expanded( - child: Padding( - padding: const EdgeInsets.only(top: 4, right: 4), - child: AnimatedPadding( - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, - padding: EdgeInsets.only( - right: showPinnedDetail ? 336 : 0, - ), - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - palette.chromeBackground, - palette.canvas, - ], - stops: const [0.0, 0.68], - ), - ), - child: Stack( - children: [ - Positioned( - top: -180, - right: -80, - child: IgnorePointer( - child: Container( - width: 420, - height: 420, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( - colors: [ - palette.chromeHighlight - .withValues( - alpha: - brightness == - Brightness.dark - ? 0.14 - : 0.42, - ), - palette.chromeHighlight - .withValues(alpha: 0), - ], - ), - ), - ), - ), - ), - Positioned( - bottom: -220, - left: -140, - child: IgnorePointer( - child: Container( - width: 360, - height: 360, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( - colors: [ - palette.chromeInset.withValues( - alpha: - brightness == - Brightness.dark - ? 0.14 - : 0.24, - ), - palette.chromeInset.withValues( - alpha: 0, - ), - ], - ), - ), - ), - ), - ), - _buildCurrentPage(controller.openDetail), - ], - ), - ), - ), - ), - ), - ], - ), - if (controller.detailPanel != null && !showPinnedDetail) - Positioned.fill( - child: GestureDetector( - onTap: controller.closeDetail, - child: Container( - color: Colors.black.withValues(alpha: 0.12), - ), - ), - ), - if (controller.detailPanel != null) - Align( - alignment: Alignment.centerRight, - child: DetailDrawer( - data: controller.detailPanel!, - onClose: controller.closeDetail, - ), - ), - if (!showSidebar) - Positioned( - left: 0, - top: 8, - bottom: 0, - child: _SidebarRevealRail( - onExpand: () => controller.setSidebarState( - AppSidebarState.expanded, - ), - ), - ), - ], - ); - }, - ), - ), - ); - }, - ); - } - - Widget _buildCurrentPage(ValueChanged onOpenDetail) { - return IndexedStack( - index: widget.controller.destination.index, - children: WorkspaceDestination.values - .map((destination) => _pageForDestination(destination, onOpenDetail)) - .toList(), - ); - } - - Widget _pageForDestination( - WorkspaceDestination destination, - ValueChanged onOpenDetail, - ) { - return buildWorkspacePage( - destination: destination, - controller: widget.controller, - onOpenDetail: onOpenDetail, - surface: WorkspacePageSurface.desktop, - ); - } -} - -class _SidebarRevealRail extends StatefulWidget { - const _SidebarRevealRail({required this.onExpand}); - - final VoidCallback onExpand; - - @override - State<_SidebarRevealRail> createState() => _SidebarRevealRailState(); -} - -class _SidebarRevealRailState extends State<_SidebarRevealRail> { - bool _hovered = false; - - @override - Widget build(BuildContext context) { - final palette = context.palette; - - return MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: Tooltip( - message: appText('展开导航', 'Expand sidebar'), - child: GestureDetector( - onTap: widget.onExpand, - child: AnimatedContainer( - duration: const Duration(milliseconds: 180), - width: _hovered ? 22 : 10, - decoration: BoxDecoration( - gradient: _hovered - ? LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - palette.chromeHighlight.withValues(alpha: 0.92), - palette.chromeSurface, - ], - ) - : null, - color: _hovered ? null : Colors.transparent, - borderRadius: const BorderRadius.horizontal( - right: Radius.circular(14), - ), - border: Border.all( - color: _hovered ? palette.chromeStroke : Colors.transparent, - ), - boxShadow: _hovered ? [palette.chromeShadowLift] : const [], - ), - child: _hovered - ? Icon( - Icons.keyboard_double_arrow_right_rounded, - size: 16, - color: palette.textMuted, - ) - : null, - ), - ), - ), - ); - } -} +export 'app_shell_desktop.dart' if (dart.library.html) 'app_shell_web.dart'; diff --git a/lib/app/app_shell_desktop.dart b/lib/app/app_shell_desktop.dart new file mode 100644 index 00000000..1c69bca9 --- /dev/null +++ b/lib/app/app_shell_desktop.dart @@ -0,0 +1,463 @@ +import 'package:flutter/material.dart'; + +import '../features/account/account_page.dart'; +import '../features/mobile/mobile_shell.dart'; +import '../i18n/app_language.dart'; +import '../models/app_models.dart'; +import '../theme/app_palette.dart'; +import '../theme/app_theme.dart'; +import '../widgets/detail_drawer.dart'; +import '../widgets/pane_resize_handle.dart'; +import '../widgets/sidebar_navigation.dart'; +import 'app_controller.dart'; +import 'workspace_page_registry.dart'; + +class AppShell extends StatefulWidget { + const AppShell({super.key, required this.controller}); + + final AppController controller; + @override + State createState() => _AppShellState(); +} + +class _AppShellState extends State { + static const _sidebarMinWidth = 56.0; + static const _sidebarViewportPadding = 72.0; + static const _mainContentMinWidth = 640.0; + double? _sidebarExpandedWidth; + + static const _mobileDestinations = [ + WorkspaceDestination.assistant, + WorkspaceDestination.tasks, + WorkspaceDestination.skills, + WorkspaceDestination.secrets, + WorkspaceDestination.settings, + ]; + + double _clampSidebarWidth(double value, double viewportWidth) { + final responsiveMax = + (viewportWidth - _mainContentMinWidth - _sidebarViewportPadding).clamp( + _sidebarMinWidth, + viewportWidth - _sidebarViewportPadding, + ); + return value.clamp(_sidebarMinWidth, responsiveMax).toDouble(); + } + + double _defaultSidebarWidth(AppLanguage language, double viewportWidth) { + final baseWidth = language == AppLanguage.zh + ? AppSizes.sidebarExpandedWidthZh + : AppSizes.sidebarExpandedWidthEn; + return _clampSidebarWidth(baseWidth, viewportWidth); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: widget.controller, + builder: (context, _) { + final controller = widget.controller; + return Scaffold( + body: SafeArea( + bottom: false, + child: LayoutBuilder( + builder: (context, constraints) { + final palette = context.palette; + final platform = Theme.of(context).platform; + final brightness = Theme.of(context).brightness; + final isCompactMobile = + (platform == TargetPlatform.iOS || + platform == TargetPlatform.android) && + constraints.maxWidth < 900; + final isMobile = constraints.maxWidth < 900; + final sidebarState = controller.sidebarState; + final showSidebar = sidebarState != AppSidebarState.hidden; + final embedSidebarIntoAssistant = + controller.destination == WorkspaceDestination.assistant && + showSidebar; + final expandedSidebarWidth = _clampSidebarWidth( + _sidebarExpandedWidth ?? + _defaultSidebarWidth( + controller.appLanguage, + constraints.maxWidth, + ), + constraints.maxWidth, + ); + final showPinnedDetail = + controller.detailPanel != null && + constraints.maxWidth > 1280; + final mobileDestination = + controller.destination == WorkspaceDestination.account + ? WorkspaceDestination.assistant + : controller.destination; + + void openMobileDetail(DetailPanelData detail) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (sheetContext) { + return FractionallySizedBox( + heightFactor: 0.92, + child: DetailSheet( + data: detail, + onClose: () => Navigator.of(sheetContext).pop(), + ), + ); + }, + ); + } + + void openAccountSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (sheetContext) { + return Container( + margin: EdgeInsets.fromLTRB( + 12, + MediaQuery.of(sheetContext).padding.top + 12, + 12, + 12, + ), + decoration: BoxDecoration( + color: palette.surfacePrimary, + borderRadius: BorderRadius.circular(28), + border: Border.all(color: palette.strokeSoft), + ), + child: SafeArea( + top: false, + child: AccountPage(controller: controller), + ), + ); + }, + ); + } + + if (isCompactMobile) { + return MobileShell(controller: controller); + } + + if (isMobile) { + return Stack( + children: [ + Column( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Container( + color: palette.canvas.withValues(alpha: 0.18), + child: _pageForDestination( + mobileDestination, + openMobileDetail, + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 12), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: NavigationBar( + selectedIndex: _mobileDestinations.indexOf( + mobileDestination, + ), + onDestinationSelected: (index) { + controller.navigateTo( + _mobileDestinations[index], + ); + }, + destinations: _mobileDestinations + .map( + (destination) => NavigationDestination( + icon: Icon(destination.icon), + label: destination.label, + ), + ) + .toList(), + ), + ), + ), + ], + ), + Positioned( + right: 24, + bottom: 96, + child: FloatingActionButton.small( + onPressed: openAccountSheet, + child: const Icon(Icons.account_circle_rounded), + ), + ), + ], + ); + } + + return Stack( + children: [ + Row( + children: [ + if (showSidebar && !embedSidebarIntoAssistant) + SidebarNavigation( + currentSection: controller.destination, + sidebarState: sidebarState, + appLanguage: controller.appLanguage, + themeMode: controller.themeMode, + onSectionChanged: controller.navigateTo, + onToggleLanguage: controller.toggleAppLanguage, + onCycleSidebarState: controller.cycleSidebarState, + onExpandFromCollapsed: () => controller + .setSidebarState(AppSidebarState.expanded), + onOpenAccount: () => controller.navigateTo( + WorkspaceDestination.account, + ), + onOpenThemeToggle: () => controller.setThemeMode( + controller.themeMode == ThemeMode.dark + ? ThemeMode.light + : ThemeMode.dark, + ), + accountName: + controller.settings.accountUsername + .trim() + .isEmpty + ? appText('本地操作员', 'Local Operator') + : controller.settings.accountUsername, + accountSubtitle: + controller.settings.accountWorkspace + .trim() + .isEmpty + ? appText('账号', 'Account') + : controller.settings.accountWorkspace, + onOpenOnlineWorkspace: + controller.openOnlineWorkspace, + expandedWidthOverride: + sidebarState == AppSidebarState.expanded + ? expandedSidebarWidth + : null, + favoriteDestinations: controller + .assistantNavigationDestinations + .toSet(), + onToggleFavorite: + controller.toggleAssistantNavigationDestination, + ), + if (sidebarState == AppSidebarState.expanded && + !embedSidebarIntoAssistant) + PaneResizeHandle( + axis: Axis.horizontal, + onDelta: (delta) { + setState(() { + _sidebarExpandedWidth = _clampSidebarWidth( + expandedSidebarWidth + delta, + constraints.maxWidth, + ); + }); + }, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 4, right: 4), + child: AnimatedPadding( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + padding: EdgeInsets.only( + right: showPinnedDetail ? 336 : 0, + ), + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + palette.chromeBackground, + palette.canvas, + ], + stops: const [0.0, 0.68], + ), + ), + child: Stack( + children: [ + Positioned( + top: -180, + right: -80, + child: IgnorePointer( + child: Container( + width: 420, + height: 420, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + palette.chromeHighlight + .withValues( + alpha: + brightness == + Brightness.dark + ? 0.14 + : 0.42, + ), + palette.chromeHighlight + .withValues(alpha: 0), + ], + ), + ), + ), + ), + ), + Positioned( + bottom: -220, + left: -140, + child: IgnorePointer( + child: Container( + width: 360, + height: 360, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + palette.chromeInset.withValues( + alpha: + brightness == + Brightness.dark + ? 0.14 + : 0.24, + ), + palette.chromeInset.withValues( + alpha: 0, + ), + ], + ), + ), + ), + ), + ), + _buildCurrentPage(controller.openDetail), + ], + ), + ), + ), + ), + ), + ], + ), + if (controller.detailPanel != null && !showPinnedDetail) + Positioned.fill( + child: GestureDetector( + onTap: controller.closeDetail, + child: Container( + color: Colors.black.withValues(alpha: 0.12), + ), + ), + ), + if (controller.detailPanel != null) + Align( + alignment: Alignment.centerRight, + child: DetailDrawer( + data: controller.detailPanel!, + onClose: controller.closeDetail, + ), + ), + if (!showSidebar) + Positioned( + left: 0, + top: 8, + bottom: 0, + child: _SidebarRevealRail( + onExpand: () => controller.setSidebarState( + AppSidebarState.expanded, + ), + ), + ), + ], + ); + }, + ), + ), + ); + }, + ); + } + + Widget _buildCurrentPage(ValueChanged onOpenDetail) { + return IndexedStack( + index: widget.controller.destination.index, + children: WorkspaceDestination.values + .map((destination) => _pageForDestination(destination, onOpenDetail)) + .toList(), + ); + } + + Widget _pageForDestination( + WorkspaceDestination destination, + ValueChanged onOpenDetail, + ) { + return buildWorkspacePage( + destination: destination, + controller: widget.controller, + onOpenDetail: onOpenDetail, + surface: WorkspacePageSurface.desktop, + ); + } +} + +class _SidebarRevealRail extends StatefulWidget { + const _SidebarRevealRail({required this.onExpand}); + + final VoidCallback onExpand; + + @override + State<_SidebarRevealRail> createState() => _SidebarRevealRailState(); +} + +class _SidebarRevealRailState extends State<_SidebarRevealRail> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: Tooltip( + message: appText('展开导航', 'Expand sidebar'), + child: GestureDetector( + onTap: widget.onExpand, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + width: _hovered ? 22 : 10, + decoration: BoxDecoration( + gradient: _hovered + ? LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + palette.chromeHighlight.withValues(alpha: 0.92), + palette.chromeSurface, + ], + ) + : null, + color: _hovered ? null : Colors.transparent, + borderRadius: const BorderRadius.horizontal( + right: Radius.circular(14), + ), + border: Border.all( + color: _hovered ? palette.chromeStroke : Colors.transparent, + ), + boxShadow: _hovered ? [palette.chromeShadowLift] : const [], + ), + child: _hovered + ? Icon( + Icons.keyboard_double_arrow_right_rounded, + size: 16, + color: palette.textMuted, + ) + : null, + ), + ), + ), + ); + } +} diff --git a/lib/app/app_shell_web.dart b/lib/app/app_shell_web.dart new file mode 100644 index 00000000..984ae0de --- /dev/null +++ b/lib/app/app_shell_web.dart @@ -0,0 +1,258 @@ +import 'package:flutter/material.dart'; + +import '../i18n/app_language.dart'; +import '../models/app_models.dart'; +import '../theme/app_palette.dart'; +import '../theme/app_theme.dart'; +import '../web/web_assistant_page.dart'; +import '../web/web_settings_page.dart'; +import 'app_controller_web.dart'; + +class AppShell extends StatelessWidget { + const AppShell({super.key, required this.controller}); + + final AppController controller; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + return Scaffold( + body: SafeArea( + bottom: false, + child: LayoutBuilder( + builder: (context, constraints) { + final mobile = constraints.maxWidth < 900; + if (mobile) { + return Column( + children: [ + Expanded(child: _buildPage(controller)), + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: NavigationBar( + selectedIndex: + controller.destination == + WorkspaceDestination.settings + ? 1 + : 0, + onDestinationSelected: (index) { + controller.navigateTo( + index == 0 + ? WorkspaceDestination.assistant + : WorkspaceDestination.settings, + ); + }, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.chat_bubble_outline_rounded), + label: 'Assistant', + ), + NavigationDestination( + icon: Icon(Icons.tune_rounded), + label: 'Settings', + ), + ], + ), + ), + ), + ], + ); + } + + final palette = context.palette; + return Row( + children: [ + Container( + width: + controller.destination == + WorkspaceDestination.settings + ? 248 + : 236, + margin: const EdgeInsets.fromLTRB(4, 4, 4, 0), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + palette.chromeHighlight.withValues(alpha: 0.9), + palette.chromeSurface.withValues(alpha: 0.92), + ], + ), + borderRadius: BorderRadius.circular(AppRadius.sidebar), + border: Border.all(color: palette.chromeStroke), + boxShadow: [palette.chromeShadowAmbient], + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: palette.accentMuted, + ), + child: Icon( + Icons.crop_square_rounded, + color: palette.accent, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + 'XWorkmate', + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + Text( + appText( + 'Web Workspace', + 'Web Workspace', + ), + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: palette.textSecondary, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 18), + _WebNavItem( + destination: WorkspaceDestination.assistant, + selected: + controller.destination == + WorkspaceDestination.assistant, + onTap: () => controller.navigateTo( + WorkspaceDestination.assistant, + ), + ), + const SizedBox(height: 8), + _WebNavItem( + destination: WorkspaceDestination.settings, + selected: + controller.destination == + WorkspaceDestination.settings, + onTap: () => controller.navigateTo( + WorkspaceDestination.settings, + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: palette.surfacePrimary, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: palette.strokeSoft), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText('平台', 'Platform'), + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith(color: palette.textMuted), + ), + const SizedBox(height: 6), + Text( + appText( + 'Web 仅保留 Assistant / Settings', + 'Web keeps only Assistant / Settings', + ), + style: Theme.of( + context, + ).textTheme.bodySmall, + ), + ], + ), + ), + ], + ), + ), + ), + Expanded(child: _buildPage(controller)), + ], + ); + }, + ), + ), + ); + }, + ); + } + + Widget _buildPage(AppController controller) { + return switch (controller.destination) { + WorkspaceDestination.settings => WebSettingsPage(controller: controller), + _ => WebAssistantPage(controller: controller), + }; + } +} + +class _WebNavItem extends StatelessWidget { + const _WebNavItem({ + required this.destination, + required this.selected, + required this.onTap, + }); + + final WorkspaceDestination destination; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + return InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: selected ? palette.accentMuted : Colors.transparent, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: selected + ? palette.accent.withValues(alpha: 0.26) + : palette.strokeSoft, + ), + ), + child: Row( + children: [ + Icon(destination.icon, size: 18), + const SizedBox(width: 10), + Expanded( + child: Text( + destination.label, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/web/web_ai_gateway_client.dart b/lib/web/web_ai_gateway_client.dart new file mode 100644 index 00000000..1c211471 --- /dev/null +++ b/lib/web/web_ai_gateway_client.dart @@ -0,0 +1,376 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../runtime/runtime_models.dart'; + +class WebAiGatewayClient { + const WebAiGatewayClient(); + + Uri? normalizeBaseUrl(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) { + return null; + } + final candidate = trimmed.contains('://') ? trimmed : 'https://$trimmed'; + final uri = Uri.tryParse(candidate); + if (uri == null || uri.host.trim().isEmpty) { + return null; + } + final pathSegments = uri.pathSegments.where((item) => item.isNotEmpty); + return uri.replace( + pathSegments: pathSegments.isEmpty ? const ['v1'] : pathSegments, + query: null, + fragment: null, + ); + } + + Future testConnection({ + required String baseUrl, + required String apiKey, + }) async { + final normalizedBaseUrl = normalizeBaseUrl(baseUrl); + if (normalizedBaseUrl == null) { + return const AiGatewayConnectionCheck( + state: 'invalid', + message: 'Missing AI Gateway URL', + endpoint: '', + modelCount: 0, + ); + } + final trimmedApiKey = apiKey.trim(); + final endpoint = _modelsUri(normalizedBaseUrl).toString(); + if (trimmedApiKey.isEmpty) { + return AiGatewayConnectionCheck( + state: 'invalid', + message: 'Missing AI Gateway API key', + endpoint: endpoint, + modelCount: 0, + ); + } + try { + final models = await loadModels( + baseUrl: normalizedBaseUrl.toString(), + apiKey: trimmedApiKey, + ); + 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: networkErrorLabel(error), + endpoint: endpoint, + modelCount: 0, + ); + } + } + + Future> loadModels({ + required String baseUrl, + required String apiKey, + }) async { + final normalizedBaseUrl = normalizeBaseUrl(baseUrl); + if (normalizedBaseUrl == null || apiKey.trim().isEmpty) { + return const []; + } + final response = await http.get( + _modelsUri(normalizedBaseUrl), + headers: _headers(apiKey), + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw WebAiGatewayException( + message: _httpErrorLabel( + response.statusCode, + _extractErrorDetail(response.body), + ), + statusCode: response.statusCode, + ); + } + + final decoded = jsonDecode(_extractFirstJsonDocument(response.body)); + final payload = decoded is Map + ? decoded + : {}; + final rawModels = [ + ..._asList(payload['data']), + if (_asList(payload['data']).isEmpty) ..._asList(payload['models']), + ]; + final seen = {}; + final items = []; + for (final item in rawModels) { + final map = _asMap(item); + final modelId = + _stringValue(map['id']) ?? _stringValue(map['name']) ?? ''; + if (modelId.isEmpty || !seen.add(modelId)) { + continue; + } + items.add( + GatewayModelSummary( + id: modelId, + name: _stringValue(map['name']) ?? modelId, + provider: + _stringValue(map['provider']) ?? + _stringValue(map['owned_by']) ?? + 'Direct AI', + contextWindow: + _intValue(map['contextWindow']) ?? + _intValue(map['context_window']), + maxOutputTokens: + _intValue(map['maxOutputTokens']) ?? + _intValue(map['max_output_tokens']), + ), + ); + } + return items; + } + + Future completeChat({ + required String baseUrl, + required String apiKey, + required String model, + required List history, + }) async { + final normalizedBaseUrl = normalizeBaseUrl(baseUrl); + if (normalizedBaseUrl == null) { + throw const WebAiGatewayException(message: 'Missing AI Gateway URL'); + } + final response = await http.post( + _chatUri(normalizedBaseUrl), + headers: { + ..._headers(apiKey), + 'content-type': 'application/json; charset=utf-8', + }, + body: jsonEncode({ + 'model': model, + 'stream': false, + 'messages': history + .where((message) { + final role = message.role.trim().toLowerCase(); + return (role == 'user' || role == 'assistant') && + message.text.trim().isNotEmpty; + }) + .map( + (message) => { + 'role': message.role.trim().toLowerCase() == 'assistant' + ? 'assistant' + : 'user', + 'content': message.text.trim(), + }, + ) + .toList(growable: false), + }), + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw WebAiGatewayException( + message: _httpErrorLabel( + response.statusCode, + _extractErrorDetail(response.body), + ), + statusCode: response.statusCode, + ); + } + final decoded = jsonDecode(_extractFirstJsonDocument(response.body)); + final payload = decoded is Map + ? decoded + : {}; + final choices = _asList(payload['choices']); + final firstChoice = choices.isEmpty + ? const {} + : _asMap(choices.first); + final message = _asMap(firstChoice['message']); + final content = _stringValue(message['content']) ?? ''; + if (content.trim().isNotEmpty) { + return content.trim(); + } + final delta = _asMap(firstChoice['delta']); + final deltaContent = _stringValue(delta['content']) ?? ''; + if (deltaContent.trim().isNotEmpty) { + return deltaContent.trim(); + } + throw const FormatException('Missing assistant content'); + } + + String networkErrorLabel(Object error) { + if (error is WebAiGatewayException) { + return error.message; + } + return 'Failed: $error'; + } + + Uri _modelsUri(Uri baseUrl) { + final pathSegments = baseUrl.pathSegments + .where((item) => item.isNotEmpty) + .toList(growable: true); + if (pathSegments.isEmpty) { + pathSegments.add('v1'); + } + if (pathSegments.last != 'models') { + pathSegments.add('models'); + } + return baseUrl.replace( + pathSegments: pathSegments, + query: null, + fragment: null, + ); + } + + Uri _chatUri(Uri baseUrl) { + final pathSegments = baseUrl.pathSegments + .where((item) => item.isNotEmpty) + .toList(growable: true); + if (pathSegments.isEmpty) { + pathSegments.add('v1'); + } + if (pathSegments.last == 'models') { + pathSegments.removeLast(); + } + if (pathSegments.length >= 2 && + pathSegments[pathSegments.length - 2] == 'chat' && + pathSegments.last == 'completions') { + return baseUrl.replace(pathSegments: pathSegments); + } + pathSegments.addAll(const ['chat', 'completions']); + return baseUrl.replace( + pathSegments: pathSegments, + query: null, + fragment: null, + ); + } + + Map _headers(String apiKey) { + final trimmedApiKey = apiKey.trim(); + return { + 'accept': 'application/json', + if (trimmedApiKey.isNotEmpty) 'authorization': 'Bearer $trimmedApiKey', + if (trimmedApiKey.isNotEmpty) 'x-api-key': trimmedApiKey, + }; + } + + String _httpErrorLabel(int statusCode, String detail) { + final base = switch (statusCode) { + 400 => 'Bad request (400)', + 401 => 'Authentication failed (401)', + 403 => 'Access denied (403)', + 404 => 'Endpoint not found (404)', + 429 => 'Rate limited by AI endpoint (429)', + >= 500 => 'AI endpoint unavailable ($statusCode)', + _ => 'AI endpoint responded $statusCode', + }; + return detail.isEmpty ? base : '$base · $detail'; + } + + String _extractErrorDetail(String body) { + if (body.trim().isEmpty) { + return ''; + } + try { + final decoded = jsonDecode(_extractFirstJsonDocument(body)); + final map = decoded is Map + ? decoded + : {}; + final error = _asMap(map['error']); + return (_stringValue(error['message']) ?? + _stringValue(map['message']) ?? + _stringValue(map['detail']) ?? + '') + .trim(); + } on FormatException { + return ''; + } + } + + String _extractFirstJsonDocument(String body) { + final trimmed = body.trimLeft(); + if (trimmed.isEmpty) { + throw const FormatException('Empty response body'); + } + final start = trimmed.indexOf(RegExp(r'[\{\[]')); + if (start < 0) { + throw const FormatException('Missing JSON document'); + } + var depth = 0; + var inString = false; + var escaped = false; + for (var index = start; index < trimmed.length; index++) { + final char = trimmed[index]; + if (escaped) { + escaped = false; + continue; + } + if (char == r'\') { + escaped = true; + continue; + } + if (char == '"') { + inString = !inString; + continue; + } + if (inString) { + continue; + } + if (char == '{' || char == '[') { + depth += 1; + } else if (char == '}' || char == ']') { + depth -= 1; + if (depth == 0) { + return trimmed.substring(start, index + 1); + } + } + } + throw const FormatException('Unterminated JSON document'); + } +} + +class WebAiGatewayException implements Exception { + const WebAiGatewayException({required this.message, this.statusCode}); + + final String message; + final int? statusCode; + + @override + String toString() => message; +} + +Map _asMap(Object? value) { + if (value is Map) { + return value; + } + if (value is Map) { + return value.cast(); + } + return const {}; +} + +List _asList(Object? value) { + if (value is List) { + return value; + } + if (value is List) { + return value.cast(); + } + return const []; +} + +String? _stringValue(Object? value) { + final text = value?.toString().trim() ?? ''; + return text.isEmpty ? null : text; +} + +int? _intValue(Object? value) { + if (value is num) { + return value.toInt(); + } + return int.tryParse(value?.toString() ?? ''); +} diff --git a/lib/web/web_assistant_page.dart b/lib/web/web_assistant_page.dart new file mode 100644 index 00000000..a49faa30 --- /dev/null +++ b/lib/web/web_assistant_page.dart @@ -0,0 +1,631 @@ +import 'package:flutter/material.dart'; + +import '../app/app_controller_web.dart'; +import '../i18n/app_language.dart'; +import '../models/app_models.dart'; +import '../runtime/runtime_models.dart'; +import '../theme/app_palette.dart'; +import '../widgets/desktop_workspace_scaffold.dart'; +import '../widgets/status_badge.dart'; +import '../widgets/surface_card.dart'; +import '../widgets/top_bar.dart'; + +class WebAssistantPage extends StatefulWidget { + const WebAssistantPage({super.key, required this.controller}); + + final AppController controller; + + @override + State createState() => _WebAssistantPageState(); +} + +class _WebAssistantPageState extends State { + final TextEditingController _inputController = TextEditingController(); + final TextEditingController _searchController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + String _query = ''; + + @override + void dispose() { + _inputController.dispose(); + _searchController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final controller = widget.controller; + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + final allDirect = controller.conversationsForTarget( + AssistantExecutionTarget.aiGatewayOnly, + ); + final allRelay = controller.conversationsForTarget( + AssistantExecutionTarget.remote, + ); + final direct = _filterConversations(allDirect); + final relay = _filterConversations(allRelay); + final currentTarget = controller.assistantExecutionTarget; + final connected = + currentTarget == AssistantExecutionTarget.aiGatewayOnly + ? controller.canUseAiGatewayConversation + : controller.connection.status == RuntimeConnectionStatus.connected; + final currentMessages = controller.chatMessages; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.jumpTo( + _scrollController.position.maxScrollExtent, + ); + } + }); + + return DesktopWorkspaceScaffold( + breadcrumbs: [ + AppBreadcrumbItem( + label: appText('主页', 'Home'), + icon: Icons.home_rounded, + onTap: controller.navigateHome, + ), + AppBreadcrumbItem(label: WorkspaceDestination.assistant.label), + ], + eyebrow: appText('Web Workspace', 'Web Workspace'), + title: appText('助手', 'Assistant'), + subtitle: appText( + 'Direct AI 与 Relay Gateway 共用一个入口,左侧保留会话/任务历史。', + 'Use one Assistant surface for Direct AI and Relay Gateway, with embedded conversation history on the left.', + ), + toolbar: Wrap( + spacing: 10, + runSpacing: 10, + children: [ + FilledButton.icon( + onPressed: () => controller.createConversation( + target: controller.assistantExecutionTarget, + ), + icon: const Icon(Icons.edit_square), + label: Text(appText('新对话', 'New conversation')), + ), + OutlinedButton.icon( + onPressed: () => + controller.openSettings(tab: SettingsTab.gateway), + icon: const Icon(Icons.tune_rounded), + label: Text(appText('连接设置', 'Connection settings')), + ), + _TargetChip( + value: currentTarget, + onChanged: (value) { + if (value != null) { + controller.setAssistantExecutionTarget(value); + } + }, + ), + ], + ), + child: LayoutBuilder( + builder: (context, constraints) { + final vertical = constraints.maxWidth < 980; + final rail = _ConversationRail( + controller: controller, + query: _query, + searchController: _searchController, + onQueryChanged: (value) { + setState(() => _query = value.trim().toLowerCase()); + }, + onClearQuery: () { + _searchController.clear(); + setState(() => _query = ''); + }, + direct: direct, + relay: relay, + ); + final panel = _ConversationPanel( + controller: controller, + inputController: _inputController, + scrollController: _scrollController, + connected: connected, + currentMessages: currentMessages, + ); + + if (vertical) { + return Column( + children: [ + SizedBox(height: 300, child: rail), + const SizedBox(height: 8), + Expanded(child: panel), + ], + ); + } + + return Row( + children: [ + SizedBox(width: 320, child: rail), + const SizedBox(width: 8), + Expanded(child: panel), + ], + ); + }, + ), + ); + }, + ); + } + + List _filterConversations( + List items, + ) { + if (_query.isEmpty) { + return items; + } + return items + .where((item) { + final haystack = '${item.title}\n${item.preview}'.toLowerCase(); + return haystack.contains(_query); + }) + .toList(growable: false); + } +} + +class _ConversationRail extends StatelessWidget { + const _ConversationRail({ + required this.controller, + required this.query, + required this.searchController, + required this.onQueryChanged, + required this.onClearQuery, + required this.direct, + required this.relay, + }); + + final AppController controller; + final String query; + final TextEditingController searchController; + final ValueChanged onQueryChanged; + final VoidCallback onClearQuery; + final List direct; + final List relay; + + @override + Widget build(BuildContext context) { + return SurfaceCard( + borderRadius: 10, + tone: SurfaceCardTone.chrome, + child: Column( + key: const Key('assistant-task-rail'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: searchController, + onChanged: onQueryChanged, + decoration: InputDecoration( + hintText: appText('搜索会话', 'Search conversations'), + prefixIcon: const Icon(Icons.search_rounded), + suffixIcon: query.isEmpty + ? null + : IconButton( + onPressed: onClearQuery, + icon: const Icon(Icons.close_rounded), + ), + ), + ), + const SizedBox(height: 12), + Expanded( + child: ListView( + children: [ + _ConversationGroup( + title: appText('Direct AI Gateway', 'Direct AI Gateway'), + icon: Icons.hub_rounded, + items: direct, + emptyLabel: appText( + '还没有 Direct AI 对话', + 'No Direct AI conversations yet', + ), + onSelect: controller.switchConversation, + ), + const SizedBox(height: 12), + _ConversationGroup( + title: appText( + 'Relay OpenClaw Gateway', + 'Relay OpenClaw Gateway', + ), + icon: Icons.cloud_outlined, + items: relay, + emptyLabel: appText( + '还没有 Relay 对话', + 'No Relay conversations yet', + ), + onSelect: controller.switchConversation, + ), + ], + ), + ), + ], + ), + ); + } +} + +class _ConversationGroup extends StatelessWidget { + const _ConversationGroup({ + required this.title, + required this.icon, + required this.items, + required this.emptyLabel, + required this.onSelect, + }); + + final String title; + final IconData icon; + final List items; + final String emptyLabel; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 18, color: palette.accent), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), + ), + ), + ], + ), + const SizedBox(height: 8), + if (items.isEmpty) + Text( + emptyLabel, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: palette.textSecondary), + ), + ...items.map( + (item) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: SurfaceCard( + onTap: () => onSelect(item.sessionKey), + borderRadius: 10, + padding: const EdgeInsets.all(12), + color: item.current ? palette.accentMuted : null, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 4), + Text( + item.preview, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: palette.textSecondary), + ), + ], + ), + ), + if (item.pending) + const Padding( + padding: EdgeInsets.only(left: 8, top: 2), + child: SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ], + ), + ), + ), + ), + ], + ); + } +} + +class _ConversationPanel extends StatelessWidget { + const _ConversationPanel({ + required this.controller, + required this.inputController, + required this.scrollController, + required this.connected, + required this.currentMessages, + }); + + final AppController controller; + final TextEditingController inputController; + final ScrollController scrollController; + final bool connected; + final List currentMessages; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + final currentTarget = controller.assistantExecutionTarget; + final targetReady = currentTarget == AssistantExecutionTarget.aiGatewayOnly + ? controller.canUseAiGatewayConversation + : controller.connection.status == RuntimeConnectionStatus.connected; + + return Column( + children: [ + SurfaceCard( + borderRadius: 10, + tone: SurfaceCardTone.chrome, + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + controller.currentConversationTitle, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Text( + controller.assistantConnectionTargetLabel, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: palette.textSecondary, + ), + ), + ], + ), + ), + StatusBadge( + status: StatusInfo( + controller.assistantConnectionStatusLabel, + targetReady ? StatusTone.success : StatusTone.warning, + ), + ), + ], + ), + ), + const SizedBox(height: 8), + if (!connected) + SurfaceCard( + borderRadius: 10, + child: Row( + children: [ + const Icon(Icons.info_outline_rounded), + const SizedBox(width: 12), + Expanded( + child: Text( + currentTarget == AssistantExecutionTarget.aiGatewayOnly + ? appText( + '当前 Direct AI 配置还不完整,请先在 Settings 中保存地址、API Key 和默认模型。', + 'Direct AI is not ready yet. Save the endpoint, API key, and default model in Settings first.', + ) + : appText( + '当前 Relay Gateway 尚未连接,请先在 Settings 中保存配置并连接。', + 'Relay Gateway is offline. Save the relay config and connect from Settings first.', + ), + ), + ), + const SizedBox(width: 12), + FilledButton.tonal( + onPressed: () => + controller.openSettings(tab: SettingsTab.gateway), + child: Text(appText('打开设置', 'Open settings')), + ), + ], + ), + ), + if (!connected) const SizedBox(height: 8), + Expanded( + child: SurfaceCard( + borderRadius: 10, + padding: EdgeInsets.zero, + tone: SurfaceCardTone.chrome, + child: Column( + children: [ + Expanded( + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.all(16), + itemCount: currentMessages.length, + itemBuilder: (context, index) { + final message = currentMessages[index]; + return _MessageBubble(message: message); + }, + ), + ), + Container(height: 1, color: palette.strokeSoft), + Padding( + padding: const EdgeInsets.all(14), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: inputController, + minLines: 3, + maxLines: 6, + decoration: InputDecoration( + hintText: appText( + '输入需求、补充上下文、继续追问', + 'Describe the task, add context, or continue the conversation', + ), + ), + onSubmitted: (_) { + if (!connected) { + return; + } + final value = inputController.text; + inputController.clear(); + controller.sendMessage(value); + }, + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: Text( + currentTarget == + AssistantExecutionTarget.aiGatewayOnly + ? appText( + 'Web 端 Direct AI 只保留纯网络能力,不提供本地文件和 CLI。', + 'Direct AI on web keeps network-only capabilities and does not expose local files or CLI.', + ) + : appText( + 'Web 端 Relay 模式使用远程 OpenClaw Gateway,不区分 local / remote。', + 'Relay mode on web uses the remote OpenClaw Gateway and does not expose local / remote splits.', + ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: palette.textSecondary), + ), + ), + const SizedBox(width: 12), + FilledButton.icon( + onPressed: connected + ? () { + final value = inputController.text; + inputController.clear(); + controller.sendMessage(value); + } + : () => controller.openSettings( + tab: SettingsTab.gateway, + ), + icon: Icon( + connected + ? Icons.arrow_upward_rounded + : Icons.settings_rounded, + ), + label: Text( + connected + ? appText('提交', 'Submit') + : appText('配置', 'Configure'), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ], + ); + } +} + +class _MessageBubble extends StatelessWidget { + const _MessageBubble({required this.message}); + + final GatewayChatMessage message; + + @override + Widget build(BuildContext context) { + final palette = context.palette; + final assistant = message.role.trim().toLowerCase() == 'assistant'; + final color = message.error + ? palette.danger.withValues(alpha: 0.14) + : assistant + ? palette.surfacePrimary + : palette.accentMuted; + + return Align( + alignment: assistant ? Alignment.centerLeft : Alignment.centerRight, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: Padding( + padding: const EdgeInsets.only(bottom: 12), + child: DecoratedBox( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: palette.strokeSoft), + ), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + assistant ? 'Assistant' : 'You', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: palette.textSecondary, + ), + ), + const SizedBox(height: 6), + Text(message.text), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _TargetChip extends StatelessWidget { + const _TargetChip({required this.value, required this.onChanged}); + + final AssistantExecutionTarget value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + onChanged: onChanged, + items: + const [ + AssistantExecutionTarget.aiGatewayOnly, + AssistantExecutionTarget.remote, + ] + .map((target) { + return DropdownMenuItem( + value: target, + child: Text(_targetLabel(target)), + ); + }) + .toList(growable: false), + ), + ); + } +} + +String _targetLabel(AssistantExecutionTarget target) { + return switch (target) { + AssistantExecutionTarget.aiGatewayOnly => appText( + 'Direct AI Gateway', + 'Direct AI Gateway', + ), + AssistantExecutionTarget.remote => appText( + 'Relay OpenClaw Gateway', + 'Relay OpenClaw Gateway', + ), + _ => '', + }; +} diff --git a/lib/web/web_relay_gateway_client.dart b/lib/web/web_relay_gateway_client.dart new file mode 100644 index 00000000..d4e7f571 --- /dev/null +++ b/lib/web/web_relay_gateway_client.dart @@ -0,0 +1,734 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart' as crypto; +import 'package:cryptography/cryptography.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +import '../app/app_metadata.dart'; +import '../runtime/runtime_models.dart'; +import 'web_store.dart'; + +class GatewayPushEvent { + const GatewayPushEvent({ + required this.event, + required this.payload, + this.sequence, + }); + + final String event; + final dynamic payload; + final int? sequence; +} + +class WebRelayGatewayClient { + WebRelayGatewayClient(this._store); + + final WebStore _store; + final StreamController _events = + StreamController.broadcast(); + final Map> _pending = + >{}; + final _WebRelayIdentityManager _identityManager = _WebRelayIdentityManager(); + + WebSocketChannel? _channel; + StreamSubscription? _subscription; + int _requestCounter = 0; + GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial( + mode: RuntimeConnectionMode.remote, + ); + + Stream get events => _events.stream; + GatewayConnectionSnapshot get snapshot => _snapshot; + bool get isConnected => _snapshot.status == RuntimeConnectionStatus.connected; + String get mainSessionKey => _snapshot.mainSessionKey ?? 'main'; + + Future connect({ + required GatewayConnectionProfile profile, + required String authToken, + required String authPassword, + }) async { + await disconnect(); + final endpoint = _resolveEndpoint(profile); + if (endpoint == null) { + _snapshot = + GatewayConnectionSnapshot.initial( + mode: RuntimeConnectionMode.remote, + ).copyWith( + status: RuntimeConnectionStatus.error, + statusText: 'Missing relay endpoint', + lastError: 'Configure relay host / port first.', + lastErrorCode: 'MISSING_ENDPOINT', + ); + throw const WebRelayGatewayException('Missing relay endpoint'); + } + + final identity = await _identityManager.loadOrCreate(_store); + _snapshot = + GatewayConnectionSnapshot.initial( + mode: RuntimeConnectionMode.remote, + ).copyWith( + status: RuntimeConnectionStatus.connecting, + statusText: 'Connecting…', + remoteAddress: '${endpoint.host}:${endpoint.port}', + deviceId: identity.deviceId, + authRole: 'operator', + authScopes: const [ + 'operator.admin', + 'operator.read', + 'operator.write', + 'operator.approvals', + 'operator.pairing', + ], + connectAuthMode: authToken.trim().isNotEmpty + ? 'shared-token' + : authPassword.trim().isNotEmpty + ? 'password' + : 'none', + connectAuthFields: [ + if (authToken.trim().isNotEmpty) 'token', + if (authPassword.trim().isNotEmpty) 'password', + ], + connectAuthSources: [ + if (authToken.trim().isNotEmpty) 'browser-store', + if (authPassword.trim().isNotEmpty) 'browser-store', + ], + hasSharedAuth: + authToken.trim().isNotEmpty || authPassword.trim().isNotEmpty, + hasDeviceToken: false, + clearLastError: true, + clearLastErrorCode: true, + clearLastErrorDetailCode: true, + ); + + final uri = Uri( + scheme: endpoint.tls ? 'wss' : 'ws', + host: endpoint.host, + port: endpoint.port, + ); + final channel = WebSocketChannel.connect(uri); + final challenge = Completer(); + + _channel = channel; + _subscription = channel.stream.listen( + (dynamic raw) => _handleIncoming(raw, challenge), + onError: (Object error, StackTrace stackTrace) { + _snapshot = _snapshot.copyWith( + status: RuntimeConnectionStatus.error, + statusText: 'Relay error', + lastError: error.toString(), + lastErrorCode: 'SOCKET_FAILURE', + ); + }, + onDone: () { + if (_snapshot.status == RuntimeConnectionStatus.connected) { + _snapshot = _snapshot.copyWith( + status: RuntimeConnectionStatus.error, + statusText: 'Disconnected', + lastError: 'Relay connection closed', + lastErrorCode: 'SOCKET_CLOSED', + ); + } + }, + cancelOnError: true, + ); + + try { + final nonce = await challenge.future.timeout( + const Duration(seconds: 5), + onTimeout: () => + throw const WebRelayGatewayException('Relay challenge timeout'), + ); + final result = await _requestRaw( + 'connect', + params: await _buildConnectParams( + identity: identity, + nonce: nonce, + authToken: authToken.trim(), + authPassword: authPassword.trim(), + ), + timeout: const Duration(seconds: 12), + ); + final payload = _asMap(result.payload); + final auth = _asMap(payload['auth']); + final snapshot = _asMap(payload['snapshot']); + final sessionDefaults = _asMap(snapshot['sessionDefaults']); + final server = _asMap(payload['server']); + _snapshot = _snapshot.copyWith( + status: RuntimeConnectionStatus.connected, + statusText: 'Connected', + serverName: _stringValue(server['host']), + remoteAddress: '${endpoint.host}:${endpoint.port}', + mainSessionKey: + _stringValue(sessionDefaults['mainSessionKey']) ?? 'main', + lastConnectedAtMs: DateTime.now().millisecondsSinceEpoch, + authRole: _stringValue(auth['role']) ?? 'operator', + authScopes: _stringList(auth['scopes']), + clearLastError: true, + clearLastErrorCode: true, + clearLastErrorDetailCode: true, + ); + } catch (error) { + await disconnect(); + _snapshot = _snapshot.copyWith( + status: RuntimeConnectionStatus.error, + statusText: 'Connection failed', + lastError: error.toString(), + lastErrorCode: 'CONNECT_FAILED', + ); + rethrow; + } + } + + Future disconnect() async { + for (final pending in _pending.values) { + if (!pending.isCompleted) { + pending.completeError( + const WebRelayGatewayException('Relay request cancelled'), + ); + } + } + _pending.clear(); + await _subscription?.cancel(); + _subscription = null; + await _channel?.sink.close(); + _channel = null; + } + + Future> listSessions({int limit = 50}) async { + final payload = _asMap( + await request( + 'sessions.list', + params: { + 'includeGlobal': true, + 'includeUnknown': false, + 'includeDerivedTitles': true, + 'includeLastMessage': true, + 'limit': limit, + }, + ), + ); + return _asList(payload['sessions']) + .map((item) { + final map = _asMap(item); + return GatewaySessionSummary( + key: _stringValue(map['key']) ?? 'main', + kind: _stringValue(map['kind']), + displayName: + _stringValue(map['displayName']) ?? _stringValue(map['label']), + surface: _stringValue(map['surface']), + subject: _stringValue(map['subject']), + room: _stringValue(map['room']), + space: _stringValue(map['space']), + updatedAtMs: _doubleValue(map['updatedAt']), + sessionId: _stringValue(map['sessionId']), + systemSent: _boolValue(map['systemSent']), + abortedLastRun: _boolValue(map['abortedLastRun']), + thinkingLevel: _stringValue(map['thinkingLevel']), + verboseLevel: _stringValue(map['verboseLevel']), + inputTokens: _intValue(map['inputTokens']), + outputTokens: _intValue(map['outputTokens']), + totalTokens: _intValue(map['totalTokens']), + model: _stringValue(map['model']), + contextTokens: _intValue(map['contextTokens']), + derivedTitle: _stringValue(map['derivedTitle']), + lastMessagePreview: _stringValue(map['lastMessagePreview']), + ); + }) + .toList(growable: false); + } + + Future> loadHistory( + String sessionKey, { + int limit = 120, + }) async { + final payload = _asMap( + await request( + 'chat.history', + params: {'sessionKey': sessionKey, 'limit': limit}, + ), + ); + return _asList(payload['messages']) + .map((item) { + final map = _asMap(item); + return GatewayChatMessage( + id: _randomId(), + role: _stringValue(map['role']) ?? 'assistant', + text: _extractMessageText(map), + timestampMs: _doubleValue(map['timestamp']), + toolCallId: + _stringValue(map['toolCallId']) ?? + _stringValue(map['tool_call_id']), + toolName: + _stringValue(map['toolName']) ?? _stringValue(map['tool_name']), + stopReason: _stringValue(map['stopReason']), + pending: false, + error: false, + ); + }) + .toList(growable: false); + } + + Future sendChat({ + required String sessionKey, + required String message, + required String thinking, + }) async { + final runId = _randomId(); + final payload = _asMap( + await request( + 'chat.send', + params: { + 'sessionKey': sessionKey, + 'message': message, + 'thinking': thinking, + 'timeoutMs': 30000, + 'idempotencyKey': runId, + }, + timeout: const Duration(seconds: 35), + ), + ); + return _stringValue(payload['runId']) ?? runId; + } + + Future> listModels() async { + final payload = _asMap(await request('models.list')); + return _asList(payload['models']) + .map((item) { + final map = _asMap(item); + return GatewayModelSummary( + id: _stringValue(map['id']) ?? 'unknown', + name: + _stringValue(map['name']) ?? + _stringValue(map['id']) ?? + 'unknown', + provider: _stringValue(map['provider']) ?? 'relay', + contextWindow: _intValue(map['contextWindow']), + maxOutputTokens: _intValue(map['maxOutputTokens']), + ); + }) + .toList(growable: false); + } + + Future request( + String method, { + Map? params, + Duration timeout = const Duration(seconds: 15), + }) async { + if (_channel == null || !isConnected) { + throw const WebRelayGatewayException('Relay not connected'); + } + final result = await _requestRaw(method, params: params, timeout: timeout); + return result.payload; + } + + Future<_RelayRpcResponse> _requestRaw( + String method, { + Map? params, + Duration timeout = const Duration(seconds: 15), + }) async { + final channel = _channel; + if (channel == null) { + throw const WebRelayGatewayException('Relay not connected'); + } + final id = '${DateTime.now().microsecondsSinceEpoch}-${_requestCounter++}'; + final completer = Completer<_RelayRpcResponse>(); + _pending[id] = completer; + channel.sink.add( + jsonEncode({ + 'type': 'req', + 'id': id, + 'method': method, + if (params != null && params.isNotEmpty) 'params': params, + }), + ); + try { + return await completer.future.timeout( + timeout, + onTimeout: () => + throw WebRelayGatewayException('$method request timeout'), + ); + } finally { + _pending.remove(id); + } + } + + Future> _buildConnectParams({ + required LocalDeviceIdentity identity, + required String nonce, + required String authToken, + required String authPassword, + }) async { + const scopes = [ + 'operator.admin', + 'operator.read', + 'operator.write', + 'operator.approvals', + 'operator.pairing', + ]; + const clientId = 'xworkmate-web'; + const clientMode = 'ui'; + final signedAtMs = DateTime.now().millisecondsSinceEpoch; + final signaturePayload = _identityManager.buildDeviceAuthPayloadV3( + deviceId: identity.deviceId, + clientId: clientId, + clientMode: clientMode, + role: 'operator', + scopes: scopes, + signedAtMs: signedAtMs, + token: authToken, + nonce: nonce, + platform: 'web', + deviceFamily: 'Browser', + ); + final signature = await _identityManager.signPayload( + identity: identity, + payload: signaturePayload, + ); + + return { + 'minProtocol': 3, + 'maxProtocol': 3, + 'client': { + 'id': clientId, + 'displayName': '$kSystemAppName Browser', + 'version': kAppVersion, + 'platform': 'web', + 'deviceFamily': 'Browser', + 'modelIdentifier': 'browser', + 'mode': clientMode, + 'instanceId': + '$clientId-${identity.deviceId.substring(0, min(8, identity.deviceId.length))}', + }, + 'caps': const ['tool-events'], + 'commands': const [], + 'permissions': const {}, + 'role': 'operator', + 'scopes': scopes, + if (authToken.isNotEmpty || authPassword.isNotEmpty) + 'auth': { + if (authToken.isNotEmpty) 'token': authToken, + if (authPassword.isNotEmpty) 'password': authPassword, + }, + 'locale': 'web', + 'userAgent': '$kSystemAppName/$kAppVersion web', + 'device': { + 'id': identity.deviceId, + 'publicKey': identity.publicKeyBase64Url, + 'signature': signature, + 'signedAt': signedAtMs, + 'nonce': nonce, + }, + }; + } + + void _handleIncoming(dynamic raw, Completer challenge) { + final text = raw is String ? raw : utf8.decode(raw as List); + final decoded = jsonDecode(text) as Map; + final type = _stringValue(decoded['type']); + if (type == 'event') { + final event = _stringValue(decoded['event']) ?? ''; + final payload = decoded['payload']; + if (event == 'connect.challenge') { + final nonce = _stringValue(_asMap(payload)['nonce']); + if (nonce != null && !challenge.isCompleted) { + challenge.complete(nonce); + } + return; + } + _events.add( + GatewayPushEvent( + event: event, + payload: payload, + sequence: _intValue(decoded['seq']), + ), + ); + return; + } + if (type != 'res') { + return; + } + final id = _stringValue(decoded['id']); + if (id == null) { + return; + } + final completer = _pending.remove(id); + if (completer == null || completer.isCompleted) { + return; + } + final ok = _boolValue(decoded['ok']) ?? false; + if (!ok) { + final error = _asMap(decoded['error']); + completer.completeError( + WebRelayGatewayException( + _stringValue(error['message']) ?? 'Relay request failed', + ), + ); + return; + } + completer.complete( + _RelayRpcResponse( + ok: true, + payload: decoded['payload'], + error: _asMap(decoded['error']), + ), + ); + } + + _ResolvedRelayEndpoint? _resolveEndpoint(GatewayConnectionProfile profile) { + final rawHost = profile.host.trim(); + if (rawHost.isEmpty) { + return null; + } + final candidate = rawHost.contains('://') + ? rawHost + : '${profile.tls ? 'https' : 'http'}://$rawHost:${profile.port}'; + final uri = Uri.tryParse(candidate); + if (uri == null || uri.host.trim().isEmpty) { + return null; + } + final tls = switch (uri.scheme.trim().toLowerCase()) { + 'http' || 'ws' => false, + _ => true, + }; + return _ResolvedRelayEndpoint( + host: uri.host.trim(), + port: uri.hasPort ? uri.port : (tls ? 443 : 80), + tls: tls, + ); + } + + Future dispose() async { + await disconnect(); + await _events.close(); + } +} + +class WebRelayGatewayException implements Exception { + const WebRelayGatewayException(this.message); + + final String message; + + @override + String toString() => message; +} + +class _ResolvedRelayEndpoint { + const _ResolvedRelayEndpoint({ + required this.host, + required this.port, + required this.tls, + }); + + final String host; + final int port; + final bool tls; +} + +class _RelayRpcResponse { + const _RelayRpcResponse({ + required this.ok, + required this.payload, + required this.error, + }); + + final bool ok; + final dynamic payload; + final Map error; +} + +class _WebRelayIdentityManager { + final Ed25519 _algorithm = Ed25519(); + + Future loadOrCreate(WebStore store) async { + final existing = await store.loadRelayDeviceIdentity(); + if (existing != null && + existing.deviceId.isNotEmpty && + existing.publicKeyBase64Url.isNotEmpty && + existing.privateKeyBase64Url.isNotEmpty) { + return existing; + } + final keyPair = await _algorithm.newKeyPair(); + final publicKey = await keyPair.extractPublicKey(); + final privateKeyBytes = await keyPair.extractPrivateKeyBytes(); + final publicKeyBytes = publicKey.bytes; + final identity = LocalDeviceIdentity( + deviceId: _deriveDeviceId(publicKeyBytes), + publicKeyBase64Url: _base64UrlEncode(publicKeyBytes), + privateKeyBase64Url: _base64UrlEncode(privateKeyBytes), + createdAtMs: DateTime.now().millisecondsSinceEpoch, + ); + await store.saveRelayDeviceIdentity(identity); + return identity; + } + + Future signPayload({ + required LocalDeviceIdentity identity, + required String payload, + }) async { + final publicKeyBytes = _base64UrlDecode(identity.publicKeyBase64Url); + final privateKeyBytes = _base64UrlDecode(identity.privateKeyBase64Url); + final keyPair = SimpleKeyPairData( + privateKeyBytes, + publicKey: SimplePublicKey(publicKeyBytes, type: KeyPairType.ed25519), + type: KeyPairType.ed25519, + ); + final signature = await _algorithm.sign( + utf8.encode(payload), + keyPair: keyPair, + ); + return _base64UrlEncode(signature.bytes); + } + + String buildDeviceAuthPayloadV3({ + required String deviceId, + required String clientId, + required String clientMode, + required String role, + required List scopes, + required int signedAtMs, + required String token, + required String nonce, + required String platform, + required String deviceFamily, + }) { + return [ + 'v3', + deviceId, + clientId, + clientMode, + role, + scopes.join(','), + '$signedAtMs', + token, + nonce, + _normalizeMetadata(platform), + _normalizeMetadata(deviceFamily), + ].join('|'); + } + + String _normalizeMetadata(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return ''; + } + final buffer = StringBuffer(); + for (final rune in trimmed.runes) { + if (rune >= 65 && rune <= 90) { + buffer.writeCharCode(rune + 32); + } else { + buffer.writeCharCode(rune); + } + } + return buffer.toString(); + } + + String _deriveDeviceId(List publicKeyBytes) { + return crypto.sha256.convert(publicKeyBytes).toString(); + } + + String _base64UrlEncode(List value) { + return base64Url.encode(value).replaceAll('=', ''); + } + + Uint8List _base64UrlDecode(String value) { + final normalized = value.replaceAll('-', '+').replaceAll('_', '/'); + final padded = normalized + '=' * ((4 - normalized.length % 4) % 4); + return Uint8List.fromList(base64.decode(padded)); + } +} + +Map _asMap(Object? value) { + if (value is Map) { + return value; + } + if (value is Map) { + return value.cast(); + } + return const {}; +} + +List _asList(Object? value) { + if (value is List) { + return value; + } + if (value is List) { + return value.cast(); + } + return const []; +} + +String? _stringValue(Object? value) { + final text = value?.toString().trim() ?? ''; + return text.isEmpty ? null : text; +} + +int? _intValue(Object? value) { + if (value is num) { + return value.toInt(); + } + return int.tryParse(value?.toString() ?? ''); +} + +double? _doubleValue(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? ''); +} + +bool? _boolValue(Object? value) { + if (value is bool) { + return value; + } + if (value is num) { + return value != 0; + } + final normalized = value?.toString().trim().toLowerCase(); + if (normalized == 'true') { + return true; + } + if (normalized == 'false') { + return false; + } + return null; +} + +List _stringList(Object? value) { + return _asList( + value, + ).map(_stringValue).whereType().toList(growable: false); +} + +String _extractMessageText(Map message) { + final directContent = message['content']; + if (directContent is String) { + return directContent; + } + final parts = []; + for (final part in _asList(directContent)) { + final map = _asMap(part); + final text = _stringValue(map['text']) ?? _stringValue(map['thinking']); + if (text != null && text.isNotEmpty) { + parts.add(text); + continue; + } + final nestedContent = map['content']; + if (nestedContent is String && nestedContent.trim().isNotEmpty) { + parts.add(nestedContent.trim()); + } + } + return parts.join('\n').trim(); +} + +String _randomId() { + final random = Random.secure(); + final timestamp = DateTime.now().microsecondsSinceEpoch.toRadixString(16); + final suffix = List.generate( + 6, + (_) => random.nextInt(256), + ).map((value) => value.toRadixString(16).padLeft(2, '0')).join(); + return '$timestamp-$suffix'; +} diff --git a/lib/web/web_settings_page.dart b/lib/web/web_settings_page.dart new file mode 100644 index 00000000..7b3ca3bf --- /dev/null +++ b/lib/web/web_settings_page.dart @@ -0,0 +1,670 @@ +import 'package:flutter/material.dart'; + +import '../app/app_controller_web.dart'; +import '../app/app_metadata.dart'; +import '../i18n/app_language.dart'; +import '../models/app_models.dart'; +import '../runtime/runtime_models.dart'; +import '../theme/app_palette.dart'; +import '../widgets/desktop_workspace_scaffold.dart'; +import '../widgets/section_tabs.dart'; +import '../widgets/surface_card.dart'; +import '../widgets/top_bar.dart'; + +class WebSettingsPage extends StatefulWidget { + const WebSettingsPage({super.key, required this.controller}); + + final AppController controller; + + @override + State createState() => _WebSettingsPageState(); +} + +class _WebSettingsPageState extends State { + late final TextEditingController _directNameController; + late final TextEditingController _directBaseUrlController; + late final TextEditingController _directProviderController; + late final TextEditingController _directApiKeyController; + late final TextEditingController _relayHostController; + late final TextEditingController _relayPortController; + late final TextEditingController _relayTokenController; + late final TextEditingController _relayPasswordController; + + String _directMessage = ''; + String _relayMessage = ''; + + @override + void initState() { + super.initState(); + _directNameController = TextEditingController(); + _directBaseUrlController = TextEditingController(); + _directProviderController = TextEditingController(); + _directApiKeyController = TextEditingController(); + _relayHostController = TextEditingController(); + _relayPortController = TextEditingController(); + _relayTokenController = TextEditingController(); + _relayPasswordController = TextEditingController(); + _syncControllers(); + } + + @override + void didUpdateWidget(covariant WebSettingsPage oldWidget) { + super.didUpdateWidget(oldWidget); + _syncControllers(); + } + + @override + void dispose() { + _directNameController.dispose(); + _directBaseUrlController.dispose(); + _directProviderController.dispose(); + _directApiKeyController.dispose(); + _relayHostController.dispose(); + _relayPortController.dispose(); + _relayTokenController.dispose(); + _relayPasswordController.dispose(); + super.dispose(); + } + + void _syncControllers() { + final settings = widget.controller.settings; + _setIfDifferent(_directNameController, settings.aiGateway.name); + _setIfDifferent(_directBaseUrlController, settings.aiGateway.baseUrl); + _setIfDifferent(_directProviderController, settings.defaultProvider); + _setIfDifferent( + _directApiKeyController, + widget.controller.storedAiGatewayApiKeyMask == null + ? '' + : _directApiKeyController.text, + ); + _setIfDifferent(_relayHostController, settings.gateway.host); + _setIfDifferent(_relayPortController, '${settings.gateway.port}'); + _setIfDifferent( + _relayTokenController, + widget.controller.storedRelayTokenMask == null + ? '' + : _relayTokenController.text, + ); + _setIfDifferent( + _relayPasswordController, + widget.controller.storedRelayPasswordMask == null + ? '' + : _relayPasswordController.text, + ); + } + + @override + Widget build(BuildContext context) { + final controller = widget.controller; + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + final settings = controller.settings; + final currentTab = controller.settingsTab; + return DesktopWorkspaceScaffold( + breadcrumbs: [ + AppBreadcrumbItem( + label: appText('主页', 'Home'), + icon: Icons.home_rounded, + onTap: controller.navigateHome, + ), + AppBreadcrumbItem( + label: appText('设置', 'Settings'), + onTap: () => controller.openSettings(tab: currentTab), + ), + AppBreadcrumbItem(label: currentTab.label), + ], + eyebrow: appText('Web Preferences', 'Web Preferences'), + title: appText('设置', 'Settings'), + subtitle: appText( + 'Web 版只保留 Direct AI / Relay Gateway、界面偏好和基础信息。', + 'The web app keeps only Direct AI, Relay Gateway, appearance preferences, and basic product info.', + ), + toolbar: Wrap( + spacing: 10, + runSpacing: 10, + children: [ + FilledButton.tonalIcon( + onPressed: () => controller.navigateHome(), + icon: const Icon(Icons.chat_bubble_outline_rounded), + label: Text(appText('回到助手', 'Back to assistant')), + ), + DropdownButtonHideUnderline( + child: DropdownButton( + value: controller.themeMode, + onChanged: (value) { + if (value != null) { + controller.setThemeMode(value); + } + }, + items: ThemeMode.values + .map( + (mode) => DropdownMenuItem( + value: mode, + child: Text(_themeLabel(mode)), + ), + ) + .toList(growable: false), + ), + ), + OutlinedButton.icon( + onPressed: controller.toggleAppLanguage, + icon: const Icon(Icons.translate_rounded), + label: Text( + controller.appLanguage == AppLanguage.zh ? '中文' : 'English', + ), + ), + ], + ), + child: Column( + children: [ + SectionTabs( + items: const [ + SettingsTab.general, + SettingsTab.gateway, + SettingsTab.appearance, + SettingsTab.about, + ].map((item) => item.label).toList(), + value: currentTab.label, + onChanged: (label) { + final tab = SettingsTab.values.firstWhere( + (item) => item.label == label, + ); + controller.setSettingsTab(tab); + }, + ), + const SizedBox(height: 12), + Expanded( + child: SingleChildScrollView( + child: Column( + children: switch (currentTab) { + SettingsTab.general => _buildGeneral(context, controller), + SettingsTab.gateway => _buildGateway( + context, + controller, + settings, + ), + SettingsTab.appearance => _buildAppearance( + context, + controller, + ), + _ => _buildAbout(context), + }, + ), + ), + ), + ], + ), + ); + }, + ); + } + + List _buildGeneral(BuildContext context, AppController controller) { + return [ + SurfaceCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText('默认工作模式', 'Default work mode'), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 10), + DropdownButtonFormField( + initialValue: controller.assistantExecutionTarget, + items: + const [ + AssistantExecutionTarget.aiGatewayOnly, + AssistantExecutionTarget.remote, + ] + .map((target) { + return DropdownMenuItem( + value: target, + child: Text(_targetLabel(target)), + ); + }) + .toList(growable: false), + onChanged: (value) { + if (value != null) { + controller.setAssistantExecutionTarget(value); + } + }, + ), + const SizedBox(height: 12), + Text( + appText( + '当前会话列表会在浏览器本地保存,刷新后仍可恢复 Direct AI / Relay 的历史入口。', + 'Conversation history is stored in this browser so Direct AI and Relay entries remain available after reload.', + ), + ), + ], + ), + ), + ]; + } + + List _buildGateway( + BuildContext context, + AppController controller, + SettingsSnapshot settings, + ) { + final palette = context.palette; + return [ + SurfaceCard( + child: Row( + children: [ + Icon(Icons.warning_amber_rounded, color: palette.warning), + const SizedBox(width: 12), + Expanded( + child: Text( + appText( + 'Web 版凭证会保存在当前浏览器本地存储中,安全性低于桌面端安全存储。请仅在可信设备上使用。', + 'Web credentials are persisted in this browser and are less secure than desktop secure storage. Use only on trusted devices.', + ), + ), + ), + ], + ), + ), + const SizedBox(height: 12), + SurfaceCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText('Direct AI', 'Direct AI'), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + TextField( + controller: _directNameController, + decoration: InputDecoration(labelText: appText('名称', 'Name')), + ), + const SizedBox(height: 10), + TextField( + controller: _directProviderController, + decoration: InputDecoration( + labelText: appText('Provider 标识', 'Provider label'), + ), + ), + const SizedBox(height: 10), + TextField( + controller: _directBaseUrlController, + decoration: InputDecoration( + labelText: appText('Base URL', 'Base URL'), + hintText: 'https://api.example.com/v1', + ), + ), + const SizedBox(height: 10), + TextField( + controller: _directApiKeyController, + obscureText: true, + decoration: InputDecoration( + labelText: appText('API Key', 'API Key'), + helperText: controller.storedAiGatewayApiKeyMask == null + ? null + : '${appText('已保存', 'Stored')}: ${controller.storedAiGatewayApiKeyMask}', + ), + ), + const SizedBox(height: 10), + DropdownButtonFormField( + initialValue: controller.resolvedAiGatewayModel.isEmpty + ? null + : controller.resolvedAiGatewayModel, + items: settings.aiGateway.availableModels + .map( + (item) => DropdownMenuItem( + value: item, + child: Text(item), + ), + ) + .toList(growable: false), + onChanged: (value) { + if (value != null) { + controller.selectDirectModel(value); + } + }, + decoration: InputDecoration( + labelText: appText('默认模型', 'Default model'), + hintText: appText('先同步模型目录', 'Sync model catalog first'), + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + FilledButton( + onPressed: () => controller.saveAiGatewayConfiguration( + name: _directNameController.text, + baseUrl: _directBaseUrlController.text, + provider: _directProviderController.text, + apiKey: _directApiKeyController.text, + defaultModel: controller.resolvedAiGatewayModel, + ), + child: Text(appText('保存', 'Save')), + ), + OutlinedButton( + onPressed: controller.aiGatewayBusy + ? null + : () async { + final result = await controller + .testAiGatewayConnection( + baseUrl: _directBaseUrlController.text, + apiKey: _directApiKeyController.text, + ); + if (!mounted) { + return; + } + setState(() => _directMessage = result.message); + }, + child: Text(appText('测试连接', 'Test connection')), + ), + OutlinedButton.icon( + onPressed: controller.aiGatewayBusy + ? null + : () async { + try { + await controller.syncAiGatewayModels( + name: _directNameController.text, + baseUrl: _directBaseUrlController.text, + provider: _directProviderController.text, + apiKey: _directApiKeyController.text, + ); + if (!mounted) { + return; + } + setState(() { + _directMessage = + controller.settings.aiGateway.syncMessage; + }); + } catch (error) { + if (!mounted) { + return; + } + setState(() => _directMessage = '$error'); + } + }, + icon: controller.aiGatewayBusy + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.sync_rounded), + label: Text(appText('同步模型', 'Sync models')), + ), + ], + ), + if (_directMessage.trim().isNotEmpty) ...[ + const SizedBox(height: 10), + Text( + _directMessage, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: palette.textSecondary), + ), + ], + ], + ), + ), + const SizedBox(height: 12), + SurfaceCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText('Relay OpenClaw Gateway', 'Relay OpenClaw Gateway'), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + TextField( + controller: _relayHostController, + decoration: InputDecoration( + labelText: appText('主机或 URL', 'Host or URL'), + ), + ), + const SizedBox(height: 10), + TextField( + controller: _relayPortController, + keyboardType: TextInputType.number, + decoration: InputDecoration(labelText: appText('端口', 'Port')), + ), + const SizedBox(height: 10), + TextField( + controller: _relayTokenController, + obscureText: true, + decoration: InputDecoration( + labelText: appText('Relay Token', 'Relay token'), + helperText: controller.storedRelayTokenMask == null + ? null + : '${appText('已保存', 'Stored')}: ${controller.storedRelayTokenMask}', + ), + ), + const SizedBox(height: 10), + TextField( + controller: _relayPasswordController, + obscureText: true, + decoration: InputDecoration( + labelText: appText('Relay Password', 'Relay password'), + helperText: controller.storedRelayPasswordMask == null + ? null + : '${appText('已保存', 'Stored')}: ${controller.storedRelayPasswordMask}', + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Text( + '${appText('状态', 'Status')}: ${controller.connection.status.label} · ${controller.connection.remoteAddress ?? appText('未连接', 'Offline')}', + ), + ), + Switch( + value: settings.gateway.tls, + onChanged: (value) => controller.saveRelayConfiguration( + host: _relayHostController.text, + port: int.tryParse(_relayPortController.text.trim()) ?? 443, + tls: value, + token: _relayTokenController.text, + password: _relayPasswordController.text, + ), + ), + Text(appText('TLS', 'TLS')), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + FilledButton( + onPressed: () => controller.saveRelayConfiguration( + host: _relayHostController.text, + port: int.tryParse(_relayPortController.text.trim()) ?? 443, + tls: settings.gateway.tls, + token: _relayTokenController.text, + password: _relayPasswordController.text, + ), + child: Text(appText('保存', 'Save')), + ), + OutlinedButton.icon( + onPressed: controller.relayBusy + ? null + : () async { + try { + await controller.saveRelayConfiguration( + host: _relayHostController.text, + port: + int.tryParse( + _relayPortController.text.trim(), + ) ?? + 443, + tls: settings.gateway.tls, + token: _relayTokenController.text, + password: _relayPasswordController.text, + ); + await controller.connectRelay(); + if (!mounted) { + return; + } + setState(() { + _relayMessage = appText( + 'Relay 已连接', + 'Relay connected', + ); + }); + } catch (error) { + if (!mounted) { + return; + } + setState(() => _relayMessage = '$error'); + } + }, + icon: controller.relayBusy + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.link_rounded), + label: Text(appText('连接 Relay', 'Connect relay')), + ), + OutlinedButton( + onPressed: controller.relayBusy + ? null + : () async { + await controller.disconnectRelay(); + if (!mounted) { + return; + } + setState(() { + _relayMessage = appText( + 'Relay 已断开', + 'Relay disconnected', + ); + }); + }, + child: Text(appText('断开', 'Disconnect')), + ), + ], + ), + if (_relayMessage.trim().isNotEmpty) ...[ + const SizedBox(height: 10), + Text( + _relayMessage, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: palette.textSecondary), + ), + ], + ], + ), + ), + ]; + } + + List _buildAppearance( + BuildContext context, + AppController controller, + ) { + return [ + SurfaceCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText('界面偏好', 'Appearance'), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: controller.themeMode, + items: ThemeMode.values + .map( + (mode) => DropdownMenuItem( + value: mode, + child: Text(_themeLabel(mode)), + ), + ) + .toList(growable: false), + onChanged: (value) { + if (value != null) { + controller.setThemeMode(value); + } + }, + decoration: InputDecoration(labelText: appText('主题', 'Theme')), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: controller.toggleAppLanguage, + icon: const Icon(Icons.translate_rounded), + label: Text( + controller.appLanguage == AppLanguage.zh ? '中文' : 'English', + ), + ), + ], + ), + ), + ]; + } + + List _buildAbout(BuildContext context) { + return [ + SurfaceCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'XWorkmate Web', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text(kAppVersionLabel), + const SizedBox(height: 8), + Text( + appText( + 'Root SPA 目标部署到 https://xworkmate.svc.plus/ 。Direct AI 需要浏览器可达且支持 CORS;否则请使用 Relay 模式。', + 'The root SPA targets https://xworkmate.svc.plus/ . Direct AI endpoints must be browser-reachable and CORS-compatible; otherwise use relay mode.', + ), + ), + ], + ), + ), + ]; + } +} + +void _setIfDifferent(TextEditingController controller, String value) { + if (controller.text == value) { + return; + } + controller.value = controller.value.copyWith( + text: value, + selection: TextSelection.collapsed(offset: value.length), + composing: TextRange.empty, + ); +} + +String _themeLabel(ThemeMode mode) { + return switch (mode) { + ThemeMode.light => appText('浅色', 'Light'), + ThemeMode.dark => appText('深色', 'Dark'), + ThemeMode.system => appText('跟随系统', 'System'), + }; +} + +String _targetLabel(AssistantExecutionTarget target) { + return switch (target) { + AssistantExecutionTarget.aiGatewayOnly => appText( + 'Direct AI Gateway', + 'Direct AI Gateway', + ), + AssistantExecutionTarget.remote => appText( + 'Relay OpenClaw Gateway', + 'Relay OpenClaw Gateway', + ), + _ => '', + }; +} diff --git a/lib/web/web_store.dart b/lib/web/web_store.dart new file mode 100644 index 00000000..160266d5 --- /dev/null +++ b/lib/web/web_store.dart @@ -0,0 +1,140 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../runtime/runtime_models.dart'; + +class WebStore { + static const settingsKey = 'xworkmate.web.settings.snapshot'; + static const threadsKey = 'xworkmate.web.assistant.threads'; + static const aiGatewayApiKeyKey = 'xworkmate.web.ai_gateway.api_key'; + static const relayTokenKey = 'xworkmate.web.relay.token'; + static const relayPasswordKey = 'xworkmate.web.relay.password'; + static const relayDeviceIdentityKey = 'xworkmate.web.relay.device_identity'; + static const themeModeKey = 'xworkmate.web.theme_mode'; + + SharedPreferences? _prefs; + + Future initialize() async { + _prefs ??= await SharedPreferences.getInstance(); + } + + Future loadSettingsSnapshot() async { + await initialize(); + return SettingsSnapshot.fromJsonString(_prefs!.getString(settingsKey)); + } + + Future saveSettingsSnapshot(SettingsSnapshot snapshot) async { + await initialize(); + await _prefs!.setString(settingsKey, snapshot.toJsonString()); + } + + Future> loadAssistantThreadRecords() async { + await initialize(); + final raw = _prefs!.getString(threadsKey); + if (raw == null || raw.trim().isEmpty) { + return const []; + } + try { + final decoded = jsonDecode(raw) as List; + return decoded + .whereType() + .map( + (item) => + AssistantThreadRecord.fromJson(item.cast()), + ) + .toList(growable: false); + } catch (_) { + return const []; + } + } + + Future saveAssistantThreadRecords( + List records, + ) async { + await initialize(); + await _prefs!.setString( + threadsKey, + jsonEncode(records.map((item) => item.toJson()).toList(growable: false)), + ); + } + + Future loadAiGatewayApiKey() async { + await initialize(); + return (_prefs!.getString(aiGatewayApiKeyKey) ?? '').trim(); + } + + Future saveAiGatewayApiKey(String value) async { + await initialize(); + await _prefs!.setString(aiGatewayApiKeyKey, value.trim()); + } + + Future loadRelayToken() async { + await initialize(); + return (_prefs!.getString(relayTokenKey) ?? '').trim(); + } + + Future saveRelayToken(String value) async { + await initialize(); + await _prefs!.setString(relayTokenKey, value.trim()); + } + + Future loadRelayPassword() async { + await initialize(); + return (_prefs!.getString(relayPasswordKey) ?? '').trim(); + } + + Future saveRelayPassword(String value) async { + await initialize(); + await _prefs!.setString(relayPasswordKey, value.trim()); + } + + Future loadRelayDeviceIdentity() async { + await initialize(); + final raw = _prefs!.getString(relayDeviceIdentityKey); + if (raw == null || raw.trim().isEmpty) { + return null; + } + try { + return LocalDeviceIdentity.fromJson( + (jsonDecode(raw) as Map).cast(), + ); + } catch (_) { + return null; + } + } + + Future saveRelayDeviceIdentity(LocalDeviceIdentity identity) async { + await initialize(); + await _prefs!.setString( + relayDeviceIdentityKey, + jsonEncode(identity.toJson()), + ); + } + + Future loadThemeMode() async { + await initialize(); + return switch ((_prefs!.getString(themeModeKey) ?? '').trim()) { + 'dark' => ThemeMode.dark, + 'system' => ThemeMode.system, + _ => ThemeMode.light, + }; + } + + Future saveThemeMode(ThemeMode mode) async { + await initialize(); + await _prefs!.setString(themeModeKey, mode.name); + } + + static String? maskValue(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return null; + } + if (trimmed.length <= 4) { + return '*' * trimmed.length; + } + return '${trimmed.substring(0, 2)}${'*' * (trimmed.length - 4)}${trimmed.substring(trimmed.length - 2)}'; + } +} diff --git a/pubspec.lock b/pubspec.lock index 93b8c580..b5431110 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -304,7 +304,7 @@ packages: source: hosted version: "1.0.2" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" diff --git a/pubspec.yaml b/pubspec.yaml index 90d954e8..df24eb7d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,6 +22,7 @@ dependencies: file_selector: ^1.0.3 flutter_markdown: ^0.7.7+1 flutter_secure_storage: ^9.2.4 + http: ^1.5.0 markdown: ^7.3.0 package_info_plus: ^8.3.1 path_provider: ^2.1.5 diff --git a/test/web/web_settings_persistence_browser_test.dart b/test/web/web_settings_persistence_browser_test.dart new file mode 100644 index 00000000..93d80a2c --- /dev/null +++ b/test/web/web_settings_persistence_browser_test.dart @@ -0,0 +1,72 @@ +@TestOn('browser') +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:xworkmate/app/app_controller_web.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/web/web_store.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('web controller persists direct and relay configuration', () async { + SharedPreferences.setMockInitialValues({}); + + final controller = AppController(store: WebStore()); + await _waitForReady(controller); + + await controller.saveAiGatewayConfiguration( + name: 'Direct AI', + baseUrl: 'https://api.example.com/v1', + provider: 'openai-compatible', + apiKey: 'sk-test-web', + defaultModel: '', + ); + await controller.saveRelayConfiguration( + host: 'relay.example.com', + port: 443, + tls: true, + token: 'relay-token', + password: 'relay-password', + ); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.remote, + ); + await controller.createConversation( + target: AssistantExecutionTarget.aiGatewayOnly, + ); + + final reloaded = AppController(store: WebStore()); + await _waitForReady(reloaded); + + expect(reloaded.settings.aiGateway.baseUrl, 'https://api.example.com/v1'); + expect(reloaded.settings.defaultProvider, 'openai-compatible'); + expect(reloaded.settings.gateway.host, 'relay.example.com'); + expect(reloaded.settings.gateway.port, 443); + expect( + reloaded.settings.assistantExecutionTarget, + AssistantExecutionTarget.remote, + ); + expect(reloaded.storedAiGatewayApiKeyMask, isNotNull); + expect(reloaded.storedRelayTokenMask, isNotNull); + expect(reloaded.conversations, isNotEmpty); + + controller.dispose(); + reloaded.dispose(); + }); +} + +Future _waitForReady( + AppController controller, { + Duration timeout = const Duration(seconds: 5), +}) async { + final deadline = DateTime.now().add(timeout); + while (controller.initializing) { + if (DateTime.now().isAfter(deadline)) { + fail('controller did not initialize before timeout'); + } + await Future.delayed(const Duration(milliseconds: 20)); + } +} diff --git a/test/web/web_ui_browser_test.dart b/test/web/web_ui_browser_test.dart new file mode 100644 index 00000000..060f6a61 --- /dev/null +++ b/test/web/web_ui_browser_test.dart @@ -0,0 +1,38 @@ +@TestOn('browser') +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:xworkmate/app/app.dart'; + +void main() { + testWidgets('web shell exposes only assistant and settings surfaces', ( + WidgetTester tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1600, 1000); + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + await tester.pumpWidget(const XWorkmateApp()); + await tester.pumpAndSettle(); + + expect(find.text('助手'), findsWidgets); + expect(find.text('设置'), findsWidgets); + expect(find.text('Tasks'), findsNothing); + expect(find.byKey(const Key('assistant-task-rail')), findsOneWidget); + expect( + find.byKey(const Key('assistant-attachment-menu-button')), + findsNothing, + ); + + await tester.tap(find.text('连接设置')); + await tester.pumpAndSettle(); + + expect(find.text('设置'), findsWidgets); + expect(find.textContaining('浏览器本地存储'), findsOneWidget); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 895730af..3a541db1 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:xworkmate/app/app.dart'; @@ -17,7 +18,14 @@ void main() { expect(find.text('新对话'), findsWidgets); expect(find.byKey(const Key('assistant-task-rail')), findsOneWidget); - expect(find.text('幻灯片'), findsNothing); expect(find.textContaining('输入需求、补充上下文、继续追问'), findsOneWidget); + + if (kIsWeb) { + expect(find.text('设置'), findsWidgets); + expect(find.text('Tasks'), findsNothing); + expect(find.text('AI Gateway'), findsNothing); + } else { + expect(find.text('幻灯片'), findsNothing); + } }); } diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 00000000..8aaa46ac Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 00000000..b749bfef Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 00000000..88cfd48d Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 00000000..eb9b4d76 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 00000000..d69c5669 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 00000000..a2cfb23b --- /dev/null +++ b/web/index.html @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + XWorkmate + + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 00000000..5773a97a --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "XWorkmate", + "short_name": "XWorkmate", + "start_url": "/", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "Assistant-first Flutter Web shell for Direct AI Gateway and Relay OpenClaw Gateway.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +}