xworkmate-app/lib/app/app_controller_web.dart

3116 lines
107 KiB
Dart
Raw Normal View History

2026-03-21 15:52:25 +08:00
import 'dart:async';
import 'package:flutter/material.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
2026-03-25 13:38:40 +08:00
import '../runtime/assistant_artifacts.dart';
2026-03-21 15:52:25 +08:00
import '../runtime/runtime_models.dart';
import '../web/web_acp_client.dart';
2026-03-21 15:52:25 +08:00
import '../web/web_ai_gateway_client.dart';
2026-03-25 13:38:40 +08:00
import '../web/web_artifact_proxy_client.dart';
2026-03-21 15:52:25 +08:00
import '../web/web_relay_gateway_client.dart';
import '../web/web_session_repository.dart';
2026-03-21 15:52:25 +08:00
import '../web/web_store.dart';
import '../web/web_workspace_controllers.dart';
2026-03-21 15:52:25 +08:00
import 'app_capabilities.dart';
import 'ui_feature_manifest.dart';
2026-03-21 15:52:25 +08:00
typedef RemoteWebSessionRepositoryBuilder =
WebSessionRepository Function(
WebSessionPersistenceConfig config,
String clientId,
String accessToken,
);
2026-03-21 15:52:25 +08:00
class AppController extends ChangeNotifier {
AppController({
WebStore? store,
WebAiGatewayClient? aiGatewayClient,
WebAcpClient? acpClient,
2026-03-21 15:52:25 +08:00
WebRelayGatewayClient? relayClient,
RemoteWebSessionRepositoryBuilder? remoteSessionRepositoryBuilder,
UiFeatureManifest? uiFeatureManifest,
2026-03-21 15:52:25 +08:00
}) : _store = store ?? WebStore(),
_uiFeatureManifest = uiFeatureManifest ?? UiFeatureManifest.fallback(),
_aiGatewayClient = aiGatewayClient ?? const WebAiGatewayClient(),
_acpClient = acpClient ?? const WebAcpClient(),
_remoteSessionRepositoryBuilder =
remoteSessionRepositoryBuilder ?? _defaultRemoteSessionRepository {
2026-03-21 15:52:25 +08:00
_relayClient = relayClient ?? WebRelayGatewayClient(_store);
2026-03-25 13:38:40 +08:00
_artifactProxyClient = WebArtifactProxyClient(_relayClient);
2026-03-21 15:52:25 +08:00
_relayEventsSubscription = _relayClient.events.listen(_handleRelayEvent);
unawaited(_initialize());
}
final WebStore _store;
final UiFeatureManifest _uiFeatureManifest;
2026-03-21 15:52:25 +08:00
final WebAiGatewayClient _aiGatewayClient;
final WebAcpClient _acpClient;
final RemoteWebSessionRepositoryBuilder _remoteSessionRepositoryBuilder;
2026-03-21 15:52:25 +08:00
late final WebRelayGatewayClient _relayClient;
2026-03-25 13:38:40 +08:00
late final WebArtifactProxyClient _artifactProxyClient;
late final BrowserWebSessionRepository _browserSessionRepository =
BrowserWebSessionRepository(_store);
2026-03-21 15:52:25 +08:00
late final StreamSubscription<GatewayPushEvent> _relayEventsSubscription;
SettingsSnapshot _settings = SettingsSnapshot.defaults();
SettingsSnapshot _settingsDraft = SettingsSnapshot.defaults();
2026-03-21 15:52:25 +08:00
ThemeMode _themeMode = ThemeMode.light;
WorkspaceDestination _destination = WorkspaceDestination.assistant;
SettingsTab _settingsTab = SettingsTab.general;
bool _settingsDraftInitialized = false;
bool _pendingSettingsApply = false;
String _settingsDraftStatusMessage = '';
final Map<String, String> _draftSecretValues = <String, String>{};
2026-03-21 15:52:25 +08:00
bool _initializing = true;
String? _bootstrapError;
bool _relayBusy = false;
bool _aiGatewayBusy = false;
bool _acpBusy = false;
bool _multiAgentRunPending = false;
2026-03-21 15:52:25 +08:00
final Map<String, AssistantThreadRecord> _threadRecords =
<String, AssistantThreadRecord>{};
final Set<String> _pendingSessionKeys = <String>{};
final Map<String, String> _streamingTextBySession = <String, String>{};
final Map<String, Future<void>> _threadTurnQueues = <String, Future<void>>{};
final Map<String, String> _singleAgentRuntimeModelBySession =
<String, String>{};
final WebTasksController _tasksController = WebTasksController();
2026-03-21 15:52:25 +08:00
String _currentSessionKey = '';
String? _lastAssistantError;
String _webSessionApiTokenCache = '';
String _webSessionClientId = '';
String _sessionPersistenceStatusMessage = '';
WebAcpCapabilities _acpCapabilities = const WebAcpCapabilities.empty();
List<GatewayAgentSummary> _relayAgents = const <GatewayAgentSummary>[];
List<GatewayInstanceSummary> _relayInstances =
const <GatewayInstanceSummary>[];
List<GatewayConnectorSummary> _relayConnectors =
const <GatewayConnectorSummary>[];
List<GatewayModelSummary> _relayModels = const <GatewayModelSummary>[];
List<GatewayCronJobSummary> _relayCronJobs = const <GatewayCronJobSummary>[];
late final WebSkillsController _skillsController = WebSkillsController(
refreshVisibleSkills,
);
2026-03-21 15:52:25 +08:00
UiFeatureManifest get uiFeatureManifest => _uiFeatureManifest;
AppCapabilities get capabilities =>
AppCapabilities.fromFeatureAccess(featuresFor(UiFeaturePlatform.web));
2026-03-21 15:52:25 +08:00
WorkspaceDestination get destination => _destination;
SettingsTab get settingsTab => _settingsTab;
ThemeMode get themeMode => _themeMode;
bool get initializing => _initializing;
String? get bootstrapError => _bootstrapError;
SettingsSnapshot get settings => _settings;
SettingsSnapshot get settingsDraft =>
_settingsDraftInitialized ? _settingsDraft : _settings;
bool get supportsSkillDirectoryAuthorization => false;
List<AuthorizedSkillDirectory> get authorizedSkillDirectories =>
_settings.authorizedSkillDirectories;
List<String> get recommendedAuthorizedSkillDirectoryPaths => const <String>[
'~/.agents/skills',
'~/.codex/skills',
'~/.workbuddy/skills',
];
String get userHomeDirectory => '';
String get settingsYamlPath => '';
bool get hasSettingsDraftChanges =>
settingsDraft.toJsonString() != _settings.toJsonString() ||
_draftSecretValues.isNotEmpty;
bool get hasPendingSettingsApply => _pendingSettingsApply;
String get settingsDraftStatusMessage => _settingsDraftStatusMessage;
2026-03-21 15:52:25 +08:00
AppLanguage get appLanguage => _settings.appLanguage;
AssistantPermissionLevel get assistantPermissionLevel =>
_settings.assistantPermissionLevel;
List<AssistantFocusEntry> get assistantNavigationDestinations => _settings
.assistantNavigationDestinations
.where(supportsAssistantFocusEntry)
.toList(growable: false);
bool supportsAssistantFocusEntry(AssistantFocusEntry entry) {
final destination = entry.destination;
if (destination != null) {
return capabilities.supportsDestination(destination);
}
return capabilities.supportsDestination(WorkspaceDestination.settings);
}
2026-03-21 15:52:25 +08:00
GatewayConnectionSnapshot get connection => _relayClient.snapshot;
bool get relayBusy => _relayBusy;
bool get aiGatewayBusy => _aiGatewayBusy;
bool get acpBusy => _acpBusy;
bool get isMultiAgentRunPending => _multiAgentRunPending;
2026-03-21 15:52:25 +08:00
String? get lastAssistantError => _lastAssistantError;
String get currentSessionKey => _currentSessionKey;
WebSessionPersistenceConfig get webSessionPersistence =>
_settings.webSessionPersistence;
String get sessionPersistenceStatusMessage =>
_sessionPersistenceStatusMessage;
2026-03-21 15:52:25 +08:00
bool get supportsDesktopIntegration => false;
WebTasksController get tasksController => _tasksController;
WebSkillsController get skillsController => _skillsController;
List<GatewayAgentSummary> get agents => _relayAgents;
List<GatewayInstanceSummary> get instances => _relayInstances;
List<GatewayConnectorSummary> get connectors => _relayConnectors;
List<GatewayCronJobSummary> get cronJobs => _relayCronJobs;
String get selectedAgentId => '';
String get activeAgentName {
final current = _relayAgents.where((item) => item.name.trim().isNotEmpty);
if (current.isNotEmpty) {
return current.first.name;
}
return appText('助手', 'Assistant');
}
bool get hasStoredGatewayToken =>
hasStoredGatewayTokenForProfile(kGatewayRemoteProfileIndex) ||
hasStoredGatewayTokenForProfile(kGatewayLocalProfileIndex);
2026-03-21 15:52:25 +08:00
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);
2026-03-21 15:52:25 +08:00
String? get storedRelayTokenMask => WebStore.maskValue(
(_relayTokenByProfile[kGatewayRemoteProfileIndex] ?? '').trim(),
2026-03-21 15:52:25 +08:00
);
String? get storedRelayPasswordMask => WebStore.maskValue(
(_relayPasswordByProfile[kGatewayRemoteProfileIndex] ?? '').trim(),
2026-03-21 15:52:25 +08:00
);
String? get storedAiGatewayApiKeyMask => WebStore.maskValue(
_aiGatewayApiKeyCache.trim().isEmpty ? '' : _aiGatewayApiKeyCache,
);
String? get storedWebSessionApiTokenMask => WebStore.maskValue(
_webSessionApiTokenCache.trim().isEmpty ? '' : _webSessionApiTokenCache,
);
bool get usesRemoteSessionPersistence =>
webSessionPersistence.mode == WebSessionPersistenceMode.remote &&
RemoteWebSessionRepository.normalizeBaseUrl(
webSessionPersistence.remoteBaseUrl,
) !=
null;
2026-03-21 15:52:25 +08:00
final Map<int, String> _relayTokenByProfile = <int, String>{};
final Map<int, String> _relayPasswordByProfile = <int, String>{};
2026-03-21 15:52:25 +08:00
String _aiGatewayApiKeyCache = '';
static const String _draftAiGatewayApiKeyKey = 'ai_gateway_api_key';
static const String _draftVaultTokenKey = 'vault_token';
static const String _draftOllamaApiKeyKey = 'ollama_cloud_api_key';
UiFeatureAccess featuresFor(UiFeaturePlatform platform) {
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;
}
2026-03-21 15:52:25 +08:00
AssistantExecutionTarget get assistantExecutionTarget =>
assistantExecutionTargetForSession(_currentSessionKey);
2026-03-21 15:52:25 +08:00
AssistantExecutionTarget get currentAssistantExecutionTarget =>
assistantExecutionTarget;
2026-03-23 10:11:53 +08:00
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);
2026-03-25 13:38:40 +08:00
String assistantWorkspaceRefForSession(String sessionKey) {
final normalizedSessionKey = _normalizedSessionKey(sessionKey);
final recordRef =
_threadRecords[normalizedSessionKey]?.workspaceRef.trim() ?? '';
if (recordRef.isNotEmpty) {
return recordRef;
}
return _defaultWorkspaceRefForSession(normalizedSessionKey);
}
WorkspaceRefKind assistantWorkspaceRefKindForSession(String sessionKey) {
final normalizedSessionKey = _normalizedSessionKey(sessionKey);
final record = _threadRecords[normalizedSessionKey];
if (record != null && record.workspaceRef.trim().isNotEmpty) {
return record.workspaceRefKind;
}
return WorkspaceRefKind.objectStore;
}
Future<AssistantArtifactSnapshot> loadAssistantArtifactSnapshot({
String? sessionKey,
}) {
final resolvedSessionKey = _normalizedSessionKey(
sessionKey ?? _currentSessionKey,
);
return _artifactProxyClient.loadSnapshot(
sessionKey: resolvedSessionKey,
workspaceRef: assistantWorkspaceRefForSession(resolvedSessionKey),
workspaceRefKind: assistantWorkspaceRefKindForSession(resolvedSessionKey),
);
}
Future<AssistantArtifactPreview> loadAssistantArtifactPreview(
AssistantArtifactEntry entry, {
String? sessionKey,
}) {
final resolvedSessionKey = _normalizedSessionKey(
sessionKey ?? _currentSessionKey,
);
return _artifactProxyClient.loadPreview(
sessionKey: resolvedSessionKey,
entry: entry,
);
}
SingleAgentProvider singleAgentProviderForSession(String sessionKey) {
final normalizedSessionKey = _normalizedSessionKey(sessionKey);
final stored =
_threadRecords[normalizedSessionKey]?.singleAgentProvider ??
SingleAgentProvider.auto;
return _settings.resolveSingleAgentProvider(stored);
}
SingleAgentProvider get currentSingleAgentProvider =>
singleAgentProviderForSession(_currentSessionKey);
List<SingleAgentProvider> get singleAgentProviderOptions =>
<SingleAgentProvider>[
SingleAgentProvider.auto,
..._settings.availableSingleAgentProviders,
];
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<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 = _settings.defaultModel.trim();
if (model.isEmpty) {
return const <String>[];
}
return <String>[model];
}
List<String> get assistantModelChoices =>
assistantModelChoicesForSession(_currentSessionKey);
List<AssistantThreadSkillEntry> assistantImportedSkillsForSession(
String sessionKey,
) {
return _threadRecords[_normalizedSessionKey(sessionKey)]?.importedSkills ??
const <AssistantThreadSkillEntry>[];
}
List<String> assistantSelectedSkillKeysForSession(String sessionKey) {
final normalizedSessionKey = _normalizedSessionKey(sessionKey);
final importedKeys = assistantImportedSkillsForSession(
normalizedSessionKey,
).map((item) => item.key).toSet();
final selected =
_threadRecords[normalizedSessionKey]?.selectedSkillKeys ??
const <String>[];
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;
}
2026-03-25 13:38:40 +08:00
String _defaultWorkspaceRefForSession(String sessionKey) {
final normalizedSessionKey = _normalizedSessionKey(sessionKey);
return 'object://thread/$normalizedSessionKey';
}
void _syncThreadWorkspaceRef(String sessionKey) {
final normalizedSessionKey = _normalizedSessionKey(sessionKey);
final nextWorkspaceRef = _defaultWorkspaceRefForSession(
normalizedSessionKey,
);
final existing = _threadRecords[normalizedSessionKey];
if (existing != null &&
existing.workspaceRef == nextWorkspaceRef &&
existing.workspaceRefKind == WorkspaceRefKind.objectStore) {
return;
}
_upsertThreadRecord(
normalizedSessionKey,
workspaceRef: nextWorkspaceRef,
workspaceRefKind: WorkspaceRefKind.objectStore,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
}
List<GatewaySkillSummary> get skills => assistantImportedSkillsForSession(
_currentSessionKey,
).map(_gatewaySkillFromThreadEntry).toList(growable: false);
List<GatewayModelSummary> get models {
if (_relayModels.isNotEmpty &&
assistantExecutionTargetForSession(_currentSessionKey) !=
AssistantExecutionTarget.singleAgent) {
return _relayModels;
}
return aiGatewayConversationModelChoices
.map(
(item) => GatewayModelSummary(
id: item,
name: item,
provider: _settings.defaultProvider.trim().isEmpty
? 'gateway'
: _settings.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: _settings.aiGateway.apiKeyRef,
provider: 'LLM API',
module: 'Settings',
maskedValue: storedAiGatewayApiKeyMask!,
status: 'In Use',
),
SecretReferenceEntry(
name: _settings.aiGateway.name,
provider: 'LLM API',
module: 'Settings',
maskedValue: _settings.aiGateway.baseUrl.trim().isEmpty
? 'Not set'
: _settings.aiGateway.baseUrl.trim(),
status: _settings.aiGateway.syncState,
),
];
return entries;
}
2026-03-21 15:52:25 +08:00
List<GatewayChatMessage> get chatMessages {
final base = List<GatewayChatMessage>.from(_currentRecord.messages);
final streaming = _streamingTextBySession[_currentSessionKey]?.trim() ?? '';
if (streaming.isNotEmpty) {
base.add(
GatewayChatMessage(
id: 'streaming',
role: 'assistant',
text: streaming,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: true,
error: false,
),
);
}
return base;
}
List<WebConversationSummary> get conversations {
final archivedKeys = _settings.assistantArchivedTaskKeys
.map(_normalizedSessionKey)
.toSet();
2026-03-21 15:52:25 +08:00
final entries =
_threadRecords.values
.where(
(record) =>
!record.archived &&
!archivedKeys.contains(
_normalizedSessionKey(record.sessionKey),
),
)
2026-03-21 15:52:25 +08:00
.map(
(record) => WebConversationSummary(
sessionKey: record.sessionKey,
title: _titleForRecord(record),
preview: _previewForRecord(record),
updatedAtMs:
record.updatedAtMs ??
DateTime.now().millisecondsSinceEpoch.toDouble(),
executionTarget: assistantExecutionTargetForSession(
record.sessionKey,
),
2026-03-21 15:52:25 +08:00
pending: _pendingSessionKeys.contains(record.sessionKey),
current: record.sessionKey == _currentSessionKey,
),
)
.toList(growable: true)
..sort((left, right) {
if (left.current != right.current) {
return left.current ? -1 : 1;
}
return right.updatedAtMs.compareTo(left.updatedAtMs);
});
return entries;
}
List<WebConversationSummary> conversationsForTarget(
AssistantExecutionTarget target,
) {
return conversations
.where((item) => item.executionTarget == target)
.toList(growable: false);
}
String get aiGatewayUrl => _settings.aiGateway.baseUrl.trim();
String get resolvedAiGatewayModel {
final current = _settings.defaultModel.trim();
final choices = aiGatewayConversationModelChoices;
if (choices.contains(current)) {
return current;
}
if (choices.isNotEmpty) {
return choices.first;
}
return '';
}
List<String> get aiGatewayConversationModelChoices {
final selected = _settings.aiGateway.selectedModels
.map((item) => item.trim())
.where(
(item) =>
item.isNotEmpty &&
_settings.aiGateway.availableModels.contains(item),
)
.toList(growable: false);
if (selected.isNotEmpty) {
return selected;
}
return _settings.aiGateway.availableModels
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.toList(growable: false);
}
bool get canUseAiGatewayConversation =>
aiGatewayUrl.isNotEmpty &&
_aiGatewayApiKeyCache.trim().isNotEmpty &&
resolvedAiGatewayModel.isNotEmpty;
AssistantThreadConnectionState get currentAssistantConnectionState =>
assistantConnectionStateForSession(_currentSessionKey);
AssistantThreadConnectionState assistantConnectionStateForSession(
String sessionKey,
) {
final normalizedSessionKey = _normalizedSessionKey(sessionKey);
final target = assistantExecutionTargetForSession(normalizedSessionKey);
2026-03-23 10:11:53 +08:00
if (target == AssistantExecutionTarget.singleAgent) {
final provider = singleAgentProviderForSession(normalizedSessionKey);
final model = assistantModelForSession(normalizedSessionKey);
2026-03-22 12:29:47 +08:00
final host = _hostLabel(_settings.aiGateway.baseUrl);
if (provider == SingleAgentProvider.auto) {
final detail = _joinConnectionParts(<String>[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;
2026-03-22 12:29:47 +08:00
return AssistantThreadConnectionState(
executionTarget: target,
status: remoteReady
2026-03-22 12:29:47 +08:00
? RuntimeConnectionStatus.connected
: RuntimeConnectionStatus.offline,
primaryLabel: target.label,
detailLabel: remoteReady
? _joinConnectionParts(<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,
2026-03-22 12:29:47 +08:00
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);
2026-03-22 12:29:47 +08:00
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,
2026-03-22 12:29:47 +08:00
pairingRequired: false,
gatewayTokenMissing: false,
lastError: null,
);
}
String get assistantConnectionStatusLabel =>
currentAssistantConnectionState.primaryLabel;
2026-03-21 15:52:25 +08:00
String get assistantConnectionTargetLabel {
2026-03-22 12:29:47 +08:00
return currentAssistantConnectionState.detailLabel;
}
String _joinConnectionParts(List<String> parts) {
return parts
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.join(' · ');
2026-03-21 15:52:25 +08:00
}
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(
2026-03-23 10:11:53 +08:00
'当前会话列表会在浏览器本地保存,刷新后仍可恢复单机智能体 / Relay 的历史入口。',
'Conversation history is stored in this browser so Single Agent and Relay entries remain available after reload.',
);
}
2026-03-21 15:52:25 +08:00
String get currentConversationTitle => _titleForRecord(_currentRecord);
AssistantThreadRecord get _currentRecord {
final existing = _threadRecords[_currentSessionKey];
if (existing != null) {
return existing;
}
final target =
_sanitizeTarget(_settings.assistantExecutionTarget) ??
2026-03-23 10:11:53 +08:00
AssistantExecutionTarget.singleAgent;
2026-03-21 15:52:25 +08:00
final record = _newRecord(target: target);
_threadRecords[record.sessionKey] = record;
_currentSessionKey = record.sessionKey;
return record;
}
Future<void> _initialize() async {
try {
await _store.initialize();
_themeMode = await _store.loadThemeMode();
_settings = _sanitizeSettings(await _store.loadSettingsSnapshot());
_aiGatewayApiKeyCache = await _store.loadAiGatewayApiKey();
for (final profileIndex in <int>[
kGatewayLocalProfileIndex,
kGatewayRemoteProfileIndex,
]) {
_relayTokenByProfile[profileIndex] = await _store.loadRelayToken(
profileIndex: profileIndex,
);
_relayPasswordByProfile[profileIndex] = await _store.loadRelayPassword(
profileIndex: profileIndex,
);
}
_webSessionClientId = await _store.loadOrCreateWebSessionClientId();
final records = await _loadThreadRecords();
2026-03-21 15:52:25 +08:00
for (final record in records) {
final sanitized = _sanitizeRecord(record);
_threadRecords[sanitized.sessionKey] = sanitized;
}
if (_threadRecords.isEmpty) {
final record = _newRecord(
target: _settings.assistantExecutionTarget,
title: appText('新对话', 'New conversation'),
);
_threadRecords[record.sessionKey] = record;
}
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;
_recomputeDerivedWorkspaceState();
2026-03-21 15:52:25 +08:00
} catch (error) {
_bootstrapError = '$error';
} finally {
_initializing = false;
notifyListeners();
}
}
void navigateTo(WorkspaceDestination destination) {
if (!capabilities.supportsDestination(destination)) {
return;
}
_destination = destination;
notifyListeners();
}
Future<void> saveWebSessionPersistenceConfiguration({
required WebSessionPersistenceMode mode,
required String remoteBaseUrl,
required String apiToken,
}) async {
final trimmedRemoteBaseUrl = remoteBaseUrl.trim();
final normalizedRemoteBaseUrl = RemoteWebSessionRepository.normalizeBaseUrl(
trimmedRemoteBaseUrl,
);
if (mode == WebSessionPersistenceMode.remote &&
trimmedRemoteBaseUrl.isNotEmpty &&
normalizedRemoteBaseUrl == null) {
_sessionPersistenceStatusMessage = appText(
'Session API URL 必须使用 HTTPS仅 localhost / 127.0.0.1 允许 HTTP 作为开发回路。',
'Session API URLs must use HTTPS. HTTP is allowed only for localhost or 127.0.0.1 during development.',
);
notifyListeners();
return;
}
2026-03-22 10:55:22 +08:00
_settings = _settings.copyWith(
webSessionPersistence: _settings.webSessionPersistence.copyWith(
mode: mode,
remoteBaseUrl:
normalizedRemoteBaseUrl?.toString() ?? trimmedRemoteBaseUrl,
),
);
_webSessionApiTokenCache = apiToken.trim();
await _persistSettings();
await _persistThreads();
notifyListeners();
}
2026-03-21 15:52:25 +08:00
void navigateHome() {
navigateTo(WorkspaceDestination.assistant);
}
void openSettings({SettingsTab tab = SettingsTab.general}) {
_destination = WorkspaceDestination.settings;
_settingsTab = _sanitizeSettingsTab(tab);
notifyListeners();
}
void setSettingsTab(SettingsTab tab) {
_settingsTab = _sanitizeSettingsTab(tab);
notifyListeners();
}
List<DerivedTaskItem> taskItemsForTab(String tab) => switch (tab) {
'Queue' => _tasksController.queue,
'Running' => _tasksController.running,
'History' => _tasksController.history,
'Failed' => _tasksController.failed,
'Scheduled' => _tasksController.scheduled,
_ => _tasksController.queue,
};
Future<void> refreshSessions() async {
if (connection.status == RuntimeConnectionStatus.connected) {
await refreshRelaySessions();
await refreshRelayWorkspaceResources();
await refreshRelayHistory(sessionKey: _currentSessionKey);
await refreshRelaySkillsForSession(_currentSessionKey);
} else {
_recomputeDerivedWorkspaceState();
notifyListeners();
}
}
Future<void> refreshAgents() async {
await refreshRelayWorkspaceResources();
}
Future<void> refreshGatewayHealth() async {
if (connection.status != RuntimeConnectionStatus.connected) {
return;
}
await refreshRelayWorkspaceResources();
}
Future<void> refreshVisibleSkills(String? agentId) async {
final target = assistantExecutionTargetForSession(_currentSessionKey);
if (target == AssistantExecutionTarget.local ||
target == AssistantExecutionTarget.remote) {
await refreshRelaySkillsForSession(_currentSessionKey);
return;
}
await _refreshSingleAgentSkillsForSession(_currentSessionKey);
}
Future<void> toggleAssistantNavigationDestination(
AssistantFocusEntry destination,
) async {
if (!kAssistantNavigationDestinationCandidates.contains(destination) ||
!supportsAssistantFocusEntry(destination)) {
return;
}
final current = assistantNavigationDestinations;
final next = current.contains(destination)
? current.where((item) => item != destination).toList(growable: false)
: <AssistantFocusEntry>[...current, destination];
_settings = _settings.copyWith(assistantNavigationDestinations: next);
if (_settingsDraftInitialized) {
_settingsDraft = settingsDraft.copyWith(
assistantNavigationDestinations: next,
);
}
notifyListeners();
await _persistSettings();
}
2026-03-21 15:52:25 +08:00
Future<void> setThemeMode(ThemeMode mode) async {
if (_themeMode == mode) {
return;
}
_themeMode = mode;
await _store.saveThemeMode(mode);
notifyListeners();
}
Future<void> saveSettingsDraft(SettingsSnapshot snapshot) async {
_settingsDraft = snapshot;
_settingsDraftInitialized = true;
_settingsDraftStatusMessage = appText(
'草稿已更新,点击顶部保存持久化。',
'Draft updated. Use the top Save button to persist it.',
);
notifyListeners();
}
Future<AuthorizedSkillDirectory?> authorizeSkillDirectory({
String suggestedPath = '',
}) async {
return null;
}
Future<List<AuthorizedSkillDirectory>> authorizeSkillDirectories({
List<String> suggestedPaths = const <String>[],
}) async {
return const <AuthorizedSkillDirectory>[];
}
Future<void> saveAuthorizedSkillDirectories(
List<AuthorizedSkillDirectory> directories,
) async {
_settings = _settings.copyWith(
authorizedSkillDirectories: normalizeAuthorizedSkillDirectories(
directories: directories,
),
);
if (_settingsDraftInitialized) {
_settingsDraft = _settingsDraft.copyWith(
authorizedSkillDirectories: _settings.authorizedSkillDirectories,
);
}
await _persistSettings();
notifyListeners();
}
void saveAiGatewayApiKeyDraft(String value) {
_saveSecretDraft(_draftAiGatewayApiKeyKey, value);
}
void saveVaultTokenDraft(String value) {
_saveSecretDraft(_draftVaultTokenKey, value);
}
void saveOllamaCloudApiKeyDraft(String value) {
_saveSecretDraft(_draftOllamaApiKeyKey, value);
}
Future<String> testOllamaConnection({required bool cloud}) async {
return cloud
? 'Cloud test unavailable on web'
: 'Local test unavailable on web';
}
Future<String> testOllamaConnectionDraft({
required bool cloud,
required SettingsSnapshot snapshot,
String apiKeyOverride = '',
}) async {
return testOllamaConnection(cloud: cloud);
}
Future<String> testVaultConnection() async {
return 'Vault test unavailable on web';
}
Future<String> testVaultConnectionDraft({
required SettingsSnapshot snapshot,
String tokenOverride = '',
}) async {
return testVaultConnection();
}
Future<({String state, String message, String endpoint})>
testGatewayConnectionDraft({
required GatewayConnectionProfile profile,
required AssistantExecutionTarget executionTarget,
String tokenOverride = '',
String passwordOverride = '',
}) async {
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<void> persistSettingsDraft() async {
if (!hasSettingsDraftChanges) {
_settingsDraftStatusMessage = appText(
'没有需要保存的更改。',
'There are no changes to save.',
);
notifyListeners();
return;
}
_settings = settingsDraft;
await _persistDraftSecrets();
await _persistSettings();
_settingsDraft = _settings;
_settingsDraftInitialized = true;
_pendingSettingsApply = true;
_settingsDraftStatusMessage = appText(
'已保存配置,不立即生效。',
'Settings saved. They do not take effect until Apply.',
);
notifyListeners();
}
Future<void> applySettingsDraft() async {
if (hasSettingsDraftChanges) {
await persistSettingsDraft();
}
if (!_pendingSettingsApply) {
_settingsDraftStatusMessage = appText(
'没有需要应用的更改。',
'There are no saved changes to apply.',
);
notifyListeners();
return;
}
_settingsDraft = _settings;
_settingsDraftInitialized = true;
_pendingSettingsApply = false;
_settingsDraftStatusMessage = appText(
'已按当前配置生效。',
'The current configuration is now in effect.',
);
notifyListeners();
}
2026-03-21 15:52:25 +08:00
Future<void> toggleAppLanguage() async {
final next = _settings.appLanguage == AppLanguage.zh
? AppLanguage.en
: AppLanguage.zh;
_settings = _settings.copyWith(appLanguage: next);
await _persistSettings();
notifyListeners();
}
Future<void> createConversation({AssistantExecutionTarget? target}) async {
final inheritedTarget =
_sanitizeTarget(target) ??
assistantExecutionTargetForSession(_currentSessionKey);
final inheritedRecord =
_threadRecords[_normalizedSessionKey(_currentSessionKey)];
2026-03-25 13:38:40 +08:00
final baseRecord = _newRecord(
target: inheritedTarget,
title: appText('新对话', 'New conversation'),
);
final record = baseRecord.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),
workspaceRef: inheritedRecord?.workspaceRef.trim().isNotEmpty == true
? inheritedRecord!.workspaceRef
: _defaultWorkspaceRefForSession(baseRecord.sessionKey),
workspaceRefKind:
inheritedRecord?.workspaceRefKind ?? WorkspaceRefKind.objectStore,
);
2026-03-21 15:52:25 +08:00
_threadRecords[record.sessionKey] = record;
_currentSessionKey = record.sessionKey;
_lastAssistantError = null;
_settings = _settings.copyWith(assistantLastSessionKey: record.sessionKey);
_recomputeDerivedWorkspaceState();
await _persistSettings();
2026-03-21 15:52:25 +08:00
await _persistThreads();
notifyListeners();
}
Future<void> switchConversation(String sessionKey) async {
final normalizedSessionKey = _normalizedSessionKey(sessionKey);
if (!_threadRecords.containsKey(normalizedSessionKey)) {
return;
}
final previousSessionKey = _normalizedSessionKey(_currentSessionKey);
if (previousSessionKey == normalizedSessionKey) {
2026-03-21 15:52:25 +08:00
return;
}
if (assistantExecutionTargetForSession(previousSessionKey) !=
AssistantExecutionTarget.singleAgent) {
_streamingTextBySession.remove(previousSessionKey);
}
_currentSessionKey = normalizedSessionKey;
2026-03-21 15:52:25 +08:00
_lastAssistantError = null;
_settings = _settings.copyWith(
assistantLastSessionKey: normalizedSessionKey,
);
2026-03-25 13:38:40 +08:00
_syncThreadWorkspaceRef(normalizedSessionKey);
await _persistSettings();
2026-03-21 15:52:25 +08:00
notifyListeners();
final target = assistantExecutionTargetForSession(normalizedSessionKey);
await _applyAssistantExecutionTarget(
target,
sessionKey: normalizedSessionKey,
persistDefaultSelection: false,
);
if (target == AssistantExecutionTarget.singleAgent) {
await _refreshSingleAgentSkillsForSession(normalizedSessionKey);
return;
}
if (target == AssistantExecutionTarget.local ||
target == AssistantExecutionTarget.remote) {
await refreshRelayHistory(sessionKey: normalizedSessionKey);
await refreshRelaySkillsForSession(normalizedSessionKey);
2026-03-21 15:52:25 +08:00
}
}
Future<void> setAssistantExecutionTarget(
AssistantExecutionTarget target,
) async {
final resolvedTarget =
_sanitizeTarget(target) ??
assistantExecutionTargetForSession(_currentSessionKey);
final sessionKey = _normalizedSessionKey(_currentSessionKey);
_upsertThreadRecord(
sessionKey,
executionTarget: resolvedTarget,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
gatewayEntryState: _gatewayEntryStateForTarget(resolvedTarget),
2026-03-25 13:38:40 +08:00
workspaceRef: _defaultWorkspaceRefForSession(sessionKey),
workspaceRefKind: WorkspaceRefKind.objectStore,
);
2026-03-21 15:52:25 +08:00
_settings = _settings.copyWith(assistantExecutionTarget: resolvedTarget);
await _persistSettings();
await _persistThreads();
notifyListeners();
await _applyAssistantExecutionTarget(
resolvedTarget,
sessionKey: sessionKey,
persistDefaultSelection: true,
);
if (resolvedTarget == AssistantExecutionTarget.singleAgent) {
await _refreshSingleAgentSkillsForSession(sessionKey);
} else if (resolvedTarget == AssistantExecutionTarget.local ||
resolvedTarget == AssistantExecutionTarget.remote) {
await refreshRelaySkillsForSession(sessionKey);
}
notifyListeners();
}
Future<void> setSingleAgentProvider(SingleAgentProvider provider) async {
final resolvedProvider = _settings.resolveSingleAgentProvider(provider);
if (!singleAgentProviderOptions.contains(resolvedProvider)) {
return;
}
final sessionKey = _normalizedSessionKey(_currentSessionKey);
if (singleAgentProviderForSession(sessionKey) == resolvedProvider) {
return;
}
_singleAgentRuntimeModelBySession.remove(sessionKey);
_upsertThreadRecord(
sessionKey,
singleAgentProvider: resolvedProvider,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
2026-03-21 15:52:25 +08:00
);
await _persistThreads();
notifyListeners();
if (assistantExecutionTargetForSession(sessionKey) ==
AssistantExecutionTarget.singleAgent) {
await _refreshSingleAgentSkillsForSession(sessionKey);
}
}
Future<void> 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<void> 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<void> selectAssistantModel(String modelId) async {
await selectAssistantModelForSession(_currentSessionKey, modelId);
}
Future<void> saveAssistantTaskTitle(String sessionKey, String title) async {
final normalizedSessionKey = _normalizedSessionKey(sessionKey);
if (!_threadRecords.containsKey(normalizedSessionKey)) {
return;
}
final trimmedTitle = title.trim();
final nextTitles = Map<String, String>.from(
_settings.assistantCustomTaskTitles,
);
if (trimmedTitle.isEmpty) {
nextTitles.remove(normalizedSessionKey);
} else {
nextTitles[normalizedSessionKey] = trimmedTitle;
}
_settings = _settings.copyWith(assistantCustomTaskTitles: nextTitles);
_upsertThreadRecord(normalizedSessionKey, title: trimmedTitle);
2026-03-21 15:52:25 +08:00
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<void> 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;
}
}
_recomputeDerivedWorkspaceState();
await _persistSettings();
await _persistThreads();
notifyListeners();
}
Future<void> 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();
}
2026-03-21 15:52:25 +08:00
Future<void> saveAiGatewayConfiguration({
required String name,
required String baseUrl,
required String provider,
required String apiKey,
required String defaultModel,
}) async {
final normalizedBaseUrl = _aiGatewayClient.normalizeBaseUrl(baseUrl);
_settings = _settings.copyWith(
defaultProvider: provider.trim().isEmpty ? 'gateway' : provider.trim(),
defaultModel: defaultModel.trim(),
aiGateway: _settings.aiGateway.copyWith(
2026-03-23 10:11:53 +08:00
name: name.trim().isEmpty ? 'Single Agent' : name.trim(),
2026-03-21 15:52:25 +08:00
baseUrl: normalizedBaseUrl?.toString() ?? baseUrl.trim(),
),
);
_aiGatewayApiKeyCache = apiKey.trim();
await _store.saveAiGatewayApiKey(_aiGatewayApiKeyCache);
await _persistSettings();
notifyListeners();
}
Future<AiGatewayConnectionCheck> testAiGatewayConnection({
required String baseUrl,
required String apiKey,
}) async {
_aiGatewayBusy = true;
notifyListeners();
try {
return await _aiGatewayClient.testConnection(
baseUrl: baseUrl,
apiKey: apiKey,
);
} finally {
_aiGatewayBusy = false;
notifyListeners();
}
}
Future<void> syncAiGatewayModels({
required String name,
required String baseUrl,
required String provider,
required String apiKey,
}) async {
_aiGatewayBusy = true;
notifyListeners();
try {
final models = await _aiGatewayClient.loadModels(
baseUrl: baseUrl,
apiKey: apiKey,
);
final availableModels = models
.map((item) => item.id)
.toList(growable: false);
final selectedModels = availableModels.take(5).toList(growable: false);
final resolvedDefaultModel =
_settings.defaultModel.trim().isNotEmpty &&
availableModels.contains(_settings.defaultModel.trim())
? _settings.defaultModel.trim()
: selectedModels.isNotEmpty
? selectedModels.first
: '';
_settings = _settings.copyWith(
defaultProvider: provider.trim().isEmpty ? 'gateway' : provider.trim(),
defaultModel: resolvedDefaultModel,
aiGateway: _settings.aiGateway.copyWith(
2026-03-23 10:11:53 +08:00
name: name.trim().isEmpty ? 'Single Agent' : name.trim(),
2026-03-21 15:52:25 +08:00
baseUrl:
_aiGatewayClient.normalizeBaseUrl(baseUrl)?.toString() ??
baseUrl.trim(),
availableModels: availableModels,
selectedModels: selectedModels,
syncState: 'ready',
syncMessage: 'Loaded ${availableModels.length} model(s)',
),
);
_aiGatewayApiKeyCache = apiKey.trim();
await _store.saveAiGatewayApiKey(_aiGatewayApiKeyCache);
await _persistSettings();
_recomputeDerivedWorkspaceState();
2026-03-21 15:52:25 +08:00
} catch (error) {
_settings = _settings.copyWith(
aiGateway: _settings.aiGateway.copyWith(
syncState: 'error',
syncMessage: _aiGatewayClient.networkErrorLabel(error),
),
);
await _persistSettings();
_recomputeDerivedWorkspaceState();
2026-03-21 15:52:25 +08:00
rethrow;
} finally {
_aiGatewayBusy = false;
notifyListeners();
}
}
Future<void> saveRelayConfiguration({
required String host,
required int port,
required bool tls,
required String token,
required String password,
int profileIndex = kGatewayRemoteProfileIndex,
2026-03-21 15:52:25 +08:00
}) async {
final baseProfile = profileIndex == kGatewayLocalProfileIndex
? _settings.primaryLocalGatewayProfile
: _settings.primaryRemoteGatewayProfile;
final mode = profileIndex == kGatewayLocalProfileIndex
? RuntimeConnectionMode.local
: RuntimeConnectionMode.remote;
2026-03-21 15:52:25 +08:00
_settings = _settings.copyWith(
gatewayProfiles: replaceGatewayProfileAt(
_settings.gatewayProfiles,
profileIndex,
baseProfile.copyWith(
mode: mode,
useSetupCode: false,
setupCode: '',
host: host.trim(),
port: port,
tls: mode == RuntimeConnectionMode.local ? false : tls,
),
2026-03-21 15:52:25 +08:00
),
);
_relayTokenByProfile[profileIndex] = token.trim();
_relayPasswordByProfile[profileIndex] = password.trim();
await _store.saveRelayToken(
_relayTokenByProfile[profileIndex] ?? '',
profileIndex: profileIndex,
);
await _store.saveRelayPassword(
_relayPasswordByProfile[profileIndex] ?? '',
profileIndex: profileIndex,
);
2026-03-21 15:52:25 +08:00
await _persistSettings();
notifyListeners();
}
Future<void> 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<void> connectRelay({AssistantExecutionTarget? target}) async {
2026-03-21 15:52:25 +08:00
_relayBusy = true;
notifyListeners();
try {
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: '',
);
2026-03-21 15:52:25 +08:00
await _relayClient.connect(
profile: profile,
authToken: (_relayTokenByProfile[profileIndex] ?? '').trim(),
authPassword: (_relayPasswordByProfile[profileIndex] ?? '').trim(),
2026-03-21 15:52:25 +08:00
);
final acpEndpoint = _acpEndpointForTarget(resolvedTarget);
if (acpEndpoint != null) {
await _refreshAcpCapabilities(acpEndpoint);
}
2026-03-21 15:52:25 +08:00
await refreshRelaySessions();
await refreshRelayWorkspaceResources();
await refreshRelayHistory(sessionKey: _currentSessionKey);
await refreshRelaySkillsForSession(_currentSessionKey);
2026-03-21 15:52:25 +08:00
} finally {
_relayBusy = false;
notifyListeners();
}
}
Future<void> disconnectRelay() async {
_relayBusy = true;
notifyListeners();
try {
await _relayClient.disconnect();
_relayAgents = const <GatewayAgentSummary>[];
_relayInstances = const <GatewayInstanceSummary>[];
_relayConnectors = const <GatewayConnectorSummary>[];
_relayModels = const <GatewayModelSummary>[];
_relayCronJobs = const <GatewayCronJobSummary>[];
_recomputeDerivedWorkspaceState();
2026-03-21 15:52:25 +08:00
} finally {
_relayBusy = false;
notifyListeners();
}
}
Future<void> refreshRelaySessions() async {
if (connection.status != RuntimeConnectionStatus.connected) {
return;
}
final target = _assistantExecutionTargetForMode(connection.mode);
2026-03-21 15:52:25 +08:00
final sessions = await _relayClient.listSessions(limit: 50);
for (final session in sessions) {
final sessionKey = _normalizedSessionKey(session.key);
final existing = _threadRecords[sessionKey];
2026-03-21 15:52:25 +08:00
final next = AssistantThreadRecord(
sessionKey: sessionKey,
2026-03-21 15:52:25 +08:00
messages: existing?.messages ?? const <GatewayChatMessage>[],
updatedAtMs:
session.updatedAtMs ??
existing?.updatedAtMs ??
DateTime.now().millisecondsSinceEpoch.toDouble(),
title: (session.derivedTitle ?? session.displayName ?? session.key)
.trim(),
archived: false,
executionTarget: existing?.executionTarget ?? target,
2026-03-21 15:52:25 +08:00
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),
2026-03-25 13:38:40 +08:00
workspaceRef: existing?.workspaceRef.trim().isNotEmpty == true
? existing!.workspaceRef
: _defaultWorkspaceRefForSession(sessionKey),
workspaceRefKind:
existing?.workspaceRefKind ?? WorkspaceRefKind.objectStore,
2026-03-21 15:52:25 +08:00
);
_threadRecords[sessionKey] = next;
2026-03-21 15:52:25 +08:00
}
await _persistThreads();
_recomputeDerivedWorkspaceState();
2026-03-21 15:52:25 +08:00
notifyListeners();
}
Future<void> refreshRelayModels() async {
if (connection.status != RuntimeConnectionStatus.connected) {
return;
}
final models = await _relayClient.listModels();
_relayModels = models;
2026-03-21 15:52:25 +08:00
final availableModels = models
.map((item) => item.id.trim())
.where((item) => item.isNotEmpty)
.toList(growable: false);
if (availableModels.isEmpty) {
return;
}
final defaultModel = _settings.defaultModel.trim().isNotEmpty
? _settings.defaultModel.trim()
: availableModels.first;
_settings = _settings.copyWith(
defaultModel: defaultModel,
aiGateway: _settings.aiGateway.copyWith(
availableModels: _settings.aiGateway.availableModels.isEmpty
? availableModels
: _settings.aiGateway.availableModels,
),
);
await _persistSettings();
_recomputeDerivedWorkspaceState();
notifyListeners();
}
Future<void> refreshRelayWorkspaceResources() async {
if (connection.status != RuntimeConnectionStatus.connected) {
return;
}
try {
_relayAgents = await _relayClient.listAgents();
} catch (_) {
_relayAgents = const <GatewayAgentSummary>[];
}
try {
_relayInstances = await _relayClient.listInstances();
} catch (_) {
_relayInstances = const <GatewayInstanceSummary>[];
}
try {
_relayConnectors = await _relayClient.listConnectors();
} catch (_) {
_relayConnectors = const <GatewayConnectorSummary>[];
}
try {
_relayCronJobs = await _relayClient.listCronJobs();
} catch (_) {
_relayCronJobs = const <GatewayCronJobSummary>[];
}
await refreshRelayModels();
_recomputeDerivedWorkspaceState();
2026-03-21 15:52:25 +08:00
notifyListeners();
}
Future<void> refreshRelayHistory({String? sessionKey}) async {
final resolvedKey = _normalizedSessionKey(sessionKey ?? _currentSessionKey);
2026-03-21 15:52:25 +08:00
if (resolvedKey.isEmpty ||
connection.status != RuntimeConnectionStatus.connected) {
return;
}
final target = _assistantExecutionTargetForMode(connection.mode);
2026-03-21 15:52:25 +08:00
final messages = await _relayClient.loadHistory(resolvedKey, limit: 120);
final existing = _threadRecords[resolvedKey];
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),
);
2026-03-21 15:52:25 +08:00
_threadRecords[resolvedKey] = next;
_streamingTextBySession.remove(resolvedKey);
await _persistThreads();
_recomputeDerivedWorkspaceState();
2026-03-21 15:52:25 +08:00
notifyListeners();
}
Future<void> 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<dynamic>? ?? const <dynamic>[])
.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);
final importedKeys = skills.map((item) => item.key).toSet();
final nextSelected =
(_threadRecords[normalizedSessionKey]?.selectedSkillKeys ??
const <String>[])
.where(importedKeys.contains)
.toList(growable: false);
_upsertThreadRecord(
normalizedSessionKey,
importedSkills: skills,
selectedSkillKeys: nextSelected,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
await _persistThreads();
_recomputeDerivedWorkspaceState();
notifyListeners();
} catch (_) {
// Best effort: skill discovery should not block chat flows.
}
}
Future<void> _refreshSingleAgentSkillsForSession(String sessionKey) async {
final normalizedSessionKey = _normalizedSessionKey(sessionKey);
if (assistantExecutionTargetForSession(normalizedSessionKey) !=
AssistantExecutionTarget.singleAgent) {
return;
}
final endpoint = _acpEndpointForTarget(AssistantExecutionTarget.remote);
if (endpoint == null) {
await _replaceThreadSkillsForSession(
normalizedSessionKey,
const <AssistantThreadSkillEntry>[],
);
return;
}
final provider = singleAgentProviderForSession(normalizedSessionKey);
try {
await _refreshAcpCapabilities(endpoint);
final response = await _acpClient.request(
endpoint: endpoint,
method: 'skills.status',
params: <String, dynamic>{
'sessionId': normalizedSessionKey,
'threadId': normalizedSessionKey,
'mode': 'single-agent',
'provider': provider.providerId,
},
);
final result = _castMap(response['result']);
final payload = result.isNotEmpty ? result : response;
final skills = (payload['skills'] as List<dynamic>? ?? const <dynamic>[])
.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() ?? provider.providerId,
sourcePath: item['path']?.toString().trim() ?? '',
scope: item['scope']?.toString().trim().isNotEmpty == true
? item['scope'].toString().trim()
: 'session',
sourceLabel:
item['sourceLabel']?.toString().trim().isNotEmpty == true
? item['sourceLabel'].toString().trim()
: (item['source']?.toString().trim().isNotEmpty == true
? item['source'].toString().trim()
: provider.label),
),
)
.where((entry) => entry.key.isNotEmpty && entry.label.isNotEmpty)
.toList(growable: false);
await _replaceThreadSkillsForSession(normalizedSessionKey, skills);
} on WebAcpException catch (error) {
if (_unsupportedAcpSkillsStatus(error)) {
await _replaceThreadSkillsForSession(
normalizedSessionKey,
const <AssistantThreadSkillEntry>[],
);
}
} catch (_) {
// Keep current skills when transient ACP failures happen.
}
}
Future<void> _replaceThreadSkillsForSession(
String sessionKey,
List<AssistantThreadSkillEntry> importedSkills,
) async {
final normalizedSessionKey = _normalizedSessionKey(sessionKey);
final importedKeys = importedSkills.map((item) => item.key).toSet();
final nextSelected =
(_threadRecords[normalizedSessionKey]?.selectedSkillKeys ??
const <String>[])
.where(importedKeys.contains)
.toList(growable: false);
_upsertThreadRecord(
normalizedSessionKey,
importedSkills: importedSkills,
selectedSkillKeys: nextSelected,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
await _persistThreads();
_recomputeDerivedWorkspaceState();
notifyListeners();
}
Future<void> sendMessage(
String rawMessage, {
String thinking = 'medium',
List<GatewayChatAttachmentPayload> attachments =
const <GatewayChatAttachmentPayload>[],
List<String> selectedSkillLabels = const <String>[],
bool useMultiAgent = false,
}) async {
2026-03-21 15:52:25 +08:00
final trimmed = rawMessage.trim();
if (trimmed.isEmpty) {
return;
}
2026-03-25 13:38:40 +08:00
_syncThreadWorkspaceRef(_currentSessionKey);
const maxAttachmentBytes = 10 * 1024 * 1024;
final totalAttachmentBytes = attachments.fold<int>(
0,
(total, item) => total + _base64Size(item.content),
2026-03-21 15:52:25 +08:00
);
if (totalAttachmentBytes > maxAttachmentBytes) {
_lastAssistantError = appText(
'附件总大小超过 10MB请减少附件后重试。',
'Attachments exceed the 10MB limit. Remove some files and try again.',
);
notifyListeners();
return;
}
final sessionKey = _normalizedSessionKey(_currentSessionKey);
await _enqueueThreadTurn<void>(sessionKey, () async {
_lastAssistantError = null;
final target = assistantExecutionTargetForSession(sessionKey);
final current = _threadRecords[sessionKey] ?? _newRecord(target: target);
final nextMessages = <GatewayChatMessage>[
...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();
2026-03-21 15:52:25 +08:00
try {
if (useMultiAgent && _settings.multiAgent.enabled) {
await runMultiAgentCollaboration(
rawPrompt: trimmed,
composedPrompt: trimmed,
attachments: attachments,
selectedSkillLabels: selectedSkillLabels,
2026-03-21 15:52:25 +08:00
);
return;
2026-03-21 15:52:25 +08:00
}
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<GatewayChatMessage>.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: <String, dynamic>{
if (selectedSkillLabels.isNotEmpty)
'selectedSkills': selectedSkillLabels,
},
);
}
} catch (error) {
2026-03-21 15:52:25 +08:00
_appendAssistantMessage(
sessionKey: sessionKey,
text: error.toString(),
error: true,
2026-03-21 15:52:25 +08:00
);
_lastAssistantError = error.toString();
_pendingSessionKeys.remove(sessionKey);
_streamingTextBySession.remove(sessionKey);
await _persistThreads();
notifyListeners();
}
});
}
Future<void> runMultiAgentCollaboration({
required String rawPrompt,
required String composedPrompt,
required List<GatewayChatAttachmentPayload> attachments,
required List<String> selectedSkillLabels,
}) async {
final sessionKey = _normalizedSessionKey(_currentSessionKey);
await _enqueueThreadTurn<void>(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) {
2026-03-21 15:52:25 +08:00
throw Exception(
appText(
'当前线程的 ACP 端点不可用,请先配置并连接 Gateway。',
'ACP endpoint is unavailable for this thread. Configure and connect Gateway first.',
2026-03-21 15:52:25 +08:00
),
);
}
await _refreshAcpCapabilities(endpoint);
final inlineAttachments = attachments
.map(
(item) => <String, dynamic>{
'name': item.fileName,
'mimeType': item.mimeType,
'content': item.content,
'sizeBytes': _base64Size(item.content),
},
)
.toList(growable: false);
final params = <String, dynamic>{
'sessionId': sessionKey,
'threadId': sessionKey,
'mode': 'multi-agent',
'taskPrompt': composedPrompt,
'workingDirectory': '',
'selectedSkills': selectedSkillLabels,
'attachments': attachments
.map(
(item) => <String, dynamic>{
'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,
2026-03-21 15:52:25 +08:00
);
_lastAssistantError = error.toString();
} finally {
_multiAgentRunPending = false;
_acpBusy = false;
_pendingSessionKeys.remove(sessionKey);
await _persistThreads();
notifyListeners();
2026-03-21 15:52:25 +08:00
}
});
}
Future<void> abortRun() async {
final sessionKey = _normalizedSessionKey(_currentSessionKey);
if (_multiAgentRunPending || _acpBusy) {
final target = assistantExecutionTargetForSession(sessionKey);
final endpoint = _acpEndpointForTarget(
target == AssistantExecutionTarget.singleAgent
? AssistantExecutionTarget.remote
: target,
2026-03-21 15:52:25 +08:00
);
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);
2026-03-21 15:52:25 +08:00
notifyListeners();
return;
2026-03-21 15:52:25 +08:00
}
}
Future<void> selectDirectModel(String model) async {
final trimmed = model.trim();
if (trimmed.isEmpty) {
return;
}
await selectAssistantModel(trimmed);
2026-03-21 15:52:25 +08:00
_settings = _settings.copyWith(defaultModel: trimmed);
await _persistSettings();
notifyListeners();
}
Future<void> _sendSingleAgentViaAcp({
required String sessionKey,
required String prompt,
required SingleAgentProvider provider,
required String model,
required String thinking,
required List<GatewayChatAttachmentPayload> attachments,
required List<String> 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) => <String, dynamic>{
'name': item.fileName,
'mimeType': item.mimeType,
'content': item.content,
'sizeBytes': _base64Size(item.content),
},
)
.toList(growable: false);
final response = await _requestAcpSessionMessage(
endpoint: endpoint,
params: <String, dynamic>{
'sessionId': sessionKey,
'threadId': sessionKey,
'mode': 'single-agent',
'provider': provider.providerId,
'model': model.trim(),
'thinking': thinking,
'taskPrompt': prompt,
'workingDirectory': '',
'selectedSkills': selectedSkillLabels,
'attachments': attachments
.map(
(item) => <String, dynamic>{
'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();
}
}
void _recomputeDerivedWorkspaceState() {
final archivedKeys = _settings.assistantArchivedTaskKeys
.map(_normalizedSessionKey)
.toSet();
final visibleThreads = _threadRecords.values
.where((record) {
return !record.archived &&
!archivedKeys.contains(_normalizedSessionKey(record.sessionKey));
})
.toList(growable: false);
_tasksController.recompute(
threads: visibleThreads,
cronJobs: _relayCronJobs,
currentSessionKey: _currentSessionKey,
pendingSessionKeys: _pendingSessionKeys,
);
}
GatewaySkillSummary _gatewaySkillFromThreadEntry(
AssistantThreadSkillEntry item,
) {
return GatewaySkillSummary(
name: item.label,
description: item.description,
source: item.source,
skillKey: item.key,
primaryEnv: null,
eligible: true,
disabled: false,
missingBins: const <String>[],
missingEnv: const <String>[],
missingConfig: const <String>[],
);
}
2026-03-21 15:52:25 +08:00
@override
void dispose() {
unawaited(_relayEventsSubscription.cancel());
unawaited(_relayClient.dispose());
super.dispose();
}
SettingsTab _sanitizeSettingsTab(SettingsTab tab) {
return switch (tab) {
SettingsTab.workspace ||
SettingsTab.agents ||
SettingsTab.diagnostics ||
SettingsTab.experimental => SettingsTab.gateway,
_ => tab,
};
}
SettingsSnapshot _sanitizeSettings(SettingsSnapshot snapshot) {
final allowedDestinations = featuresFor(
UiFeaturePlatform.web,
).allowedDestinations;
final target = featuresFor(UiFeaturePlatform.web).sanitizeExecutionTarget(
_sanitizeTarget(snapshot.assistantExecutionTarget),
);
final assistantNavigationDestinations =
normalizeAssistantNavigationDestinations(
snapshot.assistantNavigationDestinations,
).where((entry) {
final destination = entry.destination;
if (destination != null) {
return allowedDestinations.contains(destination);
}
return allowedDestinations.contains(WorkspaceDestination.settings);
}).toList(growable: false);
final normalizedSessionBaseUrl =
RemoteWebSessionRepository.normalizeBaseUrl(
snapshot.webSessionPersistence.remoteBaseUrl,
)?.toString() ??
2026-03-22 10:55:22 +08:00
'';
final localProfile = snapshot.primaryLocalGatewayProfile.copyWith(
mode: RuntimeConnectionMode.local,
useSetupCode: false,
setupCode: '',
tls: false,
);
final remoteProfile = snapshot.primaryRemoteGatewayProfile.copyWith(
mode: RuntimeConnectionMode.remote,
useSetupCode: false,
setupCode: '',
);
2026-03-21 15:52:25 +08:00
return snapshot.copyWith(
assistantExecutionTarget: target,
gatewayProfiles: replaceGatewayProfileAt(
replaceGatewayProfileAt(
snapshot.gatewayProfiles,
kGatewayLocalProfileIndex,
localProfile,
),
kGatewayRemoteProfileIndex,
remoteProfile,
2026-03-21 15:52:25 +08:00
),
webSessionPersistence: snapshot.webSessionPersistence.copyWith(
remoteBaseUrl: normalizedSessionBaseUrl,
),
assistantNavigationDestinations: assistantNavigationDestinations,
2026-03-21 15:52:25 +08:00
);
}
AssistantThreadRecord _sanitizeRecord(AssistantThreadRecord record) {
final target =
_sanitizeTarget(record.executionTarget) ??
2026-03-23 10:11:53 +08:00
AssistantExecutionTarget.singleAgent;
2026-03-21 15:52:25 +08:00
return record.copyWith(
executionTarget: target,
title: record.title.trim().isEmpty
? appText('新对话', 'New conversation')
: record.title.trim(),
2026-03-25 13:38:40 +08:00
workspaceRef: record.workspaceRef.trim().isEmpty
? _defaultWorkspaceRefForSession(record.sessionKey)
: record.workspaceRef.trim(),
workspaceRefKind: record.workspaceRef.trim().isEmpty
? WorkspaceRefKind.objectStore
: record.workspaceRefKind,
2026-03-21 15:52:25 +08:00
);
}
AssistantExecutionTarget? _sanitizeTarget(AssistantExecutionTarget? target) {
return switch (target) {
AssistantExecutionTarget.local => AssistantExecutionTarget.local,
2026-03-21 15:52:25 +08:00
AssistantExecutionTarget.remote => AssistantExecutionTarget.remote,
2026-03-23 10:11:53 +08:00
AssistantExecutionTarget.singleAgent =>
AssistantExecutionTarget.singleAgent,
_ => AssistantExecutionTarget.singleAgent,
2026-03-21 15:52:25 +08:00
};
}
AssistantThreadRecord _newRecord({
required AssistantExecutionTarget target,
String? title,
}) {
final timestamp = DateTime.now().millisecondsSinceEpoch;
final prefix = switch (target) {
AssistantExecutionTarget.singleAgent => 'single',
AssistantExecutionTarget.local => 'local',
AssistantExecutionTarget.remote => 'remote',
};
2026-03-21 15:52:25 +08:00
return AssistantThreadRecord(
sessionKey: '$prefix:$timestamp',
messages: const <GatewayChatMessage>[],
updatedAtMs: timestamp.toDouble(),
title: title ?? appText('新对话', 'New conversation'),
archived: false,
executionTarget: target,
messageViewMode: AssistantMessageViewMode.rendered,
2026-03-25 13:38:40 +08:00
workspaceRef: 'object://thread/$prefix:$timestamp',
workspaceRefKind: WorkspaceRefKind.objectStore,
2026-03-21 15:52:25 +08:00
);
}
void _appendAssistantMessage({
required String sessionKey,
required String text,
required bool error,
}) {
final existing =
_threadRecords[sessionKey] ??
_newRecord(target: assistantExecutionTarget);
final messages = <GatewayChatMessage>[
...existing.messages,
GatewayChatMessage(
id: _messageId(),
role: 'assistant',
text: text,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: error ? 'error' : null,
pending: false,
error: error,
),
];
_threadRecords[sessionKey] = existing.copyWith(
messages: messages,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
title: _deriveThreadTitle(existing.title, messages, fallback: sessionKey),
);
_pendingSessionKeys.remove(sessionKey);
_streamingTextBySession.remove(sessionKey);
_recomputeDerivedWorkspaceState();
2026-03-21 15:52:25 +08:00
}
void _handleRelayEvent(GatewayPushEvent event) {
if (event.event != 'chat') {
return;
}
final payload = _castMap(event.payload);
final sessionKey = _normalizedSessionKey(
payload['sessionKey']?.toString() ?? '',
);
2026-03-21 15:52:25 +08:00
if (sessionKey.isEmpty) {
return;
}
final state = payload['state']?.toString().trim() ?? '';
final message = _castMap(payload['message']);
final text = _extractMessageText(message);
if (text.isNotEmpty && state == 'delta') {
_appendStreamingText(sessionKey, text);
} else if (text.isNotEmpty && state == 'final') {
_clearStreamingText(sessionKey);
_appendAssistantMessage(sessionKey: sessionKey, text: text, error: false);
2026-03-21 15:52:25 +08:00
}
if (state == 'final' || state == 'aborted' || state == 'error') {
_pendingSessionKeys.remove(sessionKey);
if (state == 'error' && text.isNotEmpty) {
_appendAssistantMessage(
sessionKey: sessionKey,
text: text,
error: true,
);
}
_clearStreamingText(sessionKey);
2026-03-21 15:52:25 +08:00
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<GatewayChatMessage>? messages,
double? updatedAtMs,
String? title,
bool? archived,
AssistantExecutionTarget? executionTarget,
AssistantMessageViewMode? messageViewMode,
List<AssistantThreadSkillEntry>? importedSkills,
List<String>? selectedSkillKeys,
String? assistantModelId,
SingleAgentProvider? singleAgentProvider,
String? gatewayEntryState,
bool clearGatewayEntryState = false,
2026-03-25 13:38:40 +08:00
String? workspaceRef,
WorkspaceRefKind? workspaceRefKind,
}) {
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,
2026-03-25 13:38:40 +08:00
workspaceRef: workspaceRef ?? existing.workspaceRef,
workspaceRefKind: workspaceRefKind ?? existing.workspaceRefKind,
);
_recomputeDerivedWorkspaceState();
}
Future<void> _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<T> _enqueueThreadTurn<T>(String threadId, Future<T> Function() task) {
final normalizedThreadId = _normalizedSessionKey(threadId);
final previous =
_threadTurnQueues[normalizedThreadId] ?? Future<void>.value();
final completer = Completer<T>();
late final Future<void> 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<GatewayChatAttachmentPayload> 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<Map<String, dynamic>> _requestAcpSessionMessage({
required Uri endpoint,
required Map<String, dynamic> params,
required bool hasInlineAttachments,
void Function(Map<String, dynamic> 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<String, dynamic>.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<void> _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');
}
bool _unsupportedAcpSkillsStatus(WebAcpException error) {
final code = (error.code ?? '').trim();
if (code == '-32601' || code == 'METHOD_NOT_FOUND') {
return true;
}
final message = error.toString().toLowerCase();
return message.contains('unknown method') ||
message.contains('method not found') ||
message.contains('skills.status');
}
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<String, dynamic> 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));
}
2026-03-21 15:52:25 +08:00
Future<void> _persistSettings() async {
await _store.saveSettingsSnapshot(_settings);
}
void _saveSecretDraft(String key, String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
_draftSecretValues.remove(key);
} else {
_draftSecretValues[key] = trimmed;
}
_settingsDraftStatusMessage = appText(
'草稿已更新,点击顶部保存持久化。',
'Draft updated. Use the top Save button to persist it.',
);
notifyListeners();
}
Future<void> _persistDraftSecrets() async {
final aiGatewayApiKey = _draftSecretValues[_draftAiGatewayApiKeyKey];
if ((aiGatewayApiKey ?? '').isNotEmpty) {
_aiGatewayApiKeyCache = aiGatewayApiKey!;
await _store.saveAiGatewayApiKey(_aiGatewayApiKeyCache);
}
_draftSecretValues.clear();
}
2026-03-21 15:52:25 +08:00
Future<void> _persistThreads() async {
final records = _threadRecords.values.toList(growable: false);
await _browserSessionRepository.saveThreadRecords(records);
final invalidRemoteConfigMessage = _invalidRemoteSessionConfigMessage();
if (invalidRemoteConfigMessage != null) {
_sessionPersistenceStatusMessage = invalidRemoteConfigMessage;
return;
}
final remoteRepository = _resolveRemoteSessionRepository();
if (remoteRepository == null) {
_sessionPersistenceStatusMessage = '';
return;
}
try {
await remoteRepository.saveThreadRecords(records);
_sessionPersistenceStatusMessage = appText(
'远端 Session API 已同步,浏览器缓存仍保留一份本地副本。',
'Remote session API synced successfully; the browser cache remains as a local fallback.',
);
} catch (error) {
_sessionPersistenceStatusMessage = _sessionPersistenceErrorLabel(error);
}
}
Future<List<AssistantThreadRecord>> _loadThreadRecords() async {
final browserRecords = await _browserSessionRepository.loadThreadRecords();
final invalidRemoteConfigMessage = _invalidRemoteSessionConfigMessage();
if (invalidRemoteConfigMessage != null) {
_sessionPersistenceStatusMessage = invalidRemoteConfigMessage;
return browserRecords;
}
final remoteRepository = _resolveRemoteSessionRepository();
if (remoteRepository == null) {
_sessionPersistenceStatusMessage = '';
return browserRecords;
}
try {
final remoteRecords = await remoteRepository.loadThreadRecords();
if (remoteRecords.isNotEmpty) {
_sessionPersistenceStatusMessage = appText(
'远端 Session API 已启用,并覆盖浏览器中的本地缓存。',
'Remote session API is active and overrides the browser cache.',
);
await _browserSessionRepository.saveThreadRecords(remoteRecords);
return remoteRecords;
}
2026-03-22 10:55:22 +08:00
_sessionPersistenceStatusMessage = appText(
'远端 Session API 已启用,但当前为空;浏览器缓存不会自动导入远端。',
'The remote session API is active but empty, and the browser cache will not be imported automatically.',
);
return const <AssistantThreadRecord>[];
} catch (error) {
_sessionPersistenceStatusMessage = _sessionPersistenceErrorLabel(error);
return browserRecords;
}
}
WebSessionRepository? _resolveRemoteSessionRepository() {
final config = _settings.webSessionPersistence;
if (config.mode != WebSessionPersistenceMode.remote) {
return null;
}
final normalizedBaseUrl = RemoteWebSessionRepository.normalizeBaseUrl(
config.remoteBaseUrl,
);
if (normalizedBaseUrl == null) {
return null;
}
return _remoteSessionRepositoryBuilder(
config.copyWith(remoteBaseUrl: normalizedBaseUrl.toString()),
_webSessionClientId,
_webSessionApiTokenCache,
);
}
String? _invalidRemoteSessionConfigMessage() {
final config = _settings.webSessionPersistence;
if (config.mode != WebSessionPersistenceMode.remote ||
config.remoteBaseUrl.trim().isEmpty) {
return null;
}
if (RemoteWebSessionRepository.normalizeBaseUrl(config.remoteBaseUrl) !=
null) {
return null;
}
return appText(
'Session API URL 无效。请使用 HTTPS或仅在 localhost / 127.0.0.1 开发环境中使用 HTTP。',
'The Session API URL is invalid. Use HTTPS, or HTTP only for localhost / 127.0.0.1 during development.',
);
}
String _sessionPersistenceErrorLabel(Object error) {
return appText(
'远端 Session API 当前不可用,已回退到浏览器缓存。${error.toString()}',
'The remote session API is unavailable, so XWorkmate fell back to the browser cache. ${error.toString()}',
);
}
static WebSessionRepository _defaultRemoteSessionRepository(
WebSessionPersistenceConfig config,
String clientId,
String accessToken,
) {
return RemoteWebSessionRepository(
baseUrl: config.remoteBaseUrl,
clientId: clientId,
accessToken: accessToken,
2026-03-21 15:52:25 +08:00
);
}
String _titleForRecord(AssistantThreadRecord record) {
final customTitle =
_settings
.assistantCustomTaskTitles[_normalizedSessionKey(record.sessionKey)]
?.trim() ??
'';
if (customTitle.isNotEmpty) {
return customTitle;
}
2026-03-21 15:52:25 +08:00
final title = record.title.trim();
if (title.isNotEmpty) {
return title;
}
return _deriveThreadTitle('', record.messages, fallback: record.sessionKey);
}
String _previewForRecord(AssistantThreadRecord record) {
for (final message in record.messages.reversed) {
final text = message.text.trim();
if (text.isNotEmpty) {
return text;
}
}
return appText(
'等待描述这个任务的第一条消息',
'Waiting for the first message of this task',
);
}
String _deriveThreadTitle(
String currentTitle,
List<GatewayChatMessage> messages, {
String fallback = '',
}) {
final trimmedCurrent = currentTitle.trim();
if (trimmedCurrent.isNotEmpty &&
trimmedCurrent != appText('新对话', 'New conversation')) {
return trimmedCurrent;
}
for (final message in messages) {
if (message.role.trim().toLowerCase() != 'user') {
continue;
}
final text = message.text.trim();
if (text.isEmpty) {
continue;
}
return text.length <= 32 ? text : '${text.substring(0, 32)}...';
}
return fallback.isEmpty ? appText('新对话', 'New conversation') : fallback;
}
String _hostLabel(String rawUrl) {
final normalized = _aiGatewayClient.normalizeBaseUrl(rawUrl);
return normalized?.host.trim() ?? '';
}
String _messageId() {
return DateTime.now().microsecondsSinceEpoch.toString();
}
Map<String, dynamic> _castMap(Object? value) {
if (value is Map<String, dynamic>) {
return value;
}
if (value is Map) {
return value.cast<String, dynamic>();
}
return const <String, dynamic>{};
}
String _extractMessageText(Map<String, dynamic> message) {
final directContent = message['content'];
if (directContent is String) {
return directContent;
}
final parts = <String>[];
if (directContent is List) {
for (final part in directContent) {
final map = _castMap(part);
final text = map['text']?.toString().trim();
if (text != null && text.isNotEmpty) {
parts.add(text);
}
}
}
return parts.join('\n').trim();
}
}
class _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;
}
2026-03-21 15:52:25 +08:00
class WebConversationSummary {
const WebConversationSummary({
required this.sessionKey,
required this.title,
required this.preview,
required this.updatedAtMs,
required this.executionTarget,
required this.pending,
required this.current,
});
final String sessionKey;
final String title;
final String preview;
final double updatedAtMs;
final AssistantExecutionTarget executionTarget;
final bool pending;
final bool current;
}