xworkmate-app/lib/app/app_controller_web_sessions.dart
2026-04-06 19:01:24 +08:00

576 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<AssistantArtifactSnapshot> loadAssistantArtifactSnapshot({
String? sessionKey,
}) {
final resolvedSessionKey = normalizedSessionKeyInternal(
sessionKey ?? currentSessionKeyInternal,
);
return artifactProxyClientInternal.loadSnapshot(
sessionKey: resolvedSessionKey,
workspacePath: assistantWorkspacePathForSession(resolvedSessionKey),
workspaceKind: assistantWorkspaceKindForSession(resolvedSessionKey),
);
}
Future<AssistantArtifactPreview> 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<SingleAgentProvider> get singleAgentProviderOptions =>
settingsInternal.savedSingleAgentProviders;
List<SingleAgentProvider> get availableSingleAgentProviders =>
singleAgentProviderOptions;
List<AssistantExecutionTarget> visibleAssistantExecutionTargets(
Iterable<AssistantExecutionTarget> supportedTargets,
) {
return settingsInternal.visibleAssistantExecutionTargets(
supportedTargets: supportedTargets,
availableSingleAgentProviders: availableSingleAgentProviders,
);
}
bool singleAgentUsesAiChatFallbackForSession(String sessionKey) {
final provider = singleAgentProviderForSession(sessionKey);
return provider == SingleAgentProvider.auto && canUseAiGatewayConversation;
}
bool get currentSingleAgentUsesAiChatFallback =>
singleAgentUsesAiChatFallbackForSession(currentSessionKeyInternal);
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) {
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 settingsInternal.defaultModel.trim();
}
String get resolvedAssistantModel =>
assistantModelForSession(currentSessionKeyInternal);
List<String> assistantModelChoicesForSession(String sessionKey) {
final target = assistantExecutionTargetForSession(sessionKey);
if (target == AssistantExecutionTarget.singleAgent) {
if (singleAgentUsesAiChatFallbackForSession(sessionKey)) {
return aiGatewayConversationModelChoices;
}
final runtime = singleAgentRuntimeModelForSession(sessionKey);
if (runtime.isNotEmpty) {
return <String>[runtime];
}
final recordModel = assistantModelForSession(sessionKey);
if (recordModel.isNotEmpty) {
return <String>[recordModel];
}
return aiGatewayConversationModelChoices;
}
final model = settingsInternal.defaultModel.trim();
if (model.isEmpty) {
return const <String>[];
}
return <String>[model];
}
List<String> get assistantModelChoices =>
assistantModelChoicesForSession(currentSessionKeyInternal);
List<AssistantThreadSkillEntry> assistantImportedSkillsForSession(
String sessionKey,
) {
return threadRecordsInternal[normalizedSessionKeyInternal(sessionKey)]
?.importedSkills ??
const <AssistantThreadSkillEntry>[];
}
List<String> assistantSelectedSkillKeysForSession(String sessionKey) {
final normalizedSessionKey = normalizedSessionKeyInternal(sessionKey);
final importedKeys = assistantImportedSkillsForSession(
normalizedSessionKey,
).map((item) => item.key).toSet();
final selected =
threadRecordsInternal[normalizedSessionKey]?.selectedSkillKeys ??
const <String>[];
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<GatewaySkillSummary> get skills => assistantImportedSkillsForSession(
currentSessionKeyInternal,
).map(gatewaySkillFromThreadEntryInternal).toList(growable: false);
List<GatewayModelSummary> 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 =>
currentSingleAgentUsesAiChatFallback && !canUseAiGatewayConversation;
List<SecretReferenceEntry> get secretReferences {
final entries = <SecretReferenceEntry>[
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<GatewayChatMessage> get chatMessages {
final base = List<GatewayChatMessage>.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<WebConversationSummary> 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<WebConversationSummary> 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<String> 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(<String>[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(<String>[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<String> 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);
}
}