From e396d6b176aca44a3e8416466e93cd0cbffe28fb Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Tue, 24 Mar 2026 17:34:14 +0800 Subject: [PATCH] feat(web): complete assistant thread session parity --- config/feature_flags.yaml | 24 +- lib/app/app_controller_web.dart | 1634 +++++++++++++++-- lib/app/ui_feature_manifest.dart | 24 +- lib/web/web_acp_client.dart | 251 +++ lib/web/web_assistant_page.dart | 741 ++++++-- lib/web/web_relay_gateway_client.dart | 31 +- lib/web/web_settings_page.dart | 827 +++++---- lib/web/web_store.dart | 55 +- ...istant_controller_parity_browser_test.dart | 306 +++ test/web/web_ui_browser_test.dart | 7 +- 10 files changed, 3226 insertions(+), 674 deletions(-) create mode 100644 lib/web/web_acp_client.dart create mode 100644 test/web/web_assistant_controller_parity_browser_test.dart diff --git a/config/feature_flags.yaml b/config/feature_flags.yaml index 3d42ac69..3549fd3b 100644 --- a/config/feature_flags.yaml +++ b/config/feature_flags.yaml @@ -420,22 +420,22 @@ web: description: Web relay gateway assistant mode ui_surface: web_assistant_page file_attachments: - enabled: false - release_tier: experimental - build_modes: [] - description: Web does not expose file attachments in assistant composer + enabled: true + release_tier: stable + build_modes: [debug, profile, release] + description: Web file attachment action in assistant composer ui_surface: web_assistant_page multi_agent: - enabled: false - release_tier: experimental - build_modes: [] - description: Web does not expose multi-agent assistant toggle + enabled: true + release_tier: stable + build_modes: [debug, profile, release] + description: Web multi-agent toggle in assistant composer ui_surface: web_assistant_page local_gateway: - enabled: false - release_tier: experimental - build_modes: [] - description: Web does not expose local gateway assistant mode + enabled: true + release_tier: stable + build_modes: [debug, profile, release] + description: Web local gateway assistant mode ui_surface: web_assistant_page local_runtime: enabled: false diff --git a/lib/app/app_controller_web.dart b/lib/app/app_controller_web.dart index ce1f8279..fc8e525b 100644 --- a/lib/app/app_controller_web.dart +++ b/lib/app/app_controller_web.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import '../i18n/app_language.dart'; import '../models/app_models.dart'; import '../runtime/runtime_models.dart'; +import '../web/web_acp_client.dart'; import '../web/web_ai_gateway_client.dart'; import '../web/web_relay_gateway_client.dart'; import '../web/web_session_repository.dart'; @@ -23,12 +24,14 @@ class AppController extends ChangeNotifier { AppController({ WebStore? store, WebAiGatewayClient? aiGatewayClient, + WebAcpClient? acpClient, WebRelayGatewayClient? relayClient, RemoteWebSessionRepositoryBuilder? remoteSessionRepositoryBuilder, UiFeatureManifest? uiFeatureManifest, }) : _store = store ?? WebStore(), _uiFeatureManifest = uiFeatureManifest ?? UiFeatureManifest.fallback(), _aiGatewayClient = aiGatewayClient ?? const WebAiGatewayClient(), + _acpClient = acpClient ?? const WebAcpClient(), _remoteSessionRepositoryBuilder = remoteSessionRepositoryBuilder ?? _defaultRemoteSessionRepository { _relayClient = relayClient ?? WebRelayGatewayClient(_store); @@ -39,6 +42,7 @@ class AppController extends ChangeNotifier { final WebStore _store; final UiFeatureManifest _uiFeatureManifest; final WebAiGatewayClient _aiGatewayClient; + final WebAcpClient _acpClient; final RemoteWebSessionRepositoryBuilder _remoteSessionRepositoryBuilder; late final WebRelayGatewayClient _relayClient; late final BrowserWebSessionRepository _browserSessionRepository = @@ -59,15 +63,21 @@ class AppController extends ChangeNotifier { String? _bootstrapError; bool _relayBusy = false; bool _aiGatewayBusy = false; + bool _acpBusy = false; + bool _multiAgentRunPending = false; final Map _threadRecords = {}; final Set _pendingSessionKeys = {}; final Map _streamingTextBySession = {}; + final Map> _threadTurnQueues = >{}; + final Map _singleAgentRuntimeModelBySession = + {}; String _currentSessionKey = ''; String? _lastAssistantError; String _webSessionApiTokenCache = ''; String _webSessionClientId = ''; String _sessionPersistenceStatusMessage = ''; + WebAcpCapabilities _acpCapabilities = const WebAcpCapabilities.empty(); UiFeatureManifest get uiFeatureManifest => _uiFeatureManifest; AppCapabilities get capabilities => @@ -89,6 +99,8 @@ class AppController extends ChangeNotifier { GatewayConnectionSnapshot get connection => _relayClient.snapshot; bool get relayBusy => _relayBusy; bool get aiGatewayBusy => _aiGatewayBusy; + bool get acpBusy => _acpBusy; + bool get isMultiAgentRunPending => _multiAgentRunPending; String? get lastAssistantError => _lastAssistantError; String get currentSessionKey => _currentSessionKey; WebSessionPersistenceConfig get webSessionPersistence => @@ -96,14 +108,26 @@ class AppController extends ChangeNotifier { String get sessionPersistenceStatusMessage => _sessionPersistenceStatusMessage; bool get supportsDesktopIntegration => false; - bool get hasStoredGatewayToken => storedRelayTokenMask != null; + bool get hasStoredGatewayToken => + hasStoredGatewayTokenForProfile(kGatewayRemoteProfileIndex) || + hasStoredGatewayTokenForProfile(kGatewayLocalProfileIndex); bool get hasStoredAiGatewayApiKey => storedAiGatewayApiKeyMask != null; String? get storedGatewayTokenMask => storedRelayTokenMask; + String? storedRelayTokenMaskForProfile(int profileIndex) => WebStore.maskValue( + (_relayTokenByProfile[profileIndex] ?? '').trim(), + ); + String? storedRelayPasswordMaskForProfile(int profileIndex) => WebStore.maskValue( + (_relayPasswordByProfile[profileIndex] ?? '').trim(), + ); + bool hasStoredGatewayTokenForProfile(int profileIndex) => + ((_relayTokenByProfile[profileIndex] ?? '').trim().isNotEmpty); + bool hasStoredGatewayPasswordForProfile(int profileIndex) => + ((_relayPasswordByProfile[profileIndex] ?? '').trim().isNotEmpty); String? get storedRelayTokenMask => WebStore.maskValue( - _relayTokenCache.trim().isEmpty ? '' : _relayTokenCache, + (_relayTokenByProfile[kGatewayRemoteProfileIndex] ?? '').trim(), ); String? get storedRelayPasswordMask => WebStore.maskValue( - _relayPasswordCache.trim().isEmpty ? '' : _relayPasswordCache, + (_relayPasswordByProfile[kGatewayRemoteProfileIndex] ?? '').trim(), ); String? get storedAiGatewayApiKeyMask => WebStore.maskValue( _aiGatewayApiKeyCache.trim().isEmpty ? '' : _aiGatewayApiKeyCache, @@ -118,8 +142,8 @@ class AppController extends ChangeNotifier { ) != null; - String _relayTokenCache = ''; - String _relayPasswordCache = ''; + final Map _relayTokenByProfile = {}; + final Map _relayPasswordByProfile = {}; String _aiGatewayApiKeyCache = ''; static const String _draftAiGatewayApiKeyKey = 'ai_gateway_api_key'; @@ -130,12 +154,159 @@ class AppController extends ChangeNotifier { return _uiFeatureManifest.forPlatform(platform); } + AssistantExecutionTarget assistantExecutionTargetForSession( + String sessionKey, + ) { + final normalizedSessionKey = _normalizedSessionKey(sessionKey); + final recordTarget = _sanitizeTarget( + _threadRecords[normalizedSessionKey]?.executionTarget, + ); + final fallback = _sanitizeTarget(_settings.assistantExecutionTarget); + return recordTarget ?? fallback ?? AssistantExecutionTarget.singleAgent; + } + AssistantExecutionTarget get assistantExecutionTarget => - _currentRecord.executionTarget ?? _settings.assistantExecutionTarget; + assistantExecutionTargetForSession(_currentSessionKey); AssistantExecutionTarget get currentAssistantExecutionTarget => assistantExecutionTarget; bool get isSingleAgentMode => assistantExecutionTarget == AssistantExecutionTarget.singleAgent; + + AssistantMessageViewMode assistantMessageViewModeForSession( + String sessionKey, + ) { + final normalizedSessionKey = _normalizedSessionKey(sessionKey); + return _threadRecords[normalizedSessionKey]?.messageViewMode ?? + AssistantMessageViewMode.rendered; + } + + AssistantMessageViewMode get currentAssistantMessageViewMode => + assistantMessageViewModeForSession(_currentSessionKey); + + SingleAgentProvider singleAgentProviderForSession(String sessionKey) { + final normalizedSessionKey = _normalizedSessionKey(sessionKey); + return _threadRecords[normalizedSessionKey]?.singleAgentProvider ?? + SingleAgentProvider.auto; + } + + SingleAgentProvider get currentSingleAgentProvider => + singleAgentProviderForSession(_currentSessionKey); + + List get singleAgentProviderOptions => + _acpCapabilities.providers.isEmpty + ? const [ + SingleAgentProvider.auto, + ...kBuiltinExternalAcpProviders, + ] + : [ + SingleAgentProvider.auto, + ...kBuiltinExternalAcpProviders.where( + _acpCapabilities.providers.contains, + ), + ]; + + bool singleAgentUsesAiChatFallbackForSession(String sessionKey) { + final provider = singleAgentProviderForSession(sessionKey); + return provider == SingleAgentProvider.auto && canUseAiGatewayConversation; + } + + bool get currentSingleAgentUsesAiChatFallback => + singleAgentUsesAiChatFallbackForSession(_currentSessionKey); + + String singleAgentRuntimeModelForSession(String sessionKey) { + return _singleAgentRuntimeModelBySession[_normalizedSessionKey(sessionKey)] + ?.trim() ?? + ''; + } + + String get currentSingleAgentRuntimeModel => + singleAgentRuntimeModelForSession(_currentSessionKey); + + String assistantModelForSession(String sessionKey) { + final normalizedSessionKey = _normalizedSessionKey(sessionKey); + final target = assistantExecutionTargetForSession(normalizedSessionKey); + final recordModel = + _threadRecords[normalizedSessionKey]?.assistantModelId.trim() ?? ''; + if (target == AssistantExecutionTarget.singleAgent) { + if (singleAgentUsesAiChatFallbackForSession(normalizedSessionKey)) { + if (recordModel.isNotEmpty) { + return recordModel; + } + return resolvedAiGatewayModel; + } + final runtimeModel = singleAgentRuntimeModelForSession( + normalizedSessionKey, + ); + if (runtimeModel.isNotEmpty) { + return runtimeModel; + } + if (recordModel.isNotEmpty) { + return recordModel; + } + return resolvedAiGatewayModel; + } + if (recordModel.isNotEmpty) { + return recordModel; + } + return _settings.defaultModel.trim(); + } + + String get resolvedAssistantModel => assistantModelForSession(_currentSessionKey); + + List assistantModelChoicesForSession(String sessionKey) { + final target = assistantExecutionTargetForSession(sessionKey); + if (target == AssistantExecutionTarget.singleAgent) { + if (singleAgentUsesAiChatFallbackForSession(sessionKey)) { + return aiGatewayConversationModelChoices; + } + final runtime = singleAgentRuntimeModelForSession(sessionKey); + if (runtime.isNotEmpty) { + return [runtime]; + } + final recordModel = assistantModelForSession(sessionKey); + if (recordModel.isNotEmpty) { + return [recordModel]; + } + return aiGatewayConversationModelChoices; + } + final model = _settings.defaultModel.trim(); + if (model.isEmpty) { + return const []; + } + return [model]; + } + + List get assistantModelChoices => + assistantModelChoicesForSession(_currentSessionKey); + + List assistantImportedSkillsForSession( + String sessionKey, + ) { + return _threadRecords[_normalizedSessionKey(sessionKey)]?.importedSkills ?? + const []; + } + + List assistantSelectedSkillKeysForSession(String sessionKey) { + final normalizedSessionKey = _normalizedSessionKey(sessionKey); + final importedKeys = assistantImportedSkillsForSession( + normalizedSessionKey, + ).map((item) => item.key).toSet(); + final selected = + _threadRecords[normalizedSessionKey]?.selectedSkillKeys ?? + const []; + return selected + .where((item) => importedKeys.contains(item)) + .toList(growable: false); + } + + int get currentAssistantSkillCount { + final target = assistantExecutionTargetForSession(_currentSessionKey); + if (target == AssistantExecutionTarget.singleAgent) { + return assistantImportedSkillsForSession(_currentSessionKey).length; + } + return assistantImportedSkillsForSession(_currentSessionKey).length; + } + List get chatMessages { final base = List.from(_currentRecord.messages); final streaming = _streamingTextBySession[_currentSessionKey]?.trim() ?? ''; @@ -158,8 +329,16 @@ class AppController extends ChangeNotifier { } List get conversations { + final archivedKeys = _settings.assistantArchivedTaskKeys + .map(_normalizedSessionKey) + .toSet(); final entries = _threadRecords.values + .where( + (record) => + !record.archived && + !archivedKeys.contains(_normalizedSessionKey(record.sessionKey)), + ) .map( (record) => WebConversationSummary( sessionKey: record.sessionKey, @@ -168,9 +347,9 @@ class AppController extends ChangeNotifier { updatedAtMs: record.updatedAtMs ?? DateTime.now().millisecondsSinceEpoch.toDouble(), - executionTarget: - _sanitizeTarget(record.executionTarget) ?? - AssistantExecutionTarget.singleAgent, + executionTarget: assistantExecutionTargetForSession( + record.sessionKey, + ), pending: _pendingSessionKeys.contains(record.sessionKey), current: record.sessionKey == _currentSessionKey, ), @@ -229,34 +408,83 @@ class AppController extends ChangeNotifier { _aiGatewayApiKeyCache.trim().isNotEmpty && resolvedAiGatewayModel.isNotEmpty; - AssistantThreadConnectionState get currentAssistantConnectionState { - final target = currentAssistantExecutionTarget; + AssistantThreadConnectionState get currentAssistantConnectionState => + assistantConnectionStateForSession(_currentSessionKey); + + AssistantThreadConnectionState assistantConnectionStateForSession( + String sessionKey, + ) { + final normalizedSessionKey = _normalizedSessionKey(sessionKey); + final target = assistantExecutionTargetForSession(normalizedSessionKey); if (target == AssistantExecutionTarget.singleAgent) { + final provider = singleAgentProviderForSession(normalizedSessionKey); + final model = assistantModelForSession(normalizedSessionKey); final host = _hostLabel(_settings.aiGateway.baseUrl); - final model = resolvedAiGatewayModel; - final detail = _joinConnectionParts([model, host]); + if (provider == SingleAgentProvider.auto) { + final detail = _joinConnectionParts([model, host]); + return AssistantThreadConnectionState( + executionTarget: target, + status: canUseAiGatewayConversation + ? RuntimeConnectionStatus.connected + : RuntimeConnectionStatus.offline, + primaryLabel: target.label, + detailLabel: detail.isEmpty + ? appText('单机智能体未配置', 'Single Agent not configured') + : detail, + ready: canUseAiGatewayConversation, + pairingRequired: false, + gatewayTokenMissing: false, + lastError: null, + ); + } + final remoteAddress = _gatewayAddressLabel( + _settings.primaryRemoteGatewayProfile, + ); + final remoteReady = + connection.status == RuntimeConnectionStatus.connected && + connection.mode == RuntimeConnectionMode.remote; return AssistantThreadConnectionState( executionTarget: target, - status: canUseAiGatewayConversation + status: remoteReady ? RuntimeConnectionStatus.connected : RuntimeConnectionStatus.offline, primaryLabel: target.label, - detailLabel: detail.isEmpty - ? appText('单机智能体未配置', 'Single Agent not configured') - : detail, - ready: canUseAiGatewayConversation, + detailLabel: remoteReady + ? _joinConnectionParts([provider.label, model]) + : appText( + '${provider.label} 需要 Remote ACP(${remoteAddress.isEmpty ? 'Remote Gateway' : remoteAddress})', + '${provider.label} requires Remote ACP (${remoteAddress.isEmpty ? 'Remote Gateway' : remoteAddress}).', + ), + ready: remoteReady, pairingRequired: false, gatewayTokenMissing: false, lastError: null, ); } + final expectedMode = target == AssistantExecutionTarget.local + ? RuntimeConnectionMode.local + : RuntimeConnectionMode.remote; + final profile = target == AssistantExecutionTarget.local + ? _settings.primaryLocalGatewayProfile + : _settings.primaryRemoteGatewayProfile; + final matchesTarget = connection.mode == expectedMode; + final detail = matchesTarget + ? (connection.remoteAddress?.trim().isNotEmpty == true + ? connection.remoteAddress!.trim() + : _gatewayAddressLabel(profile)) + : _gatewayAddressLabel(profile); return AssistantThreadConnectionState( executionTarget: target, - status: connection.status, - primaryLabel: connection.status.label, - detailLabel: - connection.remoteAddress ?? appText('Relay 未连接', 'Relay offline'), - ready: connection.status == RuntimeConnectionStatus.connected, + status: matchesTarget ? connection.status : RuntimeConnectionStatus.offline, + primaryLabel: (matchesTarget + ? connection.status + : RuntimeConnectionStatus.offline) + .label, + detailLabel: detail.isEmpty + ? appText('Relay 未连接', 'Relay offline') + : detail, + ready: + matchesTarget && connection.status == RuntimeConnectionStatus.connected, pairingRequired: false, gatewayTokenMissing: false, lastError: null, @@ -312,8 +540,17 @@ class AppController extends ChangeNotifier { _themeMode = await _store.loadThemeMode(); _settings = _sanitizeSettings(await _store.loadSettingsSnapshot()); _aiGatewayApiKeyCache = await _store.loadAiGatewayApiKey(); - _relayTokenCache = await _store.loadRelayToken(); - _relayPasswordCache = await _store.loadRelayPassword(); + for (final profileIndex in [ + kGatewayLocalProfileIndex, + kGatewayRemoteProfileIndex, + ]) { + _relayTokenByProfile[profileIndex] = await _store.loadRelayToken( + profileIndex: profileIndex, + ); + _relayPasswordByProfile[profileIndex] = await _store.loadRelayPassword( + profileIndex: profileIndex, + ); + } _webSessionClientId = await _store.loadOrCreateWebSessionClientId(); final records = await _loadThreadRecords(); for (final record in records) { @@ -327,7 +564,20 @@ class AppController extends ChangeNotifier { ); _threadRecords[record.sessionKey] = record; } - _currentSessionKey = conversations.first.sessionKey; + final preferredSession = _normalizedSessionKey( + _settings.assistantLastSessionKey, + ); + if (preferredSession.isNotEmpty && + _threadRecords.containsKey(preferredSession)) { + _currentSessionKey = preferredSession; + } else { + final visible = conversations; + if (visible.isNotEmpty) { + _currentSessionKey = visible.first.sessionKey; + } else { + _currentSessionKey = _threadRecords.keys.first; + } + } _settingsDraft = _settings; _settingsDraftInitialized = true; } catch (error) { @@ -456,11 +706,52 @@ class AppController extends ChangeNotifier { String tokenOverride = '', String passwordOverride = '', }) async { - return ( - state: 'unsupported', - message: 'Gateway test unavailable on web', - endpoint: '', + final resolvedTarget = + _sanitizeTarget(executionTarget) ?? AssistantExecutionTarget.remote; + if (resolvedTarget == AssistantExecutionTarget.singleAgent) { + return ( + state: 'error', + message: appText( + 'Single Agent 不需要 Gateway 连通性测试。', + 'Single Agent does not require a gateway connectivity test.', + ), + endpoint: '', + ); + } + final expectedMode = resolvedTarget == AssistantExecutionTarget.local + ? RuntimeConnectionMode.local + : RuntimeConnectionMode.remote; + final candidateProfile = profile.copyWith( + mode: expectedMode, + useSetupCode: false, + setupCode: '', + tls: expectedMode == RuntimeConnectionMode.local ? false : profile.tls, ); + final endpoint = _gatewayAddressLabel(candidateProfile); + final client = WebRelayGatewayClient(_store); + try { + await client.connect( + profile: candidateProfile, + authToken: tokenOverride.trim(), + authPassword: passwordOverride.trim(), + ); + return ( + state: 'connected', + message: appText( + '连接测试成功。', + 'Connection test succeeded.', + ), + endpoint: endpoint, + ); + } catch (error) { + return ( + state: 'error', + message: error.toString(), + endpoint: endpoint, + ); + } finally { + await client.dispose(); + } } Future persistSettingsDraft() async { @@ -517,28 +808,62 @@ class AppController extends ChangeNotifier { } Future createConversation({AssistantExecutionTarget? target}) async { - final resolvedTarget = - _sanitizeTarget(target) ?? _settings.assistantExecutionTarget; - final record = _newRecord(target: resolvedTarget); + final inheritedTarget = + _sanitizeTarget(target) ?? + assistantExecutionTargetForSession(_currentSessionKey); + final inheritedRecord = _threadRecords[_normalizedSessionKey( + _currentSessionKey, + )]; + final record = _newRecord( + target: inheritedTarget, + title: appText('新对话', 'New conversation'), + ).copyWith( + messageViewMode: + inheritedRecord?.messageViewMode ?? AssistantMessageViewMode.rendered, + singleAgentProvider: + inheritedRecord?.singleAgentProvider ?? SingleAgentProvider.auto, + assistantModelId: inheritedRecord?.assistantModelId ?? '', + importedSkills: inheritedRecord?.importedSkills ?? const [], + selectedSkillKeys: inheritedRecord?.selectedSkillKeys ?? const [], + gatewayEntryState: _gatewayEntryStateForTarget(inheritedTarget), + ); _threadRecords[record.sessionKey] = record; _currentSessionKey = record.sessionKey; _lastAssistantError = null; + _settings = _settings.copyWith(assistantLastSessionKey: record.sessionKey); + await _persistSettings(); await _persistThreads(); notifyListeners(); } Future switchConversation(String sessionKey) async { - if (!_threadRecords.containsKey(sessionKey)) { + final normalizedSessionKey = _normalizedSessionKey(sessionKey); + if (!_threadRecords.containsKey(normalizedSessionKey)) { return; } - _currentSessionKey = sessionKey; + final previousSessionKey = _normalizedSessionKey(_currentSessionKey); + if (previousSessionKey == normalizedSessionKey) { + return; + } + if (assistantExecutionTargetForSession(previousSessionKey) != + AssistantExecutionTarget.singleAgent) { + _streamingTextBySession.remove(previousSessionKey); + } + _currentSessionKey = normalizedSessionKey; _lastAssistantError = null; + _settings = _settings.copyWith(assistantLastSessionKey: normalizedSessionKey); + await _persistSettings(); notifyListeners(); - final record = _threadRecords[sessionKey]!; - if (_sanitizeTarget(record.executionTarget) == - AssistantExecutionTarget.remote && - connection.status == RuntimeConnectionStatus.connected) { - await refreshRelayHistory(sessionKey: sessionKey); + final target = assistantExecutionTargetForSession(normalizedSessionKey); + await _applyAssistantExecutionTarget( + target, + sessionKey: normalizedSessionKey, + persistDefaultSelection: false, + ); + if (target == AssistantExecutionTarget.local || + target == AssistantExecutionTarget.remote) { + await refreshRelayHistory(sessionKey: normalizedSessionKey); + await refreshRelaySkillsForSession(normalizedSessionKey); } } @@ -546,14 +871,187 @@ class AppController extends ChangeNotifier { AssistantExecutionTarget target, ) async { final resolvedTarget = - _sanitizeTarget(target) ?? AssistantExecutionTarget.singleAgent; - _settings = _settings.copyWith(assistantExecutionTarget: resolvedTarget); - _replaceCurrentRecord( - _currentRecord.copyWith(executionTarget: resolvedTarget), + _sanitizeTarget(target) ?? assistantExecutionTargetForSession(_currentSessionKey); + final sessionKey = _normalizedSessionKey(_currentSessionKey); + _upsertThreadRecord( + sessionKey, + executionTarget: resolvedTarget, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + gatewayEntryState: _gatewayEntryStateForTarget(resolvedTarget), ); + _settings = _settings.copyWith(assistantExecutionTarget: resolvedTarget); await _persistSettings(); await _persistThreads(); notifyListeners(); + await _applyAssistantExecutionTarget( + resolvedTarget, + sessionKey: sessionKey, + persistDefaultSelection: true, + ); + if (resolvedTarget == AssistantExecutionTarget.local || + resolvedTarget == AssistantExecutionTarget.remote) { + await refreshRelaySkillsForSession(sessionKey); + } + notifyListeners(); + } + + Future setSingleAgentProvider(SingleAgentProvider provider) async { + if (!singleAgentProviderOptions.contains(provider)) { + return; + } + final sessionKey = _normalizedSessionKey(_currentSessionKey); + if (singleAgentProviderForSession(sessionKey) == provider) { + return; + } + _singleAgentRuntimeModelBySession.remove(sessionKey); + _upsertThreadRecord( + sessionKey, + singleAgentProvider: provider, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + await _persistThreads(); + notifyListeners(); + } + + Future setAssistantMessageViewMode( + AssistantMessageViewMode mode, + ) async { + final sessionKey = _normalizedSessionKey(_currentSessionKey); + if (assistantMessageViewModeForSession(sessionKey) == mode) { + return; + } + _upsertThreadRecord( + sessionKey, + messageViewMode: mode, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + await _persistThreads(); + notifyListeners(); + } + + Future selectAssistantModelForSession( + String sessionKey, + String modelId, + ) async { + final trimmed = modelId.trim(); + if (trimmed.isEmpty) { + return; + } + final normalizedSessionKey = _normalizedSessionKey(sessionKey); + if (assistantModelForSession(normalizedSessionKey) == trimmed) { + return; + } + _upsertThreadRecord( + normalizedSessionKey, + assistantModelId: trimmed, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + await _persistThreads(); + notifyListeners(); + } + + Future selectAssistantModel(String modelId) async { + await selectAssistantModelForSession(_currentSessionKey, modelId); + } + + Future saveAssistantTaskTitle(String sessionKey, String title) async { + final normalizedSessionKey = _normalizedSessionKey(sessionKey); + if (!_threadRecords.containsKey(normalizedSessionKey)) { + return; + } + final trimmedTitle = title.trim(); + final nextTitles = Map.from(_settings.assistantCustomTaskTitles); + if (trimmedTitle.isEmpty) { + nextTitles.remove(normalizedSessionKey); + } else { + nextTitles[normalizedSessionKey] = trimmedTitle; + } + _settings = _settings.copyWith(assistantCustomTaskTitles: nextTitles); + _upsertThreadRecord(normalizedSessionKey, title: trimmedTitle); + await _persistSettings(); + await _persistThreads(); + notifyListeners(); + } + + bool isAssistantTaskArchived(String sessionKey) { + final normalizedSessionKey = _normalizedSessionKey(sessionKey); + final archivedKeys = _settings.assistantArchivedTaskKeys + .map(_normalizedSessionKey) + .toSet(); + if (archivedKeys.contains(normalizedSessionKey)) { + return true; + } + return _threadRecords[normalizedSessionKey]?.archived ?? false; + } + + Future saveAssistantTaskArchived(String sessionKey, bool archived) async { + final normalizedSessionKey = _normalizedSessionKey(sessionKey); + if (!_threadRecords.containsKey(normalizedSessionKey)) { + return; + } + final archivedKeys = _settings.assistantArchivedTaskKeys + .map(_normalizedSessionKey) + .toSet(); + if (archived) { + archivedKeys.add(normalizedSessionKey); + } else { + archivedKeys.remove(normalizedSessionKey); + } + _settings = _settings.copyWith( + assistantArchivedTaskKeys: archivedKeys.toList(growable: false), + ); + _upsertThreadRecord( + normalizedSessionKey, + archived: archived, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + if (archived && _currentSessionKey == normalizedSessionKey) { + final fallback = _threadRecords.values + .where((record) => !record.archived && record.sessionKey != normalizedSessionKey) + .toList(growable: false); + if (fallback.isNotEmpty) { + _currentSessionKey = fallback.first.sessionKey; + } else { + final newRecord = _newRecord( + target: _settings.assistantExecutionTarget, + title: appText('新对话', 'New conversation'), + ); + _threadRecords[newRecord.sessionKey] = newRecord; + _currentSessionKey = newRecord.sessionKey; + } + } + await _persistSettings(); + await _persistThreads(); + notifyListeners(); + } + + Future toggleAssistantSkillForSession( + String sessionKey, + String skillKey, + ) async { + final normalizedSessionKey = _normalizedSessionKey(sessionKey); + final normalizedSkillKey = skillKey.trim(); + if (normalizedSkillKey.isEmpty) { + return; + } + final importedKeys = assistantImportedSkillsForSession( + normalizedSessionKey, + ).map((item) => item.key).toSet(); + if (!importedKeys.contains(normalizedSkillKey)) { + return; + } + final selected = assistantSelectedSkillKeysForSession(normalizedSessionKey) + .toSet(); + if (!selected.add(normalizedSkillKey)) { + selected.remove(normalizedSkillKey); + } + _upsertThreadRecord( + normalizedSessionKey, + selectedSkillKeys: selected.toList(growable: false), + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + await _persistThreads(); + notifyListeners(); } Future saveAiGatewayConfiguration({ @@ -657,50 +1155,99 @@ class AppController extends ChangeNotifier { required bool tls, required String token, required String password, + int profileIndex = kGatewayRemoteProfileIndex, }) async { - final remoteProfile = _settings.primaryRemoteGatewayProfile; + final baseProfile = profileIndex == kGatewayLocalProfileIndex + ? _settings.primaryLocalGatewayProfile + : _settings.primaryRemoteGatewayProfile; + final mode = profileIndex == kGatewayLocalProfileIndex + ? RuntimeConnectionMode.local + : RuntimeConnectionMode.remote; _settings = _settings.copyWith( gatewayProfiles: replaceGatewayProfileAt( _settings.gatewayProfiles, - kGatewayRemoteProfileIndex, - remoteProfile.copyWith( - mode: RuntimeConnectionMode.remote, + profileIndex, + baseProfile.copyWith( + mode: mode, useSetupCode: false, setupCode: '', host: host.trim(), port: port, - tls: tls, + tls: mode == RuntimeConnectionMode.local ? false : tls, ), ), ); - _relayTokenCache = token.trim(); - _relayPasswordCache = password.trim(); - await _store.saveRelayToken(_relayTokenCache); - await _store.saveRelayPassword(_relayPasswordCache); + _relayTokenByProfile[profileIndex] = token.trim(); + _relayPasswordByProfile[profileIndex] = password.trim(); + await _store.saveRelayToken( + _relayTokenByProfile[profileIndex] ?? '', + profileIndex: profileIndex, + ); + await _store.saveRelayPassword( + _relayPasswordByProfile[profileIndex] ?? '', + profileIndex: profileIndex, + ); await _persistSettings(); notifyListeners(); } - Future connectRelay() async { + Future applyRelayConfiguration({ + required int profileIndex, + required String host, + required int port, + required bool tls, + required String token, + required String password, + }) async { + await saveRelayConfiguration( + profileIndex: profileIndex, + host: host, + port: port, + tls: tls, + token: token, + password: password, + ); + final currentTarget = assistantExecutionTargetForSession(_currentSessionKey); + final currentProfileIndex = _profileIndexForTarget(currentTarget); + if (currentProfileIndex == profileIndex) { + await connectRelay(target: currentTarget); + } + } + + Future connectRelay({AssistantExecutionTarget? target}) async { _relayBusy = true; notifyListeners(); try { - final remoteProfile = _settings.primaryRemoteGatewayProfile.copyWith( - mode: RuntimeConnectionMode.remote, + final resolvedTarget = + _sanitizeTarget(target) ?? + (() { + final current = assistantExecutionTargetForSession(_currentSessionKey); + return current == AssistantExecutionTarget.local || + current == AssistantExecutionTarget.remote + ? current + : AssistantExecutionTarget.remote; + })(); + final profileIndex = _profileIndexForTarget(resolvedTarget); + final profile = _profileForTarget(resolvedTarget).copyWith( + mode: resolvedTarget == AssistantExecutionTarget.local + ? RuntimeConnectionMode.local + : RuntimeConnectionMode.remote, useSetupCode: false, setupCode: '', ); await _relayClient.connect( - profile: remoteProfile, - authToken: _relayTokenCache, - authPassword: _relayPasswordCache, + profile: profile, + authToken: (_relayTokenByProfile[profileIndex] ?? '').trim(), + authPassword: (_relayPasswordByProfile[profileIndex] ?? '').trim(), ); - await refreshRelaySessions(); - await refreshRelayModels(); - if (_sanitizeTarget(_currentRecord.executionTarget) == - AssistantExecutionTarget.remote) { - await refreshRelayHistory(sessionKey: _currentSessionKey); + final acpEndpoint = _acpEndpointForTarget(resolvedTarget); + if (acpEndpoint != null) { + await _refreshAcpCapabilities(acpEndpoint); } + await refreshRelaySessions(); + await refreshRelaySkillsForSession(_currentSessionKey); + await refreshRelayModels(); + await refreshRelayHistory(sessionKey: _currentSessionKey); } finally { _relayBusy = false; notifyListeners(); @@ -722,11 +1269,13 @@ class AppController extends ChangeNotifier { if (connection.status != RuntimeConnectionStatus.connected) { return; } + final target = _assistantExecutionTargetForMode(connection.mode); final sessions = await _relayClient.listSessions(limit: 50); for (final session in sessions) { - final existing = _threadRecords[session.key]; + final sessionKey = _normalizedSessionKey(session.key); + final existing = _threadRecords[sessionKey]; final next = AssistantThreadRecord( - sessionKey: session.key, + sessionKey: sessionKey, messages: existing?.messages ?? const [], updatedAtMs: session.updatedAtMs ?? @@ -735,11 +1284,18 @@ class AppController extends ChangeNotifier { title: (session.derivedTitle ?? session.displayName ?? session.key) .trim(), archived: false, - executionTarget: AssistantExecutionTarget.remote, + executionTarget: existing?.executionTarget ?? target, messageViewMode: existing?.messageViewMode ?? AssistantMessageViewMode.rendered, + importedSkills: existing?.importedSkills ?? const [], + selectedSkillKeys: existing?.selectedSkillKeys ?? const [], + assistantModelId: existing?.assistantModelId ?? '', + singleAgentProvider: + existing?.singleAgentProvider ?? SingleAgentProvider.auto, + gatewayEntryState: + existing?.gatewayEntryState ?? _gatewayEntryStateForTarget(target), ); - _threadRecords[session.key] = next; + _threadRecords[sessionKey] = next; } await _persistThreads(); notifyListeners(); @@ -773,113 +1329,370 @@ class AppController extends ChangeNotifier { } Future refreshRelayHistory({String? sessionKey}) async { - final resolvedKey = (sessionKey ?? _currentSessionKey).trim(); + final resolvedKey = _normalizedSessionKey(sessionKey ?? _currentSessionKey); if (resolvedKey.isEmpty || connection.status != RuntimeConnectionStatus.connected) { return; } + final target = _assistantExecutionTargetForMode(connection.mode); 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, - ); + final next = (existing ?? _newRecord(target: target)).copyWith( + sessionKey: resolvedKey, + messages: messages, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + title: _deriveThreadTitle( + existing?.title ?? '', + messages, + fallback: resolvedKey, + ), + executionTarget: existing?.executionTarget ?? target, + gatewayEntryState: + existing?.gatewayEntryState ?? _gatewayEntryStateForTarget(target), + ); _threadRecords[resolvedKey] = next; _streamingTextBySession.remove(resolvedKey); await _persistThreads(); notifyListeners(); } - Future sendMessage(String rawMessage) async { + Future refreshRelaySkillsForSession(String sessionKey) async { + final normalizedSessionKey = _normalizedSessionKey(sessionKey); + final target = assistantExecutionTargetForSession(normalizedSessionKey); + if ((target != AssistantExecutionTarget.local && + target != AssistantExecutionTarget.remote) || + connection.status != RuntimeConnectionStatus.connected) { + return; + } + try { + final payload = _castMap(await _relayClient.request('skills.status')); + final skills = (payload['skills'] as List? ?? const []) + .map(_castMap) + .map( + (item) => AssistantThreadSkillEntry( + key: + item['skillKey']?.toString().trim().isNotEmpty == true + ? item['skillKey'].toString().trim() + : (item['name']?.toString().trim() ?? ''), + label: item['name']?.toString().trim() ?? '', + description: item['description']?.toString().trim() ?? '', + source: item['source']?.toString().trim() ?? 'gateway', + sourcePath: '', + scope: 'session', + sourceLabel: item['source']?.toString().trim() ?? 'gateway', + ), + ) + .where((entry) => entry.key.isNotEmpty && entry.label.isNotEmpty) + .toList(growable: false); + _upsertThreadRecord( + normalizedSessionKey, + importedSkills: skills, + selectedSkillKeys: + _threadRecords[normalizedSessionKey]?.selectedSkillKeys ?? + const [], + ); + await _persistThreads(); + notifyListeners(); + } catch (_) { + // Best effort: skill discovery should not block chat flows. + } + } + + Future sendMessage( + String rawMessage, { + String thinking = 'medium', + List attachments = + const [], + List selectedSkillLabels = const [], + bool useMultiAgent = false, + }) 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, - ), + const maxAttachmentBytes = 10 * 1024 * 1024; + final totalAttachmentBytes = attachments.fold( + 0, + (total, item) => total + _base64Size(item.content), ); - _pendingSessionKeys.add(_currentSessionKey); - await _persistThreads(); - notifyListeners(); - - try { - if (target == AssistantExecutionTarget.singleAgent) { - if (!canUseAiGatewayConversation) { - throw Exception( - appText( - '请先在 Settings 配置单机智能体所需的 LLM API Endpoint、LLM API Token 和默认模型。', - 'Configure the Single Agent LLM API Endpoint, LLM API Token, 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, + if (totalAttachmentBytes > maxAttachmentBytes) { + _lastAssistantError = appText( + '附件总大小超过 10MB,请减少附件后重试。', + 'Attachments exceed the 10MB limit. Remove some files and try again.', ); - _lastAssistantError = error.toString(); - _pendingSessionKeys.remove(_currentSessionKey); - _streamingTextBySession.remove(_currentSessionKey); + notifyListeners(); + return; + } + final sessionKey = _normalizedSessionKey(_currentSessionKey); + await _enqueueThreadTurn(sessionKey, () async { + _lastAssistantError = null; + final target = assistantExecutionTargetForSession(sessionKey); + final current = _threadRecords[sessionKey] ?? _newRecord(target: target); + final nextMessages = [ + ...current.messages, + GatewayChatMessage( + id: _messageId(), + role: 'user', + text: trimmed, + timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + ]; + _upsertThreadRecord( + sessionKey, + messages: nextMessages, + executionTarget: target, + title: _deriveThreadTitle(current.title, nextMessages), + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + _pendingSessionKeys.add(sessionKey); await _persistThreads(); notifyListeners(); + + try { + if (useMultiAgent && _settings.multiAgent.enabled) { + await runMultiAgentCollaboration( + rawPrompt: trimmed, + composedPrompt: trimmed, + attachments: attachments, + selectedSkillLabels: selectedSkillLabels, + ); + return; + } + if (target == AssistantExecutionTarget.singleAgent) { + final provider = singleAgentProviderForSession(sessionKey); + if (provider == SingleAgentProvider.auto) { + if (!canUseAiGatewayConversation) { + throw Exception( + appText( + '请先在 Settings 配置单机智能体所需的 LLM API Endpoint、LLM API Token 和默认模型。', + 'Configure the Single Agent LLM API Endpoint, LLM API Token, and default model first.', + ), + ); + } + final directPrompt = attachments.isEmpty + ? trimmed + : _augmentPromptWithAttachments(trimmed, attachments); + final directHistory = List.from(nextMessages); + if (directHistory.isNotEmpty) { + final last = directHistory.removeLast(); + directHistory.add( + last.copyWith(text: directPrompt, role: 'user', error: false), + ); + } + final reply = await _aiGatewayClient.completeChat( + baseUrl: _settings.aiGateway.baseUrl, + apiKey: _aiGatewayApiKeyCache, + model: assistantModelForSession(sessionKey), + history: directHistory, + ); + _appendAssistantMessage( + sessionKey: sessionKey, + text: reply, + error: false, + ); + } else { + await _sendSingleAgentViaAcp( + sessionKey: sessionKey, + prompt: trimmed, + provider: provider, + model: assistantModelForSession(sessionKey), + thinking: thinking, + attachments: attachments, + selectedSkillLabels: selectedSkillLabels, + ); + } + } else { + final expectedMode = target == AssistantExecutionTarget.local + ? RuntimeConnectionMode.local + : RuntimeConnectionMode.remote; + if (connection.status != RuntimeConnectionStatus.connected || + connection.mode != expectedMode) { + throw Exception( + appText( + '当前线程目标网关未连接。', + 'The gateway for this thread target is not connected.', + ), + ); + } + await _relayClient.sendChat( + sessionKey: sessionKey, + message: attachments.isEmpty + ? trimmed + : _augmentPromptWithAttachments(trimmed, attachments), + thinking: thinking, + attachments: attachments, + metadata: { + if (selectedSkillLabels.isNotEmpty) + 'selectedSkills': selectedSkillLabels, + }, + ); + } + } catch (error) { + _appendAssistantMessage( + sessionKey: sessionKey, + text: error.toString(), + error: true, + ); + _lastAssistantError = error.toString(); + _pendingSessionKeys.remove(sessionKey); + _streamingTextBySession.remove(sessionKey); + await _persistThreads(); + notifyListeners(); + } + }); + } + + Future runMultiAgentCollaboration({ + required String rawPrompt, + required String composedPrompt, + required List attachments, + required List selectedSkillLabels, + }) async { + final sessionKey = _normalizedSessionKey(_currentSessionKey); + await _enqueueThreadTurn(sessionKey, () async { + _multiAgentRunPending = true; + _acpBusy = true; + _pendingSessionKeys.add(sessionKey); + notifyListeners(); + try { + final target = assistantExecutionTargetForSession(sessionKey); + final endpoint = _acpEndpointForTarget( + target == AssistantExecutionTarget.singleAgent + ? AssistantExecutionTarget.remote + : target, + ); + if (endpoint == null) { + throw Exception( + appText( + '当前线程的 ACP 端点不可用,请先配置并连接 Gateway。', + 'ACP endpoint is unavailable for this thread. Configure and connect Gateway first.', + ), + ); + } + await _refreshAcpCapabilities(endpoint); + final inlineAttachments = attachments + .map( + (item) => { + 'name': item.fileName, + 'mimeType': item.mimeType, + 'content': item.content, + 'sizeBytes': _base64Size(item.content), + }, + ) + .toList(growable: false); + final params = { + 'sessionId': sessionKey, + 'threadId': sessionKey, + 'mode': 'multi-agent', + 'taskPrompt': composedPrompt, + 'workingDirectory': '', + 'selectedSkills': selectedSkillLabels, + 'attachments': attachments + .map( + (item) => { + 'name': item.fileName, + 'description': item.mimeType, + 'path': '', + }, + ) + .toList(growable: false), + if (inlineAttachments.isNotEmpty) 'inlineAttachments': inlineAttachments, + 'aiGatewayBaseUrl': _settings.aiGateway.baseUrl.trim(), + 'aiGatewayApiKey': _aiGatewayApiKeyCache.trim(), + }; + String? summary; + final response = await _requestAcpSessionMessage( + endpoint: endpoint, + params: params, + hasInlineAttachments: inlineAttachments.isNotEmpty, + onNotification: (notification) { + final update = _acpSessionUpdateFromNotification( + notification, + sessionKey: sessionKey, + ); + if (update == null) { + return; + } + if (update.type == 'delta' && update.text.isNotEmpty) { + _appendStreamingText(sessionKey, update.text); + notifyListeners(); + return; + } + if (update.message.isNotEmpty && + (update.type == 'step' || update.type == 'status')) { + _appendAssistantMessage( + sessionKey: sessionKey, + text: update.message, + error: update.error, + ); + notifyListeners(); + } + }, + ); + final result = _castMap(response['result']); + summary = result['summary']?.toString().trim().isNotEmpty == true + ? result['summary'].toString().trim() + : result['output']?.toString().trim(); + _clearStreamingText(sessionKey); + _appendAssistantMessage( + sessionKey: sessionKey, + text: (summary ?? '').trim().isNotEmpty + ? summary!.trim() + : appText( + '多 Agent 协作完成。', + 'Multi-agent collaboration completed.', + ), + error: false, + ); + } catch (error) { + _clearStreamingText(sessionKey); + _appendAssistantMessage( + sessionKey: sessionKey, + text: error.toString(), + error: true, + ); + _lastAssistantError = error.toString(); + } finally { + _multiAgentRunPending = false; + _acpBusy = false; + _pendingSessionKeys.remove(sessionKey); + await _persistThreads(); + notifyListeners(); + } + }); + } + + Future abortRun() async { + final sessionKey = _normalizedSessionKey(_currentSessionKey); + if (_multiAgentRunPending || _acpBusy) { + final target = assistantExecutionTargetForSession(sessionKey); + final endpoint = _acpEndpointForTarget( + target == AssistantExecutionTarget.singleAgent + ? AssistantExecutionTarget.remote + : target, + ); + if (endpoint != null) { + try { + await _acpClient.cancelSession( + endpoint: endpoint, + sessionId: sessionKey, + threadId: sessionKey, + ); + } catch (_) { + // Best effort. + } + } + _multiAgentRunPending = false; + _acpBusy = false; + _pendingSessionKeys.remove(sessionKey); + _clearStreamingText(sessionKey); + notifyListeners(); + return; } } @@ -888,11 +1701,116 @@ class AppController extends ChangeNotifier { if (trimmed.isEmpty) { return; } + await selectAssistantModel(trimmed); _settings = _settings.copyWith(defaultModel: trimmed); await _persistSettings(); notifyListeners(); } + Future _sendSingleAgentViaAcp({ + required String sessionKey, + required String prompt, + required SingleAgentProvider provider, + required String model, + required String thinking, + required List attachments, + required List selectedSkillLabels, + }) async { + final endpoint = _acpEndpointForTarget(AssistantExecutionTarget.remote); + if (endpoint == null) { + throw Exception( + appText( + 'Remote ACP 端点不可用,请先配置 Remote Gateway。', + 'Remote ACP endpoint is unavailable. Configure Remote Gateway first.', + ), + ); + } + await _refreshAcpCapabilities(endpoint); + if (_acpCapabilities.providers.isNotEmpty && + !_acpCapabilities.providers.contains(provider)) { + throw Exception( + appText( + '${provider.label} 在当前 Remote ACP 端点不可用。', + '${provider.label} is unavailable on the current Remote ACP endpoint.', + ), + ); + } + _acpBusy = true; + notifyListeners(); + try { + String streamed = ''; + String output = ''; + final inlineAttachments = attachments + .map( + (item) => { + 'name': item.fileName, + 'mimeType': item.mimeType, + 'content': item.content, + 'sizeBytes': _base64Size(item.content), + }, + ) + .toList(growable: false); + final response = await _requestAcpSessionMessage( + endpoint: endpoint, + params: { + 'sessionId': sessionKey, + 'threadId': sessionKey, + 'mode': 'single-agent', + 'provider': provider.providerId, + 'model': model.trim(), + 'thinking': thinking, + 'taskPrompt': prompt, + 'workingDirectory': '', + 'selectedSkills': selectedSkillLabels, + 'attachments': attachments + .map( + (item) => { + 'name': item.fileName, + 'description': item.mimeType, + 'path': '', + }, + ) + .toList(growable: false), + if (inlineAttachments.isNotEmpty) 'inlineAttachments': inlineAttachments, + }, + hasInlineAttachments: inlineAttachments.isNotEmpty, + onNotification: (notification) { + final update = _acpSessionUpdateFromNotification( + notification, + sessionKey: sessionKey, + ); + if (update == null) { + return; + } + if (update.type == 'delta' && update.text.isNotEmpty) { + streamed += update.text; + _appendStreamingText(sessionKey, update.text); + notifyListeners(); + } + }, + ); + final result = _castMap(response['result']); + output = + result['output']?.toString().trim().isNotEmpty == true + ? result['output'].toString().trim() + : streamed.trim(); + _singleAgentRuntimeModelBySession[sessionKey] = + (result['model']?.toString().trim() ?? model.trim()); + _clearStreamingText(sessionKey); + final finalOutput = output.trim(); + _appendAssistantMessage( + sessionKey: sessionKey, + text: finalOutput.isEmpty + ? appText('执行完成。', 'Completed.') + : finalOutput, + error: false, + ); + } finally { + _acpBusy = false; + notifyListeners(); + } + } + @override void dispose() { unawaited(_relayEventsSubscription.cancel()); @@ -911,24 +1829,35 @@ class AppController extends ChangeNotifier { } SettingsSnapshot _sanitizeSettings(SettingsSnapshot snapshot) { - final target = - _sanitizeTarget(snapshot.assistantExecutionTarget) ?? - AssistantExecutionTarget.singleAgent; + final target = featuresFor(UiFeaturePlatform.web).sanitizeExecutionTarget( + _sanitizeTarget(snapshot.assistantExecutionTarget), + ); final normalizedSessionBaseUrl = RemoteWebSessionRepository.normalizeBaseUrl( snapshot.webSessionPersistence.remoteBaseUrl, )?.toString() ?? ''; + final localProfile = snapshot.primaryLocalGatewayProfile.copyWith( + mode: RuntimeConnectionMode.local, + useSetupCode: false, + setupCode: '', + tls: false, + ); + final remoteProfile = snapshot.primaryRemoteGatewayProfile.copyWith( + mode: RuntimeConnectionMode.remote, + useSetupCode: false, + setupCode: '', + ); return snapshot.copyWith( assistantExecutionTarget: target, gatewayProfiles: replaceGatewayProfileAt( - snapshot.gatewayProfiles, - kGatewayRemoteProfileIndex, - snapshot.primaryRemoteGatewayProfile.copyWith( - mode: RuntimeConnectionMode.remote, - useSetupCode: false, - setupCode: '', + replaceGatewayProfileAt( + snapshot.gatewayProfiles, + kGatewayLocalProfileIndex, + localProfile, ), + kGatewayRemoteProfileIndex, + remoteProfile, ), webSessionPersistence: snapshot.webSessionPersistence.copyWith( remoteBaseUrl: normalizedSessionBaseUrl, @@ -951,6 +1880,7 @@ class AppController extends ChangeNotifier { AssistantExecutionTarget? _sanitizeTarget(AssistantExecutionTarget? target) { return switch (target) { + AssistantExecutionTarget.local => AssistantExecutionTarget.local, AssistantExecutionTarget.remote => AssistantExecutionTarget.remote, AssistantExecutionTarget.singleAgent => AssistantExecutionTarget.singleAgent, @@ -963,9 +1893,11 @@ class AppController extends ChangeNotifier { String? title, }) { final timestamp = DateTime.now().millisecondsSinceEpoch; - final prefix = target == AssistantExecutionTarget.remote - ? 'relay' - : 'direct'; + final prefix = switch (target) { + AssistantExecutionTarget.singleAgent => 'single', + AssistantExecutionTarget.local => 'local', + AssistantExecutionTarget.remote => 'remote', + }; return AssistantThreadRecord( sessionKey: '$prefix:$timestamp', messages: const [], @@ -977,11 +1909,6 @@ class AppController extends ChangeNotifier { ); } - void _replaceCurrentRecord(AssistantThreadRecord record) { - _threadRecords[record.sessionKey] = record; - _currentSessionKey = record.sessionKey; - } - void _appendAssistantMessage({ required String sessionKey, required String text, @@ -1018,24 +1945,359 @@ class AppController extends ChangeNotifier { return; } final payload = _castMap(event.payload); - final sessionKey = (payload['sessionKey']?.toString().trim() ?? '').trim(); + final sessionKey = _normalizedSessionKey( + payload['sessionKey']?.toString() ?? '', + ); 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 (text.isNotEmpty && state == 'delta') { + _appendStreamingText(sessionKey, text); + } else if (text.isNotEmpty && state == 'final') { + _clearStreamingText(sessionKey); + _appendAssistantMessage(sessionKey: sessionKey, text: text, error: false); } if (state == 'final' || state == 'aborted' || state == 'error') { _pendingSessionKeys.remove(sessionKey); + if (state == 'error' && text.isNotEmpty) { + _appendAssistantMessage(sessionKey: sessionKey, text: text, error: true); + } + _clearStreamingText(sessionKey); unawaited(refreshRelaySessions()); unawaited(refreshRelayHistory(sessionKey: sessionKey)); } notifyListeners(); } + String _normalizedSessionKey(String sessionKey) { + final trimmed = sessionKey.trim(); + return trimmed.isEmpty ? 'main' : trimmed; + } + + AssistantExecutionTarget _assistantExecutionTargetForMode( + RuntimeConnectionMode mode, + ) { + return switch (mode) { + RuntimeConnectionMode.local => AssistantExecutionTarget.local, + RuntimeConnectionMode.remote => AssistantExecutionTarget.remote, + RuntimeConnectionMode.unconfigured => AssistantExecutionTarget.remote, + }; + } + + int _profileIndexForTarget(AssistantExecutionTarget target) { + return switch (target) { + AssistantExecutionTarget.local => kGatewayLocalProfileIndex, + AssistantExecutionTarget.remote => kGatewayRemoteProfileIndex, + AssistantExecutionTarget.singleAgent => kGatewayRemoteProfileIndex, + }; + } + + GatewayConnectionProfile _profileForTarget(AssistantExecutionTarget target) { + return switch (target) { + AssistantExecutionTarget.local => _settings.primaryLocalGatewayProfile, + AssistantExecutionTarget.remote => _settings.primaryRemoteGatewayProfile, + AssistantExecutionTarget.singleAgent => + _settings.primaryRemoteGatewayProfile, + }; + } + + String _gatewayAddressLabel(GatewayConnectionProfile profile) { + final host = profile.host.trim(); + if (host.isEmpty || profile.port <= 0) { + return appText('未连接目标', 'No target'); + } + return '$host:${profile.port}'; + } + + String _gatewayEntryStateForTarget(AssistantExecutionTarget target) { + return target.promptValue; + } + + void _upsertThreadRecord( + String sessionKey, { + List? messages, + double? updatedAtMs, + String? title, + bool? archived, + AssistantExecutionTarget? executionTarget, + AssistantMessageViewMode? messageViewMode, + List? importedSkills, + List? selectedSkillKeys, + String? assistantModelId, + SingleAgentProvider? singleAgentProvider, + String? gatewayEntryState, + bool clearGatewayEntryState = false, + }) { + final key = _normalizedSessionKey(sessionKey); + final resolvedTarget = + _sanitizeTarget(executionTarget) ?? assistantExecutionTargetForSession(key); + final existing = _threadRecords[key] ?? _newRecord(target: resolvedTarget); + _threadRecords[key] = existing.copyWith( + sessionKey: key, + messages: messages ?? existing.messages, + updatedAtMs: updatedAtMs ?? existing.updatedAtMs, + title: title ?? existing.title, + archived: archived ?? existing.archived, + executionTarget: resolvedTarget, + messageViewMode: messageViewMode ?? existing.messageViewMode, + importedSkills: importedSkills ?? existing.importedSkills, + selectedSkillKeys: selectedSkillKeys ?? existing.selectedSkillKeys, + assistantModelId: assistantModelId ?? existing.assistantModelId, + singleAgentProvider: singleAgentProvider ?? existing.singleAgentProvider, + gatewayEntryState: gatewayEntryState ?? existing.gatewayEntryState, + clearGatewayEntryState: clearGatewayEntryState, + ); + } + + Future _applyAssistantExecutionTarget( + AssistantExecutionTarget target, { + required String sessionKey, + required bool persistDefaultSelection, + }) async { + final normalizedSessionKey = _normalizedSessionKey(sessionKey); + final resolvedTarget = + _sanitizeTarget(target) ?? + assistantExecutionTargetForSession(normalizedSessionKey); + _upsertThreadRecord( + normalizedSessionKey, + executionTarget: resolvedTarget, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + gatewayEntryState: _gatewayEntryStateForTarget(resolvedTarget), + ); + if (persistDefaultSelection) { + _settings = _settings.copyWith( + assistantExecutionTarget: resolvedTarget, + assistantLastSessionKey: normalizedSessionKey, + ); + await _persistSettings(); + await _persistThreads(); + } else { + await _persistThreads(); + } + if (resolvedTarget == AssistantExecutionTarget.singleAgent) { + return; + } + final targetProfile = _profileForTarget(resolvedTarget); + if (targetProfile.host.trim().isEmpty || targetProfile.port <= 0) { + return; + } + final expectedMode = resolvedTarget == AssistantExecutionTarget.local + ? RuntimeConnectionMode.local + : RuntimeConnectionMode.remote; + if (connection.status == RuntimeConnectionStatus.connected && + connection.mode == expectedMode) { + return; + } + try { + await connectRelay(target: resolvedTarget); + } catch (error) { + _lastAssistantError = error.toString(); + } + } + + Future _enqueueThreadTurn(String threadId, Future Function() task) { + final normalizedThreadId = _normalizedSessionKey(threadId); + final previous = + _threadTurnQueues[normalizedThreadId] ?? Future.value(); + final completer = Completer(); + late final Future next; + next = previous + .catchError((_) {}) + .then((_) async { + try { + completer.complete(await task()); + } catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } + }) + .whenComplete(() { + if (identical(_threadTurnQueues[normalizedThreadId], next)) { + _threadTurnQueues.remove(normalizedThreadId); + } + }); + _threadTurnQueues[normalizedThreadId] = next; + return completer.future; + } + + String _augmentPromptWithAttachments( + String prompt, + List attachments, + ) { + if (attachments.isEmpty) { + return prompt; + } + final buffer = StringBuffer(prompt.trim()); + buffer.write('\n\n'); + buffer.writeln( + appText( + '附件(仅供本轮参考):', + 'Attachments (for this turn only):', + ), + ); + for (final item in attachments) { + final name = item.fileName.trim().isEmpty ? 'attachment' : item.fileName; + final mime = item.mimeType.trim().isEmpty + ? 'application/octet-stream' + : item.mimeType; + buffer.writeln('- $name ($mime)'); + } + return buffer.toString().trim(); + } + + Uri? _acpEndpointForTarget(AssistantExecutionTarget target) { + final resolvedTarget = target == AssistantExecutionTarget.singleAgent + ? AssistantExecutionTarget.remote + : target; + final profile = _profileForTarget(resolvedTarget); + final host = profile.host.trim(); + if (host.isEmpty) { + return null; + } + final candidate = host.contains('://') + ? host + : '${profile.tls ? 'https' : 'http'}://$host:${profile.port}'; + final uri = Uri.tryParse(candidate); + if (uri == null || uri.host.trim().isEmpty) { + return null; + } + final scheme = uri.scheme.trim().isEmpty + ? (profile.tls ? 'https' : 'http') + : uri.scheme.trim().toLowerCase(); + final resolvedPort = uri.hasPort ? uri.port : (scheme == 'https' ? 443 : 80); + return uri.replace( + scheme: scheme, + port: resolvedPort, + path: '', + query: null, + fragment: null, + ); + } + + Future> _requestAcpSessionMessage({ + required Uri endpoint, + required Map params, + required bool hasInlineAttachments, + void Function(Map notification)? onNotification, + }) async { + try { + return await _acpClient.request( + endpoint: endpoint, + method: 'session.message', + params: params, + onNotification: onNotification, + ); + } on WebAcpException catch (error) { + if (!hasInlineAttachments || !_canFallbackInlineAttachments(error)) { + rethrow; + } + final fallbackParams = Map.from(params) + ..remove('inlineAttachments'); + try { + return await _acpClient.request( + endpoint: endpoint, + method: 'session.message', + params: fallbackParams, + onNotification: onNotification, + ); + } on Object catch (fallbackError) { + throw Exception( + appText( + 'ACP 暂不支持 inline 附件,回退旧协议也失败:$fallbackError', + 'ACP does not support inline attachments, and fallback to legacy attachment payload failed: $fallbackError', + ), + ); + } + } + } + + Future _refreshAcpCapabilities(Uri endpoint) async { + try { + _acpCapabilities = await _acpClient.loadCapabilities(endpoint: endpoint); + } catch (_) { + _acpCapabilities = const WebAcpCapabilities.empty(); + } + } + + bool _canFallbackInlineAttachments(WebAcpException error) { + final code = (error.code ?? '').trim(); + if (code == '-32602' || code == 'INVALID_PARAMS') { + return true; + } + final message = error.toString().toLowerCase(); + return message.contains('inlineattachment') || + message.contains('unexpected field') || + message.contains('unknown field') || + message.contains('invalid params'); + } + + int _base64Size(String base64) { + final normalized = base64.trim().split(',').last.trim(); + if (normalized.isEmpty) { + return 0; + } + final padding = normalized.endsWith('==') + ? 2 + : (normalized.endsWith('=') ? 1 : 0); + return (normalized.length * 3 ~/ 4) - padding; + } + + _AcpSessionUpdate? _acpSessionUpdateFromNotification( + Map notification, { + required String sessionKey, + }) { + final method = notification['method']?.toString().trim().toLowerCase() ?? ''; + final params = _castMap(notification['params']); + final payload = params.isNotEmpty ? params : _castMap(notification['payload']); + final event = payload['event']?.toString().trim().toLowerCase() ?? method; + final type = payload['type']?.toString().trim().toLowerCase() ?? + payload['state']?.toString().trim().toLowerCase() ?? + event; + final payloadSession = _normalizedSessionKey( + payload['sessionId']?.toString() ?? + payload['threadId']?.toString() ?? + payload['sessionKey']?.toString() ?? + sessionKey, + ); + if (payloadSession != _normalizedSessionKey(sessionKey)) { + return null; + } + final messageMap = _castMap(payload['message']); + final messageText = + _extractMessageText(messageMap).trim().isNotEmpty + ? _extractMessageText(messageMap).trim() + : payload['message']?.toString().trim() ?? ''; + final text = payload['delta']?.toString() ?? + payload['text']?.toString() ?? + payload['outputDelta']?.toString() ?? + ''; + final error = + (payload['error'] is bool && payload['error'] as bool) || + type == 'error' || + event.contains('error'); + return _AcpSessionUpdate( + type: type, + text: text, + message: messageText, + error: error, + ); + } + + void _appendStreamingText(String sessionKey, String delta) { + if (delta.isEmpty) { + return; + } + final key = _normalizedSessionKey(sessionKey); + final current = _streamingTextBySession[key] ?? ''; + _streamingTextBySession[key] = '$current$delta'; + } + + void _clearStreamingText(String sessionKey) { + _streamingTextBySession.remove(_normalizedSessionKey(sessionKey)); + } + Future _persistSettings() async { await _store.saveSettingsSnapshot(_settings); } @@ -1174,6 +2436,14 @@ class AppController extends ChangeNotifier { } String _titleForRecord(AssistantThreadRecord record) { + final customTitle = + _settings + .assistantCustomTaskTitles[_normalizedSessionKey(record.sessionKey)] + ?.trim() ?? + ''; + if (customTitle.isNotEmpty) { + return customTitle; + } final title = record.title.trim(); if (title.isNotEmpty) { return title; @@ -1255,6 +2525,20 @@ class AppController extends ChangeNotifier { } } +class _AcpSessionUpdate { + const _AcpSessionUpdate({ + required this.type, + required this.text, + required this.message, + required this.error, + }); + + final String type; + final String text; + final String message; + final bool error; +} + class WebConversationSummary { const WebConversationSummary({ required this.sessionKey, diff --git a/lib/app/ui_feature_manifest.dart b/lib/app/ui_feature_manifest.dart index 6d61cd11..3ac9dd61 100644 --- a/lib/app/ui_feature_manifest.dart +++ b/lib/app/ui_feature_manifest.dart @@ -543,22 +543,22 @@ web: description: Web relay gateway assistant mode ui_surface: web_assistant_page file_attachments: - enabled: false - release_tier: experimental - build_modes: [] - description: Web does not expose file attachments in assistant composer + enabled: true + release_tier: stable + build_modes: [debug, profile, release] + description: Web file attachment action in assistant composer ui_surface: web_assistant_page multi_agent: - enabled: false - release_tier: experimental - build_modes: [] - description: Web does not expose multi-agent assistant toggle + enabled: true + release_tier: stable + build_modes: [debug, profile, release] + description: Web multi-agent toggle in assistant composer ui_surface: web_assistant_page local_gateway: - enabled: false - release_tier: experimental - build_modes: [] - description: Web does not expose local gateway assistant mode + enabled: true + release_tier: stable + build_modes: [debug, profile, release] + description: Web local gateway assistant mode ui_surface: web_assistant_page local_runtime: enabled: false diff --git a/lib/web/web_acp_client.dart b/lib/web/web_acp_client.dart new file mode 100644 index 00000000..510e6335 --- /dev/null +++ b/lib/web/web_acp_client.dart @@ -0,0 +1,251 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:web_socket_channel/web_socket_channel.dart'; + +import '../runtime/runtime_models.dart'; + +class WebAcpException implements Exception { + const WebAcpException(this.message, {this.code, this.details}); + + final String message; + final String? code; + final Object? details; + + @override + String toString() => code == null ? message : '$code: $message'; +} + +class WebAcpCapabilities { + const WebAcpCapabilities({ + required this.singleAgent, + required this.multiAgent, + required this.providers, + required this.raw, + }); + + const WebAcpCapabilities.empty() + : singleAgent = false, + multiAgent = false, + providers = const {}, + raw = const {}; + + final bool singleAgent; + final bool multiAgent; + final Set providers; + final Map raw; +} + +class WebAcpClient { + const WebAcpClient(); + + static const Duration _defaultTimeout = Duration(seconds: 120); + + Future loadCapabilities({ + required Uri endpoint, + }) async { + final response = await request( + endpoint: endpoint, + method: 'acp.capabilities', + params: const {}, + ); + final result = _asMap(response['result']); + final caps = _asMap(result['capabilities']); + final providers = {}; + for (final raw in [ + ..._asList(result['providers']), + ..._asList(caps['providers']), + ]) { + if (raw == null) { + continue; + } + final provider = SingleAgentProviderCopy.fromJsonValue( + raw.toString().trim().toLowerCase(), + ); + if (provider != SingleAgentProvider.auto) { + providers.add(provider); + } + } + final singleAgent = + _boolValue(result['singleAgent']) ?? + _boolValue(caps['single_agent']) ?? + providers.isNotEmpty; + final multiAgent = + _boolValue(result['multiAgent']) ?? + _boolValue(caps['multi_agent']) ?? + false; + return WebAcpCapabilities( + singleAgent: singleAgent, + multiAgent: multiAgent, + providers: providers, + raw: result, + ); + } + + Future cancelSession({ + required Uri endpoint, + required String sessionId, + required String threadId, + }) async { + await request( + endpoint: endpoint, + method: 'session.cancel', + params: {'sessionId': sessionId, 'threadId': threadId}, + ); + } + + Future> request({ + required Uri endpoint, + required String method, + required Map params, + void Function(Map notification)? onNotification, + Duration timeout = _defaultTimeout, + }) async { + final requestId = '${DateTime.now().microsecondsSinceEpoch}-$method'; + final wsEndpoint = _resolveWebSocketEndpoint(endpoint); + if (wsEndpoint == null) { + throw const WebAcpException( + 'Missing ACP endpoint', + code: 'ACP_ENDPOINT_MISSING', + ); + } + final socket = WebSocketChannel.connect(wsEndpoint); + final completer = Completer>(); + late final StreamSubscription subscription; + subscription = socket.stream.listen( + (raw) { + final json = _decodeMap(raw); + final id = _stringValue(json['id']); + final methodName = _stringValue(json['method']) ?? ''; + if (id == requestId && + (json.containsKey('result') || json.containsKey('error'))) { + if (!completer.isCompleted) { + completer.complete(json); + } + return; + } + if (methodName.isNotEmpty && onNotification != null) { + onNotification(json); + } + }, + onError: (Object error, StackTrace stackTrace) { + if (!completer.isCompleted) { + completer.completeError( + WebAcpException(error.toString(), code: 'ACP_WS_RUNTIME_ERROR'), + ); + } + }, + onDone: () { + if (!completer.isCompleted) { + completer.completeError( + const WebAcpException( + 'ACP websocket closed before response', + code: 'ACP_WS_EARLY_CLOSE', + ), + ); + } + }, + cancelOnError: true, + ); + + try { + await socket.ready; + socket.sink.add( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': requestId, + 'method': method, + 'params': params, + }), + ); + final response = await completer.future.timeout(timeout); + _throwIfJsonRpcError(response); + return response; + } finally { + await subscription.cancel(); + await socket.sink.close(); + } + } + + static Uri? _resolveWebSocketEndpoint(Uri? endpoint) { + if (endpoint == null || endpoint.host.trim().isEmpty) { + return null; + } + final scheme = endpoint.scheme.trim().toLowerCase(); + final wsScheme = switch (scheme) { + 'https' || 'wss' => 'wss', + _ => 'ws', + }; + return endpoint.replace(path: '/acp', query: null, fragment: null, scheme: wsScheme); + } + + void _throwIfJsonRpcError(Map response) { + final error = _asMap(response['error']); + if (error.isEmpty) { + return; + } + throw WebAcpException( + _stringValue(error['message']) ?? 'ACP request failed', + code: _stringValue(error['code']), + details: error['data'], + ); + } + + static Map _decodeMap(Object? raw) { + if (raw is Map) { + return raw; + } + if (raw is Map) { + return raw.cast(); + } + if (raw is String) { + final decoded = jsonDecode(raw); + if (decoded is Map) { + return decoded; + } + if (decoded is Map) { + return decoded.cast(); + } + } + return const {}; + } + + static Map _asMap(Object? value) { + if (value is Map) { + return value; + } + if (value is Map) { + return value.cast(); + } + return const {}; + } + + static List _asList(Object? value) { + if (value is List) { + return value; + } + if (value is List) { + return value.cast(); + } + return const []; + } + + static String? _stringValue(Object? value) { + final text = value?.toString().trim(); + return (text == null || text.isEmpty) ? null : text; + } + + static bool? _boolValue(Object? value) { + if (value is bool) { + return value; + } + final text = value?.toString().trim().toLowerCase(); + if (text == 'true') { + return true; + } + if (text == 'false') { + return false; + } + return null; + } +} diff --git a/lib/web/web_assistant_page.dart b/lib/web/web_assistant_page.dart index 63f5f9a4..0b72c271 100644 --- a/lib/web/web_assistant_page.dart +++ b/lib/web/web_assistant_page.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; + +import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import '../app/app_controller_web.dart'; @@ -24,7 +27,13 @@ class _WebAssistantPageState extends State { final TextEditingController _inputController = TextEditingController(); final TextEditingController _searchController = TextEditingController(); final ScrollController _scrollController = ScrollController(); + String _query = ''; + String _thinkingLevel = 'medium'; + AssistantPermissionLevel _permissionLevel = + AssistantPermissionLevel.defaultAccess; + bool _useMultiAgent = false; + final List<_WebComposerAttachment> _attachments = <_WebComposerAttachment>[]; @override void dispose() { @@ -41,28 +50,25 @@ class _WebAssistantPageState extends State { animation: controller, builder: (context, _) { final uiFeatures = controller.featuresFor(UiFeaturePlatform.web); - final allDirect = controller.conversationsForTarget( + final allSingle = controller.conversationsForTarget( AssistantExecutionTarget.singleAgent, ); - final allRelay = controller.conversationsForTarget( + final allLocal = controller.conversationsForTarget( + AssistantExecutionTarget.local, + ); + final allRemote = controller.conversationsForTarget( AssistantExecutionTarget.remote, ); - final direct = _filterConversations(allDirect); - final relay = _filterConversations(allRelay); - final currentTarget = controller.assistantExecutionTarget; - final availableTargets = uiFeatures.availableExecutionTargets - .where( - (target) => - target == AssistantExecutionTarget.singleAgent || - target == AssistantExecutionTarget.remote, - ) - .toList(growable: false); - final connected = - currentTarget == AssistantExecutionTarget.singleAgent - ? controller.canUseAiGatewayConversation - : controller.connection.status == RuntimeConnectionStatus.connected; - final currentMessages = controller.chatMessages; + final single = _filterConversations(allSingle); + final local = _filterConversations(allLocal); + final remote = _filterConversations(allRemote); + final availableTargets = uiFeatures.availableExecutionTargets; + final currentTarget = controller.assistantExecutionTarget; + final connectionState = controller.currentAssistantConnectionState; + final connected = connectionState.ready; + + final currentMessages = controller.chatMessages; WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.jumpTo( @@ -71,6 +77,13 @@ class _WebAssistantPageState extends State { } }); + final selectedSkillKeys = controller.assistantSelectedSkillKeysForSession( + controller.currentSessionKey, + ); + final importedSkills = controller.assistantImportedSkillsForSession( + controller.currentSessionKey, + ); + return DesktopWorkspaceScaffold( breadcrumbs: [ AppBreadcrumbItem( @@ -83,8 +96,8 @@ class _WebAssistantPageState extends State { eyebrow: appText('Web Workspace', 'Web Workspace'), title: appText('助手', 'Assistant'), subtitle: appText( - '单机智能体与 Relay Gateway 共用一个入口,左侧保留会话/任务历史。', - 'Use one Assistant surface for Single Agent and Relay Gateway, with embedded conversation history on the left.', + 'Web 助手保持任务线程会话隔离,支持 Single Agent / Local / Remote 三种模式。', + 'Web Assistant keeps per-thread session isolation with Single Agent / Local / Remote modes.', ), toolbar: Wrap( spacing: 10, @@ -98,8 +111,7 @@ class _WebAssistantPageState extends State { label: Text(appText('新对话', 'New conversation')), ), OutlinedButton.icon( - onPressed: () => - controller.openSettings(tab: SettingsTab.gateway), + onPressed: () => controller.openSettings(tab: SettingsTab.gateway), icon: const Icon(Icons.tune_rounded), label: Text(appText('连接设置', 'Connection settings')), ), @@ -116,7 +128,7 @@ class _WebAssistantPageState extends State { ), child: LayoutBuilder( builder: (context, constraints) { - final vertical = constraints.maxWidth < 980; + final vertical = constraints.maxWidth < 1080; final rail = _ConversationRail( controller: controller, query: _query, @@ -128,23 +140,52 @@ class _WebAssistantPageState extends State { _searchController.clear(); setState(() => _query = ''); }, - showDirect: uiFeatures.supportsDirectAi, - showRelay: uiFeatures.supportsRelayGateway, - direct: direct, - relay: relay, + showSingle: uiFeatures.supportsDirectAi, + showLocal: uiFeatures.supportsLocalGateway, + showRemote: uiFeatures.supportsRelayGateway, + single: single, + local: local, + remote: remote, + onRename: (sessionKey) => _renameConversation(sessionKey), + onArchive: (sessionKey) => + controller.saveAssistantTaskArchived(sessionKey, true), ); + final panel = _ConversationPanel( controller: controller, inputController: _inputController, scrollController: _scrollController, connected: connected, currentMessages: currentMessages, + connectionState: connectionState, + thinkingLevel: _thinkingLevel, + permissionLevel: _permissionLevel, + useMultiAgent: _useMultiAgent, + importedSkills: importedSkills, + selectedSkillKeys: selectedSkillKeys, + attachments: _attachments, + onThinkingChanged: (value) { + setState(() => _thinkingLevel = value); + }, + onPermissionChanged: (value) { + setState(() => _permissionLevel = value); + }, + onToggleMultiAgent: (value) { + setState(() => _useMultiAgent = value); + }, + onAddAttachment: _pickAttachments, + onRemoveAttachment: (index) { + setState(() { + _attachments.removeAt(index); + }); + }, + onSubmit: _submitPrompt, ); if (vertical) { return Column( children: [ - SizedBox(height: 300, child: rail), + SizedBox(height: 320, child: rail), const SizedBox(height: 8), Expanded(child: panel), ], @@ -153,7 +194,7 @@ class _WebAssistantPageState extends State { return Row( children: [ - SizedBox(width: 320, child: rail), + SizedBox(width: 340, child: rail), const SizedBox(width: 8), Expanded(child: panel), ], @@ -178,6 +219,129 @@ class _WebAssistantPageState extends State { }) .toList(growable: false); } + + Future _renameConversation(String sessionKey) async { + final controller = widget.controller; + final initial = controller.conversations + .firstWhere( + (item) => item.sessionKey == sessionKey, + orElse: () => WebConversationSummary( + sessionKey: sessionKey, + title: '', + preview: '', + updatedAtMs: 0, + executionTarget: AssistantExecutionTarget.singleAgent, + pending: false, + current: false, + ), + ) + .title; + final renameController = TextEditingController(text: initial); + final value = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(appText('重命名任务线程', 'Rename task thread')), + content: TextField( + controller: renameController, + autofocus: true, + decoration: InputDecoration( + hintText: appText('输入标题', 'Enter a title'), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(appText('取消', 'Cancel')), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(renameController.text), + child: Text(appText('保存', 'Save')), + ), + ], + ); + }, + ); + renameController.dispose(); + if (value == null) { + return; + } + await controller.saveAssistantTaskTitle(sessionKey, value); + } + + Future _pickAttachments() async { + final controller = widget.controller; + final uiFeatures = controller.featuresFor(UiFeaturePlatform.web); + if (!uiFeatures.supportsFileAttachments) { + return; + } + final files = await openFiles( + acceptedTypeGroups: const [ + XTypeGroup( + label: 'Images', + extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'], + ), + XTypeGroup( + label: 'Documents', + extensions: ['txt', 'md', 'json', 'csv', 'pdf', 'yaml', 'yml'], + ), + ], + ); + if (!mounted || files.isEmpty) { + return; + } + setState(() { + _attachments.addAll(files.map(_WebComposerAttachment.fromXFile)); + }); + } + + Future _submitPrompt() async { + final controller = widget.controller; + final value = _inputController.text.trim(); + if (value.isEmpty) { + return; + } + + final payloads = []; + for (final attachment in _attachments) { + final bytes = await attachment.file.readAsBytes(); + payloads.add( + GatewayChatAttachmentPayload( + type: attachment.mimeType.startsWith('image/') ? 'image' : 'file', + mimeType: attachment.mimeType, + fileName: attachment.name, + content: base64Encode(bytes), + ), + ); + } + + final selectedSkillLabels = controller + .assistantImportedSkillsForSession(controller.currentSessionKey) + .where( + (item) => controller + .assistantSelectedSkillKeysForSession(controller.currentSessionKey) + .contains(item.key), + ) + .map((item) => item.label) + .where((item) => item.trim().isNotEmpty) + .toList(growable: false); + + await controller.sendMessage( + value, + thinking: _thinkingLevel, + attachments: payloads, + selectedSkillLabels: selectedSkillLabels, + useMultiAgent: _useMultiAgent, + ); + + if (!mounted) { + return; + } + _inputController.clear(); + setState(() { + _attachments.clear(); + }); + } } class _ConversationRail extends StatelessWidget { @@ -187,10 +351,14 @@ class _ConversationRail extends StatelessWidget { required this.searchController, required this.onQueryChanged, required this.onClearQuery, - required this.showDirect, - required this.showRelay, - required this.direct, - required this.relay, + required this.showSingle, + required this.showLocal, + required this.showRemote, + required this.single, + required this.local, + required this.remote, + required this.onRename, + required this.onArchive, }); final AppController controller; @@ -198,10 +366,14 @@ class _ConversationRail extends StatelessWidget { final TextEditingController searchController; final ValueChanged onQueryChanged; final VoidCallback onClearQuery; - final bool showDirect; - final bool showRelay; - final List direct; - final List relay; + final bool showSingle; + final bool showLocal; + final bool showRemote; + final List single; + final List local; + final List remote; + final ValueChanged onRename; + final ValueChanged onArchive; @override Widget build(BuildContext context) { @@ -216,7 +388,7 @@ class _ConversationRail extends StatelessWidget { controller: searchController, onChanged: onQueryChanged, decoration: InputDecoration( - hintText: appText('搜索会话', 'Search conversations'), + hintText: appText('搜索任务线程', 'Search task threads'), prefixIcon: const Icon(Icons.search_rounded), suffixIcon: query.isEmpty ? null @@ -230,32 +402,49 @@ class _ConversationRail extends StatelessWidget { Expanded( child: ListView( children: [ - if (showDirect) + if (showSingle) _ConversationGroup( title: appText('Single Agent', 'Single Agent'), icon: Icons.hub_rounded, - items: direct, + items: single, emptyLabel: appText( - '还没有单机智能体对话', - 'No Single Agent conversations yet', + '还没有 Single Agent 任务线程', + 'No Single Agent task threads yet', ), onSelect: controller.switchConversation, + onRename: onRename, + onArchive: onArchive, ), - if (showDirect && showRelay) const SizedBox(height: 12), - if (showRelay) + if (showLocal) ...[ + const SizedBox(height: 12), _ConversationGroup( - title: appText( - 'Relay OpenClaw Gateway', - 'Relay OpenClaw Gateway', - ), - icon: Icons.cloud_outlined, - items: relay, + title: appText('Local Gateway', 'Local Gateway'), + icon: Icons.lan_rounded, + items: local, emptyLabel: appText( - '还没有 Relay 对话', - 'No Relay conversations yet', + '还没有 Local Gateway 任务线程', + 'No Local Gateway task threads yet', ), onSelect: controller.switchConversation, + onRename: onRename, + onArchive: onArchive, ), + ], + if (showRemote) ...[ + const SizedBox(height: 12), + _ConversationGroup( + title: appText('Remote Gateway', 'Remote Gateway'), + icon: Icons.cloud_outlined, + items: remote, + emptyLabel: appText( + '还没有 Remote Gateway 任务线程', + 'No Remote Gateway task threads yet', + ), + onSelect: controller.switchConversation, + onRename: onRename, + onArchive: onArchive, + ), + ], ], ), ), @@ -272,6 +461,8 @@ class _ConversationGroup extends StatelessWidget { required this.items, required this.emptyLabel, required this.onSelect, + required this.onRename, + required this.onArchive, }); final String title; @@ -279,6 +470,8 @@ class _ConversationGroup extends StatelessWidget { final List items; final String emptyLabel; final ValueChanged onSelect; + final ValueChanged onRename; + final ValueChanged onArchive; @override Widget build(BuildContext context) { @@ -318,40 +511,56 @@ class _ConversationGroup extends StatelessWidget { borderRadius: 10, padding: const EdgeInsets.all(12), color: item.current ? palette.accentMuted : null, - child: Row( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( + Row( + children: [ + Expanded( + child: Text( item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium ?.copyWith(fontWeight: FontWeight.w700), ), - const SizedBox(height: 4), - Text( + ), + IconButton( + tooltip: appText('重命名', 'Rename'), + onPressed: () => onRename(item.sessionKey), + icon: const Icon(Icons.drive_file_rename_outline_rounded), + ), + IconButton( + tooltip: appText('归档', 'Archive'), + onPressed: () => onArchive(item.sessionKey), + icon: const Icon(Icons.archive_outlined), + ), + ], + ), + const SizedBox(height: 4), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: 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), ), - ), + if (item.pending) + const Padding( + padding: EdgeInsets.only(left: 8, top: 2), + child: SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ], + ), ], ), ), @@ -369,6 +578,19 @@ class _ConversationPanel extends StatelessWidget { required this.scrollController, required this.connected, required this.currentMessages, + required this.connectionState, + required this.thinkingLevel, + required this.permissionLevel, + required this.useMultiAgent, + required this.importedSkills, + required this.selectedSkillKeys, + required this.attachments, + required this.onThinkingChanged, + required this.onPermissionChanged, + required this.onToggleMultiAgent, + required this.onAddAttachment, + required this.onRemoveAttachment, + required this.onSubmit, }); final AppController controller; @@ -376,47 +598,140 @@ class _ConversationPanel extends StatelessWidget { final ScrollController scrollController; final bool connected; final List currentMessages; + final AssistantThreadConnectionState connectionState; + final String thinkingLevel; + final AssistantPermissionLevel permissionLevel; + final bool useMultiAgent; + final List importedSkills; + final List selectedSkillKeys; + final List<_WebComposerAttachment> attachments; + final ValueChanged onThinkingChanged; + final ValueChanged onPermissionChanged; + final ValueChanged onToggleMultiAgent; + final Future Function() onAddAttachment; + final ValueChanged onRemoveAttachment; + final Future Function() onSubmit; @override Widget build(BuildContext context) { final palette = context.palette; final currentTarget = controller.assistantExecutionTarget; - final targetReady = currentTarget == AssistantExecutionTarget.singleAgent - ? controller.canUseAiGatewayConversation - : controller.connection.status == RuntimeConnectionStatus.connected; + final modelChoices = controller.assistantModelChoices; return Column( children: [ SurfaceCard( borderRadius: 10, tone: SurfaceCardTone.chrome, - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - controller.currentConversationTitle, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - ), + 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, + ), + ), + ], ), - const SizedBox(height: 6), - Text( - controller.assistantConnectionTargetLabel, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: palette.textSecondary, - ), + ), + StatusBadge( + status: StatusInfo( + controller.assistantConnectionStatusLabel, + connected ? StatusTone.success : StatusTone.warning, ), - ], - ), + ), + ], ), - StatusBadge( - status: StatusInfo( - controller.assistantConnectionStatusLabel, - targetReady ? StatusTone.success : StatusTone.warning, - ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _CompactDropdown( + key: const Key('assistant-target-button'), + value: currentTarget, + items: controller + .featuresFor(UiFeaturePlatform.web) + .availableExecutionTargets, + labelBuilder: _targetLabel, + onChanged: (value) { + if (value != null) { + controller.setAssistantExecutionTarget(value); + } + }, + ), + if (currentTarget == AssistantExecutionTarget.singleAgent) + _CompactDropdown( + key: const Key('assistant-single-agent-provider-button'), + value: controller.currentSingleAgentProvider, + items: controller.singleAgentProviderOptions, + labelBuilder: (item) => item.label, + onChanged: (value) { + if (value != null) { + controller.setSingleAgentProvider(value); + } + }, + ), + if (modelChoices.isNotEmpty) + _CompactDropdown( + key: const Key('assistant-model-button'), + value: controller.resolvedAssistantModel, + items: modelChoices, + labelBuilder: (item) => item, + onChanged: (value) { + if (value != null) { + controller.selectAssistantModel(value); + } + }, + ), + _CompactDropdown( + key: const Key('assistant-message-view-mode-button'), + value: controller.currentAssistantMessageViewMode, + items: AssistantMessageViewMode.values, + labelBuilder: (item) => item.label, + onChanged: (value) { + if (value != null) { + controller.setAssistantMessageViewMode(value); + } + }, + ), + _CompactDropdown( + key: const Key('assistant-thinking-button'), + value: thinkingLevel, + items: const ['low', 'medium', 'high'], + labelBuilder: _thinkingLabel, + onChanged: (value) { + if (value != null) { + onThinkingChanged(value); + } + }, + ), + _CompactDropdown( + key: const Key('assistant-permission-button'), + value: permissionLevel, + items: AssistantPermissionLevel.values, + labelBuilder: (item) => item.label, + onChanged: (value) { + if (value != null) { + onPermissionChanged(value); + } + }, + ), + ], ), ], ), @@ -433,19 +748,18 @@ class _ConversationPanel extends StatelessWidget { child: Text( currentTarget == AssistantExecutionTarget.singleAgent ? appText( - '当前单机智能体配置还不完整,请先在 Settings 中保存 LLM API Endpoint、LLM API Token 和默认模型。', - 'Single Agent is not ready yet. Save the LLM API Endpoint, LLM API Token, and default model in Settings first.', + '当前线程未就绪。请检查 Single Agent 配置,或切换到可连接的 Gateway 目标。', + 'This thread is not ready. Check Single Agent configuration, or switch to a connected gateway target.', ) : appText( - '当前 Relay Gateway 尚未连接,请先在 Settings 中保存配置并连接。', - 'Relay Gateway is offline. Save the relay config and connect from Settings first.', + '当前线程目标网关未连接。请先在 Settings 中 Test / Save / Apply。', + 'The gateway target for this thread is offline. Use Test / Save / Apply in Settings first.', ), ), ), const SizedBox(width: 12), FilledButton.tonal( - onPressed: () => - controller.openSettings(tab: SettingsTab.gateway), + onPressed: () => controller.openSettings(tab: SettingsTab.gateway), child: Text(appText('打开设置', 'Open settings')), ), ], @@ -459,6 +773,28 @@ class _ConversationPanel extends StatelessWidget { tone: SurfaceCardTone.chrome, child: Column( children: [ + if (importedSkills.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Wrap( + spacing: 8, + runSpacing: 8, + children: importedSkills.map((skill) { + final selected = selectedSkillKeys.contains(skill.key); + return FilterChip( + label: Text(skill.label), + selected: selected, + onSelected: (_) => controller.toggleAssistantSkillForSession( + controller.currentSessionKey, + skill.key, + ), + ); + }).toList(growable: false), + ), + ), + ), Expanded( child: ListView.builder( controller: scrollController, @@ -475,26 +811,40 @@ class _ConversationPanel extends StatelessWidget { padding: const EdgeInsets.all(14), child: Column( children: [ + if (attachments.isNotEmpty) + Align( + alignment: Alignment.centerLeft, + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (var index = 0; index < attachments.length; index++) + InputChip( + avatar: Icon(attachments[index].icon, size: 16), + label: Text(attachments[index].name), + onDeleted: () => onRemoveAttachment(index), + ), + ], + ), + ), + if (attachments.isNotEmpty) const SizedBox(height: 8), Row( children: [ Expanded( child: TextField( controller: inputController, minLines: 3, - maxLines: 6, + maxLines: 8, decoration: InputDecoration( hintText: appText( - '输入需求、补充上下文、继续追问', - 'Describe the task, add context, or continue the conversation', + '输入任务说明、上下文和期望输出', + 'Describe the task, context, and expected output', ), ), onSubmitted: (_) { - if (!connected) { - return; + if (connected) { + onSubmit(); } - final value = inputController.text; - inputController.clear(); - controller.sendMessage(value); }, ), ), @@ -503,43 +853,48 @@ class _ConversationPanel extends StatelessWidget { const SizedBox(height: 10), Row( children: [ + Row( + children: [ + Checkbox( + value: useMultiAgent, + onChanged: (value) { + onToggleMultiAgent(value ?? false); + }, + ), + Text(appText('Multi-Agent', 'Multi-Agent')), + ], + ), + const SizedBox(width: 8), + IconButton( + key: const Key('assistant-attachment-menu-button'), + tooltip: appText('添加附件', 'Add attachment'), + onPressed: onAddAttachment, + icon: const Icon(Icons.attach_file_rounded), + ), Expanded( child: Text( - currentTarget == - AssistantExecutionTarget.singleAgent - ? appText( - 'Web 端单机智能体只保留纯网络能力,不提供本地文件和 CLI。', - 'Single Agent on web keeps network-only capabilities and does not expose local files or CLI.', - ) + controller.lastAssistantError?.trim().isNotEmpty == true + ? controller.lastAssistantError!.trim() : appText( - 'Web 端 Relay 模式使用远程 OpenClaw Gateway,不区分 local / remote。', - 'Relay mode on web uses the remote OpenClaw Gateway and does not expose local / remote splits.', + '附件仅支持手动选择,单次总量上限 10MB。', + 'Attachments are explicit user picks only, with a 10MB total limit per send.', ), - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: palette.textSecondary), + 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'), - ), + onPressed: connected ? onSubmit : null, + icon: controller.relayBusy || controller.acpBusy + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.arrow_upward_rounded), + label: Text(appText('发送', 'Send')), ), ], ), @@ -573,7 +928,7 @@ class _MessageBubble extends StatelessWidget { return Align( alignment: assistant ? Alignment.centerLeft : Alignment.centerRight, child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 720), + constraints: const BoxConstraints(maxWidth: 760), child: Padding( padding: const EdgeInsets.only(bottom: 12), child: DecoratedBox( @@ -623,28 +978,118 @@ class _TargetChip extends StatelessWidget { value: value, onChanged: onChanged, items: targets - .map((target) { - return DropdownMenuItem( + .map( + (target) => DropdownMenuItem( value: target, child: Text(_targetLabel(target)), - ); - }) + ), + ) .toList(growable: false), ), ); } } +class _CompactDropdown extends StatelessWidget { + const _CompactDropdown({ + super.key, + required this.value, + required this.items, + required this.labelBuilder, + required this.onChanged, + }); + + final T value; + final List items; + final String Function(T item) labelBuilder; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + if (items.isEmpty) { + return const SizedBox.shrink(); + } + return DropdownButtonHideUnderline( + child: DropdownButton( + value: items.contains(value) ? value : items.first, + onChanged: onChanged, + items: items + .map( + (item) => DropdownMenuItem( + value: item, + child: Text(labelBuilder(item)), + ), + ) + .toList(growable: false), + ), + ); + } +} + +class _WebComposerAttachment { + const _WebComposerAttachment({ + required this.file, + required this.name, + required this.mimeType, + required this.icon, + }); + + final XFile file; + final String name; + final String mimeType; + final IconData icon; + + factory _WebComposerAttachment.fromXFile(XFile file) { + final extension = file.name.split('.').last.toLowerCase(); + final mimeType = file.mimeType?.trim().isNotEmpty == true + ? file.mimeType!.trim() + : switch (extension) { + 'png' => 'image/png', + 'jpg' || 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'webp' => 'image/webp', + 'json' => 'application/json', + 'csv' => 'text/csv', + 'txt' || 'log' || 'md' || 'yaml' || 'yml' => 'text/plain', + 'pdf' => 'application/pdf', + _ => 'application/octet-stream', + }; + final icon = mimeType.startsWith('image/') + ? Icons.image_outlined + : mimeType == 'application/pdf' + ? Icons.picture_as_pdf_outlined + : Icons.insert_drive_file_outlined; + return _WebComposerAttachment( + file: file, + name: file.name, + mimeType: mimeType, + icon: icon, + ); + } +} + +String _thinkingLabel(String level) { + return switch (level) { + 'low' => appText('低', 'Low'), + 'medium' => appText('中', 'Medium'), + 'high' => appText('高', 'High'), + _ => level, + }; +} + String _targetLabel(AssistantExecutionTarget target) { return switch (target) { AssistantExecutionTarget.singleAgent => appText( 'Single Agent', 'Single Agent', ), - AssistantExecutionTarget.remote => appText( - 'Relay OpenClaw Gateway', - 'Relay OpenClaw Gateway', + AssistantExecutionTarget.local => appText( + 'Local Gateway', + 'Local Gateway', + ), + AssistantExecutionTarget.remote => appText( + 'Remote Gateway', + 'Remote Gateway', ), - _ => '', }; } diff --git a/lib/web/web_relay_gateway_client.dart b/lib/web/web_relay_gateway_client.dart index d4e7f571..98438398 100644 --- a/lib/web/web_relay_gateway_client.dart +++ b/lib/web/web_relay_gateway_client.dart @@ -37,7 +37,7 @@ class WebRelayGatewayClient { StreamSubscription? _subscription; int _requestCounter = 0; GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial( - mode: RuntimeConnectionMode.remote, + mode: RuntimeConnectionMode.unconfigured, ); Stream get events => _events.stream; @@ -51,11 +51,14 @@ class WebRelayGatewayClient { required String authPassword, }) async { await disconnect(); + final targetMode = profile.mode == RuntimeConnectionMode.local + ? RuntimeConnectionMode.local + : RuntimeConnectionMode.remote; final endpoint = _resolveEndpoint(profile); if (endpoint == null) { _snapshot = GatewayConnectionSnapshot.initial( - mode: RuntimeConnectionMode.remote, + mode: targetMode, ).copyWith( status: RuntimeConnectionStatus.error, statusText: 'Missing relay endpoint', @@ -68,7 +71,7 @@ class WebRelayGatewayClient { final identity = await _identityManager.loadOrCreate(_store); _snapshot = GatewayConnectionSnapshot.initial( - mode: RuntimeConnectionMode.remote, + mode: targetMode, ).copyWith( status: RuntimeConnectionStatus.connecting, statusText: 'Connecting…', @@ -136,6 +139,7 @@ class WebRelayGatewayClient { ); try { + await channel.ready; final nonce = await challenge.future.timeout( const Duration(seconds: 5), onTimeout: () => @@ -159,6 +163,7 @@ class WebRelayGatewayClient { _snapshot = _snapshot.copyWith( status: RuntimeConnectionStatus.connected, statusText: 'Connected', + mode: targetMode, serverName: _stringValue(server['host']), remoteAddress: '${endpoint.host}:${endpoint.port}', mainSessionKey: @@ -173,6 +178,7 @@ class WebRelayGatewayClient { } catch (error) { await disconnect(); _snapshot = _snapshot.copyWith( + mode: targetMode, status: RuntimeConnectionStatus.error, statusText: 'Connection failed', lastError: error.toString(), @@ -195,6 +201,13 @@ class WebRelayGatewayClient { _subscription = null; await _channel?.sink.close(); _channel = null; + if (_snapshot.status != RuntimeConnectionStatus.offline) { + _snapshot = _snapshot.copyWith( + status: RuntimeConnectionStatus.offline, + statusText: 'Offline', + clearRemoteAddress: true, + ); + } } Future> listSessions({int limit = 50}) async { @@ -275,8 +288,15 @@ class WebRelayGatewayClient { required String sessionKey, required String message, required String thinking, + List attachments = + const [], + Map metadata = const {}, }) async { final runId = _randomId(); + final normalizedMetadata = { + for (final entry in metadata.entries) + if (entry.key.trim().isNotEmpty) entry.key: entry.value, + }; final payload = _asMap( await request( 'chat.send', @@ -284,6 +304,11 @@ class WebRelayGatewayClient { 'sessionKey': sessionKey, 'message': message, 'thinking': thinking, + if (attachments.isNotEmpty) + 'attachments': attachments + .map((item) => item.toJson()) + .toList(growable: false), + if (normalizedMetadata.isNotEmpty) 'metadata': normalizedMetadata, 'timeoutMs': 30000, 'idempotencyKey': runId, }, diff --git a/lib/web/web_settings_page.dart b/lib/web/web_settings_page.dart index 477cc33b..da55b55d 100644 --- a/lib/web/web_settings_page.dart +++ b/lib/web/web_settings_page.dart @@ -26,16 +26,22 @@ class _WebSettingsPageState extends State { 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; + late final TextEditingController _localHostController; + late final TextEditingController _localPortController; + late final TextEditingController _localTokenController; + late final TextEditingController _localPasswordController; + late final TextEditingController _remoteHostController; + late final TextEditingController _remotePortController; + late final TextEditingController _remoteTokenController; + late final TextEditingController _remotePasswordController; late final TextEditingController _sessionRemoteBaseUrlController; late final TextEditingController _sessionApiTokenController; late WebSessionPersistenceMode _sessionPersistenceMode; + bool _remoteTls = true; String _directMessage = ''; - String _relayMessage = ''; + String _localGatewayMessage = ''; + String _remoteGatewayMessage = ''; String _sessionPersistenceMessage = ''; @override @@ -45,10 +51,14 @@ class _WebSettingsPageState extends State { _directBaseUrlController = TextEditingController(); _directProviderController = TextEditingController(); _directApiKeyController = TextEditingController(); - _relayHostController = TextEditingController(); - _relayPortController = TextEditingController(); - _relayTokenController = TextEditingController(); - _relayPasswordController = TextEditingController(); + _localHostController = TextEditingController(); + _localPortController = TextEditingController(); + _localTokenController = TextEditingController(); + _localPasswordController = TextEditingController(); + _remoteHostController = TextEditingController(); + _remotePortController = TextEditingController(); + _remoteTokenController = TextEditingController(); + _remotePasswordController = TextEditingController(); _sessionRemoteBaseUrlController = TextEditingController(); _sessionApiTokenController = TextEditingController(); _sessionPersistenceMode = widget.controller.webSessionPersistence.mode; @@ -67,10 +77,14 @@ class _WebSettingsPageState extends State { _directBaseUrlController.dispose(); _directProviderController.dispose(); _directApiKeyController.dispose(); - _relayHostController.dispose(); - _relayPortController.dispose(); - _relayTokenController.dispose(); - _relayPasswordController.dispose(); + _localHostController.dispose(); + _localPortController.dispose(); + _localTokenController.dispose(); + _localPasswordController.dispose(); + _remoteHostController.dispose(); + _remotePortController.dispose(); + _remoteTokenController.dispose(); + _remotePasswordController.dispose(); _sessionRemoteBaseUrlController.dispose(); _sessionApiTokenController.dispose(); super.dispose(); @@ -78,7 +92,8 @@ class _WebSettingsPageState extends State { void _syncControllers() { final settings = widget.controller.settings; - final relayProfile = settings.primaryRemoteGatewayProfile; + final localProfile = settings.primaryLocalGatewayProfile; + final remoteProfile = settings.primaryRemoteGatewayProfile; _setIfDifferent(_directNameController, settings.aiGateway.name); _setIfDifferent(_directBaseUrlController, settings.aiGateway.baseUrl); _setIfDifferent(_directProviderController, settings.defaultProvider); @@ -88,19 +103,46 @@ class _WebSettingsPageState extends State { ? '' : _directApiKeyController.text, ); - _setIfDifferent(_relayHostController, relayProfile.host); - _setIfDifferent(_relayPortController, '${relayProfile.port}'); + _setIfDifferent(_localHostController, localProfile.host); + _setIfDifferent(_localPortController, '${localProfile.port}'); + _setIfDifferent(_remoteHostController, remoteProfile.host); + _setIfDifferent(_remotePortController, '${remoteProfile.port}'); + _remoteTls = remoteProfile.tls; _setIfDifferent( - _relayTokenController, - widget.controller.storedRelayTokenMask == null + _localTokenController, + widget.controller.storedRelayTokenMaskForProfile( + kGatewayLocalProfileIndex, + ) == + null ? '' - : _relayTokenController.text, + : _localTokenController.text, ); _setIfDifferent( - _relayPasswordController, - widget.controller.storedRelayPasswordMask == null + _localPasswordController, + widget.controller.storedRelayPasswordMaskForProfile( + kGatewayLocalProfileIndex, + ) == + null ? '' - : _relayPasswordController.text, + : _localPasswordController.text, + ); + _setIfDifferent( + _remoteTokenController, + widget.controller.storedRelayTokenMaskForProfile( + kGatewayRemoteProfileIndex, + ) == + null + ? '' + : _remoteTokenController.text, + ); + _setIfDifferent( + _remotePasswordController, + widget.controller.storedRelayPasswordMaskForProfile( + kGatewayRemoteProfileIndex, + ) == + null + ? '' + : _remotePasswordController.text, ); _sessionPersistenceMode = settings.webSessionPersistence.mode; _setIfDifferent( @@ -225,11 +267,6 @@ class _WebSettingsPageState extends State { final targets = controller .featuresFor(UiFeaturePlatform.web) .availableExecutionTargets - .where( - (target) => - target == AssistantExecutionTarget.singleAgent || - target == AssistantExecutionTarget.remote, - ) .toList(growable: false); return [ SurfaceCard( @@ -271,7 +308,6 @@ class _WebSettingsPageState extends State { SettingsSnapshot settings, ) { final palette = context.palette; - final relayProfile = settings.primaryRemoteGatewayProfile; return [ SurfaceCard( child: Row( @@ -290,6 +326,217 @@ class _WebSettingsPageState extends State { ), ), const SizedBox(height: 12), + SurfaceCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText('单机智能体', 'Single Agent'), + 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('LLM API Endpoint', 'LLM API Endpoint'), + hintText: 'https://api.example.com/v1', + ), + ), + const SizedBox(height: 10), + TextField( + controller: _directApiKeyController, + obscureText: true, + decoration: InputDecoration( + labelText: appText('LLM API Token', 'LLM API Token'), + 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: [ + 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', 'Test')), + ), + FilledButton( + onPressed: controller.aiGatewayBusy + ? null + : () async { + await controller.saveAiGatewayConfiguration( + name: _directNameController.text, + baseUrl: _directBaseUrlController.text, + provider: _directProviderController.text, + apiKey: _directApiKeyController.text, + defaultModel: controller.resolvedAiGatewayModel, + ); + if (!mounted) { + return; + } + setState(() { + _directMessage = appText( + '配置已保存,尚未同步模型目录。', + 'Configuration saved; model catalog not synced yet.', + ); + }); + }, + child: Text(appText('Save', 'Save')), + ), + FilledButton.icon( + onPressed: controller.aiGatewayBusy + ? null + : () async { + await controller.saveAiGatewayConfiguration( + name: _directNameController.text, + baseUrl: _directBaseUrlController.text, + provider: _directProviderController.text, + apiKey: _directApiKeyController.text, + defaultModel: controller.resolvedAiGatewayModel, + ); + 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.play_circle_outline_rounded), + label: Text(appText('Apply', 'Apply')), + ), + ], + ), + if (_directMessage.trim().isNotEmpty) ...[ + const SizedBox(height: 10), + Text( + _directMessage, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: palette.textSecondary), + ), + ], + ], + ), + ), + const SizedBox(height: 12), + _buildGatewayCard( + context, + controller: controller, + title: appText('Local Gateway', 'Local Gateway'), + executionTarget: AssistantExecutionTarget.local, + profileIndex: kGatewayLocalProfileIndex, + hostController: _localHostController, + portController: _localPortController, + tokenController: _localTokenController, + passwordController: _localPasswordController, + tokenMask: controller.storedRelayTokenMaskForProfile( + kGatewayLocalProfileIndex, + ), + passwordMask: controller.storedRelayPasswordMaskForProfile( + kGatewayLocalProfileIndex, + ), + tls: false, + onTlsChanged: null, + message: _localGatewayMessage, + onMessageChanged: (value) { + setState(() => _localGatewayMessage = value); + }, + ), + const SizedBox(height: 12), + _buildGatewayCard( + context, + controller: controller, + title: appText('Remote Gateway', 'Remote Gateway'), + executionTarget: AssistantExecutionTarget.remote, + profileIndex: kGatewayRemoteProfileIndex, + hostController: _remoteHostController, + portController: _remotePortController, + tokenController: _remoteTokenController, + passwordController: _remotePasswordController, + tokenMask: controller.storedRelayTokenMaskForProfile( + kGatewayRemoteProfileIndex, + ), + passwordMask: controller.storedRelayPasswordMaskForProfile( + kGatewayRemoteProfileIndex, + ), + tls: _remoteTls, + onTlsChanged: (value) { + setState(() => _remoteTls = value); + }, + message: _remoteGatewayMessage, + onMessageChanged: (value) { + setState(() => _remoteGatewayMessage = value); + }, + ), + const SizedBox(height: 12), SurfaceCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -376,7 +623,26 @@ class _WebSettingsPageState extends State { controller.sessionPersistenceStatusMessage; }); }, - child: Text(appText('保存会话存储', 'Save session store')), + child: Text(appText('Save', 'Save')), + ), + FilledButton.tonal( + onPressed: () async { + await controller.saveWebSessionPersistenceConfiguration( + mode: _sessionPersistenceMode, + remoteBaseUrl: _sessionRemoteBaseUrlController.text, + apiToken: _sessionApiTokenController.text, + ); + if (!mounted) { + return; + } + setState(() { + _sessionPersistenceMessage = appText( + '会话存储配置已应用到当前浏览器会话。', + 'Session persistence settings are now applied to this browser session.', + ); + }); + }, + child: Text(appText('Apply', 'Apply')), ), ], ), @@ -395,299 +661,233 @@ class _WebSettingsPageState extends State { ], ), ), - const SizedBox(height: 12), - SurfaceCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - appText('单机智能体', 'Single Agent'), - style: Theme.of(context).textTheme.titleMedium, + ]; + } + + Widget _buildGatewayCard( + BuildContext context, { + required AppController controller, + required String title, + required AssistantExecutionTarget executionTarget, + required int profileIndex, + required TextEditingController hostController, + required TextEditingController portController, + required TextEditingController tokenController, + required TextEditingController passwordController, + required String? tokenMask, + required String? passwordMask, + required bool tls, + required ValueChanged? onTlsChanged, + required String message, + required ValueChanged onMessageChanged, + }) { + final expectedMode = executionTarget == AssistantExecutionTarget.local + ? RuntimeConnectionMode.local + : RuntimeConnectionMode.remote; + final matchesTarget = controller.connection.mode == expectedMode; + final status = matchesTarget + ? controller.connection.status.label + : RuntimeConnectionStatus.offline.label; + final endpoint = '${hostController.text.trim()}:${_parsePort(portController.text, fallback: 443)}'; + final statusEndpoint = matchesTarget + ? (controller.connection.remoteAddress?.trim().isNotEmpty == true + ? controller.connection.remoteAddress!.trim() + : endpoint) + : endpoint; + + return SurfaceCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + TextField( + controller: hostController, + decoration: InputDecoration( + labelText: appText('主机或 URL', 'Host or URL'), ), - 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('LLM API Endpoint', 'LLM API Endpoint'), - hintText: 'https://api.example.com/v1', - ), - ), - const SizedBox(height: 10), - TextField( - controller: _directApiKeyController, - obscureText: true, - decoration: InputDecoration( - labelText: appText('LLM API Token', 'LLM API Token'), - helperText: controller.storedAiGatewayApiKeyMask == null - ? null - : '${appText('已保存', 'Stored')}: ${controller.storedAiGatewayApiKeyMask}', - ), - ), - const SizedBox(height: 10), - DropdownButtonFormField( - initialValue: controller.resolvedAiGatewayModel.isEmpty + ), + const SizedBox(height: 10), + TextField( + controller: portController, + keyboardType: TextInputType.number, + decoration: InputDecoration(labelText: appText('端口', 'Port')), + ), + const SizedBox(height: 10), + TextField( + controller: tokenController, + obscureText: true, + decoration: InputDecoration( + labelText: appText('Gateway Token', 'Gateway token'), + helperText: tokenMask == null ? 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'), - ), + : '${appText('已保存', 'Stored')}: $tokenMask', ), - const SizedBox(height: 12), - Wrap( - spacing: 10, - runSpacing: 10, - children: [ - 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')), + ), + const SizedBox(height: 10), + TextField( + controller: passwordController, + obscureText: true, + decoration: InputDecoration( + labelText: appText('Gateway Password', 'Gateway password'), + helperText: passwordMask == null + ? null + : '${appText('已保存', 'Stored')}: $passwordMask', + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Text( + '${appText('状态', 'Status')}: $status · $statusEndpoint', ), - FilledButton.icon( - onPressed: controller.aiGatewayBusy - ? null - : () async { - await controller.saveAiGatewayConfiguration( - name: _directNameController.text, - baseUrl: _directBaseUrlController.text, - provider: _directProviderController.text, - apiKey: _directApiKeyController.text, - defaultModel: controller.resolvedAiGatewayModel, - ); - 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.check_circle_outline_rounded), - label: Text(appText('保存/应用', 'Save / Apply')), - ), - ], - ), - 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: relayProfile.tls, - onChanged: (value) => controller.saveRelayConfiguration( - host: _relayHostController.text, - port: int.tryParse(_relayPortController.text.trim()) ?? 443, - tls: value, - token: _relayTokenController.text, - password: _relayPasswordController.text, - ), - ), + if (onTlsChanged != null) ...[ + Switch(value: tls, onChanged: onTlsChanged), 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: relayProfile.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: relayProfile.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(); + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + OutlinedButton( + onPressed: controller.relayBusy + ? null + : () async { + final profile = _gatewayProfileDraft( + executionTarget: executionTarget, + host: hostController.text, + portText: portController.text, + tls: tls, + ); + final result = await controller.testGatewayConnectionDraft( + profile: profile, + executionTarget: executionTarget, + tokenOverride: tokenController.text, + passwordOverride: passwordController.text, + ); + if (!mounted) { + return; + } + onMessageChanged( + '${result.state.toUpperCase()} · ${result.message}', + ); + }, + child: Text(appText('Test', 'Test')), + ), + FilledButton( + onPressed: controller.relayBusy + ? null + : () async { + await controller.saveRelayConfiguration( + profileIndex: profileIndex, + host: hostController.text, + port: _parsePort(portController.text, fallback: 443), + tls: tls, + token: tokenController.text, + password: passwordController.text, + ); + if (!mounted) { + return; + } + onMessageChanged( + appText( + '配置已保存,尚未应用到当前线程连接。', + 'Configuration saved but not applied to active thread connections yet.', + ), + ); + }, + child: Text(appText('Save', 'Save')), + ), + FilledButton.icon( + onPressed: controller.relayBusy + ? null + : () async { + try { + await controller.applyRelayConfiguration( + profileIndex: profileIndex, + host: hostController.text, + port: _parsePort(portController.text, fallback: 443), + tls: tls, + token: tokenController.text, + password: passwordController.text, + ); 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), + onMessageChanged( + appText( + '配置已应用;当前线程目标匹配时将使用新连接。', + 'Configuration applied. Threads targeting this gateway now use the updated connection.', + ), + ); + } catch (error) { + if (!mounted) { + return; + } + onMessageChanged('$error'); + } + }, + icon: controller.relayBusy + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.play_circle_outline_rounded), + label: Text(appText('Apply', 'Apply')), ), ], + ), + if (message.trim().isNotEmpty) ...[ + const SizedBox(height: 10), + Text( + message, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: context.palette.textSecondary), + ), ], - ), + ], ), - ]; + ); + } + + GatewayConnectionProfile _gatewayProfileDraft({ + required AssistantExecutionTarget executionTarget, + required String host, + required String portText, + required bool tls, + }) { + final mode = executionTarget == AssistantExecutionTarget.local + ? RuntimeConnectionMode.local + : RuntimeConnectionMode.remote; + final defaults = executionTarget == AssistantExecutionTarget.local + ? GatewayConnectionProfile.defaultsLocal() + : GatewayConnectionProfile.defaultsRemote(); + return defaults.copyWith( + mode: mode, + host: host.trim(), + port: _parsePort(portText, fallback: defaults.port), + tls: mode == RuntimeConnectionMode.local ? false : tls, + useSetupCode: false, + setupCode: '', + ); + } + + int _parsePort(String value, {required int fallback}) { + final parsed = int.tryParse(value.trim()); + if (parsed == null || parsed <= 0) { + return fallback; + } + return parsed; } List _buildAppearance( @@ -786,10 +986,13 @@ String _targetLabel(AssistantExecutionTarget target) { 'Single Agent', 'Single Agent', ), - AssistantExecutionTarget.remote => appText( - 'Relay OpenClaw Gateway', - 'Relay OpenClaw Gateway', + AssistantExecutionTarget.local => appText( + 'Local Gateway', + 'Local Gateway', + ), + AssistantExecutionTarget.remote => appText( + 'Remote Gateway', + 'Remote Gateway', ), - _ => '', }; } diff --git a/lib/web/web_store.dart b/lib/web/web_store.dart index 182a0028..4f66f100 100644 --- a/lib/web/web_store.dart +++ b/lib/web/web_store.dart @@ -10,8 +10,11 @@ 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'; + // Legacy remote-only keys (kept for migration fallback). static const relayTokenKey = 'xworkmate.web.relay.token'; static const relayPasswordKey = 'xworkmate.web.relay.password'; + static const relayTokenProfilePrefix = 'xworkmate.web.relay.token.'; + static const relayPasswordProfilePrefix = 'xworkmate.web.relay.password.'; static const relayDeviceIdentityKey = 'xworkmate.web.relay.device_identity'; static const sessionClientIdKey = 'xworkmate.web.session.client_id'; static const themeModeKey = 'xworkmate.web.theme_mode'; @@ -72,24 +75,50 @@ class WebStore { await _prefs!.setString(aiGatewayApiKeyKey, value.trim()); } - Future loadRelayToken() async { + Future loadRelayToken({int? profileIndex}) async { await initialize(); - return (_prefs!.getString(relayTokenKey) ?? '').trim(); + final scopedKey = _relayTokenScopedKey(profileIndex); + final scoped = (_prefs!.getString(scopedKey) ?? '').trim(); + if (scoped.isNotEmpty) { + return scoped; + } + // Backward compatibility: old builds persisted a single remote token. + if (profileIndex == null || profileIndex == kGatewayRemoteProfileIndex) { + return (_prefs!.getString(relayTokenKey) ?? '').trim(); + } + return ''; } - Future saveRelayToken(String value) async { + Future saveRelayToken(String value, {int? profileIndex}) async { await initialize(); - await _prefs!.setString(relayTokenKey, value.trim()); + final trimmed = value.trim(); + await _prefs!.setString(_relayTokenScopedKey(profileIndex), trimmed); + if (profileIndex == null || profileIndex == kGatewayRemoteProfileIndex) { + await _prefs!.setString(relayTokenKey, trimmed); + } } - Future loadRelayPassword() async { + Future loadRelayPassword({int? profileIndex}) async { await initialize(); - return (_prefs!.getString(relayPasswordKey) ?? '').trim(); + final scopedKey = _relayPasswordScopedKey(profileIndex); + final scoped = (_prefs!.getString(scopedKey) ?? '').trim(); + if (scoped.isNotEmpty) { + return scoped; + } + // Backward compatibility: old builds persisted a single remote password. + if (profileIndex == null || profileIndex == kGatewayRemoteProfileIndex) { + return (_prefs!.getString(relayPasswordKey) ?? '').trim(); + } + return ''; } - Future saveRelayPassword(String value) async { + Future saveRelayPassword(String value, {int? profileIndex}) async { await initialize(); - await _prefs!.setString(relayPasswordKey, value.trim()); + final trimmed = value.trim(); + await _prefs!.setString(_relayPasswordScopedKey(profileIndex), trimmed); + if (profileIndex == null || profileIndex == kGatewayRemoteProfileIndex) { + await _prefs!.setString(relayPasswordKey, trimmed); + } } Future loadOrCreateWebSessionClientId() async { @@ -161,4 +190,14 @@ class WebStore { ).join(); return 'web-$timestamp-$suffix'; } + + static String _relayTokenScopedKey(int? profileIndex) { + final resolved = profileIndex ?? kGatewayRemoteProfileIndex; + return '$relayTokenProfilePrefix$resolved'; + } + + static String _relayPasswordScopedKey(int? profileIndex) { + final resolved = profileIndex ?? kGatewayRemoteProfileIndex; + return '$relayPasswordProfilePrefix$resolved'; + } } diff --git a/test/web/web_assistant_controller_parity_browser_test.dart b/test/web/web_assistant_controller_parity_browser_test.dart new file mode 100644 index 00000000..172ef2ff --- /dev/null +++ b/test/web/web_assistant_controller_parity_browser_test.dart @@ -0,0 +1,306 @@ +@TestOn('browser') +library; + +import 'dart:async'; + +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_acp_client.dart'; +import 'package:xworkmate/web/web_relay_gateway_client.dart'; +import 'package:xworkmate/web/web_store.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('thread-scoped assistant context persists across reload on web', () async { + SharedPreferences.setMockInitialValues({}); + + final fakeRelay = _FakeRelayGatewayClient(WebStore()); + final fakeAcp = _FakeAcpClient(); + final controller = AppController( + store: WebStore(), + relayClient: fakeRelay, + acpClient: fakeAcp, + ); + await _waitForReady(controller); + + await controller.saveRelayConfiguration( + profileIndex: kGatewayLocalProfileIndex, + host: '', + port: 18789, + tls: false, + token: '', + password: '', + ); + await controller.saveRelayConfiguration( + profileIndex: kGatewayRemoteProfileIndex, + host: '', + port: 443, + tls: true, + token: '', + password: '', + ); + + final threadSingle = controller.currentSessionKey; + await controller.setSingleAgentProvider(SingleAgentProvider.codex); + await controller.setAssistantMessageViewMode(AssistantMessageViewMode.raw); + await controller.selectAssistantModelForSession(threadSingle, 'single-model'); + await controller.saveAssistantTaskTitle(threadSingle, 'Thread Single'); + + await controller.createConversation(target: AssistantExecutionTarget.local); + final threadLocal = controller.currentSessionKey; + await controller.setAssistantExecutionTarget(AssistantExecutionTarget.local); + await controller.selectAssistantModelForSession(threadLocal, 'local-model'); + await controller.saveAssistantTaskTitle(threadLocal, 'Thread Local'); + + await controller.createConversation(target: AssistantExecutionTarget.remote); + final threadRemote = controller.currentSessionKey; + await controller.setAssistantExecutionTarget(AssistantExecutionTarget.remote); + await controller.setAssistantMessageViewMode(AssistantMessageViewMode.raw); + await controller.selectAssistantModelForSession(threadRemote, 'remote-model'); + await controller.saveAssistantTaskTitle(threadRemote, 'Thread Remote'); + await controller.saveAssistantTaskArchived(threadRemote, true); + + expect( + controller.assistantExecutionTargetForSession(threadSingle), + AssistantExecutionTarget.singleAgent, + ); + expect( + controller.singleAgentProviderForSession(threadSingle), + SingleAgentProvider.codex, + ); + expect( + controller.assistantMessageViewModeForSession(threadSingle), + AssistantMessageViewMode.raw, + ); + expect(controller.assistantModelForSession(threadSingle), 'single-model'); + + expect(controller.assistantModelForSession(threadLocal), 'local-model'); + + expect( + controller.isAssistantTaskArchived(threadRemote), + isTrue, + ); + expect( + controller.conversations.where((item) => item.sessionKey == threadRemote), + isEmpty, + ); + + controller.dispose(); + + final reloaded = AppController( + store: WebStore(), + relayClient: _FakeRelayGatewayClient(WebStore()), + acpClient: fakeAcp, + ); + await _waitForReady(reloaded); + + expect( + reloaded.assistantExecutionTargetForSession(threadSingle), + AssistantExecutionTarget.singleAgent, + ); + expect( + reloaded.singleAgentProviderForSession(threadSingle), + SingleAgentProvider.codex, + ); + expect( + reloaded.assistantMessageViewModeForSession(threadSingle), + AssistantMessageViewMode.raw, + ); + expect(reloaded.assistantModelForSession(threadSingle), 'single-model'); + expect(reloaded.assistantModelForSession(threadLocal), 'local-model'); + expect(reloaded.isAssistantTaskArchived(threadRemote), isTrue); + + reloaded.dispose(); + }); + + test('gateway Save does not connect but Apply connects current target profile', + () async { + SharedPreferences.setMockInitialValues({}); + + final fakeRelay = _FakeRelayGatewayClient(WebStore()); + final controller = AppController( + store: WebStore(), + relayClient: fakeRelay, + acpClient: _FakeAcpClient(), + ); + await _waitForReady(controller); + + await controller.setAssistantExecutionTarget(AssistantExecutionTarget.remote); + fakeRelay.connectCalls = 0; + + await controller.saveRelayConfiguration( + profileIndex: kGatewayRemoteProfileIndex, + host: 'remote.example.com', + port: 443, + tls: true, + token: 'remote-token', + password: '', + ); + expect(fakeRelay.connectCalls, 0); + + await controller.applyRelayConfiguration( + profileIndex: kGatewayRemoteProfileIndex, + host: 'remote.example.com', + port: 443, + tls: true, + token: 'remote-token', + password: '', + ); + + expect(fakeRelay.connectCalls, greaterThanOrEqualTo(1)); + expect(fakeRelay.lastConnectMode, RuntimeConnectionMode.remote); + + controller.dispose(); + }); +} + +class _FakeRelayGatewayClient extends WebRelayGatewayClient { + _FakeRelayGatewayClient( + super.store, { + GatewayConnectionSnapshot? initialSnapshot, + }) : _snapshot = + initialSnapshot ?? + GatewayConnectionSnapshot.initial(mode: RuntimeConnectionMode.remote); + + final StreamController _eventsController = + StreamController.broadcast(); + GatewayConnectionSnapshot _snapshot; + + int connectCalls = 0; + RuntimeConnectionMode? lastConnectMode; + + @override + Stream get events => _eventsController.stream; + + @override + GatewayConnectionSnapshot get snapshot => _snapshot; + + @override + Future connect({ + required GatewayConnectionProfile profile, + required String authToken, + required String authPassword, + }) async { + connectCalls += 1; + lastConnectMode = profile.mode; + _snapshot = GatewayConnectionSnapshot.initial(mode: profile.mode).copyWith( + status: RuntimeConnectionStatus.connected, + statusText: 'Connected', + remoteAddress: '${profile.host}:${profile.port}', + ); + } + + @override + Future disconnect() async { + _snapshot = _snapshot.copyWith( + status: RuntimeConnectionStatus.offline, + statusText: 'Offline', + clearRemoteAddress: true, + ); + } + + @override + Future> listSessions({int limit = 50}) async { + return const []; + } + + @override + Future> loadHistory( + String sessionKey, { + int limit = 120, + }) async { + return const []; + } + + @override + Future sendChat({ + required String sessionKey, + required String message, + required String thinking, + List attachments = + const [], + Map metadata = const {}, + }) async { + return 'fake-run'; + } + + @override + Future> listModels() async { + return const []; + } + + @override + Future request( + String method, { + Map? params, + Duration timeout = const Duration(seconds: 15), + }) async { + if (method == 'skills.status') { + return const {'skills': []}; + } + return const {}; + } + + @override + Future dispose() async { + await _eventsController.close(); + } +} + +class _FakeAcpClient extends WebAcpClient { + @override + Future loadCapabilities({required Uri endpoint}) async { + return WebAcpCapabilities( + singleAgent: true, + multiAgent: true, + providers: { + SingleAgentProvider.codex, + SingleAgentProvider.opencode, + SingleAgentProvider.claude, + SingleAgentProvider.gemini, + }, + raw: {}, + ); + } + + @override + Future cancelSession({ + required Uri endpoint, + required String sessionId, + required String threadId, + }) async {} + + @override + Future> request({ + required Uri endpoint, + required String method, + required Map params, + void Function(Map notification)? onNotification, + Duration timeout = const Duration(seconds: 120), + }) async { + return { + 'result': { + 'output': 'ok', + 'summary': 'ok', + 'model': params['model']?.toString() ?? 'fake-model', + }, + }; + } +} + +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 index 060f6a61..0675a4d9 100644 --- a/test/web/web_ui_browser_test.dart +++ b/test/web/web_ui_browser_test.dart @@ -24,15 +24,14 @@ void main() { 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, - ); + expect(find.byKey(const Key('assistant-attachment-menu-button')), findsOneWidget); await tester.tap(find.text('连接设置')); await tester.pumpAndSettle(); expect(find.text('设置'), findsWidgets); expect(find.textContaining('浏览器本地存储'), findsOneWidget); + expect(find.textContaining('Local Gateway'), findsWidgets); + expect(find.textContaining('Remote Gateway'), findsWidgets); }); }