// ignore_for_file: unused_import, unnecessary_import import 'dart:async'; import 'package:flutter/material.dart'; import '../i18n/app_language.dart'; import '../models/app_models.dart'; import '../runtime/assistant_artifacts.dart'; import '../runtime/runtime_models.dart'; import '../web/web_acp_client.dart'; import '../web/web_ai_gateway_client.dart'; import '../web/web_artifact_proxy_client.dart'; import '../web/web_relay_gateway_client.dart'; import '../web/web_session_repository.dart'; import '../web/web_store.dart'; import '../web/web_workspace_controllers.dart'; import 'app_capabilities.dart'; import 'ui_feature_manifest.dart'; import 'app_controller_web_core.dart'; import 'app_controller_web_workspace.dart'; import 'app_controller_web_session_actions.dart'; import 'app_controller_web_gateway_config.dart'; import 'app_controller_web_gateway_relay.dart'; import 'app_controller_web_gateway_chat.dart'; import 'app_controller_web_helpers.dart'; extension AppControllerWebSessions on AppController { TaskThread? taskThreadForSessionInternal(String sessionKey) { final normalizedSessionKey = normalizedSessionKeyInternal(sessionKey); return threadRepositoryInternal.taskThreadForSession(normalizedSessionKey); } TaskThread requireTaskThreadForSessionInternal(String sessionKey) { final normalizedSessionKey = normalizedSessionKeyInternal(sessionKey); return threadRepositoryInternal.requireTaskThreadForSession( normalizedSessionKey, ); } AssistantExecutionTarget assistantExecutionTargetForSession( String sessionKey, ) { final normalizedSessionKey = normalizedSessionKeyInternal(sessionKey); final record = taskThreadForSessionInternal(normalizedSessionKey); final recordTarget = switch (record?.executionBinding.executionMode) { ThreadExecutionMode.localAgent => AssistantExecutionTarget.singleAgent, ThreadExecutionMode.gatewayLocal => AssistantExecutionTarget.local, ThreadExecutionMode.gatewayRemote => AssistantExecutionTarget.remote, null => null, }; final fallback = sanitizeTargetInternal( settingsInternal.assistantExecutionTarget, ); return recordTarget ?? fallback ?? AssistantExecutionTarget.singleAgent; } AssistantExecutionTarget get assistantExecutionTarget => assistantExecutionTargetForSession(currentSessionKeyInternal); AssistantExecutionTarget get currentAssistantExecutionTarget => assistantExecutionTarget; bool get isSingleAgentMode => assistantExecutionTarget == AssistantExecutionTarget.singleAgent; AssistantMessageViewMode assistantMessageViewModeForSession( String sessionKey, ) { final normalizedSessionKey = normalizedSessionKeyInternal(sessionKey); return threadRecordsInternal[normalizedSessionKey]?.messageViewMode ?? AssistantMessageViewMode.rendered; } AssistantMessageViewMode get currentAssistantMessageViewMode => assistantMessageViewModeForSession(currentSessionKeyInternal); String assistantWorkspacePathForSession(String sessionKey) { final normalizedSessionKey = normalizedSessionKeyInternal(sessionKey); return taskThreadForSessionInternal( normalizedSessionKey, )?.workspaceBinding.workspacePath.trim() ?? ''; } WorkspaceRefKind assistantWorkspaceKindForSession(String sessionKey) { final record = requireTaskThreadForSessionInternal(sessionKey); return record.workspaceBinding.workspaceKind == WorkspaceKind.localFs ? WorkspaceRefKind.localPath : WorkspaceRefKind.remotePath; } String assistantWorkspaceDisplayPathForSession(String sessionKey) { final normalizedSessionKey = normalizedSessionKeyInternal(sessionKey); return taskThreadForSessionInternal( normalizedSessionKey, )?.workspaceBinding.displayPath.trim() ?? ''; } Future loadAssistantArtifactSnapshot({ String? sessionKey, }) { final resolvedSessionKey = normalizedSessionKeyInternal( sessionKey ?? currentSessionKeyInternal, ); return artifactProxyClientInternal.loadSnapshot( sessionKey: resolvedSessionKey, workspacePath: assistantWorkspacePathForSession(resolvedSessionKey), workspaceKind: assistantWorkspaceKindForSession(resolvedSessionKey), ); } Future loadAssistantArtifactPreview( AssistantArtifactEntry entry, { String? sessionKey, }) { final resolvedSessionKey = normalizedSessionKeyInternal( sessionKey ?? currentSessionKeyInternal, ); return artifactProxyClientInternal.loadPreview( sessionKey: resolvedSessionKey, entry: entry, ); } SingleAgentProvider singleAgentProviderForSession(String sessionKey) { final normalizedSessionKey = normalizedSessionKeyInternal(sessionKey); final stored = SingleAgentProviderCopy.fromJsonValue( taskThreadForSessionInternal( normalizedSessionKey, )?.executionBinding.providerId ?? '', ); return settingsInternal.sanitizeSingleAgentProviderSelection(stored); } SingleAgentProvider get currentSingleAgentProvider => singleAgentProviderForSession(currentSessionKeyInternal); List get singleAgentProviderOptions => settingsInternal.savedSingleAgentProviders; List get availableSingleAgentProviders => singleAgentProviderOptions; List visibleAssistantExecutionTargets( Iterable supportedTargets, ) { return settingsInternal.visibleAssistantExecutionTargets( supportedTargets: supportedTargets, availableSingleAgentProviders: availableSingleAgentProviders, ); } String singleAgentRuntimeModelForSession(String sessionKey) { return taskThreadForSessionInternal( normalizedSessionKeyInternal(sessionKey), )?.latestResolvedRuntimeModel.trim() ?? ''; } String get currentSingleAgentRuntimeModel => singleAgentRuntimeModelForSession(currentSessionKeyInternal); String assistantModelForSession(String sessionKey) { final normalizedSessionKey = normalizedSessionKeyInternal(sessionKey); final target = assistantExecutionTargetForSession(normalizedSessionKey); final recordModel = threadRecordsInternal[normalizedSessionKey]?.assistantModelId.trim() ?? ''; if (target == AssistantExecutionTarget.singleAgent) { final runtimeModel = singleAgentRuntimeModelForSession( normalizedSessionKey, ); if (runtimeModel.isNotEmpty) { return runtimeModel; } if (recordModel.isNotEmpty) { return recordModel; } return ''; } if (recordModel.isNotEmpty) { return recordModel; } return settingsInternal.defaultModel.trim(); } String get resolvedAssistantModel => assistantModelForSession(currentSessionKeyInternal); List assistantModelChoicesForSession(String sessionKey) { final target = assistantExecutionTargetForSession(sessionKey); if (target == AssistantExecutionTarget.singleAgent) { final runtime = singleAgentRuntimeModelForSession(sessionKey); if (runtime.isNotEmpty) { return [runtime]; } final recordModel = assistantModelForSession(sessionKey); if (recordModel.isNotEmpty) { return [recordModel]; } return const []; } final model = settingsInternal.defaultModel.trim(); if (model.isEmpty) { return const []; } return [model]; } List get assistantModelChoices => assistantModelChoicesForSession(currentSessionKeyInternal); List assistantImportedSkillsForSession( String sessionKey, ) { return threadRecordsInternal[normalizedSessionKeyInternal(sessionKey)] ?.importedSkills ?? const []; } List assistantSelectedSkillKeysForSession(String sessionKey) { final normalizedSessionKey = normalizedSessionKeyInternal(sessionKey); final importedKeys = assistantImportedSkillsForSession( normalizedSessionKey, ).map((item) => item.key).toSet(); final selected = threadRecordsInternal[normalizedSessionKey]?.selectedSkillKeys ?? const []; return selected .where((item) => importedKeys.contains(item)) .toList(growable: false); } int get currentAssistantSkillCount { final target = assistantExecutionTargetForSession( currentSessionKeyInternal, ); if (target == AssistantExecutionTarget.singleAgent) { return assistantImportedSkillsForSession( currentSessionKeyInternal, ).length; } return assistantImportedSkillsForSession(currentSessionKeyInternal).length; } List get skills => assistantImportedSkillsForSession( currentSessionKeyInternal, ).map(gatewaySkillFromThreadEntryInternal).toList(growable: false); List get models { if (relayModelsInternal.isNotEmpty && assistantExecutionTargetForSession(currentSessionKeyInternal) != AssistantExecutionTarget.singleAgent) { return relayModelsInternal; } return aiGatewayConversationModelChoices .map( (item) => GatewayModelSummary( id: item, name: item, provider: settingsInternal.defaultProvider.trim().isEmpty ? 'gateway' : settingsInternal.defaultProvider.trim(), contextWindow: null, maxOutputTokens: null, ), ) .toList(growable: false); } bool get currentSingleAgentNeedsAiGatewayConfiguration => assistantExecutionTargetForSession(currentSessionKeyInternal) == AssistantExecutionTarget.singleAgent && !availableSingleAgentProviders.any( webAcpClientInternal.capabilities.providers.contains, ); List get secretReferences { final entries = [ if (storedRelayTokenMaskForProfile(kGatewayLocalProfileIndex) != null) SecretReferenceEntry( name: 'gateway_token.local', provider: 'Gateway', module: 'Assistant', maskedValue: storedRelayTokenMaskForProfile( kGatewayLocalProfileIndex, )!, status: 'In Use', ), if (storedRelayPasswordMaskForProfile(kGatewayLocalProfileIndex) != null) SecretReferenceEntry( name: 'gateway_password.local', provider: 'Gateway', module: 'Assistant', maskedValue: storedRelayPasswordMaskForProfile( kGatewayLocalProfileIndex, )!, status: 'In Use', ), if (storedRelayTokenMaskForProfile(kGatewayRemoteProfileIndex) != null) SecretReferenceEntry( name: 'gateway_token.remote', provider: 'Gateway', module: 'Assistant', maskedValue: storedRelayTokenMaskForProfile( kGatewayRemoteProfileIndex, )!, status: 'In Use', ), if (storedRelayPasswordMaskForProfile(kGatewayRemoteProfileIndex) != null) SecretReferenceEntry( name: 'gateway_password.remote', provider: 'Gateway', module: 'Assistant', maskedValue: storedRelayPasswordMaskForProfile( kGatewayRemoteProfileIndex, )!, status: 'In Use', ), if (storedAiGatewayApiKeyMask != null) SecretReferenceEntry( name: settingsInternal.aiGateway.apiKeyRef, provider: 'LLM API', module: 'Settings', maskedValue: storedAiGatewayApiKeyMask!, status: 'In Use', ), SecretReferenceEntry( name: settingsInternal.aiGateway.name, provider: 'LLM API', module: 'Settings', maskedValue: settingsInternal.aiGateway.baseUrl.trim().isEmpty ? 'Not set' : settingsInternal.aiGateway.baseUrl.trim(), status: settingsInternal.aiGateway.syncState, ), ]; return entries; } List get chatMessages { final base = List.from(currentRecordInternal.messages); final streaming = streamingTextBySessionInternal[currentSessionKeyInternal]?.trim() ?? ''; if (streaming.isNotEmpty) { base.add( GatewayChatMessage( id: 'streaming', role: 'assistant', text: streaming, timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(), toolCallId: null, toolName: null, stopReason: null, pending: true, error: false, ), ); } return base; } List get conversations { final archivedKeys = settingsInternal.assistantArchivedTaskKeys .map(normalizedSessionKeyInternal) .toSet(); final entries = threadRecordsInternal.values .where( (record) => !record.archived && !archivedKeys.contains( normalizedSessionKeyInternal(record.sessionKey), ), ) .map( (record) => WebConversationSummary( sessionKey: record.sessionKey, title: titleForRecordInternal(record), preview: previewForRecordInternal(record), updatedAtMs: record.updatedAtMs ?? DateTime.now().millisecondsSinceEpoch.toDouble(), executionTarget: assistantExecutionTargetForSession( record.sessionKey, ), pending: pendingSessionKeysInternal.contains(record.sessionKey), current: record.sessionKey == currentSessionKeyInternal, ), ) .toList(growable: true) ..sort((left, right) { if (left.current != right.current) { return left.current ? -1 : 1; } return right.updatedAtMs.compareTo(left.updatedAtMs); }); return entries; } List conversationsForTarget( AssistantExecutionTarget target, ) { return conversations .where((item) => item.executionTarget == target) .toList(growable: false); } String get aiGatewayUrl => settingsInternal.aiGateway.baseUrl.trim(); String get resolvedAiGatewayModel { final current = settingsInternal.defaultModel.trim(); final choices = aiGatewayConversationModelChoices; if (choices.contains(current)) { return current; } if (choices.isNotEmpty) { return choices.first; } return ''; } List get aiGatewayConversationModelChoices { final selected = settingsInternal.aiGateway.selectedModels .map((item) => item.trim()) .where( (item) => item.isNotEmpty && settingsInternal.aiGateway.availableModels.contains(item), ) .toList(growable: false); if (selected.isNotEmpty) { return selected; } return settingsInternal.aiGateway.availableModels .map((item) => item.trim()) .where((item) => item.isNotEmpty) .toList(growable: false); } bool get canUseAiGatewayConversation => aiGatewayUrl.isNotEmpty && aiGatewayApiKeyCacheInternal.trim().isNotEmpty && resolvedAiGatewayModel.isNotEmpty; AssistantThreadConnectionState get currentAssistantConnectionState => assistantConnectionStateForSession(currentSessionKeyInternal); AssistantThreadConnectionState assistantConnectionStateForSession( String sessionKey, ) { final normalizedSessionKey = normalizedSessionKeyInternal(sessionKey); final target = assistantExecutionTargetForSession(normalizedSessionKey); if (target == AssistantExecutionTarget.singleAgent) { final provider = singleAgentProviderForSession(normalizedSessionKey); final model = assistantModelForSession(normalizedSessionKey); final host = hostLabelInternal(settingsInternal.aiGateway.baseUrl); if (provider == SingleAgentProvider.auto) { final detail = joinConnectionPartsInternal([model, host]); return AssistantThreadConnectionState( executionTarget: target, status: canUseAiGatewayConversation ? RuntimeConnectionStatus.connected : RuntimeConnectionStatus.offline, primaryLabel: appText('ACP Server Remote', 'ACP Server Remote'), detailLabel: detail.isEmpty ? appText('单机智能体未配置', 'Single Agent not configured') : detail, ready: canUseAiGatewayConversation, pairingRequired: false, gatewayTokenMissing: false, lastError: null, ); } final remoteAddress = gatewayAddressLabelInternal( settingsInternal.primaryRemoteGatewayProfile, ); final remoteReady = connection.status == RuntimeConnectionStatus.connected && connection.mode == RuntimeConnectionMode.remote; return AssistantThreadConnectionState( executionTarget: target, status: remoteReady ? RuntimeConnectionStatus.connected : RuntimeConnectionStatus.offline, primaryLabel: appText('ACP Server Remote', 'ACP Server Remote'), detailLabel: remoteReady ? joinConnectionPartsInternal([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 ? settingsInternal.primaryLocalGatewayProfile : settingsInternal.primaryRemoteGatewayProfile; final matchesTarget = connection.mode == expectedMode; final detail = matchesTarget ? (connection.remoteAddress?.trim().isNotEmpty == true ? connection.remoteAddress!.trim() : gatewayAddressLabelInternal(profile)) : gatewayAddressLabelInternal(profile); return AssistantThreadConnectionState( executionTarget: target, 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, ); } String get assistantConnectionStatusLabel => currentAssistantConnectionState.primaryLabel; String get assistantConnectionTargetLabel { return currentAssistantConnectionState.detailLabel; } String joinConnectionPartsInternal(List parts) { return parts .map((item) => item.trim()) .where((item) => item.isNotEmpty) .join(' · '); } String get conversationPersistenceSummary { if (usesRemoteSessionPersistence) { return appText( '当前会话会同步到远端 Session API,并在浏览器中保留一份本地缓存用于恢复。', 'Conversation history syncs to the remote session API and keeps a browser cache for local recovery.', ); } return appText( '当前会话列表会在浏览器本地保存,刷新后仍可恢复单机智能体 / Relay 的历史入口。', 'Conversation history is stored in this browser so Single Agent and Relay entries remain available after reload.', ); } String get currentConversationTitle => titleForRecordInternal(currentRecordInternal); TaskThread get currentRecordInternal { return requireTaskThreadForSessionInternal(currentSessionKeyInternal); } }