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';
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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';
|
2026-03-22 10:50:29 +08:00
|
|
|
|
import '../web/web_session_repository.dart';
|
2026-03-21 15:52:25 +08:00
|
|
|
|
import '../web/web_store.dart';
|
2026-03-24 20:48:59 +08:00
|
|
|
|
import '../web/web_workspace_controllers.dart';
|
2026-03-21 15:52:25 +08:00
|
|
|
|
import 'app_capabilities.dart';
|
2026-03-22 10:49:13 +08:00
|
|
|
|
import 'ui_feature_manifest.dart';
|
2026-03-21 15:52:25 +08:00
|
|
|
|
|
2026-03-22 10:50:29 +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,
|
2026-03-24 17:34:14 +08:00
|
|
|
|
WebAcpClient? acpClient,
|
2026-03-21 15:52:25 +08:00
|
|
|
|
WebRelayGatewayClient? relayClient,
|
2026-03-22 10:50:29 +08:00
|
|
|
|
RemoteWebSessionRepositoryBuilder? remoteSessionRepositoryBuilder,
|
2026-03-22 10:49:13 +08:00
|
|
|
|
UiFeatureManifest? uiFeatureManifest,
|
2026-03-21 15:52:25 +08:00
|
|
|
|
}) : _store = store ?? WebStore(),
|
2026-03-22 10:49:13 +08:00
|
|
|
|
_uiFeatureManifest = uiFeatureManifest ?? UiFeatureManifest.fallback(),
|
2026-03-22 10:50:29 +08:00
|
|
|
|
_aiGatewayClient = aiGatewayClient ?? const WebAiGatewayClient(),
|
2026-03-24 17:34:14 +08:00
|
|
|
|
_acpClient = acpClient ?? const WebAcpClient(),
|
2026-03-22 10:50:29 +08:00
|
|
|
|
_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;
|
2026-03-22 10:49:13 +08:00
|
|
|
|
final UiFeatureManifest _uiFeatureManifest;
|
2026-03-21 15:52:25 +08:00
|
|
|
|
final WebAiGatewayClient _aiGatewayClient;
|
2026-03-24 17:34:14 +08:00
|
|
|
|
final WebAcpClient _acpClient;
|
2026-03-22 10:50:29 +08:00
|
|
|
|
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;
|
2026-03-22 10:50:29 +08:00
|
|
|
|
late final BrowserWebSessionRepository _browserSessionRepository =
|
|
|
|
|
|
BrowserWebSessionRepository(_store);
|
2026-03-21 15:52:25 +08:00
|
|
|
|
|
|
|
|
|
|
late final StreamSubscription<GatewayPushEvent> _relayEventsSubscription;
|
|
|
|
|
|
|
|
|
|
|
|
SettingsSnapshot _settings = SettingsSnapshot.defaults();
|
2026-03-22 16:01:04 +08:00
|
|
|
|
SettingsSnapshot _settingsDraft = SettingsSnapshot.defaults();
|
2026-03-21 15:52:25 +08:00
|
|
|
|
ThemeMode _themeMode = ThemeMode.light;
|
|
|
|
|
|
WorkspaceDestination _destination = WorkspaceDestination.assistant;
|
|
|
|
|
|
SettingsTab _settingsTab = SettingsTab.general;
|
2026-03-22 16:01:04 +08:00
|
|
|
|
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;
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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>{};
|
2026-03-24 17:34:14 +08:00
|
|
|
|
final Map<String, Future<void>> _threadTurnQueues = <String, Future<void>>{};
|
|
|
|
|
|
final Map<String, String> _singleAgentRuntimeModelBySession =
|
|
|
|
|
|
<String, String>{};
|
2026-03-24 20:48:59 +08:00
|
|
|
|
final WebTasksController _tasksController = WebTasksController();
|
2026-03-21 15:52:25 +08:00
|
|
|
|
String _currentSessionKey = '';
|
|
|
|
|
|
String? _lastAssistantError;
|
2026-03-22 10:50:29 +08:00
|
|
|
|
String _webSessionApiTokenCache = '';
|
|
|
|
|
|
String _webSessionClientId = '';
|
|
|
|
|
|
String _sessionPersistenceStatusMessage = '';
|
2026-03-24 17:34:14 +08:00
|
|
|
|
WebAcpCapabilities _acpCapabilities = const WebAcpCapabilities.empty();
|
2026-03-24 20:48:59 +08:00
|
|
|
|
List<GatewayAgentSummary> _relayAgents = const <GatewayAgentSummary>[];
|
|
|
|
|
|
List<GatewayInstanceSummary> _relayInstances =
|
|
|
|
|
|
const <GatewayInstanceSummary>[];
|
|
|
|
|
|
List<GatewayConnectorSummary> _relayConnectors =
|
|
|
|
|
|
const <GatewayConnectorSummary>[];
|
|
|
|
|
|
List<GatewayModelSummary> _relayModels = const <GatewayModelSummary>[];
|
2026-03-25 11:08:33 +08:00
|
|
|
|
List<GatewayCronJobSummary> _relayCronJobs = const <GatewayCronJobSummary>[];
|
2026-03-24 20:48:59 +08:00
|
|
|
|
late final WebSkillsController _skillsController = WebSkillsController(
|
|
|
|
|
|
refreshVisibleSkills,
|
|
|
|
|
|
);
|
2026-03-21 15:52:25 +08:00
|
|
|
|
|
2026-03-22 10:49:13 +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;
|
2026-03-22 16:01:04 +08:00
|
|
|
|
SettingsSnapshot get settingsDraft =>
|
|
|
|
|
|
_settingsDraftInitialized ? _settingsDraft : _settings;
|
2026-03-25 19:22:10 +08:00
|
|
|
|
bool get supportsSkillDirectoryAuthorization => false;
|
|
|
|
|
|
List<AuthorizedSkillDirectory> get authorizedSkillDirectories =>
|
|
|
|
|
|
_settings.authorizedSkillDirectories;
|
|
|
|
|
|
List<String> get recommendedAuthorizedSkillDirectoryPaths => const <String>[
|
|
|
|
|
|
'~/.agents/skills',
|
2026-03-26 13:42:10 +08:00
|
|
|
|
'~/.codex/skills',
|
|
|
|
|
|
'~/.workbuddy/skills',
|
2026-03-25 19:22:10 +08:00
|
|
|
|
];
|
|
|
|
|
|
String get userHomeDirectory => '';
|
|
|
|
|
|
String get settingsYamlPath => '';
|
2026-03-22 16:01:04 +08:00
|
|
|
|
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;
|
2026-03-24 18:26:39 +08:00
|
|
|
|
AssistantPermissionLevel get assistantPermissionLevel =>
|
|
|
|
|
|
_settings.assistantPermissionLevel;
|
2026-03-26 16:40:38 +08:00
|
|
|
|
List<AssistantFocusEntry> get assistantNavigationDestinations => _settings
|
2026-03-25 11:08:33 +08:00
|
|
|
|
.assistantNavigationDestinations
|
2026-03-26 16:40:38 +08:00
|
|
|
|
.where(supportsAssistantFocusEntry)
|
2026-03-25 11:08:33 +08:00
|
|
|
|
.toList(growable: false);
|
2026-03-26 16:40:38 +08:00
|
|
|
|
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;
|
2026-03-24 17:34:14 +08:00
|
|
|
|
bool get acpBusy => _acpBusy;
|
|
|
|
|
|
bool get isMultiAgentRunPending => _multiAgentRunPending;
|
2026-03-21 15:52:25 +08:00
|
|
|
|
String? get lastAssistantError => _lastAssistantError;
|
|
|
|
|
|
String get currentSessionKey => _currentSessionKey;
|
2026-03-22 10:50:29 +08:00
|
|
|
|
WebSessionPersistenceConfig get webSessionPersistence =>
|
|
|
|
|
|
_settings.webSessionPersistence;
|
|
|
|
|
|
String get sessionPersistenceStatusMessage =>
|
|
|
|
|
|
_sessionPersistenceStatusMessage;
|
2026-03-21 15:52:25 +08:00
|
|
|
|
bool get supportsDesktopIntegration => false;
|
2026-03-24 20:48:59 +08:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
2026-03-25 11:08:33 +08:00
|
|
|
|
|
2026-03-24 17:34:14 +08:00
|
|
|
|
bool get hasStoredGatewayToken =>
|
|
|
|
|
|
hasStoredGatewayTokenForProfile(kGatewayRemoteProfileIndex) ||
|
|
|
|
|
|
hasStoredGatewayTokenForProfile(kGatewayLocalProfileIndex);
|
2026-03-21 15:52:25 +08:00
|
|
|
|
bool get hasStoredAiGatewayApiKey => storedAiGatewayApiKeyMask != null;
|
|
|
|
|
|
String? get storedGatewayTokenMask => storedRelayTokenMask;
|
2026-03-25 11:08:33 +08:00
|
|
|
|
String? storedRelayTokenMaskForProfile(int profileIndex) =>
|
|
|
|
|
|
WebStore.maskValue((_relayTokenByProfile[profileIndex] ?? '').trim());
|
|
|
|
|
|
String? storedRelayPasswordMaskForProfile(int profileIndex) =>
|
|
|
|
|
|
WebStore.maskValue((_relayPasswordByProfile[profileIndex] ?? '').trim());
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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(
|
2026-03-24 17:34:14 +08:00
|
|
|
|
(_relayTokenByProfile[kGatewayRemoteProfileIndex] ?? '').trim(),
|
2026-03-21 15:52:25 +08:00
|
|
|
|
);
|
|
|
|
|
|
String? get storedRelayPasswordMask => WebStore.maskValue(
|
2026-03-24 17:34:14 +08:00
|
|
|
|
(_relayPasswordByProfile[kGatewayRemoteProfileIndex] ?? '').trim(),
|
2026-03-21 15:52:25 +08:00
|
|
|
|
);
|
|
|
|
|
|
String? get storedAiGatewayApiKeyMask => WebStore.maskValue(
|
|
|
|
|
|
_aiGatewayApiKeyCache.trim().isEmpty ? '' : _aiGatewayApiKeyCache,
|
|
|
|
|
|
);
|
2026-03-22 10:50:29 +08:00
|
|
|
|
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
|
|
|
|
|
2026-03-24 17:34:14 +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 = '';
|
|
|
|
|
|
|
2026-03-22 16:01:04 +08:00
|
|
|
|
static const String _draftAiGatewayApiKeyKey = 'ai_gateway_api_key';
|
|
|
|
|
|
static const String _draftVaultTokenKey = 'vault_token';
|
|
|
|
|
|
static const String _draftOllamaApiKeyKey = 'ollama_cloud_api_key';
|
|
|
|
|
|
|
2026-03-22 10:49:13 +08:00
|
|
|
|
UiFeatureAccess featuresFor(UiFeaturePlatform platform) {
|
|
|
|
|
|
return _uiFeatureManifest.forPlatform(platform);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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 =>
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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;
|
2026-03-24 17:34:14 +08:00
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 17:34:14 +08:00
|
|
|
|
SingleAgentProvider singleAgentProviderForSession(String sessionKey) {
|
|
|
|
|
|
final normalizedSessionKey = _normalizedSessionKey(sessionKey);
|
2026-03-26 10:01:28 +08:00
|
|
|
|
final stored =
|
|
|
|
|
|
_threadRecords[normalizedSessionKey]?.singleAgentProvider ??
|
2026-03-24 17:34:14 +08:00
|
|
|
|
SingleAgentProvider.auto;
|
2026-03-26 10:01:28 +08:00
|
|
|
|
return _settings.resolveSingleAgentProvider(stored);
|
2026-03-24 17:34:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
SingleAgentProvider get currentSingleAgentProvider =>
|
|
|
|
|
|
singleAgentProviderForSession(_currentSessionKey);
|
|
|
|
|
|
|
|
|
|
|
|
List<SingleAgentProvider> get singleAgentProviderOptions =>
|
2026-03-26 10:01:28 +08:00
|
|
|
|
<SingleAgentProvider>[
|
|
|
|
|
|
SingleAgentProvider.auto,
|
|
|
|
|
|
..._settings.availableSingleAgentProviders,
|
|
|
|
|
|
];
|
2026-03-24 17:34:14 +08:00
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 11:08:33 +08:00
|
|
|
|
String get resolvedAssistantModel =>
|
|
|
|
|
|
assistantModelForSession(_currentSessionKey);
|
2026-03-24 17:34:14 +08:00
|
|
|
|
|
|
|
|
|
|
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(),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 20:48:59 +08:00
|
|
|
|
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',
|
2026-03-25 11:08:33 +08:00
|
|
|
|
maskedValue: storedRelayTokenMaskForProfile(
|
|
|
|
|
|
kGatewayLocalProfileIndex,
|
|
|
|
|
|
)!,
|
2026-03-24 20:48:59 +08:00
|
|
|
|
status: 'In Use',
|
|
|
|
|
|
),
|
|
|
|
|
|
if (storedRelayPasswordMaskForProfile(kGatewayLocalProfileIndex) != null)
|
|
|
|
|
|
SecretReferenceEntry(
|
|
|
|
|
|
name: 'gateway_password.local',
|
|
|
|
|
|
provider: 'Gateway',
|
|
|
|
|
|
module: 'Assistant',
|
2026-03-25 11:08:33 +08:00
|
|
|
|
maskedValue: storedRelayPasswordMaskForProfile(
|
|
|
|
|
|
kGatewayLocalProfileIndex,
|
|
|
|
|
|
)!,
|
2026-03-24 20:48:59 +08:00
|
|
|
|
status: 'In Use',
|
|
|
|
|
|
),
|
|
|
|
|
|
if (storedRelayTokenMaskForProfile(kGatewayRemoteProfileIndex) != null)
|
|
|
|
|
|
SecretReferenceEntry(
|
|
|
|
|
|
name: 'gateway_token.remote',
|
|
|
|
|
|
provider: 'Gateway',
|
|
|
|
|
|
module: 'Assistant',
|
2026-03-25 11:08:33 +08:00
|
|
|
|
maskedValue: storedRelayTokenMaskForProfile(
|
|
|
|
|
|
kGatewayRemoteProfileIndex,
|
|
|
|
|
|
)!,
|
2026-03-24 20:48:59 +08:00
|
|
|
|
status: 'In Use',
|
|
|
|
|
|
),
|
|
|
|
|
|
if (storedRelayPasswordMaskForProfile(kGatewayRemoteProfileIndex) != null)
|
|
|
|
|
|
SecretReferenceEntry(
|
|
|
|
|
|
name: 'gateway_password.remote',
|
|
|
|
|
|
provider: 'Gateway',
|
|
|
|
|
|
module: 'Assistant',
|
2026-03-25 11:08:33 +08:00
|
|
|
|
maskedValue: storedRelayPasswordMaskForProfile(
|
|
|
|
|
|
kGatewayRemoteProfileIndex,
|
|
|
|
|
|
)!,
|
2026-03-24 20:48:59 +08:00
|
|
|
|
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 {
|
2026-03-24 17:34:14 +08:00
|
|
|
|
final archivedKeys = _settings.assistantArchivedTaskKeys
|
|
|
|
|
|
.map(_normalizedSessionKey)
|
|
|
|
|
|
.toSet();
|
2026-03-21 15:52:25 +08:00
|
|
|
|
final entries =
|
|
|
|
|
|
_threadRecords.values
|
2026-03-24 17:34:14 +08:00
|
|
|
|
.where(
|
|
|
|
|
|
(record) =>
|
|
|
|
|
|
!record.archived &&
|
2026-03-25 11:08:33 +08:00
|
|
|
|
!archivedKeys.contains(
|
|
|
|
|
|
_normalizedSessionKey(record.sessionKey),
|
|
|
|
|
|
),
|
2026-03-24 17:34:14 +08:00
|
|
|
|
)
|
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(),
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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) {
|
2026-03-24 17:34:14 +08:00
|
|
|
|
final provider = singleAgentProviderForSession(normalizedSessionKey);
|
|
|
|
|
|
final model = assistantModelForSession(normalizedSessionKey);
|
2026-03-22 12:29:47 +08:00
|
|
|
|
final host = _hostLabel(_settings.aiGateway.baseUrl);
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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,
|
2026-03-24 17:34:14 +08:00
|
|
|
|
status: remoteReady
|
2026-03-22 12:29:47 +08:00
|
|
|
|
? RuntimeConnectionStatus.connected
|
|
|
|
|
|
: RuntimeConnectionStatus.offline,
|
|
|
|
|
|
primaryLabel: target.label,
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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,
|
2026-03-25 11:08:33 +08:00
|
|
|
|
status: matchesTarget
|
|
|
|
|
|
? connection.status
|
|
|
|
|
|
: RuntimeConnectionStatus.offline,
|
|
|
|
|
|
primaryLabel:
|
|
|
|
|
|
(matchesTarget ? connection.status : RuntimeConnectionStatus.offline)
|
|
|
|
|
|
.label,
|
2026-03-24 17:34:14 +08:00
|
|
|
|
detailLabel: detail.isEmpty
|
|
|
|
|
|
? appText('Relay 未连接', 'Relay offline')
|
|
|
|
|
|
: detail,
|
|
|
|
|
|
ready:
|
2026-03-25 11:08:33 +08:00
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 10:50:29 +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-22 10:50:29 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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();
|
2026-03-24 17:34:14 +08:00
|
|
|
|
for (final profileIndex in <int>[
|
|
|
|
|
|
kGatewayLocalProfileIndex,
|
|
|
|
|
|
kGatewayRemoteProfileIndex,
|
|
|
|
|
|
]) {
|
|
|
|
|
|
_relayTokenByProfile[profileIndex] = await _store.loadRelayToken(
|
|
|
|
|
|
profileIndex: profileIndex,
|
|
|
|
|
|
);
|
|
|
|
|
|
_relayPasswordByProfile[profileIndex] = await _store.loadRelayPassword(
|
|
|
|
|
|
profileIndex: profileIndex,
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-22 10:50:29 +08:00
|
|
|
|
_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;
|
|
|
|
|
|
}
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-22 16:01:04 +08:00
|
|
|
|
_settingsDraft = _settings;
|
|
|
|
|
|
_settingsDraftInitialized = true;
|
2026-03-24 20:48:59 +08:00
|
|
|
|
_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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 10:50:29 +08:00
|
|
|
|
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();
|
2026-03-22 10:50:29 +08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 20:48:59 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-25 11:08:33 +08:00
|
|
|
|
await _refreshSingleAgentSkillsForSession(_currentSessionKey);
|
2026-03-24 20:48:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 18:26:39 +08:00
|
|
|
|
Future<void> toggleAssistantNavigationDestination(
|
2026-03-26 16:40:38 +08:00
|
|
|
|
AssistantFocusEntry destination,
|
2026-03-24 18:26:39 +08:00
|
|
|
|
) async {
|
|
|
|
|
|
if (!kAssistantNavigationDestinationCandidates.contains(destination) ||
|
2026-03-26 16:40:38 +08:00
|
|
|
|
!supportsAssistantFocusEntry(destination)) {
|
2026-03-24 18:26:39 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
final current = assistantNavigationDestinations;
|
|
|
|
|
|
final next = current.contains(destination)
|
|
|
|
|
|
? current.where((item) => item != destination).toList(growable: false)
|
2026-03-26 16:40:38 +08:00
|
|
|
|
: <AssistantFocusEntry>[...current, destination];
|
2026-03-24 18:26:39 +08:00
|
|
|
|
_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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 16:01:04 +08:00
|
|
|
|
Future<void> saveSettingsDraft(SettingsSnapshot snapshot) async {
|
|
|
|
|
|
_settingsDraft = snapshot;
|
|
|
|
|
|
_settingsDraftInitialized = true;
|
|
|
|
|
|
_settingsDraftStatusMessage = appText(
|
|
|
|
|
|
'草稿已更新,点击顶部保存持久化。',
|
|
|
|
|
|
'Draft updated. Use the top Save button to persist it.',
|
|
|
|
|
|
);
|
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 19:22:10 +08:00
|
|
|
|
Future<AuthorizedSkillDirectory?> authorizeSkillDirectory({
|
|
|
|
|
|
String suggestedPath = '',
|
|
|
|
|
|
}) async {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-26 09:31:21 +08:00
|
|
|
|
Future<List<AuthorizedSkillDirectory>> authorizeSkillDirectories({
|
|
|
|
|
|
List<String> suggestedPaths = const <String>[],
|
|
|
|
|
|
}) async {
|
|
|
|
|
|
return const <AuthorizedSkillDirectory>[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 19:22:10 +08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 16:01:04 +08:00
|
|
|
|
void saveAiGatewayApiKeyDraft(String value) {
|
|
|
|
|
|
_saveSecretDraft(_draftAiGatewayApiKeyKey, value);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void saveVaultTokenDraft(String value) {
|
|
|
|
|
|
_saveSecretDraft(_draftVaultTokenKey, value);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void saveOllamaCloudApiKeyDraft(String value) {
|
|
|
|
|
|
_saveSecretDraft(_draftOllamaApiKeyKey, value);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 17:07:27 +08:00
|
|
|
|
Future<String> testOllamaConnection({required bool cloud}) async {
|
2026-03-22 20:02:00 +08:00
|
|
|
|
return cloud
|
|
|
|
|
|
? 'Cloud test unavailable on web'
|
|
|
|
|
|
: 'Local test unavailable on web';
|
2026-03-22 17:07:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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,
|
2026-03-22 17:07:27 +08:00
|
|
|
|
);
|
2026-03-24 17:34:14 +08:00
|
|
|
|
final endpoint = _gatewayAddressLabel(candidateProfile);
|
|
|
|
|
|
final client = WebRelayGatewayClient(_store);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await client.connect(
|
|
|
|
|
|
profile: candidateProfile,
|
|
|
|
|
|
authToken: tokenOverride.trim(),
|
|
|
|
|
|
authPassword: passwordOverride.trim(),
|
|
|
|
|
|
);
|
|
|
|
|
|
return (
|
|
|
|
|
|
state: 'connected',
|
2026-03-25 11:08:33 +08:00
|
|
|
|
message: appText('连接测试成功。', 'Connection test succeeded.'),
|
2026-03-24 17:34:14 +08:00
|
|
|
|
endpoint: endpoint,
|
|
|
|
|
|
);
|
|
|
|
|
|
} catch (error) {
|
2026-03-25 11:08:33 +08:00
|
|
|
|
return (state: 'error', message: error.toString(), endpoint: endpoint);
|
2026-03-24 17:34:14 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
await client.dispose();
|
|
|
|
|
|
}
|
2026-03-22 17:07:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 16:01:04 +08:00
|
|
|
|
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(
|
2026-03-22 18:17:24 +08:00
|
|
|
|
'已保存配置,不立即生效。',
|
|
|
|
|
|
'Settings saved. They do not take effect until Apply.',
|
2026-03-22 16:01:04 +08:00
|
|
|
|
);
|
|
|
|
|
|
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(
|
2026-03-22 18:17:24 +08:00
|
|
|
|
'已按当前配置生效。',
|
|
|
|
|
|
'The current configuration is now in effect.',
|
2026-03-22 16:01:04 +08:00
|
|
|
|
);
|
|
|
|
|
|
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 {
|
2026-03-24 17:34:14 +08:00
|
|
|
|
final inheritedTarget =
|
|
|
|
|
|
_sanitizeTarget(target) ??
|
|
|
|
|
|
assistantExecutionTargetForSession(_currentSessionKey);
|
2026-03-25 11:08:33 +08:00
|
|
|
|
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;
|
2026-03-24 17:34:14 +08:00
|
|
|
|
_settings = _settings.copyWith(assistantLastSessionKey: record.sessionKey);
|
2026-03-24 20:48:59 +08:00
|
|
|
|
_recomputeDerivedWorkspaceState();
|
2026-03-24 17:34:14 +08:00
|
|
|
|
await _persistSettings();
|
2026-03-21 15:52:25 +08:00
|
|
|
|
await _persistThreads();
|
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> switchConversation(String sessionKey) async {
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-24 17:34:14 +08:00
|
|
|
|
if (assistantExecutionTargetForSession(previousSessionKey) !=
|
|
|
|
|
|
AssistantExecutionTarget.singleAgent) {
|
|
|
|
|
|
_streamingTextBySession.remove(previousSessionKey);
|
|
|
|
|
|
}
|
|
|
|
|
|
_currentSessionKey = normalizedSessionKey;
|
2026-03-21 15:52:25 +08:00
|
|
|
|
_lastAssistantError = null;
|
2026-03-25 11:08:33 +08:00
|
|
|
|
_settings = _settings.copyWith(
|
|
|
|
|
|
assistantLastSessionKey: normalizedSessionKey,
|
|
|
|
|
|
);
|
2026-03-25 13:38:40 +08:00
|
|
|
|
_syncThreadWorkspaceRef(normalizedSessionKey);
|
2026-03-24 17:34:14 +08:00
|
|
|
|
await _persistSettings();
|
2026-03-21 15:52:25 +08:00
|
|
|
|
notifyListeners();
|
2026-03-24 17:34:14 +08:00
|
|
|
|
final target = assistantExecutionTargetForSession(normalizedSessionKey);
|
|
|
|
|
|
await _applyAssistantExecutionTarget(
|
|
|
|
|
|
target,
|
|
|
|
|
|
sessionKey: normalizedSessionKey,
|
|
|
|
|
|
persistDefaultSelection: false,
|
|
|
|
|
|
);
|
2026-03-25 11:08:33 +08:00
|
|
|
|
if (target == AssistantExecutionTarget.singleAgent) {
|
|
|
|
|
|
await _refreshSingleAgentSkillsForSession(normalizedSessionKey);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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 =
|
2026-03-25 11:08:33 +08:00
|
|
|
|
_sanitizeTarget(target) ??
|
|
|
|
|
|
assistantExecutionTargetForSession(_currentSessionKey);
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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-24 17:34:14 +08:00
|
|
|
|
);
|
2026-03-21 15:52:25 +08:00
|
|
|
|
_settings = _settings.copyWith(assistantExecutionTarget: resolvedTarget);
|
2026-03-24 17:34:14 +08:00
|
|
|
|
await _persistSettings();
|
|
|
|
|
|
await _persistThreads();
|
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
await _applyAssistantExecutionTarget(
|
|
|
|
|
|
resolvedTarget,
|
|
|
|
|
|
sessionKey: sessionKey,
|
|
|
|
|
|
persistDefaultSelection: true,
|
|
|
|
|
|
);
|
2026-03-25 11:08:33 +08:00
|
|
|
|
if (resolvedTarget == AssistantExecutionTarget.singleAgent) {
|
|
|
|
|
|
await _refreshSingleAgentSkillsForSession(sessionKey);
|
|
|
|
|
|
} else if (resolvedTarget == AssistantExecutionTarget.local ||
|
2026-03-24 17:34:14 +08:00
|
|
|
|
resolvedTarget == AssistantExecutionTarget.remote) {
|
|
|
|
|
|
await refreshRelaySkillsForSession(sessionKey);
|
|
|
|
|
|
}
|
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> setSingleAgentProvider(SingleAgentProvider provider) async {
|
2026-03-26 10:01:28 +08:00
|
|
|
|
final resolvedProvider = _settings.resolveSingleAgentProvider(provider);
|
|
|
|
|
|
if (!singleAgentProviderOptions.contains(resolvedProvider)) {
|
2026-03-24 17:34:14 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
final sessionKey = _normalizedSessionKey(_currentSessionKey);
|
2026-03-26 10:01:28 +08:00
|
|
|
|
if (singleAgentProviderForSession(sessionKey) == resolvedProvider) {
|
2026-03-24 17:34:14 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
_singleAgentRuntimeModelBySession.remove(sessionKey);
|
|
|
|
|
|
_upsertThreadRecord(
|
|
|
|
|
|
sessionKey,
|
2026-03-26 10:01:28 +08:00
|
|
|
|
singleAgentProvider: resolvedProvider,
|
2026-03-24 17:34:14 +08:00
|
|
|
|
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
2026-03-21 15:52:25 +08:00
|
|
|
|
);
|
2026-03-24 17:34:14 +08:00
|
|
|
|
await _persistThreads();
|
|
|
|
|
|
notifyListeners();
|
2026-03-25 11:08:33 +08:00
|
|
|
|
if (assistantExecutionTargetForSession(sessionKey) ==
|
|
|
|
|
|
AssistantExecutionTarget.singleAgent) {
|
|
|
|
|
|
await _refreshSingleAgentSkillsForSession(sessionKey);
|
|
|
|
|
|
}
|
2026-03-24 17:34:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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();
|
2026-03-25 11:08:33 +08:00
|
|
|
|
final nextTitles = Map<String, String>.from(
|
|
|
|
|
|
_settings.assistantCustomTaskTitles,
|
|
|
|
|
|
);
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 11:08:33 +08:00
|
|
|
|
Future<void> saveAssistantTaskArchived(
|
|
|
|
|
|
String sessionKey,
|
|
|
|
|
|
bool archived,
|
|
|
|
|
|
) async {
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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
|
2026-03-25 11:08:33 +08:00
|
|
|
|
.where(
|
|
|
|
|
|
(record) =>
|
|
|
|
|
|
!record.archived && record.sessionKey != normalizedSessionKey,
|
|
|
|
|
|
)
|
2026-03-24 17:34:14 +08:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-24 20:48:59 +08:00
|
|
|
|
_recomputeDerivedWorkspaceState();
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-25 11:08:33 +08:00
|
|
|
|
final selected = assistantSelectedSkillKeysForSession(
|
|
|
|
|
|
normalizedSessionKey,
|
|
|
|
|
|
).toSet();
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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();
|
2026-03-24 20:48:59 +08:00
|
|
|
|
_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();
|
2026-03-24 20:48:59 +08:00
|
|
|
|
_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,
|
2026-03-24 17:34:14 +08:00
|
|
|
|
int profileIndex = kGatewayRemoteProfileIndex,
|
2026-03-21 15:52:25 +08:00
|
|
|
|
}) async {
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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(
|
2026-03-22 20:02:00 +08:00
|
|
|
|
gatewayProfiles: replaceGatewayProfileAt(
|
|
|
|
|
|
_settings.gatewayProfiles,
|
2026-03-24 17:34:14 +08:00
|
|
|
|
profileIndex,
|
|
|
|
|
|
baseProfile.copyWith(
|
|
|
|
|
|
mode: mode,
|
2026-03-22 20:02:00 +08:00
|
|
|
|
useSetupCode: false,
|
|
|
|
|
|
setupCode: '',
|
|
|
|
|
|
host: host.trim(),
|
|
|
|
|
|
port: port,
|
2026-03-24 17:34:14 +08:00
|
|
|
|
tls: mode == RuntimeConnectionMode.local ? false : tls,
|
2026-03-22 20:02:00 +08:00
|
|
|
|
),
|
2026-03-21 15:52:25 +08:00
|
|
|
|
),
|
|
|
|
|
|
);
|
2026-03-24 17:34:14 +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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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,
|
|
|
|
|
|
);
|
2026-03-25 11:08:33 +08:00
|
|
|
|
final currentTarget = assistantExecutionTargetForSession(
|
|
|
|
|
|
_currentSessionKey,
|
|
|
|
|
|
);
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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 {
|
2026-03-24 17:34:14 +08:00
|
|
|
|
final resolvedTarget =
|
|
|
|
|
|
_sanitizeTarget(target) ??
|
|
|
|
|
|
(() {
|
2026-03-25 11:08:33 +08:00
|
|
|
|
final current = assistantExecutionTargetForSession(
|
|
|
|
|
|
_currentSessionKey,
|
|
|
|
|
|
);
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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,
|
2026-03-22 20:02:00 +08:00
|
|
|
|
useSetupCode: false,
|
|
|
|
|
|
setupCode: '',
|
|
|
|
|
|
);
|
2026-03-21 15:52:25 +08:00
|
|
|
|
await _relayClient.connect(
|
2026-03-24 17:34:14 +08:00
|
|
|
|
profile: profile,
|
|
|
|
|
|
authToken: (_relayTokenByProfile[profileIndex] ?? '').trim(),
|
|
|
|
|
|
authPassword: (_relayPasswordByProfile[profileIndex] ?? '').trim(),
|
2026-03-21 15:52:25 +08:00
|
|
|
|
);
|
2026-03-24 17:34:14 +08:00
|
|
|
|
final acpEndpoint = _acpEndpointForTarget(resolvedTarget);
|
|
|
|
|
|
if (acpEndpoint != null) {
|
|
|
|
|
|
await _refreshAcpCapabilities(acpEndpoint);
|
|
|
|
|
|
}
|
2026-03-21 15:52:25 +08:00
|
|
|
|
await refreshRelaySessions();
|
2026-03-24 20:48:59 +08:00
|
|
|
|
await refreshRelayWorkspaceResources();
|
2026-03-24 17:34:14 +08:00
|
|
|
|
await refreshRelayHistory(sessionKey: _currentSessionKey);
|
2026-03-24 20:48:59 +08:00
|
|
|
|
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();
|
2026-03-24 20:48:59 +08:00
|
|
|
|
_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;
|
|
|
|
|
|
}
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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) {
|
2026-03-24 17:34:14 +08:00
|
|
|
|
final sessionKey = _normalizedSessionKey(session.key);
|
|
|
|
|
|
final existing = _threadRecords[sessionKey];
|
2026-03-21 15:52:25 +08:00
|
|
|
|
final next = AssistantThreadRecord(
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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,
|
2026-03-24 17:34:14 +08:00
|
|
|
|
executionTarget: existing?.executionTarget ?? target,
|
2026-03-21 15:52:25 +08:00
|
|
|
|
messageViewMode:
|
|
|
|
|
|
existing?.messageViewMode ?? AssistantMessageViewMode.rendered,
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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
|
|
|
|
);
|
2026-03-24 17:34:14 +08:00
|
|
|
|
_threadRecords[sessionKey] = next;
|
2026-03-21 15:52:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
await _persistThreads();
|
2026-03-24 20:48:59 +08:00
|
|
|
|
_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();
|
2026-03-24 20:48:59 +08:00
|
|
|
|
_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();
|
2026-03-24 20:48:59 +08:00
|
|
|
|
_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 {
|
2026-03-24 17:34:14 +08:00
|
|
|
|
final resolvedKey = _normalizedSessionKey(sessionKey ?? _currentSessionKey);
|
2026-03-21 15:52:25 +08:00
|
|
|
|
if (resolvedKey.isEmpty ||
|
|
|
|
|
|
connection.status != RuntimeConnectionStatus.connected) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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];
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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();
|
2026-03-24 20:48:59 +08:00
|
|
|
|
_recomputeDerivedWorkspaceState();
|
2026-03-21 15:52:25 +08:00
|
|
|
|
notifyListeners();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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(
|
2026-03-25 11:08:33 +08:00
|
|
|
|
key: item['skillKey']?.toString().trim().isNotEmpty == true
|
2026-03-24 17:34:14 +08:00
|
|
|
|
? 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);
|
2026-03-25 11:08:33 +08:00
|
|
|
|
final importedKeys = skills.map((item) => item.key).toSet();
|
|
|
|
|
|
final nextSelected =
|
|
|
|
|
|
(_threadRecords[normalizedSessionKey]?.selectedSkillKeys ??
|
|
|
|
|
|
const <String>[])
|
|
|
|
|
|
.where(importedKeys.contains)
|
|
|
|
|
|
.toList(growable: false);
|
2026-03-24 17:34:14 +08:00
|
|
|
|
_upsertThreadRecord(
|
|
|
|
|
|
normalizedSessionKey,
|
|
|
|
|
|
importedSkills: skills,
|
2026-03-25 11:08:33 +08:00
|
|
|
|
selectedSkillKeys: nextSelected,
|
|
|
|
|
|
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
2026-03-24 17:34:14 +08:00
|
|
|
|
);
|
|
|
|
|
|
await _persistThreads();
|
2026-03-24 20:48:59 +08:00
|
|
|
|
_recomputeDerivedWorkspaceState();
|
2026-03-24 17:34:14 +08:00
|
|
|
|
notifyListeners();
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
// Best effort: skill discovery should not block chat flows.
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 11:08:33 +08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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);
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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
|
|
|
|
);
|
2026-03-24 17:34:14 +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
|
|
|
|
|
2026-03-24 17:34:14 +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
|
|
|
|
);
|
2026-03-24 17:34:14 +08:00
|
|
|
|
return;
|
2026-03-21 15:52:25 +08:00
|
|
|
|
}
|
2026-03-24 17:34:14 +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(
|
2026-03-24 17:34:14 +08:00
|
|
|
|
sessionKey: sessionKey,
|
|
|
|
|
|
text: error.toString(),
|
|
|
|
|
|
error: true,
|
2026-03-21 15:52:25 +08:00
|
|
|
|
);
|
2026-03-24 17:34:14 +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(
|
2026-03-24 17:34:14 +08:00
|
|
|
|
'当前线程的 ACP 端点不可用,请先配置并连接 Gateway。',
|
|
|
|
|
|
'ACP endpoint is unavailable for this thread. Configure and connect Gateway first.',
|
2026-03-21 15:52:25 +08:00
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-03-24 17:34:14 +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),
|
2026-03-25 11:08:33 +08:00
|
|
|
|
if (inlineAttachments.isNotEmpty)
|
|
|
|
|
|
'inlineAttachments': inlineAttachments,
|
2026-03-24 17:34:14 +08:00
|
|
|
|
'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
|
|
|
|
);
|
2026-03-24 17:34:14 +08:00
|
|
|
|
_lastAssistantError = error.toString();
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
_multiAgentRunPending = false;
|
|
|
|
|
|
_acpBusy = false;
|
|
|
|
|
|
_pendingSessionKeys.remove(sessionKey);
|
|
|
|
|
|
await _persistThreads();
|
|
|
|
|
|
notifyListeners();
|
2026-03-21 15:52:25 +08:00
|
|
|
|
}
|
2026-03-24 17:34:14 +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
|
|
|
|
);
|
2026-03-24 17:34:14 +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();
|
2026-03-24 17:34:14 +08:00
|
|
|
|
return;
|
2026-03-21 15:52:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> selectDirectModel(String model) async {
|
|
|
|
|
|
final trimmed = model.trim();
|
|
|
|
|
|
if (trimmed.isEmpty) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-24 17:34:14 +08:00
|
|
|
|
await selectAssistantModel(trimmed);
|
2026-03-21 15:52:25 +08:00
|
|
|
|
_settings = _settings.copyWith(defaultModel: trimmed);
|
|
|
|
|
|
await _persistSettings();
|
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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),
|
2026-03-25 11:08:33 +08:00
|
|
|
|
if (inlineAttachments.isNotEmpty)
|
|
|
|
|
|
'inlineAttachments': inlineAttachments,
|
2026-03-24 17:34:14 +08:00
|
|
|
|
},
|
|
|
|
|
|
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']);
|
2026-03-25 11:08:33 +08:00
|
|
|
|
output = result['output']?.toString().trim().isNotEmpty == true
|
2026-03-24 17:34:14 +08:00
|
|
|
|
? 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();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 20:48:59 +08:00
|
|
|
|
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) {
|
2026-03-25 11:08:33 +08:00
|
|
|
|
final allowedDestinations = featuresFor(
|
|
|
|
|
|
UiFeaturePlatform.web,
|
|
|
|
|
|
).allowedDestinations;
|
2026-03-24 17:34:14 +08:00
|
|
|
|
final target = featuresFor(UiFeaturePlatform.web).sanitizeExecutionTarget(
|
|
|
|
|
|
_sanitizeTarget(snapshot.assistantExecutionTarget),
|
|
|
|
|
|
);
|
2026-03-24 18:26:39 +08:00
|
|
|
|
final assistantNavigationDestinations =
|
|
|
|
|
|
normalizeAssistantNavigationDestinations(
|
|
|
|
|
|
snapshot.assistantNavigationDestinations,
|
2026-03-26 16:40:38 +08:00
|
|
|
|
).where((entry) {
|
|
|
|
|
|
final destination = entry.destination;
|
|
|
|
|
|
if (destination != null) {
|
|
|
|
|
|
return allowedDestinations.contains(destination);
|
|
|
|
|
|
}
|
|
|
|
|
|
return allowedDestinations.contains(WorkspaceDestination.settings);
|
|
|
|
|
|
}).toList(growable: false);
|
2026-03-22 10:50:29 +08:00
|
|
|
|
final normalizedSessionBaseUrl =
|
|
|
|
|
|
RemoteWebSessionRepository.normalizeBaseUrl(
|
|
|
|
|
|
snapshot.webSessionPersistence.remoteBaseUrl,
|
|
|
|
|
|
)?.toString() ??
|
2026-03-22 10:55:22 +08:00
|
|
|
|
'';
|
2026-03-24 17:34:14 +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,
|
2026-03-22 20:02:00 +08:00
|
|
|
|
gatewayProfiles: replaceGatewayProfileAt(
|
2026-03-24 17:34:14 +08:00
|
|
|
|
replaceGatewayProfileAt(
|
|
|
|
|
|
snapshot.gatewayProfiles,
|
|
|
|
|
|
kGatewayLocalProfileIndex,
|
|
|
|
|
|
localProfile,
|
2026-03-22 20:02:00 +08:00
|
|
|
|
),
|
2026-03-24 17:34:14 +08:00
|
|
|
|
kGatewayRemoteProfileIndex,
|
|
|
|
|
|
remoteProfile,
|
2026-03-21 15:52:25 +08:00
|
|
|
|
),
|
2026-03-22 10:50:29 +08:00
|
|
|
|
webSessionPersistence: snapshot.webSessionPersistence.copyWith(
|
|
|
|
|
|
remoteBaseUrl: normalizedSessionBaseUrl,
|
|
|
|
|
|
),
|
2026-03-24 18:26:39 +08:00
|
|
|
|
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) {
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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;
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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);
|
2026-03-24 20:48:59 +08:00
|
|
|
|
_recomputeDerivedWorkspaceState();
|
2026-03-21 15:52:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _handleRelayEvent(GatewayPushEvent event) {
|
|
|
|
|
|
if (event.event != 'chat') {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
final payload = _castMap(event.payload);
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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);
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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);
|
2026-03-24 17:34:14 +08:00
|
|
|
|
if (state == 'error' && text.isNotEmpty) {
|
2026-03-25 11:08:33 +08:00
|
|
|
|
_appendAssistantMessage(
|
|
|
|
|
|
sessionKey: sessionKey,
|
|
|
|
|
|
text: text,
|
|
|
|
|
|
error: true,
|
|
|
|
|
|
);
|
2026-03-24 17:34:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
_clearStreamingText(sessionKey);
|
2026-03-21 15:52:25 +08:00
|
|
|
|
unawaited(refreshRelaySessions());
|
|
|
|
|
|
unawaited(refreshRelayHistory(sessionKey: sessionKey));
|
|
|
|
|
|
}
|
|
|
|
|
|
notifyListeners();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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,
|
2026-03-24 17:34:14 +08:00
|
|
|
|
}) {
|
|
|
|
|
|
final key = _normalizedSessionKey(sessionKey);
|
|
|
|
|
|
final resolvedTarget =
|
2026-03-25 11:08:33 +08:00
|
|
|
|
_sanitizeTarget(executionTarget) ??
|
|
|
|
|
|
assistantExecutionTargetForSession(key);
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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,
|
2026-03-24 17:34:14 +08:00
|
|
|
|
);
|
2026-03-24 20:48:59 +08:00
|
|
|
|
_recomputeDerivedWorkspaceState();
|
2026-03-24 17:34:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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');
|
2026-03-25 11:08:33 +08:00
|
|
|
|
buffer.writeln(appText('附件(仅供本轮参考):', 'Attachments (for this turn only):'));
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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();
|
2026-03-25 11:08:33 +08:00
|
|
|
|
final resolvedPort = uri.hasPort
|
|
|
|
|
|
? uri.port
|
|
|
|
|
|
: (scheme == 'https' ? 443 : 80);
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 11:08:33 +08:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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,
|
|
|
|
|
|
}) {
|
2026-03-25 11:08:33 +08:00
|
|
|
|
final method =
|
|
|
|
|
|
notification['method']?.toString().trim().toLowerCase() ?? '';
|
2026-03-24 17:34:14 +08:00
|
|
|
|
final params = _castMap(notification['params']);
|
2026-03-25 11:08:33 +08:00
|
|
|
|
final payload = params.isNotEmpty
|
|
|
|
|
|
? params
|
|
|
|
|
|
: _castMap(notification['payload']);
|
2026-03-24 17:34:14 +08:00
|
|
|
|
final event = payload['event']?.toString().trim().toLowerCase() ?? method;
|
2026-03-25 11:08:33 +08:00
|
|
|
|
final type =
|
|
|
|
|
|
payload['type']?.toString().trim().toLowerCase() ??
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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']);
|
2026-03-25 11:08:33 +08:00
|
|
|
|
final messageText = _extractMessageText(messageMap).trim().isNotEmpty
|
2026-03-24 17:34:14 +08:00
|
|
|
|
? _extractMessageText(messageMap).trim()
|
|
|
|
|
|
: payload['message']?.toString().trim() ?? '';
|
2026-03-25 11:08:33 +08:00
|
|
|
|
final text =
|
|
|
|
|
|
payload['delta']?.toString() ??
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-22 16:01:04 +08:00
|
|
|
|
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 {
|
2026-03-22 10:50:29 +08:00
|
|
|
|
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>[];
|
2026-03-22 10:50:29 +08:00
|
|
|
|
} 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) {
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-24 17:34:14 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|