xworkmate-app/lib/app/app_controller_desktop.dart

5580 lines
186 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

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

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'app_metadata.dart';
import 'app_capabilities.dart';
import 'app_store_policy.dart';
import 'ui_feature_manifest.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/device_identity_store.dart';
import '../runtime/aris_bundle.dart';
import '../runtime/go_core.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/desktop_platform_service.dart';
import '../runtime/gateway_runtime.dart';
import '../runtime/runtime_controllers.dart';
import '../runtime/runtime_models.dart';
import '../runtime/secure_config_store.dart';
import '../runtime/runtime_coordinator.dart';
import '../runtime/direct_single_agent_app_server_client.dart';
import '../runtime/gateway_acp_client.dart';
import '../runtime/codex_runtime.dart';
import '../runtime/codex_config_bridge.dart';
import '../runtime/code_agent_node_orchestrator.dart';
import '../runtime/assistant_artifacts.dart';
import '../runtime/desktop_thread_artifact_service.dart';
import '../runtime/mode_switcher.dart';
import '../runtime/agent_registry.dart';
import '../runtime/multi_agent_orchestrator.dart';
import '../runtime/platform_environment.dart';
import '../runtime/single_agent_runner.dart';
import '../runtime/skill_directory_access.dart';
enum CodexCooperationState { notStarted, bridgeOnly, registered }
class _SingleAgentSkillScanRoot {
const _SingleAgentSkillScanRoot({
required this.path,
required this.source,
required this.scope,
this.bookmark = '',
});
final String path;
final String source;
final String scope;
final String bookmark;
_SingleAgentSkillScanRoot copyWith({
String? path,
String? source,
String? scope,
String? bookmark,
}) {
return _SingleAgentSkillScanRoot(
path: path ?? this.path,
source: source ?? this.source,
scope: scope ?? this.scope,
bookmark: bookmark ?? this.bookmark,
);
}
}
const String _singleAgentLocalSkillsCacheRelativePath =
'cache/single-agent-local-skills.json';
const int _singleAgentLocalSkillsCacheSchemaVersion = 4;
class AppController extends ChangeNotifier {
static const List<_SingleAgentSkillScanRoot>
_defaultSingleAgentGlobalSkillScanRoots = <_SingleAgentSkillScanRoot>[
_SingleAgentSkillScanRoot(
path: '/etc/skills',
source: 'system',
scope: 'system',
),
_SingleAgentSkillScanRoot(
path: '~/.agents/skills',
source: 'agents',
scope: 'user',
),
];
static const List<_SingleAgentSkillScanRoot>
_defaultSingleAgentWorkspaceSkillScanRoots = <_SingleAgentSkillScanRoot>[
_SingleAgentSkillScanRoot(
path: 'skills',
source: 'workspace',
scope: 'workspace',
),
];
AppController({
SecureConfigStore? store,
RuntimeCoordinator? runtimeCoordinator,
DesktopPlatformService? desktopPlatformService,
UiFeatureManifest? uiFeatureManifest,
SkillDirectoryAccessService? skillDirectoryAccessService,
List<String>? singleAgentSharedSkillScanRootOverrides,
List<SingleAgentProvider>? availableSingleAgentProvidersOverride,
ArisBundleRepository? arisBundleRepository,
SingleAgentRunner? singleAgentRunner,
}) {
_store = store ?? SecureConfigStore();
_uiFeatureManifest = uiFeatureManifest ?? UiFeatureManifest.fallback();
_hostUiFeaturePlatform = Platform.isIOS || Platform.isAndroid
? UiFeaturePlatform.mobile
: UiFeaturePlatform.desktop;
final resolvedRuntimeCoordinator =
runtimeCoordinator ??
RuntimeCoordinator(
gateway: GatewayRuntime(
store: _store,
identityStore: DeviceIdentityStore(_store),
),
codex: CodexRuntime(),
configBridge: CodexConfigBridge(),
);
_runtimeCoordinator = resolvedRuntimeCoordinator;
_codeAgentNodeOrchestrator = CodeAgentNodeOrchestrator(_runtimeCoordinator);
_codeAgentBridgeRegistry = AgentRegistry(_runtimeCoordinator.gateway);
_settingsController = SettingsController(_store);
_agentsController = GatewayAgentsController(_runtimeCoordinator.gateway);
_sessionsController = GatewaySessionsController(
_runtimeCoordinator.gateway,
);
_chatController = GatewayChatController(_runtimeCoordinator.gateway);
_instancesController = InstancesController(_runtimeCoordinator.gateway);
_skillsController = SkillsController(_runtimeCoordinator.gateway);
_connectorsController = ConnectorsController(_runtimeCoordinator.gateway);
_modelsController = ModelsController(
_runtimeCoordinator.gateway,
_settingsController,
);
_cronJobsController = CronJobsController(_runtimeCoordinator.gateway);
_devicesController = DevicesController(_runtimeCoordinator.gateway);
_tasksController = DerivedTasksController();
_desktopPlatformService =
desktopPlatformService ?? createDesktopPlatformService();
_skillDirectoryAccessService =
skillDirectoryAccessService ?? createSkillDirectoryAccessService();
_singleAgentSharedSkillScanRootOverrides =
singleAgentSharedSkillScanRootOverrides?.toList(growable: false);
_gatewayAcpClient = GatewayAcpClient(
endpointResolver: _resolveGatewayAcpEndpoint,
);
_singleAgentAppServerClient = DirectSingleAgentAppServerClient(
endpointResolver: _resolveSingleAgentEndpoint,
);
_availableSingleAgentProvidersOverride =
availableSingleAgentProvidersOverride;
_arisBundleRepository = arisBundleRepository ?? ArisBundleRepository();
_goCoreLocator = GoCoreLocator();
_singleAgentRunner =
singleAgentRunner ??
DefaultSingleAgentRunner(appServerClient: _singleAgentAppServerClient);
_multiAgentOrchestrator = MultiAgentOrchestrator(
config: _resolveMultiAgentConfig(_settingsController.snapshot),
arisBundleRepository: _arisBundleRepository,
goCoreLocator: _goCoreLocator,
);
_attachChildListeners();
unawaited(_initialize());
}
late final SecureConfigStore _store;
late final UiFeatureManifest _uiFeatureManifest;
late final UiFeaturePlatform _hostUiFeaturePlatform;
late final RuntimeCoordinator _runtimeCoordinator;
late final CodeAgentNodeOrchestrator _codeAgentNodeOrchestrator;
late final AgentRegistry _codeAgentBridgeRegistry;
late final SettingsController _settingsController;
late final GatewayAgentsController _agentsController;
late final GatewaySessionsController _sessionsController;
late final GatewayChatController _chatController;
late final InstancesController _instancesController;
late final SkillsController _skillsController;
late final ConnectorsController _connectorsController;
late final ModelsController _modelsController;
late final CronJobsController _cronJobsController;
late final DevicesController _devicesController;
late final DerivedTasksController _tasksController;
late final DesktopPlatformService _desktopPlatformService;
late final SkillDirectoryAccessService _skillDirectoryAccessService;
late final List<String>? _singleAgentSharedSkillScanRootOverrides;
late final GatewayAcpClient _gatewayAcpClient;
late final DirectSingleAgentAppServerClient _singleAgentAppServerClient;
late final List<SingleAgentProvider>? _availableSingleAgentProvidersOverride;
late final ArisBundleRepository _arisBundleRepository;
late final GoCoreLocator _goCoreLocator;
late final SingleAgentRunner _singleAgentRunner;
late final MultiAgentOrchestrator _multiAgentOrchestrator;
Map<SingleAgentProvider, DirectSingleAgentCapabilities>
_singleAgentCapabilitiesByProvider =
const <SingleAgentProvider, DirectSingleAgentCapabilities>{};
final Map<String, List<GatewayChatMessage>> _assistantThreadMessages =
<String, List<GatewayChatMessage>>{};
final Map<String, AssistantThreadRecord> _assistantThreadRecords =
<String, AssistantThreadRecord>{};
final Map<String, List<GatewayChatMessage>> _localSessionMessages =
<String, List<GatewayChatMessage>>{};
final Map<String, List<GatewayChatMessage>> _gatewayHistoryCache =
<String, List<GatewayChatMessage>>{};
final Map<String, String> _aiGatewayStreamingTextBySession =
<String, String>{};
final Map<String, String> _singleAgentRuntimeModelBySession =
<String, String>{};
final DesktopThreadArtifactService _threadArtifactService =
DesktopThreadArtifactService();
List<AssistantThreadSkillEntry> _singleAgentSharedImportedSkills =
const <AssistantThreadSkillEntry>[];
bool _singleAgentLocalSkillsHydrated = false;
Future<void>? _singleAgentSharedSkillsRefreshInFlight;
final Map<String, HttpClient> _aiGatewayStreamingClients =
<String, HttpClient>{};
final Set<String> _aiGatewayPendingSessionKeys = <String>{};
final Set<String> _aiGatewayAbortedSessionKeys = <String>{};
final Set<String> _singleAgentExternalCliPendingSessionKeys = <String>{};
final Map<String, Future<void>> _assistantThreadTurnQueues =
<String, Future<void>>{};
bool _multiAgentRunPending = false;
int _localMessageCounter = 0;
WorkspaceDestination _destination = WorkspaceDestination.assistant;
ThemeMode _themeMode = ThemeMode.light;
AppSidebarState _sidebarState = AppSidebarState.expanded;
ModulesTab _modulesTab = ModulesTab.nodes;
SecretsTab _secretsTab = SecretsTab.vault;
AiGatewayTab _aiGatewayTab = AiGatewayTab.models;
SettingsTab _settingsTab = SettingsTab.general;
SettingsDetailPage? _settingsDetail;
SettingsNavigationContext? _settingsNavigationContext;
DetailPanelData? _detailPanel;
SettingsSnapshot _settingsDraft = SettingsSnapshot.defaults();
SettingsSnapshot _lastAppliedSettings = SettingsSnapshot.defaults();
final Map<String, String> _draftSecretValues = <String, String>{};
bool _settingsDraftInitialized = false;
bool _pendingSettingsApply = false;
bool _pendingGatewayApply = false;
bool _pendingAiGatewayApply = false;
String _settingsDraftStatusMessage = '';
bool _initializing = true;
String? _bootstrapError;
StreamSubscription<GatewayPushEvent>? _runtimeEventsSubscription;
bool _disposed = false;
String _resolvedUserHomeDirectory = resolveUserHomeDirectory();
SettingsSnapshot _lastObservedSettingsSnapshot = SettingsSnapshot.defaults();
Future<void> _assistantThreadPersistQueue = Future<void>.value();
Future<void> _settingsObservationQueue = Future<void>.value();
List<_SingleAgentSkillScanRoot> get _singleAgentSharedSkillScanRoots {
final configuredRoots =
(_singleAgentSharedSkillScanRootOverrides?.map(
_singleAgentSharedSkillScanRootFromOverride,
))?.toList(growable: false) ??
_defaultSingleAgentGlobalSkillScanRoots;
final authorizedByPath = <String, AuthorizedSkillDirectory>{
for (final directory in settings.authorizedSkillDirectories)
normalizeAuthorizedSkillDirectoryPath(directory.path): directory,
};
final resolvedRoots = <_SingleAgentSkillScanRoot>[];
final seenPaths = <String>{};
for (final root in configuredRoots) {
final resolvedPath = _resolveSingleAgentSkillRootPath(root.path);
if (resolvedPath.isEmpty || !seenPaths.add(resolvedPath)) {
continue;
}
final authorizedDirectory = authorizedByPath.remove(resolvedPath);
final bookmark = authorizedDirectory?.bookmark.trim() ?? '';
resolvedRoots.add(root.copyWith(bookmark: bookmark));
}
for (final directory in authorizedByPath.values) {
resolvedRoots.add(
_singleAgentSharedSkillScanRootFromAuthorizedDirectory(directory),
);
}
return resolvedRoots;
}
WorkspaceDestination get destination => _destination;
UiFeatureManifest get uiFeatureManifest => _uiFeatureManifest;
AppCapabilities get capabilities =>
AppCapabilities.fromFeatureAccess(featuresFor(_hostUiFeaturePlatform));
ThemeMode get themeMode => _themeMode;
AppSidebarState get sidebarState => _sidebarState;
ModulesTab get modulesTab => _modulesTab;
SecretsTab get secretsTab => _secretsTab;
AiGatewayTab get aiGatewayTab => _aiGatewayTab;
SettingsTab get settingsTab => _settingsTab;
SettingsDetailPage? get settingsDetail => _settingsDetail;
SettingsNavigationContext? get settingsNavigationContext =>
_settingsNavigationContext;
DetailPanelData? get detailPanel => _detailPanel;
bool get initializing => _initializing;
String? get bootstrapError => _bootstrapError;
UiFeatureAccess featuresFor(UiFeaturePlatform platform) {
final manifest = applyAppleAppStorePolicy(
_uiFeatureManifest,
hostPlatform: platform,
isAppleHost: Platform.isIOS || Platform.isMacOS,
);
return manifest.forPlatform(platform);
}
RuntimeCoordinator get runtimeCoordinator => _runtimeCoordinator;
GatewayRuntime get _runtime => _runtimeCoordinator.gateway;
GatewayRuntime get runtime => _runtime;
/// Whether Codex bridge is enabled and configured
bool get isCodexBridgeEnabled => _isCodexBridgeEnabled;
bool _isCodexBridgeEnabled = false;
bool _isCodexBridgeBusy = false;
String? _codexBridgeError;
String? _codexRuntimeWarning;
String? _resolvedCodexCliPath;
CodexCooperationState _codexCooperationState =
CodexCooperationState.notStarted;
SettingsController get settingsController => _settingsController;
GatewayAgentsController get agentsController => _agentsController;
GatewaySessionsController get sessionsController => _sessionsController;
MultiAgentOrchestrator get multiAgentOrchestrator => _multiAgentOrchestrator;
GatewayChatController get chatController => _chatController;
InstancesController get instancesController => _instancesController;
SkillsController get skillsController => _skillsController;
ConnectorsController get connectorsController => _connectorsController;
ModelsController get modelsController => _modelsController;
CronJobsController get cronJobsController => _cronJobsController;
DevicesController get devicesController => _devicesController;
DerivedTasksController get tasksController => _tasksController;
DesktopIntegrationState get desktopIntegration =>
_desktopPlatformService.state;
bool get supportsDesktopIntegration => desktopIntegration.isSupported;
bool get desktopPlatformBusy => _desktopPlatformBusy;
GatewayConnectionSnapshot get connection => _runtime.snapshot;
SettingsSnapshot get settings => _settingsController.snapshot;
SettingsSnapshot get settingsDraft =>
_settingsDraftInitialized ? _settingsDraft : settings;
bool get supportsSkillDirectoryAuthorization =>
_skillDirectoryAccessService.isSupported;
List<AuthorizedSkillDirectory> get authorizedSkillDirectories =>
settings.authorizedSkillDirectories;
List<String> get recommendedAuthorizedSkillDirectoryPaths =>
_defaultSingleAgentGlobalSkillScanRoots
.map((item) => item.path)
.toList(growable: false);
String get userHomeDirectory => _resolvedUserHomeDirectory;
String get settingsYamlPath => defaultUserSettingsFilePath() ?? '';
bool get hasSettingsDraftChanges =>
settingsDraft.toJsonString() != settings.toJsonString() ||
_draftSecretValues.isNotEmpty;
bool get hasPendingSettingsApply => _pendingSettingsApply;
String get settingsDraftStatusMessage => _settingsDraftStatusMessage;
List<GatewayAgentSummary> get agents => _agentsController.agents;
List<GatewaySessionSummary> get sessions => isSingleAgentMode
? _assistantSessionSummaries()
: _sessionsController.sessions;
List<GatewaySessionSummary> get assistantSessions => _assistantSessions();
List<GatewayInstanceSummary> get instances => _instancesController.items;
List<GatewaySkillSummary> get skills => _skillsController.items;
List<GatewayConnectorSummary> get connectors => _connectorsController.items;
List<GatewayModelSummary> get models => _modelsController.items;
List<GatewayCronJobSummary> get cronJobs => _cronJobsController.items;
GatewayDevicePairingList get devices => _devicesController.items;
String get selectedAgentId => _agentsController.selectedAgentId;
String get activeAgentName => _agentsController.activeAgentName;
String get currentSessionKey => _sessionsController.currentSessionKey;
String? get activeRunId => _chatController.activeRunId;
AppLanguage get appLanguage => settings.appLanguage;
AssistantExecutionTarget get assistantExecutionTarget =>
currentAssistantExecutionTarget;
AssistantExecutionTarget get currentAssistantExecutionTarget =>
assistantExecutionTargetForSession(currentSessionKey);
AssistantMessageViewMode get currentAssistantMessageViewMode =>
assistantMessageViewModeForSession(currentSessionKey);
AssistantPermissionLevel get assistantPermissionLevel =>
settings.assistantPermissionLevel;
bool get hasStoredGatewayCredential =>
hasStoredGatewayTokenForProfile(_activeGatewayProfileIndex) ||
hasStoredGatewayPasswordForProfile(_activeGatewayProfileIndex) ||
_settingsController.secureRefs.containsKey(
'gateway_device_token_operator',
);
bool get hasStoredGatewayToken =>
hasStoredGatewayTokenForProfile(_activeGatewayProfileIndex);
String? get storedGatewayTokenMask =>
storedGatewayTokenMaskForProfile(_activeGatewayProfileIndex);
String get aiGatewayUrl => settings.aiGateway.baseUrl.trim();
bool get hasStoredAiGatewayApiKey =>
_settingsController.secureRefs.containsKey('ai_gateway_api_key');
bool get isSingleAgentMode =>
currentAssistantExecutionTarget == AssistantExecutionTarget.singleAgent;
bool get isCodexBridgeBusy => _isCodexBridgeBusy;
String? get codexBridgeError => _codexBridgeError;
String? get codexRuntimeWarning => _codexRuntimeWarning;
String? get resolvedCodexCliPath => _resolvedCodexCliPath;
bool get hasDetectedCodexCli => _resolvedCodexCliPath != null;
String get configuredCodexCliPath => settings.codexCliPath.trim();
CodeAgentRuntimeMode get configuredCodeAgentRuntimeMode =>
settings.codeAgentRuntimeMode;
CodeAgentRuntimeMode get effectiveCodeAgentRuntimeMode =>
configuredCodeAgentRuntimeMode;
CodexCooperationState get codexCooperationState => _codexCooperationState;
bool get isMultiAgentRunPending => _multiAgentRunPending;
bool _desktopPlatformBusy = false;
static const String _draftAiGatewayApiKeyKey = 'ai_gateway_api_key';
static const String _draftVaultTokenKey = 'vault_token';
static const String _draftOllamaApiKeyKey = 'ollama_cloud_api_key';
bool get hasAssistantPendingRun =>
assistantSessionHasPendingRun(currentSessionKey);
bool get canUseAiGatewayConversation =>
aiGatewayUrl.isNotEmpty &&
hasStoredAiGatewayApiKey &&
resolvedAiGatewayModel.isNotEmpty;
int get _activeGatewayProfileIndex {
final target = currentAssistantExecutionTarget;
if (target == AssistantExecutionTarget.singleAgent) {
return kGatewayRemoteProfileIndex;
}
return _gatewayProfileIndexForExecutionTarget(target);
}
bool hasStoredGatewayTokenForProfile(int profileIndex) =>
_settingsController.hasStoredGatewayTokenForProfile(profileIndex);
bool hasStoredGatewayPasswordForProfile(int profileIndex) =>
_settingsController.hasStoredGatewayPasswordForProfile(profileIndex);
String? storedGatewayTokenMaskForProfile(int profileIndex) =>
_settingsController.storedGatewayTokenMaskForProfile(profileIndex);
String? storedGatewayPasswordMaskForProfile(int profileIndex) =>
_settingsController.storedGatewayPasswordMaskForProfile(profileIndex);
List<SingleAgentProvider> get availableSingleAgentProviders =>
(_availableSingleAgentProvidersOverride ?? kBuiltinExternalAcpProviders)
.where((item) => item != SingleAgentProvider.auto)
.where(_canUseSingleAgentProvider)
.toList(growable: false);
bool get hasAnyAvailableSingleAgentProvider =>
availableSingleAgentProviders.isNotEmpty;
bool _canUseSingleAgentProvider(SingleAgentProvider provider) {
final override = _availableSingleAgentProvidersOverride;
if (override != null) {
return provider != SingleAgentProvider.auto &&
override.contains(provider);
}
if (provider == SingleAgentProvider.auto) {
return hasAnyAvailableSingleAgentProvider;
}
final capabilities = _singleAgentCapabilitiesByProvider[provider];
return capabilities?.available == true &&
capabilities!.supportsProvider(provider);
}
SingleAgentProvider? _resolvedSingleAgentProvider(
SingleAgentProvider selection,
) {
if (selection != SingleAgentProvider.auto) {
return _canUseSingleAgentProvider(selection) ? selection : null;
}
for (final provider in SingleAgentProvider.values) {
if (provider == SingleAgentProvider.auto) {
continue;
}
if (_canUseSingleAgentProvider(provider)) {
return provider;
}
}
return null;
}
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;
}
final available = settings.aiGateway.availableModels
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.toList(growable: false);
if (available.isNotEmpty) {
return available;
}
return const <String>[];
}
String get resolvedAiGatewayModel {
final current = settings.defaultModel.trim();
final choices = aiGatewayConversationModelChoices;
if (choices.contains(current)) {
return current;
}
if (choices.isNotEmpty) {
return choices.first;
}
return '';
}
String get resolvedAssistantModel {
return assistantModelForSession(currentSessionKey);
}
String _resolvedAssistantModelForTarget(AssistantExecutionTarget target) {
if (target == AssistantExecutionTarget.singleAgent) {
return '';
}
final resolved = resolvedDefaultModel.trim();
if (resolved.isNotEmpty) {
return resolved;
}
return '';
}
List<AssistantThreadSkillEntry> assistantImportedSkillsForSession(
String sessionKey,
) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
return _assistantThreadRecords[normalizedSessionKey]?.importedSkills ??
const <AssistantThreadSkillEntry>[];
}
int assistantSkillCountForSession(String sessionKey) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
if (assistantExecutionTargetForSession(normalizedSessionKey) ==
AssistantExecutionTarget.singleAgent) {
return assistantImportedSkillsForSession(normalizedSessionKey).length;
}
return skills.length;
}
int get currentAssistantSkillCount =>
assistantSkillCountForSession(currentSessionKey);
List<String> assistantSelectedSkillKeysForSession(String sessionKey) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
final importedKeys = assistantImportedSkillsForSession(
normalizedSessionKey,
).map((item) => item.key).toSet();
final selected =
_assistantThreadRecords[normalizedSessionKey]?.selectedSkillKeys ??
const <String>[];
return selected
.where((item) => importedKeys.contains(item))
.toList(growable: false);
}
List<AssistantThreadSkillEntry> assistantSelectedSkillsForSession(
String sessionKey,
) {
final selectedKeys = assistantSelectedSkillKeysForSession(
sessionKey,
).toSet();
return assistantImportedSkillsForSession(
sessionKey,
).where((item) => selectedKeys.contains(item.key)).toList(growable: false);
}
String assistantModelForSession(String sessionKey) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
final target = assistantExecutionTargetForSession(normalizedSessionKey);
if (target == AssistantExecutionTarget.singleAgent) {
if (singleAgentUsesAiChatFallbackForSession(normalizedSessionKey)) {
final recordModel =
_assistantThreadRecords[normalizedSessionKey]?.assistantModelId
.trim() ??
'';
if (recordModel.isNotEmpty) {
return recordModel;
}
return resolvedAiGatewayModel;
}
return singleAgentRuntimeModelForSession(normalizedSessionKey);
}
final recordModel =
_assistantThreadRecords[normalizedSessionKey]?.assistantModelId
.trim() ??
'';
if (recordModel.isNotEmpty) {
return recordModel;
}
return _resolvedAssistantModelForTarget(target);
}
String assistantWorkspaceRefForSession(String sessionKey) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
final recordRef =
_assistantThreadRecords[normalizedSessionKey]?.workspaceRef.trim() ??
'';
if (recordRef.isNotEmpty) {
return recordRef;
}
return _defaultWorkspaceRefForSession(normalizedSessionKey);
}
WorkspaceRefKind assistantWorkspaceRefKindForSession(String sessionKey) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
final record = _assistantThreadRecords[normalizedSessionKey];
if (record != null && record.workspaceRef.trim().isNotEmpty) {
return record.workspaceRefKind;
}
return _defaultWorkspaceRefKindForTarget(
assistantExecutionTargetForSession(normalizedSessionKey),
);
}
Future<AssistantArtifactSnapshot> loadAssistantArtifactSnapshot({
String? sessionKey,
}) {
final resolvedSessionKey = _normalizedAssistantSessionKey(
sessionKey ?? currentSessionKey,
);
return _threadArtifactService.loadSnapshot(
workspaceRef: assistantWorkspaceRefForSession(resolvedSessionKey),
workspaceRefKind: assistantWorkspaceRefKindForSession(resolvedSessionKey),
);
}
Future<AssistantArtifactPreview> loadAssistantArtifactPreview(
AssistantArtifactEntry entry, {
String? sessionKey,
}) {
final resolvedSessionKey = _normalizedAssistantSessionKey(
sessionKey ?? currentSessionKey,
);
return _threadArtifactService.loadPreview(
entry: entry,
workspaceRef: assistantWorkspaceRefForSession(resolvedSessionKey),
workspaceRefKind: assistantWorkspaceRefKindForSession(resolvedSessionKey),
);
}
SingleAgentProvider singleAgentProviderForSession(String sessionKey) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
return sanitizeAppStoreSingleAgentProvider(
_assistantThreadRecords[normalizedSessionKey]?.singleAgentProvider ??
SingleAgentProvider.auto,
isAppleHost: Platform.isIOS || Platform.isMacOS,
);
}
SingleAgentProvider get currentSingleAgentProvider =>
singleAgentProviderForSession(currentSessionKey);
SingleAgentProvider? singleAgentResolvedProviderForSession(
String sessionKey,
) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
return _resolvedSingleAgentProvider(
singleAgentProviderForSession(normalizedSessionKey),
);
}
SingleAgentProvider? get currentSingleAgentResolvedProvider =>
singleAgentResolvedProviderForSession(currentSessionKey);
bool singleAgentUsesAiChatFallbackForSession(String sessionKey) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
if (assistantExecutionTargetForSession(normalizedSessionKey) !=
AssistantExecutionTarget.singleAgent) {
return false;
}
return !hasAnyAvailableSingleAgentProvider && canUseAiGatewayConversation;
}
bool get currentSingleAgentUsesAiChatFallback =>
singleAgentUsesAiChatFallbackForSession(currentSessionKey);
bool singleAgentNeedsAiGatewayConfigurationForSession(String sessionKey) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
if (assistantExecutionTargetForSession(normalizedSessionKey) !=
AssistantExecutionTarget.singleAgent) {
return false;
}
return !hasAnyAvailableSingleAgentProvider && !canUseAiGatewayConversation;
}
bool get currentSingleAgentNeedsAiGatewayConfiguration =>
singleAgentNeedsAiGatewayConfigurationForSession(currentSessionKey);
bool singleAgentHasResolvedProviderForSession(String sessionKey) {
return singleAgentResolvedProviderForSession(sessionKey) != null;
}
bool get currentSingleAgentHasResolvedProvider =>
singleAgentHasResolvedProviderForSession(currentSessionKey);
bool singleAgentShouldSuggestAutoSwitchForSession(String sessionKey) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
if (assistantExecutionTargetForSession(normalizedSessionKey) !=
AssistantExecutionTarget.singleAgent) {
return false;
}
final selection = singleAgentProviderForSession(normalizedSessionKey);
if (selection == SingleAgentProvider.auto) {
return false;
}
return !_canUseSingleAgentProvider(selection) &&
hasAnyAvailableSingleAgentProvider;
}
bool get currentSingleAgentShouldSuggestAutoSwitch =>
singleAgentShouldSuggestAutoSwitchForSession(currentSessionKey);
String singleAgentRuntimeModelForSession(String sessionKey) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
return _singleAgentRuntimeModelBySession[normalizedSessionKey]?.trim() ??
'';
}
String get currentSingleAgentRuntimeModel =>
singleAgentRuntimeModelForSession(currentSessionKey);
String singleAgentModelDisplayLabelForSession(String sessionKey) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
final runtimeModel = singleAgentRuntimeModelForSession(
normalizedSessionKey,
);
if (runtimeModel.isNotEmpty) {
return runtimeModel;
}
final model = assistantModelForSession(normalizedSessionKey);
if (model.isNotEmpty) {
return model;
}
if (singleAgentUsesAiChatFallbackForSession(normalizedSessionKey)) {
return appText('AI Chat fallback', 'AI Chat fallback');
}
final provider =
singleAgentResolvedProviderForSession(normalizedSessionKey) ??
singleAgentProviderForSession(normalizedSessionKey);
return appText(
'请先配置 ${provider.label} 模型',
'Configure ${provider.label} model',
);
}
String get currentSingleAgentModelDisplayLabel =>
singleAgentModelDisplayLabelForSession(currentSessionKey);
bool singleAgentShouldShowModelControlForSession(String sessionKey) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
if (assistantExecutionTargetForSession(normalizedSessionKey) !=
AssistantExecutionTarget.singleAgent) {
return true;
}
if (singleAgentUsesAiChatFallbackForSession(normalizedSessionKey)) {
return true;
}
return singleAgentRuntimeModelForSession(normalizedSessionKey).isNotEmpty;
}
bool get currentSingleAgentShouldShowModelControl =>
singleAgentShouldShowModelControlForSession(currentSessionKey);
List<SingleAgentProvider> get singleAgentProviderOptions =>
const <SingleAgentProvider>[
SingleAgentProvider.auto,
...kBuiltinExternalAcpProviders,
];
String singleAgentProviderLabelForSession(String sessionKey) {
return singleAgentProviderForSession(sessionKey).label;
}
String get assistantConversationOwnerLabel {
if (!isSingleAgentMode) {
return activeAgentName;
}
final resolvedProvider = currentSingleAgentResolvedProvider;
if (resolvedProvider != null) {
return resolvedProvider.label;
}
final provider = currentSingleAgentProvider;
if (provider != SingleAgentProvider.auto) {
return provider.label;
}
if (currentSingleAgentUsesAiChatFallback) {
return appText('AI Chat fallback', 'AI Chat fallback');
}
return appText('单机智能体', 'Single Agent');
}
AssistantThreadConnectionState get currentAssistantConnectionState =>
assistantConnectionStateForSession(currentSessionKey);
AssistantThreadConnectionState assistantConnectionStateForSession(
String sessionKey,
) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
final target = assistantExecutionTargetForSession(normalizedSessionKey);
if (target == AssistantExecutionTarget.singleAgent) {
final provider = singleAgentProviderForSession(normalizedSessionKey);
final resolvedProvider = singleAgentResolvedProviderForSession(
normalizedSessionKey,
);
final model = assistantModelForSession(normalizedSessionKey);
final fallbackReady = singleAgentUsesAiChatFallbackForSession(
normalizedSessionKey,
);
final host = _aiGatewayHostLabel(settings.aiGateway.baseUrl);
final providerReady = resolvedProvider != null;
final detail = providerReady
? _joinConnectionParts(<String>[resolvedProvider.label, model])
: fallbackReady
? _joinConnectionParts(<String>[
appText('AI Chat fallback', 'AI Chat fallback'),
model,
host,
])
: singleAgentShouldSuggestAutoSwitchForSession(normalizedSessionKey)
? appText(
'${provider.label} 不可用,可切到 Auto',
'${provider.label} is unavailable. Switch to Auto.',
)
: singleAgentNeedsAiGatewayConfigurationForSession(
normalizedSessionKey,
)
? appText(
'没有可用的外部 Agent ACP 端点,请配置 LLM API fallback。',
'No external Agent ACP endpoint is available. Configure LLM API fallback.',
)
: appText(
'当前线程的外部 Agent ACP 连接尚未就绪。',
'The external Agent ACP connection for this thread is not ready yet.',
);
return AssistantThreadConnectionState(
executionTarget: target,
status: providerReady || fallbackReady
? RuntimeConnectionStatus.connected
: RuntimeConnectionStatus.offline,
primaryLabel: target.label,
detailLabel: detail.isEmpty
? appText('未配置单机智能体', 'Single Agent is not configured')
: detail,
ready: providerReady || fallbackReady,
pairingRequired: false,
gatewayTokenMissing: false,
lastError: null,
);
}
final expectedMode = target == AssistantExecutionTarget.local
? RuntimeConnectionMode.local
: RuntimeConnectionMode.remote;
final matchesTarget = connection.mode == expectedMode;
final fallbackProfile = _gatewayProfileForAssistantExecutionTarget(target);
final fallbackAddress = _gatewayAddressLabel(fallbackProfile);
final detail = matchesTarget
? (connection.remoteAddress?.trim().isNotEmpty == true
? connection.remoteAddress!.trim()
: fallbackAddress)
: fallbackAddress;
final status = matchesTarget
? connection.status
: RuntimeConnectionStatus.offline;
return AssistantThreadConnectionState(
executionTarget: target,
status: status,
primaryLabel: status.label,
detailLabel: detail,
ready: status == RuntimeConnectionStatus.connected,
pairingRequired: matchesTarget && connection.pairingRequired,
gatewayTokenMissing: matchesTarget && connection.gatewayTokenMissing,
lastError: matchesTarget ? connection.lastError?.trim() : null,
);
}
String get assistantConnectionStatusLabel =>
currentAssistantConnectionState.primaryLabel;
String get assistantConnectionTargetLabel {
return currentAssistantConnectionState.detailLabel;
}
Future<String> loadAiGatewayApiKey() async {
return (await _store.loadAiGatewayApiKey())?.trim() ?? '';
}
Future<void> saveMultiAgentConfig(MultiAgentConfig config) async {
final resolved = _resolveMultiAgentConfig(
settings.copyWith(multiAgent: config),
);
await saveSettings(
settings.copyWith(multiAgent: resolved),
refreshAfterSave: false,
);
await refreshMultiAgentMounts(sync: resolved.autoSync);
}
Future<void> refreshMultiAgentMounts({bool sync = false}) async {
await _refreshAcpCapabilities(persistMountTargets: true);
}
Future<void> runMultiAgentCollaboration({
required String rawPrompt,
required String composedPrompt,
required List<CollaborationAttachment> attachments,
required List<String> selectedSkillLabels,
}) async {
final sessionKey = currentSessionKey.trim().isEmpty
? 'main'
: currentSessionKey;
await _enqueueThreadTurn<void>(sessionKey, () async {
final aiGatewayApiKey = await loadAiGatewayApiKey();
_multiAgentRunPending = true;
_appendLocalSessionMessage(
sessionKey,
GatewayChatMessage(
id: _nextLocalMessageId(),
role: 'user',
text: rawPrompt,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
);
_recomputeTasks();
try {
final taskStream = _gatewayAcpClient.runMultiAgent(
GatewayAcpMultiAgentRequest(
sessionId: sessionKey,
threadId: sessionKey,
prompt: composedPrompt,
workingDirectory:
_resolveCodexWorkingDirectory() ?? Directory.current.path,
attachments: attachments,
selectedSkills: selectedSkillLabels,
aiGatewayBaseUrl: aiGatewayUrl,
aiGatewayApiKey: aiGatewayApiKey,
resumeSession: true,
),
);
await for (final event in taskStream) {
if (event.type == 'result') {
final success = event.data['success'] == true;
final finalScore = event.data['finalScore'];
final iterations = event.data['iterations'];
_appendLocalSessionMessage(
sessionKey,
GatewayChatMessage(
id: _nextLocalMessageId(),
role: 'assistant',
text: success
? appText(
'多 Agent 协作完成,评分 ${finalScore ?? '-'},迭代 ${iterations ?? 0} 次。',
'Multi-agent collaboration completed with score ${finalScore ?? '-'} after ${iterations ?? 0} iteration(s).',
)
: appText(
'多 Agent 协作失败:${event.data['error'] ?? event.message}',
'Multi-agent collaboration failed: ${event.data['error'] ?? event.message}',
),
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: !success,
),
);
continue;
}
_appendLocalSessionMessage(
sessionKey,
GatewayChatMessage(
id: _nextLocalMessageId(),
role: 'assistant',
text: event.message,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: event.title,
stopReason: null,
pending: event.pending,
error: event.error,
),
);
}
} on GatewayAcpException catch (error) {
_appendLocalSessionMessage(
sessionKey,
GatewayChatMessage(
id: _nextLocalMessageId(),
role: 'assistant',
text: appText(
'多 Agent 协作不可用Gateway ACP${error.message}',
'Multi-agent collaboration is unavailable (Gateway ACP): ${error.message}',
),
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: 'Multi-Agent',
stopReason: null,
pending: false,
error: true,
),
);
} catch (error) {
_appendLocalSessionMessage(
sessionKey,
GatewayChatMessage(
id: _nextLocalMessageId(),
role: 'assistant',
text: error.toString(),
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: 'Multi-Agent',
stopReason: null,
pending: false,
error: true,
),
);
} finally {
_multiAgentRunPending = false;
_recomputeTasks();
_notifyIfActive();
}
});
}
Future<void> openOnlineWorkspace() async {
const url = 'https://www.svc.plus/Xworkmate';
try {
if (Platform.isMacOS) {
await Process.run('open', [url]);
return;
}
if (Platform.isWindows) {
await Process.run('cmd', ['/c', 'start', '', url]);
return;
}
if (Platform.isLinux) {
await Process.run('xdg-open', [url]);
}
} catch (_) {
// Best effort only. Do not surface a blocking error from a convenience link.
}
}
List<String> get aiGatewayModelChoices {
return aiGatewayConversationModelChoices;
}
List<String> get connectedGatewayModelChoices {
if (connection.status != RuntimeConnectionStatus.connected) {
return const <String>[];
}
return _modelsController.items
.map((item) => item.id.trim())
.where((item) => item.isNotEmpty)
.toList(growable: false);
}
List<String> get assistantModelChoices {
return _assistantModelChoicesForSession(currentSessionKey);
}
List<String> _assistantModelChoicesForSession(String sessionKey) {
final target = assistantExecutionTargetForSession(sessionKey);
if (target == AssistantExecutionTarget.singleAgent) {
if (singleAgentUsesAiChatFallbackForSession(sessionKey)) {
return aiGatewayConversationModelChoices;
}
final selectedModel =
_assistantThreadRecords[_normalizedAssistantSessionKey(sessionKey)]
?.assistantModelId
.trim();
if (selectedModel?.isNotEmpty == true) {
return <String>[selectedModel!];
}
return const <String>[];
}
final runtimeModels = connectedGatewayModelChoices;
if (runtimeModels.isNotEmpty) {
return runtimeModels;
}
final resolved = resolvedDefaultModel.trim();
if (resolved.isNotEmpty) {
return <String>[resolved];
}
final localDefault = settings.ollamaLocal.defaultModel.trim();
if (localDefault.isNotEmpty) {
return <String>[localDefault];
}
return const <String>[];
}
String get resolvedDefaultModel {
final current = settings.defaultModel.trim();
if (current.isNotEmpty) {
return current;
}
final localDefault = settings.ollamaLocal.defaultModel.trim();
if (localDefault.isNotEmpty) {
return localDefault;
}
final runtimeModels = connectedGatewayModelChoices;
if (runtimeModels.isNotEmpty) {
return runtimeModels.first;
}
final aiGatewayChoices = aiGatewayConversationModelChoices;
if (aiGatewayChoices.isNotEmpty) {
return aiGatewayChoices.first;
}
return '';
}
bool get canQuickConnectGateway {
final target = currentAssistantExecutionTarget;
if (target == AssistantExecutionTarget.singleAgent) {
return false;
}
final profile = _gatewayProfileForAssistantExecutionTarget(target);
if (profile.useSetupCode && profile.setupCode.trim().isNotEmpty) {
return true;
}
final host = profile.host.trim();
if (host.isEmpty || profile.port <= 0) {
return false;
}
if (profile.mode == RuntimeConnectionMode.local) {
return true;
}
final defaults = switch (target) {
AssistantExecutionTarget.singleAgent =>
GatewayConnectionProfile.emptySlot(index: kGatewayRemoteProfileIndex),
AssistantExecutionTarget.local =>
GatewayConnectionProfile.defaultsLocal(),
AssistantExecutionTarget.remote =>
GatewayConnectionProfile.defaultsRemote(),
};
return hasStoredGatewayCredential ||
host != defaults.host ||
profile.port != defaults.port ||
profile.tls != defaults.tls ||
profile.mode != defaults.mode;
}
String _joinConnectionParts(List<String> parts) {
final normalized = parts
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.toList(growable: false);
return normalized.join(' · ');
}
String _gatewayAddressLabel(GatewayConnectionProfile profile) {
final host = profile.host.trim();
if (host.isEmpty || profile.port <= 0) {
return appText('未连接目标', 'No target');
}
return '$host:${profile.port}';
}
List<SecretReferenceEntry> get secretReferences =>
_settingsController.buildSecretReferences();
List<SecretAuditEntry> get secretAuditTrail => _settingsController.auditTrail;
List<RuntimeLogEntry> get runtimeLogs => _runtime.logs;
List<WorkspaceDestination> get assistantNavigationDestinations =>
normalizeAssistantNavigationDestinations(
settings.assistantNavigationDestinations,
).where(capabilities.supportsDestination).toList(growable: false);
List<GatewayChatMessage> get chatMessages {
final sessionKey = _normalizedAssistantSessionKey(
_sessionsController.currentSessionKey,
);
final items = List<GatewayChatMessage>.from(
isSingleAgentMode
? const <GatewayChatMessage>[]
: _chatController.messages,
);
final threadItems = isSingleAgentMode
? _assistantThreadMessages[sessionKey]
: null;
if (threadItems != null && threadItems.isNotEmpty) {
items.addAll(threadItems);
}
final localItems = _localSessionMessages[sessionKey];
if (localItems != null && localItems.isNotEmpty) {
items.addAll(localItems);
}
final streaming = isSingleAgentMode
? (_aiGatewayStreamingTextBySession[sessionKey]?.trim() ?? '')
: (_chatController.streamingAssistantText?.trim() ?? '');
if (streaming.isNotEmpty) {
items.add(
GatewayChatMessage(
id: 'streaming',
role: 'assistant',
text: streaming,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: true,
error: false,
),
);
}
return items;
}
String _normalizedAssistantSessionKey(String sessionKey) {
final trimmed = sessionKey.trim();
return trimmed.isEmpty ? 'main' : trimmed;
}
AssistantExecutionTarget assistantExecutionTargetForSession(
String sessionKey,
) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
return _sanitizeExecutionTarget(
_assistantThreadRecords[normalizedSessionKey]?.executionTarget ??
settings.assistantExecutionTarget,
);
}
AssistantMessageViewMode assistantMessageViewModeForSession(
String sessionKey,
) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
return _assistantThreadRecords[normalizedSessionKey]?.messageViewMode ??
AssistantMessageViewMode.rendered;
}
String _defaultWorkspaceRefForSession(String sessionKey) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
final target = assistantExecutionTargetForSession(normalizedSessionKey);
return switch (target) {
AssistantExecutionTarget.remote => settings.remoteProjectRoot.trim(),
AssistantExecutionTarget.local ||
AssistantExecutionTarget.singleAgent => settings.workspacePath.trim(),
};
}
WorkspaceRefKind _defaultWorkspaceRefKindForTarget(
AssistantExecutionTarget target,
) {
return switch (target) {
AssistantExecutionTarget.remote => WorkspaceRefKind.remotePath,
AssistantExecutionTarget.local ||
AssistantExecutionTarget.singleAgent => WorkspaceRefKind.localPath,
};
}
void _syncAssistantWorkspaceRefForSession(
String sessionKey, {
AssistantExecutionTarget? executionTarget,
}) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
final resolvedTarget =
executionTarget ??
assistantExecutionTargetForSession(normalizedSessionKey);
final nextWorkspaceRef = _defaultWorkspaceRefForSession(
normalizedSessionKey,
);
final nextWorkspaceRefKind = _defaultWorkspaceRefKindForTarget(
resolvedTarget,
);
final existing = _assistantThreadRecords[normalizedSessionKey];
if (existing != null &&
existing.workspaceRef == nextWorkspaceRef &&
existing.workspaceRefKind == nextWorkspaceRefKind) {
return;
}
_upsertAssistantThreadRecord(
normalizedSessionKey,
executionTarget: resolvedTarget,
workspaceRef: nextWorkspaceRef,
workspaceRefKind: nextWorkspaceRefKind,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
}
List<GatewaySessionSummary> _assistantSessions() {
final archivedKeys = settings.assistantArchivedTaskKeys
.map(_normalizedAssistantSessionKey)
.toSet();
final byKey = <String, GatewaySessionSummary>{};
for (final session in _sessionsController.sessions) {
final normalizedSessionKey = _normalizedAssistantSessionKey(session.key);
if (archivedKeys.contains(normalizedSessionKey)) {
continue;
}
byKey[normalizedSessionKey] = session;
}
for (final record in _assistantThreadRecords.values) {
final normalizedSessionKey = _normalizedAssistantSessionKey(
record.sessionKey,
);
if (normalizedSessionKey.isEmpty ||
archivedKeys.contains(normalizedSessionKey) ||
record.archived) {
continue;
}
byKey.putIfAbsent(
normalizedSessionKey,
() => _assistantSessionSummaryFor(normalizedSessionKey, record: record),
);
}
final currentKey = _normalizedAssistantSessionKey(currentSessionKey);
if (!archivedKeys.contains(currentKey) && !byKey.containsKey(currentKey)) {
byKey[currentKey] = _assistantSessionSummaryFor(currentKey);
}
final items = byKey.values.toList(growable: true)
..sort(
(left, right) =>
(right.updatedAtMs ?? 0).compareTo(left.updatedAtMs ?? 0),
);
return items;
}
bool assistantSessionHasPendingRun(String sessionKey) {
final normalized = _normalizedAssistantSessionKey(sessionKey);
if (assistantExecutionTargetForSession(normalized) ==
AssistantExecutionTarget.singleAgent) {
return _aiGatewayPendingSessionKeys.contains(normalized);
}
return (_chatController.hasPendingRun || _multiAgentRunPending) &&
matchesSessionKey(normalized, _sessionsController.currentSessionKey);
}
void navigateTo(WorkspaceDestination destination) {
if (!capabilities.supportsDestination(destination)) {
return;
}
if (destination == WorkspaceDestination.aiGateway ||
destination == WorkspaceDestination.secrets) {
openSettings(tab: SettingsTab.gateway);
return;
}
final nextModulesTab = switch (destination) {
WorkspaceDestination.nodes => ModulesTab.nodes,
WorkspaceDestination.agents => ModulesTab.agents,
_ => _modulesTab,
};
final shouldClearSettingsDrillIn =
_settingsDetail != null || _settingsNavigationContext != null;
final changed =
_destination != destination ||
_detailPanel != null ||
shouldClearSettingsDrillIn ||
nextModulesTab != _modulesTab;
if (!changed) {
return;
}
_destination = destination;
_modulesTab = nextModulesTab;
_settingsDetail = null;
_settingsNavigationContext = null;
_detailPanel = null;
notifyListeners();
}
void navigateHome() {
final mainSessionKey =
_runtime.snapshot.mainSessionKey?.trim().isNotEmpty == true
? _runtime.snapshot.mainSessionKey!.trim()
: 'main';
final homeDestination =
capabilities.supportsDestination(WorkspaceDestination.assistant)
? WorkspaceDestination.assistant
: (capabilities.allowedDestinations.isEmpty
? WorkspaceDestination.assistant
: capabilities.allowedDestinations.first);
final destinationChanged = _destination != homeDestination;
final detailChanged = _detailPanel != null;
final settingsDrillInChanged =
_settingsDetail != null || _settingsNavigationContext != null;
_destination = homeDestination;
_settingsDetail = null;
_settingsNavigationContext = null;
_detailPanel = null;
if (destinationChanged || detailChanged || settingsDrillInChanged) {
notifyListeners();
}
if (_sessionsController.currentSessionKey != mainSessionKey) {
unawaited(switchSession(mainSessionKey));
}
}
void openModules({ModulesTab tab = ModulesTab.nodes}) {
if (tab == ModulesTab.gateway) {
openSettings(tab: SettingsTab.gateway);
return;
}
final destination = tab == ModulesTab.agents
? WorkspaceDestination.agents
: WorkspaceDestination.nodes;
if (!capabilities.supportsDestination(destination)) {
return;
}
final changed =
_destination != destination ||
_modulesTab != tab ||
_detailPanel != null ||
_settingsDetail != null ||
_settingsNavigationContext != null;
if (!changed) {
return;
}
_destination = destination;
_modulesTab = tab;
_detailPanel = null;
_settingsDetail = null;
_settingsNavigationContext = null;
notifyListeners();
}
void setModulesTab(ModulesTab tab) {
if (_modulesTab == tab) {
return;
}
_modulesTab = tab;
notifyListeners();
}
void openSecrets({SecretsTab tab = SecretsTab.vault}) {
if (!capabilities.supportsDestination(WorkspaceDestination.settings)) {
return;
}
_secretsTab = tab;
openSettings(tab: SettingsTab.gateway);
}
void setSecretsTab(SecretsTab tab) {
if (_secretsTab == tab) {
return;
}
_secretsTab = tab;
notifyListeners();
}
void openAiGateway({AiGatewayTab tab = AiGatewayTab.models}) {
if (!capabilities.supportsDestination(WorkspaceDestination.settings)) {
return;
}
_aiGatewayTab = tab;
openSettings(tab: SettingsTab.gateway);
}
void setAiGatewayTab(AiGatewayTab tab) {
if (_aiGatewayTab == tab) {
return;
}
_aiGatewayTab = tab;
notifyListeners();
}
void openSettings({
SettingsTab tab = SettingsTab.general,
SettingsDetailPage? detail,
SettingsNavigationContext? navigationContext,
}) {
if (!capabilities.supportsDestination(WorkspaceDestination.settings)) {
return;
}
final requestedTab = detail?.tab ?? tab;
final resolvedTab = _sanitizeSettingsTab(requestedTab);
final resolvedDetail = detail != null && resolvedTab == detail.tab
? detail
: null;
final changed =
_destination != WorkspaceDestination.settings ||
_settingsTab != resolvedTab ||
_settingsDetail != resolvedDetail ||
_settingsNavigationContext != navigationContext ||
_detailPanel != null;
if (!changed) {
return;
}
_destination = WorkspaceDestination.settings;
_settingsTab = resolvedTab;
_settingsDetail = resolvedDetail;
_settingsNavigationContext = resolvedDetail == null
? null
: navigationContext;
_detailPanel = null;
notifyListeners();
}
void setSettingsTab(SettingsTab tab, {bool clearDetail = true}) {
final resolvedTab = _sanitizeSettingsTab(tab);
final changed =
_settingsTab != resolvedTab ||
(clearDetail &&
(_settingsDetail != null || _settingsNavigationContext != null));
if (!changed) {
return;
}
_settingsTab = resolvedTab;
if (clearDetail) {
_settingsDetail = null;
_settingsNavigationContext = null;
}
notifyListeners();
}
void closeSettingsDetail() {
if (_settingsDetail == null && _settingsNavigationContext == null) {
return;
}
_settingsDetail = null;
_settingsNavigationContext = null;
notifyListeners();
}
void cycleSidebarState() {
_sidebarState = switch (_sidebarState) {
AppSidebarState.expanded => AppSidebarState.collapsed,
AppSidebarState.collapsed => AppSidebarState.hidden,
AppSidebarState.hidden => AppSidebarState.expanded,
};
notifyListeners();
}
void setSidebarState(AppSidebarState state) {
if (_sidebarState == state) {
return;
}
_sidebarState = state;
notifyListeners();
}
void setThemeMode(ThemeMode mode) {
if (_themeMode == mode) {
return;
}
_themeMode = mode;
notifyListeners();
}
Future<void> toggleAppLanguage() async {
await setAppLanguage(
settings.appLanguage == AppLanguage.zh ? AppLanguage.en : AppLanguage.zh,
);
}
Future<void> setAppLanguage(AppLanguage language) async {
if (settings.appLanguage == language) {
return;
}
setActiveAppLanguage(language);
await saveSettings(
settings.copyWith(appLanguage: language),
refreshAfterSave: false,
);
}
void openDetail(DetailPanelData detailPanel) {
_detailPanel = detailPanel;
notifyListeners();
}
void closeDetail() {
if (_detailPanel == null) {
return;
}
_detailPanel = null;
notifyListeners();
}
Future<void> connectWithSetupCode({
required String setupCode,
String token = '',
String password = '',
}) async {
final decoded = decodeGatewaySetupCode(setupCode);
final resolvedToken = token.trim().isNotEmpty
? token.trim()
: (decoded?.token.trim() ?? '');
final resolvedPassword = password.trim().isNotEmpty
? password.trim()
: (decoded?.password.trim() ?? '');
final resolvedProfileIndex = _gatewayProfileIndexForExecutionTarget(
_assistantExecutionTargetForMode(
_modeFromHost(
decoded?.host ?? settings.primaryRemoteGatewayProfile.host,
),
),
);
await _settingsController.saveGatewaySecrets(
profileIndex: resolvedProfileIndex,
token: resolvedToken,
password: resolvedPassword,
);
final resolvedTarget = _assistantExecutionTargetForMode(
_modeFromHost(decoded?.host ?? settings.primaryRemoteGatewayProfile.host),
);
final currentProfile = _gatewayProfileForAssistantExecutionTarget(
resolvedTarget,
);
final nextProfile = currentProfile.copyWith(
useSetupCode: true,
setupCode: setupCode.trim(),
host: decoded?.host ?? currentProfile.host,
port: decoded?.port ?? currentProfile.port,
tls: decoded?.tls ?? currentProfile.tls,
mode: resolvedTarget == AssistantExecutionTarget.local
? RuntimeConnectionMode.local
: RuntimeConnectionMode.remote,
);
await saveSettings(
settings
.copyWithGatewayProfileAt(
_gatewayProfileIndexForExecutionTarget(resolvedTarget),
nextProfile,
)
.copyWith(assistantExecutionTarget: resolvedTarget),
refreshAfterSave: false,
);
_upsertAssistantThreadRecord(
_sessionsController.currentSessionKey,
executionTarget: resolvedTarget,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
await _connectProfile(
nextProfile,
profileIndex: resolvedProfileIndex,
authTokenOverride: resolvedToken,
authPasswordOverride: resolvedPassword,
);
await _chatController.loadSession(_sessionsController.currentSessionKey);
}
Future<void> connectManual({
required String host,
required int port,
required bool tls,
required RuntimeConnectionMode mode,
String token = '',
String password = '',
}) async {
final nextTarget = _assistantExecutionTargetForMode(mode);
final nextProfileIndex = _gatewayProfileIndexForExecutionTarget(nextTarget);
await _settingsController.saveGatewaySecrets(
profileIndex: nextProfileIndex,
token: token.trim(),
password: password.trim(),
);
final resolvedHost =
host.trim().isEmpty && mode == RuntimeConnectionMode.local
? '127.0.0.1'
: host.trim();
final resolvedPort = mode == RuntimeConnectionMode.local && port <= 0
? 18789
: port;
final nextProfile = _gatewayProfileForAssistantExecutionTarget(nextTarget)
.copyWith(
mode: mode,
useSetupCode: false,
setupCode: '',
host: resolvedHost,
port: resolvedPort <= 0 ? 443 : resolvedPort,
tls: mode == RuntimeConnectionMode.local ? false : tls,
);
await saveSettings(
settings
.copyWithGatewayProfileAt(
_gatewayProfileIndexForExecutionTarget(nextTarget),
nextProfile,
)
.copyWith(assistantExecutionTarget: nextTarget),
refreshAfterSave: false,
);
_upsertAssistantThreadRecord(
_sessionsController.currentSessionKey,
executionTarget: nextTarget,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
await _connectProfile(
nextProfile,
profileIndex: nextProfileIndex,
authTokenOverride: token.trim(),
authPasswordOverride: password.trim(),
);
await _chatController.loadSession(_sessionsController.currentSessionKey);
}
Future<void> disconnectGateway() async {
_clearCodexGatewayRegistration();
await _runtime.disconnect(clearDesiredProfile: false);
await _settingsController.refreshDerivedState();
await _agentsController.refresh();
await _sessionsController.refresh();
_chatController.clear();
await _instancesController.refresh();
await _skillsController.refresh();
await _connectorsController.refresh();
await _modelsController.refresh();
await _cronJobsController.refresh();
_devicesController.clear();
_recomputeTasks();
}
Future<void> connectSavedGateway() async {
final target = currentAssistantExecutionTarget;
if (target == AssistantExecutionTarget.singleAgent) {
return;
}
await _connectProfile(
_gatewayProfileForAssistantExecutionTarget(target),
profileIndex: _gatewayProfileIndexForExecutionTarget(target),
);
}
Future<void> clearStoredGatewayToken({int? profileIndex}) async {
await _settingsController.clearGatewaySecrets(
profileIndex: profileIndex,
token: true,
);
}
Future<void> refreshGatewayHealth() async {
if (!_runtime.isConnected) {
return;
}
try {
await _runtime.health();
} catch (_) {}
try {
await _runtime.status();
} catch (_) {}
notifyListeners();
}
Future<void> refreshDevices({bool quiet = false}) async {
await _devicesController.refresh(quiet: quiet);
}
Future<void> approveDevicePairing(String requestId) async {
await _devicesController.approve(requestId);
await _settingsController.refreshDerivedState();
}
Future<void> rejectDevicePairing(String requestId) async {
await _devicesController.reject(requestId);
}
Future<void> removePairedDevice(String deviceId) async {
await _devicesController.remove(deviceId);
await _settingsController.refreshDerivedState();
}
Future<String?> rotateDeviceRoleToken({
required String deviceId,
required String role,
List<String> scopes = const <String>[],
}) async {
final token = await _devicesController.rotateToken(
deviceId: deviceId,
role: role,
scopes: scopes,
);
await _settingsController.refreshDerivedState();
return token;
}
Future<void> revokeDeviceRoleToken({
required String deviceId,
required String role,
}) async {
await _devicesController.revokeToken(deviceId: deviceId, role: role);
await _settingsController.refreshDerivedState();
}
Future<void> refreshAgents() async {
await _agentsController.refresh();
_sessionsController.configure(
mainSessionKey: _runtime.snapshot.mainSessionKey ?? 'main',
selectedAgentId: _agentsController.selectedAgentId,
defaultAgentId: '',
);
_recomputeTasks();
}
Future<void> selectAgent(String? agentId) async {
_agentsController.selectAgent(agentId);
if (currentAssistantExecutionTarget !=
AssistantExecutionTarget.singleAgent) {
final target = currentAssistantExecutionTarget;
final nextProfile = _gatewayProfileForAssistantExecutionTarget(
target,
).copyWith(selectedAgentId: _agentsController.selectedAgentId);
await saveSettings(
settings.copyWithGatewayProfileAt(
_gatewayProfileIndexForExecutionTarget(target),
nextProfile,
),
refreshAfterSave: false,
);
}
_sessionsController.configure(
mainSessionKey: _runtime.snapshot.mainSessionKey ?? 'main',
selectedAgentId: _agentsController.selectedAgentId,
defaultAgentId: '',
);
await _chatController.loadSession(_sessionsController.currentSessionKey);
await _skillsController.refresh(
agentId: _agentsController.selectedAgentId.isEmpty
? null
: _agentsController.selectedAgentId,
);
_recomputeTasks();
}
Future<void> refreshSessions() async {
_sessionsController.configure(
mainSessionKey: _runtime.snapshot.mainSessionKey ?? 'main',
selectedAgentId: _agentsController.selectedAgentId,
defaultAgentId: '',
);
await _sessionsController.refresh();
await _chatController.loadSession(_sessionsController.currentSessionKey);
_recomputeTasks();
}
Future<void> switchSession(String sessionKey) async {
final previousSessionKey = _normalizedAssistantSessionKey(
_sessionsController.currentSessionKey,
);
final nextSessionKey = _normalizedAssistantSessionKey(sessionKey);
final nextTarget = assistantExecutionTargetForSession(nextSessionKey);
final nextViewMode = assistantMessageViewModeForSession(nextSessionKey);
if (!isSingleAgentMode) {
_preserveGatewayHistoryForSession(previousSessionKey);
}
await _setCurrentAssistantSessionKey(nextSessionKey);
_upsertAssistantThreadRecord(
nextSessionKey,
executionTarget: nextTarget,
messageViewMode: nextViewMode,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
workspaceRef: _defaultWorkspaceRefForSession(nextSessionKey),
workspaceRefKind: _defaultWorkspaceRefKindForTarget(nextTarget),
);
await _applyAssistantExecutionTarget(
nextTarget,
sessionKey: nextSessionKey,
persistDefaultSelection: false,
);
if (nextTarget == AssistantExecutionTarget.singleAgent) {
await refreshSingleAgentSkillsForSession(nextSessionKey);
}
_recomputeTasks();
}
Future<void> sendChatMessage(
String message, {
String thinking = 'off',
List<GatewayChatAttachmentPayload> attachments =
const <GatewayChatAttachmentPayload>[],
List<CollaborationAttachment> localAttachments =
const <CollaborationAttachment>[],
List<String> selectedSkillLabels = const <String>[],
}) async {
_syncAssistantWorkspaceRefForSession(_sessionsController.currentSessionKey);
if (isSingleAgentMode) {
await _sendSingleAgentMessage(
message,
thinking: thinking,
attachments: attachments,
localAttachments: localAttachments,
);
await _flushAssistantThreadPersistence();
_recomputeTasks();
return;
}
final dispatch = _codeAgentNodeOrchestrator.buildGatewayDispatch(
_buildCodeAgentNodeState(),
);
await _chatController.sendMessage(
sessionKey: _sessionsController.currentSessionKey,
message: message,
thinking: thinking,
attachments: attachments,
agentId: dispatch.agentId,
metadata: dispatch.metadata,
);
_recomputeTasks();
}
Future<void> abortRun() async {
if (_multiAgentRunPending) {
final sessionKey = _normalizedAssistantSessionKey(
_sessionsController.currentSessionKey,
);
try {
await _gatewayAcpClient.cancelSession(
sessionId: sessionKey,
threadId: sessionKey,
);
} catch (_) {
// Best effort cancellation only.
}
_multiAgentRunPending = false;
_recomputeTasks();
_notifyIfActive();
return;
}
if (isSingleAgentMode) {
final sessionKey = _normalizedAssistantSessionKey(
_sessionsController.currentSessionKey,
);
if (_singleAgentExternalCliPendingSessionKeys.contains(sessionKey)) {
await _singleAgentRunner.abort(sessionKey);
_aiGatewayPendingSessionKeys.remove(sessionKey);
_singleAgentExternalCliPendingSessionKeys.remove(sessionKey);
_clearAiGatewayStreamingText(sessionKey);
_recomputeTasks();
_notifyIfActive();
return;
}
await _abortAiGatewayRun(_sessionsController.currentSessionKey);
return;
}
await _chatController.abortRun();
}
Future<void> setAssistantExecutionTarget(
AssistantExecutionTarget target,
) async {
final resolvedTarget = _sanitizeExecutionTarget(target);
final currentTarget = assistantExecutionTargetForSession(
_sessionsController.currentSessionKey,
);
if (currentTarget == resolvedTarget &&
settings.assistantExecutionTarget == resolvedTarget) {
return;
}
_upsertAssistantThreadRecord(
_sessionsController.currentSessionKey,
executionTarget: resolvedTarget,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
workspaceRef: switch (resolvedTarget) {
AssistantExecutionTarget.remote => settings.remoteProjectRoot.trim(),
AssistantExecutionTarget.local ||
AssistantExecutionTarget.singleAgent => settings.workspacePath.trim(),
},
workspaceRefKind: _defaultWorkspaceRefKindForTarget(resolvedTarget),
);
_recomputeTasks();
_notifyIfActive();
await _applyAssistantExecutionTarget(
resolvedTarget,
sessionKey: _sessionsController.currentSessionKey,
persistDefaultSelection: true,
);
if (resolvedTarget == AssistantExecutionTarget.singleAgent) {
await refreshSingleAgentSkillsForSession(
_sessionsController.currentSessionKey,
);
}
_recomputeTasks();
_notifyIfActive();
}
Future<void> setSingleAgentProvider(SingleAgentProvider provider) async {
final sessionKey = _normalizedAssistantSessionKey(currentSessionKey);
final sanitizedProvider = sanitizeAppStoreSingleAgentProvider(
provider,
isAppleHost: Platform.isIOS || Platform.isMacOS,
);
if (singleAgentProviderForSession(sessionKey) == sanitizedProvider) {
return;
}
_singleAgentRuntimeModelBySession.remove(sessionKey);
_upsertAssistantThreadRecord(
sessionKey,
singleAgentProvider: sanitizedProvider,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
_recomputeTasks();
_notifyIfActive();
if (assistantExecutionTargetForSession(sessionKey) ==
AssistantExecutionTarget.singleAgent) {
await refreshSingleAgentSkillsForSession(sessionKey);
}
unawaited(refreshMultiAgentMounts(sync: settings.multiAgent.autoSync));
}
Future<void> setAssistantMessageViewMode(
AssistantMessageViewMode mode,
) async {
final sessionKey = _normalizedAssistantSessionKey(
_sessionsController.currentSessionKey,
);
if (assistantMessageViewModeForSession(sessionKey) == mode) {
return;
}
_upsertAssistantThreadRecord(
sessionKey,
messageViewMode: mode,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
await _flushAssistantThreadPersistence();
_recomputeTasks();
_notifyIfActive();
}
Future<void> setAssistantPermissionLevel(
AssistantPermissionLevel level,
) async {
if (settings.assistantPermissionLevel == level) {
return;
}
await saveSettings(
settings.copyWith(assistantPermissionLevel: level),
refreshAfterSave: false,
);
}
Future<void> _applyAssistantExecutionTarget(
AssistantExecutionTarget target, {
required String sessionKey,
required bool persistDefaultSelection,
}) async {
final resolvedTarget = _sanitizeExecutionTarget(target);
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
if (resolvedTarget != AssistantExecutionTarget.singleAgent) {
_singleAgentRuntimeModelBySession.remove(normalizedSessionKey);
}
if (!matchesSessionKey(
normalizedSessionKey,
_sessionsController.currentSessionKey,
)) {
await _setCurrentAssistantSessionKey(normalizedSessionKey);
}
if (persistDefaultSelection &&
settings.assistantExecutionTarget != resolvedTarget) {
await saveSettings(
settings.copyWith(assistantExecutionTarget: resolvedTarget),
refreshAfterSave: false,
);
}
if (resolvedTarget == AssistantExecutionTarget.singleAgent) {
if (_runtime.isConnected) {
_preserveGatewayHistoryForSession(normalizedSessionKey);
}
await _ensureActiveAssistantThread();
if (_runtime.isConnected) {
try {
await disconnectGateway();
} catch (_) {
// Preserve the selected thread-bound target even when the active
// gateway session does not close cleanly on the first attempt.
}
} else {
_chatController.clear();
}
await _setCurrentAssistantSessionKey(normalizedSessionKey);
return;
}
final targetProfile = _gatewayProfileForAssistantExecutionTarget(
resolvedTarget,
);
try {
await _connectProfile(
targetProfile,
profileIndex: _gatewayProfileIndexForExecutionTarget(resolvedTarget),
);
} catch (_) {
// Keep the selected execution target even when the immediate reconnect
// fails so the user can retry or adjust gateway settings manually.
}
await _setCurrentAssistantSessionKey(normalizedSessionKey);
await _chatController.loadSession(normalizedSessionKey);
}
Future<void> selectDefaultModel(String modelId) async {
final trimmed = modelId.trim();
if (trimmed.isEmpty || settings.defaultModel == trimmed) {
return;
}
await saveSettings(
settings.copyWith(defaultModel: trimmed),
refreshAfterSave: false,
);
}
Future<void> selectAssistantModel(String modelId) async {
await selectAssistantModelForSession(currentSessionKey, modelId);
}
Future<void> selectAssistantModelForSession(
String sessionKey,
String modelId,
) async {
final trimmed = modelId.trim();
if (trimmed.isEmpty) {
return;
}
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
final choices = matchesSessionKey(normalizedSessionKey, currentSessionKey)
? assistantModelChoices
: _assistantModelChoicesForSession(normalizedSessionKey);
if (choices.isNotEmpty && !choices.contains(trimmed)) {
return;
}
if (_assistantThreadRecords[normalizedSessionKey]?.assistantModelId ==
trimmed) {
return;
}
_upsertAssistantThreadRecord(
normalizedSessionKey,
assistantModelId: trimmed,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
_recomputeTasks();
_notifyIfActive();
}
String assistantCustomTaskTitle(String sessionKey) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
final settingsTitle =
settings.assistantCustomTaskTitles[normalizedSessionKey]?.trim() ?? '';
if (settingsTitle.isNotEmpty) {
return settingsTitle;
}
return _assistantThreadRecords[normalizedSessionKey]?.title.trim() ?? '';
}
void initializeAssistantThreadContext(
String sessionKey, {
String title = '',
AssistantExecutionTarget? executionTarget,
AssistantMessageViewMode? messageViewMode,
SingleAgentProvider? singleAgentProvider,
}) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
final resolvedTarget =
executionTarget ??
assistantExecutionTargetForSession(currentSessionKey);
_upsertAssistantThreadRecord(
normalizedSessionKey,
title: title.trim(),
executionTarget: resolvedTarget,
messageViewMode:
messageViewMode ??
assistantMessageViewModeForSession(currentSessionKey),
singleAgentProvider:
singleAgentProvider ??
singleAgentProviderForSession(currentSessionKey),
workspaceRef: switch (resolvedTarget) {
AssistantExecutionTarget.remote => settings.remoteProjectRoot.trim(),
AssistantExecutionTarget.local ||
AssistantExecutionTarget.singleAgent => settings.workspacePath.trim(),
},
workspaceRefKind: _defaultWorkspaceRefKindForTarget(resolvedTarget),
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
unawaited(_persistAssistantLastSessionKey(normalizedSessionKey));
_notifyIfActive();
}
Future<void> refreshSingleAgentSkillsForSession(String sessionKey) async {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
if (assistantExecutionTargetForSession(normalizedSessionKey) !=
AssistantExecutionTarget.singleAgent) {
return;
}
final localSkills = await _singleAgentLocalSkillsForSession(
normalizedSessionKey,
);
final provider =
singleAgentResolvedProviderForSession(normalizedSessionKey) ??
currentSingleAgentResolvedProvider;
if (provider == null) {
await _replaceSingleAgentThreadSkills(normalizedSessionKey, localSkills);
return;
}
try {
await _refreshAcpCapabilities();
final response = await _gatewayAcpClient.request(
method: 'skills.status',
params: <String, dynamic>{
'sessionId': normalizedSessionKey,
'threadId': normalizedSessionKey,
'mode': 'single-agent',
'provider': provider.providerId,
},
);
final result = asMap(response['result']);
final payload = result.isNotEmpty ? result : response;
final skills = asList(payload['skills'])
.map(asMap)
.map((item) => _singleAgentSkillEntryFromAcp(item, provider))
.where((item) => item.key.isNotEmpty && item.label.isNotEmpty)
.toList(growable: false);
await _replaceSingleAgentThreadSkills(
normalizedSessionKey,
_mergeSingleAgentSkillEntries(
groups: <List<AssistantThreadSkillEntry>>[localSkills, skills],
),
);
} on GatewayAcpException catch (error) {
if (_unsupportedAcpSkillsStatus(error)) {
await _replaceSingleAgentThreadSkills(
normalizedSessionKey,
localSkills,
);
return;
}
await _replaceSingleAgentThreadSkills(normalizedSessionKey, localSkills);
} catch (_) {
await _replaceSingleAgentThreadSkills(normalizedSessionKey, localSkills);
}
}
Future<void> refreshSingleAgentLocalSkillsForSession(
String sessionKey,
) async {
await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: true);
await refreshSingleAgentSkillsForSession(sessionKey);
}
Future<void> toggleAssistantSkillForSession(
String sessionKey,
String skillKey,
) async {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
final normalizedSkillKey = skillKey.trim();
if (normalizedSkillKey.isEmpty) {
return;
}
final importedKeys = assistantImportedSkillsForSession(
normalizedSessionKey,
).map((item) => item.key).toSet();
if (!importedKeys.contains(normalizedSkillKey)) {
return;
}
final nextSelected = List<String>.from(
assistantSelectedSkillKeysForSession(normalizedSessionKey),
);
if (nextSelected.contains(normalizedSkillKey)) {
nextSelected.remove(normalizedSkillKey);
} else {
nextSelected.add(normalizedSkillKey);
}
_upsertAssistantThreadRecord(
normalizedSessionKey,
selectedSkillKeys: nextSelected,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
_notifyIfActive();
await _flushAssistantThreadPersistence();
}
Future<void> saveAssistantTaskTitle(String sessionKey, String title) async {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
if (normalizedSessionKey.isEmpty) {
return;
}
final normalizedTitle = title.trim();
final next = Map<String, String>.from(settings.assistantCustomTaskTitles);
final current = next[normalizedSessionKey]?.trim() ?? '';
if (normalizedTitle.isEmpty) {
if (current.isEmpty) {
return;
}
next.remove(normalizedSessionKey);
} else {
if (current == normalizedTitle) {
return;
}
next[normalizedSessionKey] = normalizedTitle;
}
await saveSettings(
settings.copyWith(assistantCustomTaskTitles: next),
refreshAfterSave: false,
);
_upsertAssistantThreadRecord(
normalizedSessionKey,
title: normalizedTitle,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
_recomputeTasks();
_notifyIfActive();
}
bool isAssistantTaskArchived(String sessionKey) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
return settings.assistantArchivedTaskKeys.any(
(item) => _normalizedAssistantSessionKey(item) == normalizedSessionKey,
);
}
Future<void> saveAssistantTaskArchived(
String sessionKey,
bool archived,
) async {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
if (normalizedSessionKey.isEmpty) {
return;
}
final next = <String>[
...settings.assistantArchivedTaskKeys.where(
(item) => _normalizedAssistantSessionKey(item) != normalizedSessionKey,
),
];
if (archived) {
next.add(normalizedSessionKey);
}
await saveSettings(
settings.copyWith(assistantArchivedTaskKeys: next),
refreshAfterSave: false,
);
if (archived) {
unawaited(
_enqueueThreadTurn<void>(normalizedSessionKey, () async {
try {
await _gatewayAcpClient.closeSession(
sessionId: normalizedSessionKey,
threadId: normalizedSessionKey,
);
} catch (_) {
// Best effort only.
}
}).catchError((_) {}),
);
}
_upsertAssistantThreadRecord(
normalizedSessionKey,
archived: archived,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
_recomputeTasks();
_notifyIfActive();
}
Future<void> updateAiGatewaySelection(List<String> selectedModels) async {
final available = settings.aiGateway.availableModels;
final normalized = selectedModels
.map((item) => item.trim())
.where((item) => item.isNotEmpty && available.contains(item))
.toList(growable: false);
final fallbackSelection = normalized.isNotEmpty
? normalized
: available.isNotEmpty
? <String>[available.first]
: const <String>[];
final currentDefaultModel = settings.defaultModel.trim();
final resolvedDefaultModel = fallbackSelection.contains(currentDefaultModel)
? currentDefaultModel
: fallbackSelection.isNotEmpty
? fallbackSelection.first
: '';
await saveSettings(
settings.copyWith(
aiGateway: settings.aiGateway.copyWith(
selectedModels: fallbackSelection,
),
defaultModel: resolvedDefaultModel,
),
refreshAfterSave: false,
);
}
Future<AiGatewayProfile> syncAiGatewayCatalog(
AiGatewayProfile profile, {
String apiKeyOverride = '',
}) async {
final synced = await _settingsController.syncAiGatewayCatalog(
profile,
apiKeyOverride: apiKeyOverride,
);
_modelsController.restoreFromSettings(
_settingsController.snapshot.aiGateway,
);
_recomputeTasks();
return synced;
}
Future<void> saveSettingsDraft(SettingsSnapshot snapshot) async {
if (_disposed) {
return;
}
_settingsDraft = _sanitizeFeatureFlagSettings(
_sanitizeMultiAgentSettings(
_sanitizeOllamaCloudSettings(_sanitizeCodeAgentSettings(snapshot)),
),
);
_settingsDraftInitialized = true;
_settingsDraftStatusMessage = appText(
'草稿已更新,点击顶部保存持久化。',
'Draft updated. Use the top Save button to persist it.',
);
notifyListeners();
}
void saveGatewayTokenDraft(String value, {required int profileIndex}) {
_saveSecretDraft(_draftGatewayTokenKey(profileIndex), value);
}
void saveGatewayPasswordDraft(String value, {required int profileIndex}) {
_saveSecretDraft(_draftGatewayPasswordKey(profileIndex), value);
}
void saveAiGatewayApiKeyDraft(String value) {
_saveSecretDraft(_draftAiGatewayApiKeyKey, value);
}
void saveVaultTokenDraft(String value) {
_saveSecretDraft(_draftVaultTokenKey, value);
}
void saveOllamaCloudApiKeyDraft(String value) {
_saveSecretDraft(_draftOllamaApiKeyKey, value);
}
Future<void> persistSettingsDraft() async {
if (_disposed) {
return;
}
if (!hasSettingsDraftChanges) {
_settingsDraftStatusMessage = appText(
'没有需要保存的更改。',
'There are no changes to save.',
);
notifyListeners();
return;
}
final nextSettings = settingsDraft;
_markPendingApplyDomains(settings, nextSettings);
await _persistDraftSecrets();
if (nextSettings.toJsonString() != settings.toJsonString()) {
await _persistSettingsSnapshot(nextSettings);
}
_settingsDraft = settings;
_settingsDraftInitialized = true;
_pendingSettingsApply = true;
_settingsDraftStatusMessage = appText(
'已保存配置,不立即生效。',
'Settings saved. They do not take effect until Apply.',
);
notifyListeners();
}
Future<void> applySettingsDraft() async {
if (_disposed) {
return;
}
if (hasSettingsDraftChanges) {
await persistSettingsDraft();
}
if (!_pendingSettingsApply) {
_settingsDraftStatusMessage = appText(
'没有需要应用的更改。',
'There are no saved changes to apply.',
);
notifyListeners();
return;
}
final currentSettings = settings;
await _applyPersistedSettingsSideEffects(
previous: _lastAppliedSettings,
current: currentSettings,
refreshAfterSave: true,
);
if (_pendingGatewayApply) {
await _applyPersistedGatewaySettings(currentSettings);
}
if (_pendingAiGatewayApply) {
await _applyPersistedAiGatewaySettings(currentSettings);
}
_lastAppliedSettings = settings;
_pendingSettingsApply = false;
_pendingGatewayApply = false;
_pendingAiGatewayApply = false;
_settingsDraft = settings;
_settingsDraftInitialized = true;
_settingsDraftStatusMessage = appText(
'已按当前配置生效。',
'The current configuration is now in effect.',
);
notifyListeners();
}
Future<void> saveSettings(
SettingsSnapshot snapshot, {
bool refreshAfterSave = true,
}) async {
if (_disposed) {
return;
}
final previous = settings;
await _persistSettingsSnapshot(snapshot);
if (_disposed) {
return;
}
await _applyPersistedSettingsSideEffects(
previous: previous,
current: settings,
refreshAfterSave: refreshAfterSave,
);
_lastAppliedSettings = settings;
_settingsDraft = settings;
_settingsDraftInitialized = true;
_pendingSettingsApply = false;
_pendingGatewayApply = false;
_pendingAiGatewayApply = false;
_draftSecretValues.clear();
_settingsDraftStatusMessage = '';
}
Future<void> clearAssistantLocalState() async {
await _flushAssistantThreadPersistence();
await _store.clearAssistantLocalState();
await _store.saveAssistantThreadRecords(const <AssistantThreadRecord>[]);
_assistantThreadPersistQueue = Future<void>.value();
final defaults = SettingsSnapshot.defaults();
_assistantThreadRecords.clear();
_assistantThreadMessages.clear();
_localSessionMessages.clear();
_gatewayHistoryCache.clear();
_aiGatewayStreamingTextBySession.clear();
_aiGatewayStreamingClients.clear();
_aiGatewayPendingSessionKeys.clear();
_aiGatewayAbortedSessionKeys.clear();
_singleAgentExternalCliPendingSessionKeys.clear();
_assistantThreadTurnQueues.clear();
_multiAgentRunPending = false;
setActiveAppLanguage(defaults.appLanguage);
await _settingsController.resetSnapshot(defaults);
_multiAgentOrchestrator.updateConfig(defaults.multiAgent);
_agentsController.restoreSelection(
defaults.primaryRemoteGatewayProfile.selectedAgentId,
);
_modelsController.restoreFromSettings(defaults.aiGateway);
await _setCurrentAssistantSessionKey('main', persistSelection: false);
_chatController.clear();
_recomputeTasks();
notifyListeners();
}
Future<void> refreshDesktopIntegration() async {
_desktopPlatformBusy = true;
notifyListeners();
try {
await _desktopPlatformService.refresh();
} finally {
_desktopPlatformBusy = false;
notifyListeners();
}
}
Future<void> saveLinuxDesktopConfig(LinuxDesktopConfig config) async {
await saveSettings(settings.copyWith(linuxDesktop: config));
}
Future<void> setDesktopVpnMode(VpnMode mode) async {
_desktopPlatformBusy = true;
notifyListeners();
try {
await saveSettings(
settings.copyWith(
linuxDesktop: settings.linuxDesktop.copyWith(preferredMode: mode),
),
refreshAfterSave: false,
);
await _desktopPlatformService.setMode(mode);
} finally {
_desktopPlatformBusy = false;
notifyListeners();
}
}
Future<void> connectDesktopTunnel() async {
_desktopPlatformBusy = true;
notifyListeners();
try {
await _desktopPlatformService.connectTunnel();
} finally {
_desktopPlatformBusy = false;
notifyListeners();
}
}
Future<void> disconnectDesktopTunnel() async {
_desktopPlatformBusy = true;
notifyListeners();
try {
await _desktopPlatformService.disconnectTunnel();
} finally {
_desktopPlatformBusy = false;
notifyListeners();
}
}
Future<void> setLaunchAtLogin(bool enabled) async {
await saveSettings(
settings.copyWith(launchAtLogin: enabled),
refreshAfterSave: false,
);
}
Future<AuthorizedSkillDirectory?> authorizeSkillDirectory({
String suggestedPath = '',
}) {
return _skillDirectoryAccessService.authorizeDirectory(
suggestedPath: suggestedPath,
);
}
Future<List<AuthorizedSkillDirectory>> authorizeSkillDirectories({
List<String> suggestedPaths = const <String>[],
}) {
return _skillDirectoryAccessService.authorizeDirectories(
suggestedPaths: suggestedPaths,
);
}
Future<void> saveAuthorizedSkillDirectories(
List<AuthorizedSkillDirectory> directories,
) async {
if (_disposed) {
return;
}
final previous = settings;
final previousDraft = _settingsDraft;
final hadDraftChanges = hasSettingsDraftChanges;
final draftInitialized = _settingsDraftInitialized;
final pendingSettingsApply = _pendingSettingsApply;
final pendingGatewayApply = _pendingGatewayApply;
final pendingAiGatewayApply = _pendingAiGatewayApply;
await _persistSettingsSnapshot(
previous.copyWith(
authorizedSkillDirectories: normalizeAuthorizedSkillDirectories(
directories: directories,
),
),
);
if (_disposed) {
return;
}
await _applyPersistedSettingsSideEffects(
previous: previous,
current: settings,
refreshAfterSave: false,
);
_lastAppliedSettings = settings;
if (draftInitialized && hadDraftChanges) {
_settingsDraft = previousDraft.copyWith(
authorizedSkillDirectories: settings.authorizedSkillDirectories,
);
_settingsDraftInitialized = true;
_pendingSettingsApply = pendingSettingsApply;
_pendingGatewayApply = pendingGatewayApply;
_pendingAiGatewayApply = pendingAiGatewayApply;
} else {
_settingsDraft = settings;
_settingsDraftInitialized = true;
_pendingSettingsApply = false;
_pendingGatewayApply = false;
_pendingAiGatewayApply = false;
_settingsDraftStatusMessage = '';
}
notifyListeners();
}
Future<void> toggleAssistantNavigationDestination(
WorkspaceDestination destination,
) async {
if (!kAssistantNavigationDestinationCandidates.contains(destination)) {
return;
}
if (!capabilities.supportsDestination(destination)) {
return;
}
final current = assistantNavigationDestinations;
final next = current.contains(destination)
? current.where((item) => item != destination).toList(growable: false)
: <WorkspaceDestination>[...current, destination];
await saveSettings(
settings.copyWith(assistantNavigationDestinations: next),
refreshAfterSave: false,
);
}
Future<String> testOllamaConnection({required bool cloud}) {
return _settingsController.testOllamaConnection(cloud: cloud);
}
Future<String> testOllamaConnectionDraft({
required bool cloud,
required SettingsSnapshot snapshot,
String apiKeyOverride = '',
}) {
return _settingsController.testOllamaConnectionDraft(
cloud: cloud,
localConfig: snapshot.ollamaLocal,
cloudConfig: snapshot.ollamaCloud,
apiKeyOverride: apiKeyOverride,
);
}
Future<String> testVaultConnection() {
return _settingsController.testVaultConnection();
}
Future<String> testVaultConnectionDraft({
required SettingsSnapshot snapshot,
String tokenOverride = '',
}) {
return _settingsController.testVaultConnectionDraft(
snapshot.vault,
tokenOverride: tokenOverride,
);
}
Future<({String state, String message, String endpoint})>
testGatewayConnectionDraft({
required GatewayConnectionProfile profile,
required AssistantExecutionTarget executionTarget,
String tokenOverride = '',
String passwordOverride = '',
}) async {
if (executionTarget == AssistantExecutionTarget.singleAgent ||
profile.mode == RuntimeConnectionMode.unconfigured) {
return (
state: 'inactive',
message: appText(
'当前模式使用单机智能体,不建立 OpenClaw Gateway 会话。',
'The current mode uses Single Agent and does not open an OpenClaw Gateway session.',
),
endpoint: '',
);
}
final temporaryRoot = await Directory.systemTemp.createTemp(
'xworkmate-gateway-test-',
);
final temporaryStore = SecureConfigStore(
enableSecureStorage: false,
databasePathResolver: () async =>
'${temporaryRoot.path}/settings.sqlite3',
fallbackDirectoryPathResolver: () async => temporaryRoot.path,
);
final runtime = GatewayRuntime(
store: temporaryStore,
identityStore: DeviceIdentityStore(temporaryStore),
);
await runtime.initialize();
try {
await runtime.connectProfile(
profile,
authTokenOverride: tokenOverride,
authPasswordOverride: passwordOverride,
);
try {
await runtime.health();
} catch (_) {
// Connectivity succeeded; health is best-effort for the test path.
}
final endpoint =
runtime.snapshot.remoteAddress ?? '${profile.host}:${profile.port}';
return (
state: 'success',
message: appText('连接成功。', 'Connection succeeded.'),
endpoint: endpoint,
);
} catch (error) {
return (
state: 'error',
message: error.toString(),
endpoint: '${profile.host}:${profile.port}',
);
} finally {
try {
await runtime.disconnect(clearDesiredProfile: false);
} catch (_) {
// Ignore teardown noise from temporary connectivity checks.
}
runtime.dispose();
temporaryStore.dispose();
try {
await temporaryRoot.delete(recursive: true);
} catch (_) {
// Ignore cleanup noise for temporary connectivity checks.
}
}
}
void clearRuntimeLogs() {
_runtimeCoordinator.gateway.clearLogs();
_notifyIfActive();
}
List<DerivedTaskItem> taskItemsForTab(String tab) => switch (tab) {
'Queue' => _tasksController.queue,
'Running' => _tasksController.running,
'History' => _tasksController.history,
'Failed' => _tasksController.failed,
'Scheduled' => _tasksController.scheduled,
_ => _tasksController.queue,
};
/// Enable Codex ↔ Gateway bridge
Future<void> enableCodexBridge() async {
if (_isCodexBridgeEnabled || _isCodexBridgeBusy) return;
if (blocksAppStoreEmbeddedAgentProcesses(
isAppleHost: Platform.isIOS || Platform.isMacOS,
)) {
throw StateError(
appText(
'App Store 版本不允许在应用内启动或桥接外部 CLI 进程。',
'App Store builds do not allow in-app external CLI bridge processes.',
),
);
}
_isCodexBridgeBusy = true;
_codexBridgeError = null;
try {
final gatewayUrl = aiGatewayUrl;
final apiKey = await loadAiGatewayApiKey();
if (gatewayUrl.isEmpty) {
throw StateError(
appText('LLM API Endpoint 未配置', 'LLM API Endpoint not configured'),
);
}
await _refreshAcpCapabilities(forceRefresh: true);
await _refreshSingleAgentCapabilities(forceRefresh: true);
final runtimeMode = effectiveCodeAgentRuntimeMode;
if (runtimeMode == CodeAgentRuntimeMode.externalCli &&
!_canUseSingleAgentProvider(SingleAgentProvider.codex)) {
throw StateError(
appText(
'外部 single-agent endpoint 未报告 Codex 可用,请先检查 app-server / Gateway 配置。',
'The external single-agent endpoint did not report Codex availability. Check the app-server or Gateway endpoint first.',
),
);
}
await _runtimeCoordinator.configureCodexForGateway(
gatewayUrl: gatewayUrl,
apiKey: apiKey,
);
_registerCodexExternalProvider();
_isCodexBridgeEnabled = true;
_codexCooperationState = CodexCooperationState.bridgeOnly;
await _ensureCodexGatewayRegistration();
notifyListeners();
} catch (e) {
_codexBridgeError = e.toString();
notifyListeners();
rethrow;
} finally {
_isCodexBridgeBusy = false;
notifyListeners();
}
}
/// Disable Codex ↔ Gateway bridge
Future<void> disableCodexBridge() async {
if (!_isCodexBridgeEnabled || _isCodexBridgeBusy) return;
_isCodexBridgeBusy = true;
try {
if (_runtime.isConnected && _codeAgentBridgeRegistry.isRegistered) {
await _codeAgentBridgeRegistry.unregister();
} else {
_codeAgentBridgeRegistry.clearRegistration();
}
_isCodexBridgeEnabled = false;
_codexCooperationState = CodexCooperationState.notStarted;
_codexBridgeError = null;
notifyListeners();
} catch (e) {
_codexBridgeError = e.toString();
notifyListeners();
rethrow;
} finally {
_isCodexBridgeBusy = false;
notifyListeners();
}
}
@override
void dispose() {
if (_disposed) {
return;
}
_disposed = true;
unawaited(_persistSharedSingleAgentLocalSkillsCache());
_runtimeEventsSubscription?.cancel();
_detachChildListeners();
_runtimeCoordinator.dispose();
_settingsController.dispose();
_agentsController.dispose();
_sessionsController.dispose();
_chatController.dispose();
_instancesController.dispose();
_skillsController.dispose();
_connectorsController.dispose();
_modelsController.dispose();
_cronJobsController.dispose();
_devicesController.dispose();
_tasksController.dispose();
_store.dispose();
_desktopPlatformService.dispose();
unawaited(_gatewayAcpClient.dispose());
unawaited(_singleAgentAppServerClient.dispose());
super.dispose();
}
Future<void> _initialize() async {
try {
_resolvedUserHomeDirectory = await _skillDirectoryAccessService
.resolveUserHomeDirectory();
await _settingsController.initialize();
_restoreAssistantThreads(await _store.loadAssistantThreadRecords());
await _restoreSharedSingleAgentLocalSkillsCache();
if (_disposed) {
return;
}
final bootstrap = await RuntimeBootstrapConfig.load(
workspacePathHint: settings.workspacePath,
cliPathHint: settings.cliPath,
);
if (_disposed) {
return;
}
final seeded = bootstrap.mergeIntoSettings(settings);
if (seeded.toJsonString() != settings.toJsonString()) {
await _settingsController.saveSnapshot(seeded);
if (_disposed) {
return;
}
}
final normalized = _sanitizeFeatureFlagSettings(
_sanitizeMultiAgentSettings(
_sanitizeOllamaCloudSettings(
_sanitizeCodeAgentSettings(_settingsController.snapshot),
),
),
);
if (normalized.toJsonString() !=
_settingsController.snapshot.toJsonString()) {
await _settingsController.saveSnapshot(normalized);
if (_disposed) {
return;
}
}
_lastObservedSettingsSnapshot = settings;
_modelsController.restoreFromSettings(settings.aiGateway);
_multiAgentOrchestrator.updateConfig(settings.multiAgent);
setActiveAppLanguage(settings.appLanguage);
await _desktopPlatformService.initialize(settings.linuxDesktop);
await _desktopPlatformService.setLaunchAtLogin(settings.launchAtLogin);
await _refreshResolvedCodexCliPath();
_registerCodexExternalProvider();
await _refreshSingleAgentCapabilities();
await _refreshAcpCapabilities(persistMountTargets: true);
if (_disposed) {
return;
}
final startupTarget = _sanitizeExecutionTarget(
settings.assistantExecutionTarget,
);
_agentsController.restoreSelection(
settings
.gatewayProfileForExecutionTarget(startupTarget)
?.selectedAgentId ??
'',
);
_sessionsController.configure(
mainSessionKey: _runtime.snapshot.mainSessionKey ?? 'main',
selectedAgentId: _agentsController.selectedAgentId,
defaultAgentId: '',
);
await _restoreInitialAssistantSessionSelection();
await _ensureActiveAssistantThread();
unawaited(_startupRefreshSharedSingleAgentLocalSkillsCache());
if (isSingleAgentMode) {
await refreshSingleAgentSkillsForSession(currentSessionKey);
}
_runtimeEventsSubscription = _runtimeCoordinator.gateway.events.listen(
_handleRuntimeEvent,
);
final startupProfile = settings.gatewayProfileForExecutionTarget(
startupTarget,
);
final shouldAutoConnect =
startupTarget != AssistantExecutionTarget.singleAgent &&
startupProfile != null &&
startupProfile.useSetupCode &&
startupProfile.setupCode.trim().isNotEmpty;
if (shouldAutoConnect) {
try {
await _connectProfile(
startupProfile,
profileIndex: _gatewayProfileIndexForExecutionTarget(startupTarget),
);
} catch (_) {
// Keep the shell usable when auto-connect fails.
}
}
_settingsDraft = settings;
_lastAppliedSettings = settings;
_lastObservedSettingsSnapshot = settings;
_settingsDraftInitialized = true;
_settingsDraftStatusMessage = '';
} catch (error) {
if (_disposed) {
return;
}
_bootstrapError = error.toString();
} finally {
if (!_disposed) {
_initializing = false;
_notifyIfActive();
}
}
}
Future<void> _connectProfile(
GatewayConnectionProfile profile, {
int? profileIndex,
String authTokenOverride = '',
String authPasswordOverride = '',
}) async {
await _runtime.connectProfile(
profile,
profileIndex: profileIndex,
authTokenOverride: authTokenOverride,
authPasswordOverride: authPasswordOverride,
);
await refreshGatewayHealth();
await refreshAgents();
await refreshSessions();
await _instancesController.refresh();
await _skillsController.refresh(
agentId: _agentsController.selectedAgentId.isEmpty
? null
: _agentsController.selectedAgentId,
);
await _connectorsController.refresh();
await _modelsController.refresh();
await _cronJobsController.refresh();
await _devicesController.refresh(quiet: true);
await _settingsController.refreshDerivedState();
await _ensureCodexGatewayRegistration();
_recomputeTasks();
}
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();
}
void _markPendingApplyDomains(
SettingsSnapshot previous,
SettingsSnapshot next,
) {
final hasGatewaySecretDraft = _draftSecretValues.keys.any(
(key) => _isGatewayDraftKey(key),
);
final gatewayChanged =
jsonEncode(
previous.gatewayProfiles.map((item) => item.toJson()).toList(),
) !=
jsonEncode(
next.gatewayProfiles.map((item) => item.toJson()).toList(),
) ||
previous.assistantExecutionTarget != next.assistantExecutionTarget ||
hasGatewaySecretDraft;
final aiGatewayChanged =
previous.aiGateway.toJson().toString() !=
next.aiGateway.toJson().toString() ||
previous.defaultModel != next.defaultModel ||
_draftSecretValues.containsKey(_draftAiGatewayApiKeyKey);
_pendingGatewayApply = _pendingGatewayApply || gatewayChanged;
_pendingAiGatewayApply = _pendingAiGatewayApply || aiGatewayChanged;
}
Future<void> _persistDraftSecrets() async {
for (var index = 0; index < kGatewayProfileListLength; index += 1) {
final gatewayToken = _draftSecretValues[_draftGatewayTokenKey(index)];
final gatewayPassword =
_draftSecretValues[_draftGatewayPasswordKey(index)];
if ((gatewayToken ?? '').isNotEmpty ||
(gatewayPassword ?? '').isNotEmpty) {
await _settingsController.saveGatewaySecrets(
profileIndex: index,
token: gatewayToken ?? '',
password: gatewayPassword ?? '',
);
}
}
final aiGatewayApiKey = _draftSecretValues[_draftAiGatewayApiKeyKey];
if ((aiGatewayApiKey ?? '').isNotEmpty) {
await _settingsController.saveAiGatewayApiKey(aiGatewayApiKey!);
}
final vaultToken = _draftSecretValues[_draftVaultTokenKey];
if ((vaultToken ?? '').isNotEmpty) {
await _settingsController.saveVaultToken(vaultToken!);
}
final ollamaApiKey = _draftSecretValues[_draftOllamaApiKeyKey];
if ((ollamaApiKey ?? '').isNotEmpty) {
await _settingsController.saveOllamaCloudApiKey(ollamaApiKey!);
}
_draftSecretValues.clear();
}
static String _draftGatewayTokenKey(int profileIndex) =>
'gateway_token_$profileIndex';
static String _draftGatewayPasswordKey(int profileIndex) =>
'gateway_password_$profileIndex';
static bool _isGatewayDraftKey(String key) =>
key.startsWith('gateway_token_') || key.startsWith('gateway_password_');
bool _authorizedSkillDirectoriesChanged(
SettingsSnapshot previous,
SettingsSnapshot current,
) {
return jsonEncode(
previous.authorizedSkillDirectories
.map((item) => item.toJson())
.toList(growable: false),
) !=
jsonEncode(
current.authorizedSkillDirectories
.map((item) => item.toJson())
.toList(growable: false),
);
}
Future<void> _persistSettingsSnapshot(SettingsSnapshot snapshot) async {
final sanitized = _sanitizeFeatureFlagSettings(
_sanitizeMultiAgentSettings(
_sanitizeOllamaCloudSettings(_sanitizeCodeAgentSettings(snapshot)),
),
);
_lastObservedSettingsSnapshot = sanitized;
await _settingsController.saveSnapshot(sanitized);
_settingsDraft = sanitized;
_settingsDraftInitialized = true;
}
Future<void> _applyPersistedSettingsSideEffects({
required SettingsSnapshot previous,
required SettingsSnapshot current,
required bool refreshAfterSave,
}) async {
setActiveAppLanguage(current.appLanguage);
_multiAgentOrchestrator.updateConfig(current.multiAgent);
_agentsController.restoreSelection(
current
.gatewayProfileForExecutionTarget(
_sanitizeExecutionTarget(current.assistantExecutionTarget),
)
?.selectedAgentId ??
'',
);
_modelsController.restoreFromSettings(current.aiGateway);
if (_disposed) {
return;
}
if (previous.codexCliPath != current.codexCliPath ||
previous.codeAgentRuntimeMode != current.codeAgentRuntimeMode) {
await _refreshResolvedCodexCliPath();
_registerCodexExternalProvider();
}
unawaited(_refreshSingleAgentCapabilities());
if (previous.linuxDesktop.toJson().toString() !=
current.linuxDesktop.toJson().toString() ||
previous.launchAtLogin != current.launchAtLogin) {
await _desktopPlatformService.syncConfig(current.linuxDesktop);
await _desktopPlatformService.setLaunchAtLogin(current.launchAtLogin);
if (_disposed) {
return;
}
}
if (_authorizedSkillDirectoriesChanged(previous, current)) {
await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: true);
if (_disposed) {
return;
}
if (assistantExecutionTargetForSession(currentSessionKey) ==
AssistantExecutionTarget.singleAgent) {
await refreshSingleAgentSkillsForSession(currentSessionKey);
}
}
if (refreshAfterSave) {
_recomputeTasks();
}
unawaited(_refreshAcpCapabilities(persistMountTargets: true));
notifyListeners();
}
Future<void> _applyPersistedGatewaySettings(SettingsSnapshot snapshot) async {
final target = _sanitizeExecutionTarget(snapshot.assistantExecutionTarget);
final sessionKey = _normalizedAssistantSessionKey(
_sessionsController.currentSessionKey,
);
_upsertAssistantThreadRecord(
sessionKey,
executionTarget: target,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
_recomputeTasks();
_notifyIfActive();
await _applyAssistantExecutionTarget(
target,
sessionKey: sessionKey,
persistDefaultSelection: false,
);
if (target == AssistantExecutionTarget.singleAgent) {
await refreshSingleAgentSkillsForSession(sessionKey);
}
_recomputeTasks();
_notifyIfActive();
}
Future<void> _applyPersistedAiGatewaySettings(
SettingsSnapshot snapshot,
) async {
final apiKey = await _settingsController.loadAiGatewayApiKey();
if (snapshot.aiGateway.baseUrl.trim().isEmpty || apiKey.trim().isEmpty) {
return;
}
try {
await syncAiGatewayCatalog(snapshot.aiGateway, apiKeyOverride: apiKey);
} catch (_) {
// Keep the saved draft applied even if model sync fails immediately.
}
}
Future<void> _ensureActiveAssistantThread() async {
if (!isSingleAgentMode ||
!isAssistantTaskArchived(_sessionsController.currentSessionKey)) {
return;
}
final fallback = _assistantSessionSummaries().firstWhere(
(item) => !isAssistantTaskArchived(item.key),
orElse: () => GatewaySessionSummary(
key: 'draft:${DateTime.now().millisecondsSinceEpoch}',
kind: 'assistant',
displayName: appText('新对话', 'New conversation'),
surface: 'Assistant',
subject: null,
room: null,
space: null,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
sessionId: null,
systemSent: false,
abortedLastRun: false,
thinkingLevel: null,
verboseLevel: null,
inputTokens: null,
outputTokens: null,
totalTokens: null,
model: null,
contextTokens: null,
derivedTitle: appText('新对话', 'New conversation'),
lastMessagePreview: null,
),
);
await _setCurrentAssistantSessionKey(fallback.key);
}
Future<void> _restoreInitialAssistantSessionSelection() async {
final normalized = _normalizedAssistantSessionKey(
settings.assistantLastSessionKey,
);
final known =
normalized == 'main' ||
_assistantThreadRecords.containsKey(normalized) ||
_assistantThreadMessages.containsKey(normalized);
if (normalized.isEmpty || !known || isAssistantTaskArchived(normalized)) {
return;
}
await _setCurrentAssistantSessionKey(normalized, persistSelection: false);
}
void _handleRuntimeEvent(GatewayPushEvent event) {
_chatController.handleEvent(event);
if (event.event == 'chat') {
final payload = asMap(event.payload);
final state = stringValue(payload['state']);
if (state == 'final' || state == 'aborted' || state == 'error') {
unawaited(refreshSessions());
}
}
if (event.event == 'seqGap') {
unawaited(refreshSessions());
}
if (event.event == 'device.pair.requested' ||
event.event == 'device.pair.resolved') {
unawaited(refreshDevices(quiet: true));
}
}
SettingsSnapshot _sanitizeMultiAgentSettings(SettingsSnapshot snapshot) {
final resolved = _resolveMultiAgentConfig(snapshot);
if (jsonEncode(snapshot.multiAgent.toJson()) ==
jsonEncode(resolved.toJson())) {
return snapshot;
}
return snapshot.copyWith(multiAgent: resolved);
}
SettingsSnapshot _sanitizeFeatureFlagSettings(SettingsSnapshot snapshot) {
final features = featuresFor(_hostUiFeaturePlatform);
final allowedNavigation = normalizeAssistantNavigationDestinations(
snapshot.assistantNavigationDestinations,
).where(features.allowedDestinations.contains).toList(growable: false);
final sanitizedExecutionTarget = features.sanitizeExecutionTarget(
snapshot.assistantExecutionTarget,
);
final multiAgentConfig = features.supportsMultiAgent
? snapshot.multiAgent
: snapshot.multiAgent.copyWith(enabled: false);
final experimentalCanvas =
features.allowsExperimentalSetting(
UiFeatureKeys.settingsExperimentalCanvas,
)
? snapshot.experimentalCanvas
: false;
final experimentalBridge =
features.allowsExperimentalSetting(
UiFeatureKeys.settingsExperimentalBridge,
)
? snapshot.experimentalBridge
: false;
final experimentalDebug =
features.allowsExperimentalSetting(
UiFeatureKeys.settingsExperimentalDebug,
)
? snapshot.experimentalDebug
: false;
return snapshot.copyWith(
assistantExecutionTarget: sanitizedExecutionTarget,
assistantNavigationDestinations: allowedNavigation,
multiAgent: multiAgentConfig,
experimentalCanvas: experimentalCanvas,
experimentalBridge: experimentalBridge,
experimentalDebug: experimentalDebug,
);
}
SettingsSnapshot _sanitizeOllamaCloudSettings(SettingsSnapshot snapshot) {
final rawBaseUrl = snapshot.ollamaCloud.baseUrl.trim();
final normalized = rawBaseUrl.endsWith('/')
? rawBaseUrl.substring(0, rawBaseUrl.length - 1)
: rawBaseUrl;
if (normalized != 'https://ollama.svc.plus') {
return snapshot;
}
return snapshot.copyWith(
ollamaCloud: snapshot.ollamaCloud.copyWith(baseUrl: 'https://ollama.com'),
);
}
SettingsTab _sanitizeSettingsTab(SettingsTab tab) {
return featuresFor(_hostUiFeaturePlatform).sanitizeSettingsTab(tab);
}
AssistantExecutionTarget _sanitizeExecutionTarget(
AssistantExecutionTarget? target,
) {
return featuresFor(_hostUiFeaturePlatform).sanitizeExecutionTarget(target);
}
MultiAgentConfig _resolveMultiAgentConfig(SettingsSnapshot snapshot) {
final defaults = MultiAgentConfig.defaults();
final current = snapshot.multiAgent;
final ollamaEndpoint = snapshot.ollamaLocal.endpoint.trim().isEmpty
? current.ollamaEndpoint
: snapshot.ollamaLocal.endpoint.trim();
final engineerModel = current.engineer.model.trim().isNotEmpty
? current.engineer.model.trim()
: snapshot.ollamaLocal.defaultModel.trim().isNotEmpty
? snapshot.ollamaLocal.defaultModel.trim()
: defaults.engineer.model;
final architectModel = current.architect.model.trim().isNotEmpty
? current.architect.model.trim()
: defaults.architect.model;
final testerModel = current.tester.model.trim().isNotEmpty
? current.tester.model.trim()
: defaults.tester.model;
return current.copyWith(
framework: current.arisEnabled
? MultiAgentFramework.aris
: current.framework,
arisEnabled:
current.framework == MultiAgentFramework.aris || current.arisEnabled,
ollamaEndpoint: ollamaEndpoint,
architect: current.architect.copyWith(model: architectModel),
engineer: current.engineer.copyWith(model: engineerModel),
tester: current.tester.copyWith(model: testerModel),
mountTargets: current.mountTargets.isEmpty
? MultiAgentConfig.defaults().mountTargets
: current.mountTargets,
);
}
Future<void> _sendSingleAgentMessage(
String message, {
required String thinking,
required List<GatewayChatAttachmentPayload> attachments,
required List<CollaborationAttachment> localAttachments,
}) async {
final sessionKey = _normalizedAssistantSessionKey(
_sessionsController.currentSessionKey,
);
final trimmed = message.trim();
if (trimmed.isEmpty && attachments.isEmpty) {
return;
}
await _enqueueThreadTurn<void>(sessionKey, () async {
final userText = trimmed.isEmpty ? 'See attached.' : trimmed;
_appendAssistantThreadMessage(
sessionKey,
GatewayChatMessage(
id: _nextLocalMessageId(),
role: 'user',
text: userText,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
);
_aiGatewayPendingSessionKeys.add(sessionKey);
_recomputeTasks();
_notifyIfActive();
try {
final selection = singleAgentProviderForSession(sessionKey);
final selectedSkills = assistantSelectedSkillsForSession(sessionKey);
final gatewayToken = await settingsController.loadGatewayToken();
final resolution = await _singleAgentRunner.resolveProvider(
selection: selection,
configuredCodexCliPath: configuredCodexCliPath,
gatewayToken: gatewayToken,
);
final provider = resolution.resolvedProvider;
if (provider == null) {
if (singleAgentUsesAiChatFallbackForSession(sessionKey)) {
_appendAssistantThreadMessage(
sessionKey,
GatewayChatMessage(
id: _nextLocalMessageId(),
role: 'assistant',
text: _singleAgentFallbackLabel(resolution.fallbackReason),
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: 'AI Chat fallback',
stopReason: null,
pending: false,
error: false,
),
);
await _sendAiGatewayMessage(
message,
thinking: thinking,
attachments: attachments,
sessionKeyOverride: sessionKey,
appendUserMessage: false,
managePendingState: false,
);
} else {
_appendAssistantThreadMessage(
sessionKey,
GatewayChatMessage(
id: _nextLocalMessageId(),
role: 'assistant',
text: _singleAgentUnavailableLabel(
sessionKey,
resolution.fallbackReason,
),
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: provider?.label ?? selection.label,
stopReason: null,
pending: false,
error: false,
),
);
}
return;
}
_appendAssistantThreadMessage(
sessionKey,
GatewayChatMessage(
id: _nextLocalMessageId(),
role: 'assistant',
text: appText(
'单机智能体已切换到 ${provider.label} 执行当前任务。',
'Single Agent is using ${provider.label} for this task.',
),
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: provider.label,
stopReason: null,
pending: false,
error: false,
),
);
_singleAgentExternalCliPendingSessionKeys.add(sessionKey);
final result = await _singleAgentRunner.run(
SingleAgentRunRequest(
sessionId: sessionKey,
provider: provider,
prompt: message,
model: assistantModelForSession(sessionKey),
gatewayToken: gatewayToken,
workingDirectory:
_resolveCodexWorkingDirectory() ?? Directory.current.path,
attachments: localAttachments,
selectedSkills: selectedSkills,
aiGatewayBaseUrl: aiGatewayUrl,
aiGatewayApiKey: await loadAiGatewayApiKey(),
config: settings.multiAgent,
onOutput: (text) => _appendAiGatewayStreamingText(sessionKey, text),
configuredCodexCliPath: configuredCodexCliPath,
),
);
final resolvedRuntimeModel = result.resolvedModel.trim();
if (resolvedRuntimeModel.isNotEmpty) {
_singleAgentRuntimeModelBySession[sessionKey] = resolvedRuntimeModel;
}
_clearAiGatewayStreamingText(sessionKey);
if (result.aborted) {
final partial = result.output.trim();
if (partial.isNotEmpty) {
_appendAssistantThreadMessage(
sessionKey,
GatewayChatMessage(
id: _nextLocalMessageId(),
role: 'assistant',
text: partial,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: 'aborted',
pending: false,
error: false,
),
);
}
return;
}
if (result.shouldFallbackToAiChat) {
if (singleAgentUsesAiChatFallbackForSession(sessionKey)) {
_appendAssistantThreadMessage(
sessionKey,
GatewayChatMessage(
id: _nextLocalMessageId(),
role: 'assistant',
text: _singleAgentFallbackLabel(
result.fallbackReason ?? result.errorMessage,
),
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: 'AI Chat fallback',
stopReason: null,
pending: false,
error: false,
),
);
await _sendAiGatewayMessage(
message,
thinking: thinking,
attachments: attachments,
sessionKeyOverride: sessionKey,
appendUserMessage: false,
managePendingState: false,
);
} else {
_appendAssistantThreadMessage(
sessionKey,
GatewayChatMessage(
id: _nextLocalMessageId(),
role: 'assistant',
text: _singleAgentUnavailableLabel(
sessionKey,
result.fallbackReason ?? result.errorMessage,
),
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: provider.label,
stopReason: null,
pending: false,
error: false,
),
);
}
return;
}
if (!result.success) {
_appendAssistantThreadMessage(
sessionKey,
_assistantErrorMessage(
appText(
'单机智能体执行失败:${result.errorMessage}',
'Single Agent execution failed: ${result.errorMessage}',
),
),
);
return;
}
_appendAssistantThreadMessage(
sessionKey,
GatewayChatMessage(
id: _nextLocalMessageId(),
role: 'assistant',
text: result.output,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
);
} catch (error) {
_clearAiGatewayStreamingText(sessionKey);
_appendAssistantThreadMessage(
sessionKey,
_assistantErrorMessage(error.toString()),
);
} finally {
_singleAgentExternalCliPendingSessionKeys.remove(sessionKey);
_clearAiGatewayStreamingText(sessionKey);
_aiGatewayPendingSessionKeys.remove(sessionKey);
_recomputeTasks();
_notifyIfActive();
}
});
}
Future<void> _sendAiGatewayMessage(
String message, {
required String thinking,
required List<GatewayChatAttachmentPayload> attachments,
String? sessionKeyOverride,
bool appendUserMessage = true,
bool managePendingState = true,
}) async {
final sessionKey = _normalizedAssistantSessionKey(
sessionKeyOverride ?? _sessionsController.currentSessionKey,
);
final trimmed = message.trim();
if (trimmed.isEmpty && attachments.isEmpty) {
return;
}
final baseUrl = _normalizeAiGatewayBaseUrl(settings.aiGateway.baseUrl);
if (baseUrl == null) {
_appendAssistantThreadMessage(
sessionKey,
_assistantErrorMessage(
appText(
'LLM API Endpoint 未配置,无法发送对话。',
'LLM API Endpoint is not configured, so the conversation could not be sent.',
),
),
);
return;
}
final apiKey = await loadAiGatewayApiKey();
if (apiKey.isEmpty) {
_appendAssistantThreadMessage(
sessionKey,
_assistantErrorMessage(
appText(
'LLM API Token 未配置,无法发送对话。',
'LLM API Token is not configured, so the conversation could not be sent.',
),
),
);
return;
}
final model = resolvedAiGatewayModel;
if (model.isEmpty) {
_appendAssistantThreadMessage(
sessionKey,
_assistantErrorMessage(
appText(
'当前没有可用的 LLM API 对话模型。请先在 设置 -> 集成 中同步并选择可用模型。',
'No LLM API chat model is available yet. Sync and select a supported model in Settings -> Integrations first.',
),
),
);
return;
}
if (appendUserMessage) {
final userText = trimmed.isEmpty ? 'See attached.' : trimmed;
_appendAssistantThreadMessage(
sessionKey,
GatewayChatMessage(
id: _nextLocalMessageId(),
role: 'user',
text: userText,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
);
}
if (managePendingState) {
_aiGatewayPendingSessionKeys.add(sessionKey);
_recomputeTasks();
_notifyIfActive();
}
try {
final assistantText = await _requestAiGatewayCompletion(
baseUrl: baseUrl,
apiKey: apiKey,
model: model,
thinking: thinking,
sessionKey: sessionKey,
);
_appendAssistantThreadMessage(
sessionKey,
GatewayChatMessage(
id: _nextLocalMessageId(),
role: 'assistant',
text: assistantText,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
);
} on _AiGatewayAbortException catch (error) {
final partial = error.partialText.trim();
if (partial.isNotEmpty) {
_appendAssistantThreadMessage(
sessionKey,
GatewayChatMessage(
id: _nextLocalMessageId(),
role: 'assistant',
text: partial,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: 'aborted',
pending: false,
error: false,
),
);
}
} catch (error) {
_appendAssistantThreadMessage(
sessionKey,
_assistantErrorMessage(_aiGatewayErrorLabel(error)),
);
} finally {
_aiGatewayStreamingClients.remove(sessionKey);
_clearAiGatewayStreamingText(sessionKey);
if (managePendingState) {
_aiGatewayPendingSessionKeys.remove(sessionKey);
_recomputeTasks();
_notifyIfActive();
}
}
}
Future<String> _requestAiGatewayCompletion({
required Uri baseUrl,
required String apiKey,
required String model,
required String thinking,
required String sessionKey,
}) async {
final uri = _aiGatewayChatUri(baseUrl);
final client = HttpClient()
..connectionTimeout = const Duration(seconds: 20);
_aiGatewayStreamingClients[sessionKey] = client;
try {
final request = await client
.postUrl(uri)
.timeout(const Duration(seconds: 20));
request.headers.set(
HttpHeaders.acceptHeader,
'text/event-stream, application/json',
);
request.headers.set(
HttpHeaders.contentTypeHeader,
'application/json; charset=utf-8',
);
request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $apiKey');
request.headers.set('x-api-key', apiKey);
final payload = <String, dynamic>{
'model': model,
'stream': true,
'messages': _buildAiGatewayRequestMessages(sessionKey),
};
final normalizedThinking = thinking.trim().toLowerCase();
if (normalizedThinking.isNotEmpty && normalizedThinking != 'off') {
payload['reasoning_effort'] = normalizedThinking;
}
request.add(utf8.encode(jsonEncode(payload)));
final response = await request.close().timeout(
const Duration(seconds: 60),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
final body = await response.transform(utf8.decoder).join();
throw _AiGatewayChatException(
_formatAiGatewayHttpError(
response.statusCode,
_extractAiGatewayErrorDetail(body),
),
);
}
final contentType =
response.headers.contentType?.mimeType.toLowerCase() ??
response.headers
.value(HttpHeaders.contentTypeHeader)
?.toLowerCase() ??
'';
if (contentType.contains('text/event-stream')) {
final streamed = await _readAiGatewayStreamingResponse(
response: response,
sessionKey: sessionKey,
);
if (streamed.trim().isEmpty) {
throw const FormatException('Missing assistant content');
}
return streamed.trim();
}
return await _readAiGatewayJsonCompletion(response);
} catch (error) {
if (_consumeAiGatewayAbort(sessionKey)) {
throw _AiGatewayAbortException(
_aiGatewayStreamingTextBySession[sessionKey] ?? '',
);
}
rethrow;
} finally {
_aiGatewayStreamingClients.remove(sessionKey);
client.close(force: true);
}
}
List<Map<String, String>> _buildAiGatewayRequestMessages(String sessionKey) {
final history = <GatewayChatMessage>[
...(_gatewayHistoryCache[sessionKey] ?? const <GatewayChatMessage>[]),
...(_assistantThreadMessages[sessionKey] ?? const <GatewayChatMessage>[]),
];
return history
.where((message) {
final role = message.role.trim().toLowerCase();
return (role == 'user' || role == 'assistant') &&
(message.toolName ?? '').trim().isEmpty &&
message.text.trim().isNotEmpty;
})
.map(
(message) => <String, String>{
'role': message.role.trim().toLowerCase() == 'assistant'
? 'assistant'
: 'user',
'content': message.text.trim(),
},
)
.toList(growable: false);
}
Future<String> _readAiGatewayJsonCompletion(
HttpClientResponse response,
) async {
final body = await response.transform(utf8.decoder).join();
final decoded = jsonDecode(_extractFirstJsonDocument(body));
final assistantText = _extractAiGatewayAssistantText(decoded);
if (assistantText.trim().isEmpty) {
throw const FormatException('Missing assistant content');
}
return assistantText.trim();
}
Future<String> _readAiGatewayStreamingResponse({
required HttpClientResponse response,
required String sessionKey,
}) async {
final buffer = StringBuffer();
final eventLines = <String>[];
void processEvent(String payload) {
final trimmed = payload.trim();
if (trimmed.isEmpty) {
return;
}
if (trimmed == '[DONE]') {
return;
}
final deltaText = _extractAiGatewayStreamText(trimmed);
if (deltaText.isEmpty) {
return;
}
final current = buffer.toString();
if (current.isEmpty || deltaText == current) {
buffer
..clear()
..write(deltaText);
} else if (deltaText.startsWith(current)) {
buffer
..clear()
..write(deltaText);
} else {
buffer.write(deltaText);
}
_setAiGatewayStreamingText(sessionKey, buffer.toString());
}
await for (final line
in response.transform(utf8.decoder).transform(const LineSplitter())) {
if (_consumeAiGatewayAbort(sessionKey)) {
throw _AiGatewayAbortException(buffer.toString());
}
if (line.isEmpty) {
if (eventLines.isNotEmpty) {
processEvent(eventLines.join('\n'));
eventLines.clear();
}
continue;
}
if (line.startsWith('data:')) {
eventLines.add(line.substring(5).trimLeft());
}
}
if (eventLines.isNotEmpty) {
processEvent(eventLines.join('\n'));
}
return buffer.toString();
}
String _extractAiGatewayStreamText(String payload) {
final decoded = jsonDecode(_extractFirstJsonDocument(payload));
final map = asMap(decoded);
final choices = asList(map['choices']);
if (choices.isNotEmpty) {
final firstChoice = asMap(choices.first);
final delta = asMap(firstChoice['delta']);
final deltaContent = _extractAiGatewayContent(delta['content']);
if (deltaContent.isNotEmpty) {
return deltaContent;
}
}
return _extractAiGatewayAssistantText(decoded);
}
Future<void> _abortAiGatewayRun(String sessionKey) async {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
_aiGatewayAbortedSessionKeys.add(normalizedSessionKey);
final client = _aiGatewayStreamingClients.remove(normalizedSessionKey);
if (client != null) {
try {
client.close(force: true);
} catch (_) {
// Best effort only.
}
}
_aiGatewayPendingSessionKeys.remove(normalizedSessionKey);
_clearAiGatewayStreamingText(normalizedSessionKey);
_recomputeTasks();
_notifyIfActive();
}
bool _consumeAiGatewayAbort(String sessionKey) {
return _aiGatewayAbortedSessionKeys.remove(
_normalizedAssistantSessionKey(sessionKey),
);
}
GatewayChatMessage _assistantErrorMessage(String text) {
return GatewayChatMessage(
id: _nextLocalMessageId(),
role: 'assistant',
text: text,
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: true,
);
}
String _singleAgentFallbackLabel(String? reason) {
final detail = reason?.trim() ?? '';
return detail.isEmpty
? appText(
'未发现可用的外部 Agent ACP 端点,已回退到 AI Chat。',
'No external Agent ACP endpoint is available. Falling back to AI Chat.',
)
: appText(
'外部 Agent ACP 连接不可用,已回退到 AI Chat$detail',
'External Agent ACP connection is unavailable. Falling back to AI Chat: $detail',
);
}
String _singleAgentUnavailableLabel(String sessionKey, String? reason) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
final detail = reason?.trim() ?? '';
final selection = singleAgentProviderForSession(normalizedSessionKey);
if (singleAgentShouldSuggestAutoSwitchForSession(normalizedSessionKey)) {
return detail.isEmpty
? appText(
'当前线程固定为 ${selection.label},但它在这台设备上不可用。检测到其他外部 Agent ACP 端点时不会自动改线,可切到 Auto。',
'This thread is pinned to ${selection.label}, but it is unavailable on this device. XWorkmate will not reroute to another external Agent ACP endpoint automatically. Switch to Auto instead.',
)
: appText(
'当前线程固定为 ${selection.label}$detail 检测到其他外部 Agent ACP 端点时不会自动改线,可切到 Auto。',
'This thread is pinned to ${selection.label}: $detail XWorkmate will not reroute to another external Agent ACP endpoint automatically. Switch to Auto instead.',
);
}
if (singleAgentNeedsAiGatewayConfigurationForSession(
normalizedSessionKey,
)) {
return detail.isEmpty
? appText(
'当前没有可用的外部 Agent ACP 端点,也没有可用的 AI Chat fallback。请先配置外部 Agent 连接,或配置 LLM API。',
'No external Agent ACP endpoint is available, and AI Chat fallback is not configured. Configure an external Agent connection or configure LLM API first.',
)
: appText(
'$detail 当前没有可用的外部 Agent ACP 端点,也没有可用的 AI Chat fallback。请先配置外部 Agent 连接,或配置 LLM API。',
'$detail No external Agent ACP endpoint is available, and AI Chat fallback is not configured. Configure an external Agent connection or configure LLM API first.',
);
}
return detail.isEmpty
? appText(
'当前线程的外部 Agent ACP 连接尚未就绪。',
'The external Agent ACP connection for this thread is not ready yet.',
)
: appText(
'当前线程的外部 Agent ACP 连接尚未就绪:$detail',
'The external Agent ACP connection for this thread is not ready yet: $detail',
);
}
void _appendAssistantThreadMessage(
String sessionKey,
GatewayChatMessage message,
) {
final key = _normalizedAssistantSessionKey(sessionKey);
final next = List<GatewayChatMessage>.from(
_assistantThreadMessages[key] ?? const <GatewayChatMessage>[],
)..add(message);
_assistantThreadMessages[key] = next;
_upsertAssistantThreadRecord(
key,
messages: next,
updatedAtMs:
message.timestampMs ??
DateTime.now().millisecondsSinceEpoch.toDouble(),
);
_notifyIfActive();
}
Future<void> _flushAssistantThreadPersistence() async {
await _assistantThreadPersistQueue.catchError((_) {});
}
void _appendLocalSessionMessage(
String sessionKey,
GatewayChatMessage message,
) {
final key = _normalizedAssistantSessionKey(sessionKey);
final next = List<GatewayChatMessage>.from(
_localSessionMessages[key] ?? const <GatewayChatMessage>[],
)..add(message);
_localSessionMessages[key] = next;
_notifyIfActive();
}
void _preserveGatewayHistoryForSession(String sessionKey) {
final key = _normalizedAssistantSessionKey(sessionKey);
if (_chatController.messages.isEmpty) {
return;
}
_gatewayHistoryCache[key] = List<GatewayChatMessage>.from(
_chatController.messages,
);
}
List<GatewaySessionSummary> _assistantSessionSummaries() {
final archivedKeys = settings.assistantArchivedTaskKeys
.map(_normalizedAssistantSessionKey)
.toSet();
final items = <GatewaySessionSummary>[];
for (final record in _assistantThreadRecords.values) {
final sessionKey = _normalizedAssistantSessionKey(record.sessionKey);
if (archivedKeys.contains(sessionKey) || record.archived) {
continue;
}
items.add(_assistantSessionSummaryFor(sessionKey, record: record));
}
final currentSessionKey = _normalizedAssistantSessionKey(
_sessionsController.currentSessionKey,
);
final hasCurrent = items.any(
(item) => matchesSessionKey(item.key, currentSessionKey),
);
if (!hasCurrent && !archivedKeys.contains(currentSessionKey)) {
items.add(_assistantSessionSummaryFor(currentSessionKey));
}
items.sort((left, right) {
return (right.updatedAtMs ?? 0).compareTo(left.updatedAtMs ?? 0);
});
return items;
}
GatewaySessionSummary _assistantSessionSummaryFor(
String sessionKey, {
AssistantThreadRecord? record,
}) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
final resolvedRecord =
record ?? _assistantThreadRecords[normalizedSessionKey];
final messages =
resolvedRecord?.messages ??
_assistantThreadMessages[normalizedSessionKey] ??
const <GatewayChatMessage>[];
final preview = _assistantThreadPreview(messages);
final title = assistantCustomTaskTitle(normalizedSessionKey);
final lastMessage = messages.isNotEmpty ? messages.last : null;
final updatedAtMs =
resolvedRecord?.updatedAtMs ??
lastMessage?.timestampMs ??
DateTime.now().millisecondsSinceEpoch.toDouble();
return GatewaySessionSummary(
key: normalizedSessionKey,
kind: 'assistant',
displayName: title.isEmpty ? null : title,
surface: 'Assistant',
subject: preview,
room: null,
space: null,
updatedAtMs: updatedAtMs,
sessionId: normalizedSessionKey,
systemSent: false,
abortedLastRun: lastMessage?.error == true,
thinkingLevel: null,
verboseLevel: null,
inputTokens: null,
outputTokens: null,
totalTokens: null,
model: assistantModelForSession(normalizedSessionKey),
contextTokens: null,
derivedTitle: title.isEmpty ? null : title,
lastMessagePreview: preview,
);
}
String? _assistantThreadPreview(List<GatewayChatMessage> messages) {
for (final message in messages.reversed) {
final role = message.role.trim().toLowerCase();
if (role != 'user' && role != 'assistant') {
continue;
}
final text = message.text.trim();
if (text.isNotEmpty) {
return text;
}
}
return null;
}
String _gatewayEntryStateForTarget(AssistantExecutionTarget target) {
return target.promptValue;
}
Future<List<AssistantThreadSkillEntry>> _scanSingleAgentSkillEntries(
List<_SingleAgentSkillScanRoot> roots, {
String workspaceRef = '',
}) async {
final dedupedByName = <String, AssistantThreadSkillEntry>{};
for (final rootSpec in roots) {
var resolvedRootPath = _resolveSingleAgentSkillRootPath(
rootSpec.path,
workspaceRef: workspaceRef,
);
if (resolvedRootPath.isEmpty) {
continue;
}
SkillDirectoryAccessHandle? accessHandle;
try {
if (rootSpec.bookmark.trim().isNotEmpty) {
accessHandle = await _skillDirectoryAccessService.openDirectory(
AuthorizedSkillDirectory(
path: resolvedRootPath,
bookmark: rootSpec.bookmark,
),
);
if (accessHandle == null) {
continue;
}
resolvedRootPath = normalizeAuthorizedSkillDirectoryPath(
accessHandle.path,
);
}
final root = Directory(resolvedRootPath);
if (!await root.exists()) {
continue;
}
await for (final entity in root.list(
recursive: true,
followLinks: false,
)) {
if (entity is! File || entity.uri.pathSegments.last != 'SKILL.md') {
continue;
}
final entry = await _skillEntryFromFile(
entity,
rootSpec,
resolvedRootPath,
);
final normalizedName = entry.label.trim().toLowerCase();
if (normalizedName.isEmpty) {
continue;
}
dedupedByName[normalizedName] = entry;
}
} catch (_) {
continue;
} finally {
await accessHandle?.close();
}
}
final entries = dedupedByName.values.toList(growable: false);
entries.sort((left, right) => left.label.compareTo(right.label));
return entries;
}
Future<List<AssistantThreadSkillEntry>> _scanSingleAgentSharedSkillEntries() {
return _scanSingleAgentSkillEntries(_singleAgentSharedSkillScanRoots);
}
Future<List<AssistantThreadSkillEntry>> _scanSingleAgentWorkspaceSkillEntries(
String sessionKey,
) {
if (assistantWorkspaceRefKindForSession(sessionKey) !=
WorkspaceRefKind.localPath) {
return Future<List<AssistantThreadSkillEntry>>.value(
const <AssistantThreadSkillEntry>[],
);
}
return _scanSingleAgentSkillEntries(
_defaultSingleAgentWorkspaceSkillScanRoots,
workspaceRef: assistantWorkspaceRefForSession(sessionKey),
);
}
_SingleAgentSkillScanRoot _singleAgentSharedSkillScanRootFromOverride(
String rawPath,
) {
final normalizedPath = rawPath.trim();
final lowered = normalizedPath.toLowerCase();
return _SingleAgentSkillScanRoot(
path: normalizedPath,
source: _sourceForSkillRootPath(lowered),
scope: normalizedPath.startsWith('/etc/') ? 'system' : 'user',
);
}
_SingleAgentSkillScanRoot
_singleAgentSharedSkillScanRootFromAuthorizedDirectory(
AuthorizedSkillDirectory directory,
) {
final normalizedPath = normalizeAuthorizedSkillDirectoryPath(
directory.path,
);
final lowered = normalizedPath.toLowerCase();
return _SingleAgentSkillScanRoot(
path: normalizedPath,
source: _sourceForSkillRootPath(lowered),
scope: normalizedPath.startsWith('/etc/') ? 'system' : 'user',
bookmark: directory.bookmark,
);
}
String _resolveSingleAgentSkillRootPath(
String rawPath, {
String workspaceRef = '',
}) {
final trimmed = rawPath.trim().replaceFirst(RegExp(r'^\./'), '');
if (trimmed.isEmpty) {
return '';
}
if (trimmed.startsWith('/')) {
return trimmed;
}
if (trimmed.startsWith('~/')) {
final home = _resolvedUserHomeDirectory.trim();
return home.isEmpty ? trimmed : '$home/${trimmed.substring(2)}';
}
final normalizedWorkspace = workspaceRef.trim();
if (normalizedWorkspace.isEmpty) {
return '';
}
final base = normalizedWorkspace.endsWith('/')
? normalizedWorkspace.substring(0, normalizedWorkspace.length - 1)
: normalizedWorkspace;
return '$base/$trimmed';
}
String _sourceForSkillRootPath(String path) {
if (path == '/etc/skills' || path.startsWith('/etc/skills/')) {
return 'system';
}
if (path == '~/.agents/skills' || path.endsWith('/.agents/skills')) {
return 'agents';
}
return 'custom';
}
Future<AssistantThreadSkillEntry> _skillEntryFromFile(
File file,
_SingleAgentSkillScanRoot root,
String rootPath,
) async {
final content = await file.readAsString();
final nameMatch = RegExp(
"^name:\\s*[\"']?(.+?)[\"']?\\s*\$",
multiLine: true,
).firstMatch(content);
final descriptionMatch = RegExp(
"^description:\\s*[\"']?(.+?)[\"']?\\s*\$",
multiLine: true,
).firstMatch(content);
final directory = file.parent;
final label =
(nameMatch?.group(1) ??
directory.uri.pathSegments
.where((item) => item.isNotEmpty)
.last)
.trim();
final relativeSource = directory.path.startsWith(rootPath)
? directory.path
.substring(rootPath.length)
.replaceFirst(RegExp(r'^/'), '')
: directory.path;
final sourceSegments = <String>[
root.source,
if (root.scope != root.source) root.scope,
].where((item) => item.trim().isNotEmpty).toList(growable: false);
final sourceLabel = sourceSegments.join(' · ');
return AssistantThreadSkillEntry(
key: directory.path,
label: label,
description: (descriptionMatch?.group(1) ?? '').trim(),
source: root.source,
sourcePath: file.path,
scope: root.scope,
sourceLabel: relativeSource.isEmpty
? sourceLabel
: '$sourceLabel · $relativeSource',
);
}
void _restoreAssistantThreads(List<AssistantThreadRecord> records) {
_assistantThreadRecords.clear();
_assistantThreadMessages.clear();
_singleAgentSharedImportedSkills = const <AssistantThreadSkillEntry>[];
_singleAgentLocalSkillsHydrated = false;
final archivedKeys = settings.assistantArchivedTaskKeys
.map(_normalizedAssistantSessionKey)
.toSet();
for (final record in records) {
final sessionKey = _normalizedAssistantSessionKey(record.sessionKey);
if (sessionKey.isEmpty) {
continue;
}
final titleFromSettings = assistantCustomTaskTitle(sessionKey);
final normalizedRecord = record.copyWith(
sessionKey: sessionKey,
title: titleFromSettings.isEmpty
? record.title.trim()
: titleFromSettings,
archived: record.archived || archivedKeys.contains(sessionKey),
executionTarget:
record.executionTarget ?? settings.assistantExecutionTarget,
messageViewMode: record.messageViewMode,
selectedSkillKeys: record.selectedSkillKeys
.where(
(item) => record.importedSkills.any((skill) => skill.key == item),
)
.toList(growable: false),
assistantModelId: record.assistantModelId.trim().isEmpty
? _resolvedAssistantModelForTarget(
record.executionTarget ?? settings.assistantExecutionTarget,
)
: record.assistantModelId.trim(),
singleAgentProvider: record.singleAgentProvider,
gatewayEntryState: (record.gatewayEntryState ?? '').trim().isEmpty
? _gatewayEntryStateForTarget(
record.executionTarget ?? settings.assistantExecutionTarget,
)
: record.gatewayEntryState,
workspaceRef: record.workspaceRef.trim().isEmpty
? _defaultWorkspaceRefForSession(sessionKey)
: record.workspaceRef.trim(),
workspaceRefKind: record.workspaceRef.trim().isEmpty
? _defaultWorkspaceRefKindForTarget(
record.executionTarget ?? settings.assistantExecutionTarget,
)
: record.workspaceRefKind,
);
_assistantThreadRecords[sessionKey] = normalizedRecord;
if (normalizedRecord.messages.isNotEmpty) {
_assistantThreadMessages[sessionKey] = List<GatewayChatMessage>.from(
normalizedRecord.messages,
);
}
}
}
Future<void> _refreshSharedSingleAgentLocalSkillsCache({
required bool forceRescan,
}) async {
if (!forceRescan && _singleAgentLocalSkillsHydrated) {
return;
}
if (!forceRescan && await _restoreSharedSingleAgentLocalSkillsCache()) {
return;
}
final existingRefresh = _singleAgentSharedSkillsRefreshInFlight;
if (existingRefresh != null) {
await existingRefresh;
if (!forceRescan) {
return;
}
}
late final Future<void> refreshFuture;
refreshFuture = () async {
final sharedSkills = await _scanSingleAgentSharedSkillEntries();
_singleAgentSharedImportedSkills = sharedSkills;
_singleAgentLocalSkillsHydrated = true;
await _persistSharedSingleAgentLocalSkillsCache();
}();
_singleAgentSharedSkillsRefreshInFlight = refreshFuture;
try {
await refreshFuture;
} finally {
if (identical(_singleAgentSharedSkillsRefreshInFlight, refreshFuture)) {
_singleAgentSharedSkillsRefreshInFlight = null;
}
}
}
Future<void> ensureSharedSingleAgentLocalSkillsLoaded() async {
if (_singleAgentLocalSkillsHydrated) {
return;
}
await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: false);
}
Future<void> _startupRefreshSharedSingleAgentLocalSkillsCache() async {
await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: true);
if (_disposed) {
return;
}
if (assistantExecutionTargetForSession(currentSessionKey) ==
AssistantExecutionTarget.singleAgent) {
await refreshSingleAgentSkillsForSession(currentSessionKey);
return;
}
_notifyIfActive();
}
Future<List<AssistantThreadSkillEntry>> _singleAgentLocalSkillsForSession(
String sessionKey,
) async {
final workspaceSkills = await _scanSingleAgentWorkspaceSkillEntries(
sessionKey,
);
return _mergeSingleAgentSkillEntries(
groups: <List<AssistantThreadSkillEntry>>[
_singleAgentSharedImportedSkills,
workspaceSkills,
],
);
}
List<AssistantThreadSkillEntry> _mergeSingleAgentSkillEntries({
required List<List<AssistantThreadSkillEntry>> groups,
}) {
final merged = <String, AssistantThreadSkillEntry>{};
for (final group in groups) {
for (final skill in group) {
final normalizedName = skill.label.trim().toLowerCase();
if (normalizedName.isEmpty || merged.containsKey(normalizedName)) {
continue;
}
merged[normalizedName] = skill;
}
}
final entries = merged.values.toList(growable: false);
entries.sort((left, right) => left.label.compareTo(right.label));
return entries;
}
Future<bool> _restoreSharedSingleAgentLocalSkillsCache() async {
try {
final payload = await _store.loadSupportJson(
_singleAgentLocalSkillsCacheRelativePath,
);
if (payload == null) {
return false;
}
final schemaVersion = int.tryParse(
payload['schemaVersion']?.toString() ?? '',
);
if (schemaVersion != _singleAgentLocalSkillsCacheSchemaVersion) {
return false;
}
final skills = asList(payload['skills'])
.map(asMap)
.map(
(item) => AssistantThreadSkillEntry.fromJson(
item.cast<String, dynamic>(),
),
)
.where((item) => item.key.trim().isNotEmpty && item.label.isNotEmpty)
.toList(growable: false);
if (skills.isEmpty) {
_singleAgentSharedImportedSkills = const <AssistantThreadSkillEntry>[];
_singleAgentLocalSkillsHydrated = false;
return false;
}
_singleAgentSharedImportedSkills = skills;
_singleAgentLocalSkillsHydrated = true;
return true;
} catch (_) {
return false;
}
}
Future<void> _persistSharedSingleAgentLocalSkillsCache() async {
try {
await _store.saveSupportJson(
_singleAgentLocalSkillsCacheRelativePath,
<String, dynamic>{
'schemaVersion': _singleAgentLocalSkillsCacheSchemaVersion,
'savedAtMs': DateTime.now().millisecondsSinceEpoch.toDouble(),
'skills': _singleAgentSharedImportedSkills
.map((item) => item.toJson())
.toList(growable: false),
},
);
} catch (_) {
// Best effort only for local cache persistence.
}
}
Future<void> _replaceSingleAgentThreadSkills(
String sessionKey,
List<AssistantThreadSkillEntry> importedSkills,
) async {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
final importedKeys = importedSkills.map((item) => item.key).toSet();
final nextSelected =
(_assistantThreadRecords[normalizedSessionKey]?.selectedSkillKeys ??
const <String>[])
.where(importedKeys.contains)
.toList(growable: false);
_upsertAssistantThreadRecord(
normalizedSessionKey,
importedSkills: importedSkills,
selectedSkillKeys: nextSelected,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
_notifyIfActive();
}
AssistantThreadSkillEntry _singleAgentSkillEntryFromAcp(
Map<String, dynamic> item,
SingleAgentProvider provider,
) {
return 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),
);
}
bool _unsupportedAcpSkillsStatus(GatewayAcpException 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');
}
void _upsertAssistantThreadRecord(
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,
String? workspaceRef,
WorkspaceRefKind? workspaceRefKind,
}) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
final existing = _assistantThreadRecords[normalizedSessionKey];
final nextExecutionTarget =
executionTarget ??
existing?.executionTarget ??
settings.assistantExecutionTarget;
final nextImportedSkills =
importedSkills ??
existing?.importedSkills ??
const <AssistantThreadSkillEntry>[];
final importedKeys = nextImportedSkills.map((item) => item.key).toSet();
final nextSelectedSkillKeys =
(selectedSkillKeys ?? existing?.selectedSkillKeys ?? const <String>[])
.where(importedKeys.contains)
.toList(growable: false);
final nextMessages =
messages ??
existing?.messages ??
_assistantThreadMessages[normalizedSessionKey] ??
const <GatewayChatMessage>[];
final nextRecord = AssistantThreadRecord(
sessionKey: normalizedSessionKey,
messages: nextMessages,
updatedAtMs:
updatedAtMs ??
existing?.updatedAtMs ??
(nextMessages.isNotEmpty ? nextMessages.last.timestampMs : null),
title: title ?? existing?.title ?? '',
archived:
archived ??
existing?.archived ??
isAssistantTaskArchived(normalizedSessionKey),
executionTarget: nextExecutionTarget,
messageViewMode:
messageViewMode ??
existing?.messageViewMode ??
AssistantMessageViewMode.rendered,
importedSkills: nextImportedSkills,
selectedSkillKeys: nextSelectedSkillKeys,
assistantModelId:
assistantModelId ??
existing?.assistantModelId ??
_resolvedAssistantModelForTarget(nextExecutionTarget),
singleAgentProvider:
singleAgentProvider ??
existing?.singleAgentProvider ??
SingleAgentProvider.auto,
gatewayEntryState:
gatewayEntryState ??
existing?.gatewayEntryState ??
_gatewayEntryStateForTarget(nextExecutionTarget),
workspaceRef: workspaceRef ?? existing?.workspaceRef ?? '',
workspaceRefKind:
workspaceRefKind ??
existing?.workspaceRefKind ??
_defaultWorkspaceRefKindForTarget(nextExecutionTarget),
);
_assistantThreadRecords[normalizedSessionKey] = nextRecord;
if (messages != null) {
_assistantThreadMessages[normalizedSessionKey] =
List<GatewayChatMessage>.from(messages);
}
final snapshot = _assistantThreadRecords.values.toList(growable: false);
final nextPersist = _assistantThreadPersistQueue.catchError((_) {}).then((
_,
) async {
if (_disposed) {
return;
}
try {
await _store.saveAssistantThreadRecords(snapshot);
} catch (_) {
// Assistant thread persistence is background best-effort. Keep the
// in-memory session usable even when teardown or temp-directory
// cleanup races with the durable write.
}
});
_assistantThreadPersistQueue = nextPersist;
unawaited(nextPersist);
}
Future<void> _setCurrentAssistantSessionKey(
String sessionKey, {
bool persistSelection = true,
}) async {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
if (normalizedSessionKey.isEmpty) {
return;
}
await _sessionsController.switchSession(normalizedSessionKey);
if (persistSelection) {
await _persistAssistantLastSessionKey(normalizedSessionKey);
}
}
Future<void> _persistAssistantLastSessionKey(String sessionKey) async {
if (_disposed) {
return;
}
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
if (normalizedSessionKey.isEmpty ||
settings.assistantLastSessionKey == normalizedSessionKey) {
return;
}
try {
await saveSettings(
settings.copyWith(assistantLastSessionKey: normalizedSessionKey),
refreshAfterSave: false,
);
} catch (_) {
// Best effort only during teardown-sensitive transitions.
}
}
void _setAiGatewayStreamingText(String sessionKey, String text) {
final key = _normalizedAssistantSessionKey(sessionKey);
if (text.trim().isEmpty) {
_aiGatewayStreamingTextBySession.remove(key);
} else {
_aiGatewayStreamingTextBySession[key] = text;
}
_notifyIfActive();
}
void _appendAiGatewayStreamingText(String sessionKey, String delta) {
if (delta.isEmpty) {
return;
}
final key = _normalizedAssistantSessionKey(sessionKey);
final current = _aiGatewayStreamingTextBySession[key] ?? '';
_aiGatewayStreamingTextBySession[key] = '$current$delta';
_notifyIfActive();
}
void _clearAiGatewayStreamingText(String sessionKey) {
final key = _normalizedAssistantSessionKey(sessionKey);
if (_aiGatewayStreamingTextBySession.remove(key) != null) {
_notifyIfActive();
}
}
String _nextLocalMessageId() {
_localMessageCounter += 1;
return 'local-${DateTime.now().microsecondsSinceEpoch}-$_localMessageCounter';
}
Future<T> _enqueueThreadTurn<T>(String threadId, Future<T> Function() task) {
final normalizedThreadId = _normalizedAssistantSessionKey(threadId);
final previous =
_assistantThreadTurnQueues[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(_assistantThreadTurnQueues[normalizedThreadId], next)) {
_assistantThreadTurnQueues.remove(normalizedThreadId);
}
});
_assistantThreadTurnQueues[normalizedThreadId] = next;
return completer.future;
}
Uri? _normalizeAiGatewayBaseUrl(String raw) {
final trimmed = raw.trim();
if (trimmed.isEmpty) {
return null;
}
final candidate = trimmed.contains('://') ? trimmed : 'https://$trimmed';
final uri = Uri.tryParse(candidate);
if (uri == null || uri.host.trim().isEmpty) {
return null;
}
final pathSegments = uri.pathSegments.where((item) => item.isNotEmpty);
return uri.replace(
pathSegments: pathSegments.isEmpty ? const <String>['v1'] : pathSegments,
query: null,
fragment: null,
);
}
Uri _aiGatewayChatUri(Uri baseUrl) {
final pathSegments = baseUrl.pathSegments
.where((item) => item.isNotEmpty)
.toList(growable: true);
if (pathSegments.isEmpty) {
pathSegments.add('v1');
}
if (pathSegments.length >= 2 &&
pathSegments[pathSegments.length - 2] == 'chat' &&
pathSegments.last == 'completions') {
return baseUrl.replace(query: null, fragment: null);
}
if (pathSegments.last == 'models') {
pathSegments.removeLast();
}
if (pathSegments.last != 'chat') {
pathSegments.add('chat');
}
pathSegments.add('completions');
return baseUrl.replace(
pathSegments: pathSegments,
query: null,
fragment: null,
);
}
String _aiGatewayHostLabel(String raw) {
final uri = _normalizeAiGatewayBaseUrl(raw);
if (uri == null) {
return '';
}
if (uri.hasPort) {
return '${uri.host}:${uri.port}';
}
return uri.host;
}
String _aiGatewayErrorLabel(Object error) {
if (error is _AiGatewayChatException) {
return error.message;
}
if (error is SocketException) {
return appText('无法连接到 LLM API。', 'Unable to reach the LLM API.');
}
if (error is HandshakeException) {
return appText('LLM API TLS 握手失败。', 'LLM API TLS handshake failed.');
}
if (error is TimeoutException) {
return appText('LLM API 请求超时。', 'LLM API request timed out.');
}
if (error is FormatException) {
return appText(
'LLM API 返回了无法解析的响应。',
'LLM API returned an invalid response.',
);
}
return error.toString();
}
String _formatAiGatewayHttpError(int statusCode, String detail) {
final base = switch (statusCode) {
400 => appText(
'LLM API 请求无效 (400)',
'LLM API rejected the request (400)',
),
401 => appText(
'LLM API 鉴权失败 (401)',
'LLM API authentication failed (401)',
),
403 => appText('LLM API 拒绝访问 (403)', 'LLM API denied access (403)'),
404 => appText(
'LLM API chat 接口不存在 (404)',
'LLM API chat endpoint was not found (404)',
),
429 => appText(
'LLM API 限流 (429)',
'LLM API rate limited the request (429)',
),
>= 500 => appText(
'LLM API 当前不可用 ($statusCode)',
'LLM API is unavailable right now ($statusCode)',
),
_ => appText(
'LLM API 返回状态码 $statusCode',
'LLM API responded with status $statusCode',
),
};
final trimmed = detail.trim();
return trimmed.isEmpty ? base : '$base · $trimmed';
}
String _extractAiGatewayErrorDetail(String body) {
if (body.trim().isEmpty) {
return '';
}
try {
final decoded = jsonDecode(_extractFirstJsonDocument(body));
final map = asMap(decoded);
final error = asMap(map['error']);
return (stringValue(error['message']) ??
stringValue(map['message']) ??
stringValue(map['detail']) ??
'')
.trim();
} on FormatException {
return '';
}
}
String _extractAiGatewayAssistantText(Object? decoded) {
final map = asMap(decoded);
final choices = asList(map['choices']);
if (choices.isNotEmpty) {
final firstChoice = asMap(choices.first);
final message = asMap(firstChoice['message']);
final content = _extractAiGatewayContent(message['content']);
if (content.isNotEmpty) {
return content;
}
}
final output = asList(map['output']);
for (final item in output) {
final entry = asMap(item);
final content = _extractAiGatewayContent(entry['content']);
if (content.isNotEmpty) {
return content;
}
}
final direct = _extractAiGatewayContent(map['content']);
if (direct.isNotEmpty) {
return direct;
}
return stringValue(map['output_text'])?.trim() ?? '';
}
String _extractAiGatewayContent(Object? content) {
if (content is String) {
return content.trim();
}
final parts = <String>[];
for (final item in asList(content)) {
final map = asMap(item);
final nestedText = stringValue(map['text']);
if (nestedText != null && nestedText.trim().isNotEmpty) {
parts.add(nestedText.trim());
continue;
}
final type = stringValue(map['type']) ?? '';
if (type == 'output_text') {
final text = stringValue(map['text']) ?? stringValue(map['value']);
if (text != null && text.trim().isNotEmpty) {
parts.add(text.trim());
}
}
}
return parts.join('\n').trim();
}
String _extractFirstJsonDocument(String body) {
final trimmed = body.trimLeft();
if (trimmed.isEmpty) {
throw const FormatException('Empty response body');
}
final start = trimmed.indexOf(RegExp(r'[\{\[]'));
if (start < 0) {
throw const FormatException('Missing JSON document');
}
var depth = 0;
var inString = false;
var escaped = false;
for (var index = start; index < trimmed.length; index++) {
final char = trimmed[index];
if (escaped) {
escaped = false;
continue;
}
if (char == r'\') {
escaped = true;
continue;
}
if (char == '"') {
inString = !inString;
continue;
}
if (inString) {
continue;
}
if (char == '{' || char == '[') {
depth += 1;
} else if (char == '}' || char == ']') {
depth -= 1;
if (depth == 0) {
return trimmed.substring(start, index + 1);
}
}
}
throw const FormatException('Unterminated JSON document');
}
SettingsSnapshot _sanitizeCodeAgentSettings(SettingsSnapshot snapshot) {
_codexRuntimeWarning =
snapshot.codeAgentRuntimeMode == CodeAgentRuntimeMode.builtIn
? appText(
'内置 Codex 仍处于实验阶段;建议优先使用 External Codex CLI。',
'Built-in Codex is still experimental; External Codex CLI is recommended.',
)
: null;
final normalizedPath = snapshot.codexCliPath.trim();
if (normalizedPath == snapshot.codexCliPath) {
return snapshot;
}
return snapshot.copyWith(codexCliPath: normalizedPath);
}
Future<void> _refreshAcpCapabilities({
bool forceRefresh = false,
bool persistMountTargets = false,
}) async {
GatewayAcpCapabilities capabilities;
try {
capabilities = await _gatewayAcpClient.loadCapabilities(
forceRefresh: forceRefresh,
);
} catch (_) {
capabilities = const GatewayAcpCapabilities.empty();
}
if (persistMountTargets && !_disposed) {
final currentConfig = settings.multiAgent;
final nextTargets = _mergeAcpCapabilitiesIntoMountTargets(
currentConfig.mountTargets,
capabilities,
);
final nextConfig = currentConfig.copyWith(mountTargets: nextTargets);
if (jsonEncode(nextConfig.toJson()) !=
jsonEncode(currentConfig.toJson())) {
await _settingsController.saveSnapshot(
settings.copyWith(multiAgent: nextConfig),
);
_multiAgentOrchestrator.updateConfig(nextConfig);
}
}
_notifyIfActive();
}
Future<void> _refreshSingleAgentCapabilities({
bool forceRefresh = false,
}) async {
final gatewayToken = await settingsController.loadGatewayToken();
final next = <SingleAgentProvider, DirectSingleAgentCapabilities>{};
for (final provider in kBuiltinExternalAcpProviders) {
final profile = settings.externalAcpEndpointForProvider(provider);
if (!profile.enabled || profile.endpoint.trim().isEmpty) {
next[provider] = const DirectSingleAgentCapabilities.unavailable(
endpoint: '',
);
continue;
}
try {
next[provider] = await _singleAgentAppServerClient.loadCapabilities(
provider: provider,
forceRefresh: forceRefresh,
gatewayToken: gatewayToken,
);
} catch (_) {
next[provider] = const DirectSingleAgentCapabilities.unavailable(
endpoint: '',
);
}
}
_singleAgentCapabilitiesByProvider = next;
if (!_disposed) {
_notifyIfActive();
}
}
Future<void> _refreshResolvedCodexCliPath() async {
if (effectiveCodeAgentRuntimeMode != CodeAgentRuntimeMode.externalCli) {
_resolvedCodexCliPath = null;
return;
}
if (blocksAppStoreEmbeddedAgentProcesses(
isAppleHost: Platform.isIOS || Platform.isMacOS,
)) {
_resolvedCodexCliPath = null;
return;
}
final configuredPath = configuredCodexCliPath;
String? detectedPath;
if (configuredPath.isNotEmpty) {
try {
if (await File(configuredPath).exists()) {
detectedPath = configuredPath;
}
} catch (_) {
detectedPath = null;
}
}
detectedPath ??= await _runtimeCoordinator.codex.findCodexBinary();
if (_disposed) {
return;
}
_resolvedCodexCliPath = detectedPath;
}
List<ManagedMountTargetState> _mergeAcpCapabilitiesIntoMountTargets(
List<ManagedMountTargetState> current,
GatewayAcpCapabilities capabilities,
) {
final source = current.isEmpty
? ManagedMountTargetState.defaults()
: current;
final providers = capabilities.providers
.map((item) => item.providerId)
.toSet();
return source
.map((item) {
final available = switch (item.targetId) {
'codex' => providers.contains('codex'),
'opencode' => providers.contains('opencode'),
'claude' => providers.contains('claude'),
'gemini' => providers.contains('gemini'),
'aris' => capabilities.multiAgent,
'openclaw' => capabilities.multiAgent || capabilities.singleAgent,
_ => false,
};
return item.copyWith(
available: available,
discoveryState: available ? 'ready' : 'unavailable',
syncState: available ? item.syncState : 'idle',
detail: available
? appText(
'来源Gateway ACP capabilities',
'Source: Gateway ACP capabilities',
)
: appText(
'Gateway ACP 未报告该能力。',
'Gateway ACP did not report this capability.',
),
);
})
.toList(growable: false);
}
String? _resolveCodexWorkingDirectory() {
final candidate = settings.workspacePath.trim();
if (candidate.isEmpty) {
return null;
}
final directory = Directory(candidate);
return directory.existsSync() ? directory.path : null;
}
void _registerCodexExternalProvider() {
final endpoint = _resolveSingleAgentEndpoint(SingleAgentProvider.codex);
_runtimeCoordinator.registerExternalCodeAgent(
ExternalCodeAgentProvider(
id: 'codex',
name: 'Codex ACP',
command: 'xworkmate-agent-gateway',
transport: ExternalAgentTransport.websocketJsonRpc,
endpoint: endpoint?.toString() ?? '',
defaultArgs: const <String>[],
capabilities: const <String>[
'chat',
'code-edit',
'gateway-bridge',
'memory-sync',
'single-agent',
'multi-agent',
],
),
);
}
CodeAgentNodeState _buildCodeAgentNodeState() {
return CodeAgentNodeState(
selectedAgentId: _agentsController.selectedAgentId,
gatewayConnected: _runtime.isConnected,
executionTarget: currentAssistantExecutionTarget,
runtimeMode: effectiveCodeAgentRuntimeMode,
bridgeEnabled: _isCodexBridgeEnabled,
bridgeState: _codexCooperationState.name,
preferredProviderId: 'codex',
resolvedCodexCliPath: _resolvedCodexCliPath,
configuredCodexCliPath: configuredCodexCliPath,
);
}
GatewayMode _bridgeGatewayMode() {
if (!_runtime.isConnected) {
return GatewayMode.offline;
}
return switch (currentAssistantExecutionTarget) {
AssistantExecutionTarget.singleAgent => GatewayMode.offline,
AssistantExecutionTarget.local => GatewayMode.local,
AssistantExecutionTarget.remote => GatewayMode.remote,
};
}
Future<void> _ensureCodexGatewayRegistration() async {
if (!_isCodexBridgeEnabled) {
return;
}
if (!_runtime.isConnected) {
_codexCooperationState = CodexCooperationState.bridgeOnly;
_codeAgentBridgeRegistry.clearRegistration();
notifyListeners();
return;
}
if (_codeAgentBridgeRegistry.isRegistered) {
_codexCooperationState = CodexCooperationState.registered;
notifyListeners();
return;
}
try {
final dispatch = _codeAgentNodeOrchestrator.buildGatewayDispatch(
_buildCodeAgentNodeState(),
);
await _codeAgentBridgeRegistry.register(
agentType: 'code-agent-bridge',
name: 'XWorkmate Codex Bridge',
version: kAppVersion,
transport: 'stdio-bridge',
capabilities: const <AgentCapability>[
AgentCapability(
name: 'chat',
description: 'Bridge external Codex CLI chat turns.',
),
AgentCapability(
name: 'code-edit',
description: 'Bridge code editing tasks through Codex CLI.',
),
AgentCapability(
name: 'memory-sync',
description: 'Coordinate memory sync through OpenClaw Gateway.',
),
],
metadata: <String, dynamic>{
...dispatch.metadata,
'providerId': 'codex',
'runtimeMode': effectiveCodeAgentRuntimeMode.name,
'gatewayMode': _bridgeGatewayMode().name,
'binaryConfigured': (resolvedCodexCliPath ?? configuredCodexCliPath)
.trim()
.isNotEmpty,
'capabilities': const <String>[
'chat',
'code-edit',
'gateway-bridge',
'memory-sync',
],
},
);
_codexCooperationState = CodexCooperationState.registered;
_codexBridgeError = null;
} catch (error) {
_codexCooperationState = CodexCooperationState.bridgeOnly;
_codexBridgeError = error.toString();
}
notifyListeners();
}
void _clearCodexGatewayRegistration() {
_codeAgentBridgeRegistry.clearRegistration();
if (_isCodexBridgeEnabled) {
_codexCooperationState = CodexCooperationState.bridgeOnly;
} else {
_codexCooperationState = CodexCooperationState.notStarted;
}
notifyListeners();
}
void _recomputeTasks() {
_tasksController.recompute(
sessions: sessions,
cronJobs: _cronJobsController.items,
currentSessionKey: _sessionsController.currentSessionKey,
hasPendingRun: hasAssistantPendingRun,
activeAgentName: _agentsController.activeAgentName,
);
}
void _attachChildListeners() {
_runtimeCoordinator.addListener(_relayChildChange);
_settingsController.addListener(_handleSettingsControllerChange);
_agentsController.addListener(_relayChildChange);
_sessionsController.addListener(_relayChildChange);
_chatController.addListener(_relayChildChange);
_instancesController.addListener(_relayChildChange);
_skillsController.addListener(_relayChildChange);
_connectorsController.addListener(_relayChildChange);
_modelsController.addListener(_relayChildChange);
_cronJobsController.addListener(_relayChildChange);
_devicesController.addListener(_relayChildChange);
_tasksController.addListener(_relayChildChange);
_multiAgentOrchestrator.addListener(_relayChildChange);
}
void _detachChildListeners() {
_runtimeCoordinator.removeListener(_relayChildChange);
_settingsController.removeListener(_handleSettingsControllerChange);
_agentsController.removeListener(_relayChildChange);
_sessionsController.removeListener(_relayChildChange);
_chatController.removeListener(_relayChildChange);
_instancesController.removeListener(_relayChildChange);
_skillsController.removeListener(_relayChildChange);
_connectorsController.removeListener(_relayChildChange);
_modelsController.removeListener(_relayChildChange);
_cronJobsController.removeListener(_relayChildChange);
_devicesController.removeListener(_relayChildChange);
_tasksController.removeListener(_relayChildChange);
_multiAgentOrchestrator.removeListener(_relayChildChange);
}
void _handleSettingsControllerChange() {
final previous = _lastObservedSettingsSnapshot;
final current = settings;
final previousJson = previous.toJsonString();
final currentJson = current.toJsonString();
if (currentJson == previousJson) {
_notifyIfActive();
return;
}
final hadDraftChanges =
_settingsDraftInitialized &&
(_settingsDraft.toJsonString() != previousJson ||
_draftSecretValues.isNotEmpty);
if (!_settingsDraftInitialized || !hadDraftChanges) {
_settingsDraft = current;
_settingsDraftInitialized = true;
_settingsDraftStatusMessage = '';
}
_lastObservedSettingsSnapshot = current;
_settingsObservationQueue = _settingsObservationQueue
.then((_) async {
await _handleObservedSettingsChange(
previous: previous,
current: current,
);
})
.catchError((_) {});
_notifyIfActive();
}
Future<void> _handleObservedSettingsChange({
required SettingsSnapshot previous,
required SettingsSnapshot current,
}) async {
if (_disposed) {
return;
}
setActiveAppLanguage(current.appLanguage);
_multiAgentOrchestrator.updateConfig(current.multiAgent);
if (previous.codexCliPath != current.codexCliPath ||
previous.codeAgentRuntimeMode != current.codeAgentRuntimeMode) {
await _refreshResolvedCodexCliPath();
_registerCodexExternalProvider();
if (_disposed) {
return;
}
}
if (_authorizedSkillDirectoriesChanged(previous, current)) {
await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: true);
if (_disposed) {
return;
}
if (assistantExecutionTargetForSession(currentSessionKey) ==
AssistantExecutionTarget.singleAgent) {
await refreshSingleAgentSkillsForSession(currentSessionKey);
}
}
_notifyIfActive();
}
void _relayChildChange() {
_notifyIfActive();
}
void _notifyIfActive() {
if (_disposed) {
return;
}
notifyListeners();
}
Uri? _resolveSingleAgentEndpoint(SingleAgentProvider provider) {
final endpoint = settings
.externalAcpEndpointForProvider(provider)
.endpoint
.trim();
if (endpoint.isEmpty) {
return null;
}
final normalizedInput = endpoint.contains('://')
? endpoint
: 'ws://$endpoint';
final uri = Uri.tryParse(normalizedInput);
if (uri == null || uri.host.trim().isEmpty) {
return null;
}
final scheme = uri.scheme.trim().toLowerCase();
if (scheme != 'ws' &&
scheme != 'wss' &&
scheme != 'http' &&
scheme != 'https') {
return null;
}
return uri;
}
Uri? _resolveGatewayAcpEndpoint() {
final target = assistantExecutionTargetForSession(
_sessionsController.currentSessionKey,
);
if (target == AssistantExecutionTarget.singleAgent) {
final remote = _gatewayProfileBaseUri(
settings.primaryRemoteGatewayProfile,
);
if (remote != null) {
return remote;
}
return _gatewayProfileBaseUri(settings.primaryLocalGatewayProfile);
}
return _gatewayProfileBaseUri(
_gatewayProfileForAssistantExecutionTarget(target),
);
}
Uri? _gatewayProfileBaseUri(GatewayConnectionProfile profile) {
final host = profile.host.trim();
if (host.isEmpty || profile.port <= 0) {
return null;
}
return Uri(
scheme: profile.tls ? 'https' : 'http',
host: host,
port: profile.port,
);
}
RuntimeConnectionMode _modeFromHost(String host) {
final trimmed = host.trim().toLowerCase();
if (_isLoopbackHost(trimmed)) {
return RuntimeConnectionMode.local;
}
return RuntimeConnectionMode.remote;
}
bool _isLoopbackHost(String host) {
final trimmed = host.trim().toLowerCase();
return trimmed == '127.0.0.1' || trimmed == 'localhost';
}
AssistantExecutionTarget _assistantExecutionTargetForMode(
RuntimeConnectionMode mode,
) {
return switch (mode) {
RuntimeConnectionMode.unconfigured =>
AssistantExecutionTarget.singleAgent,
RuntimeConnectionMode.local => AssistantExecutionTarget.local,
RuntimeConnectionMode.remote => AssistantExecutionTarget.remote,
};
}
GatewayConnectionProfile _gatewayProfileForAssistantExecutionTarget(
AssistantExecutionTarget target,
) {
return switch (target) {
AssistantExecutionTarget.local => settings.primaryLocalGatewayProfile,
AssistantExecutionTarget.remote => settings.primaryRemoteGatewayProfile,
AssistantExecutionTarget.singleAgent => throw StateError(
'Single Agent target has no OpenClaw gateway profile.',
),
};
}
int _gatewayProfileIndexForExecutionTarget(AssistantExecutionTarget target) {
return switch (target) {
AssistantExecutionTarget.local => kGatewayLocalProfileIndex,
AssistantExecutionTarget.remote => kGatewayRemoteProfileIndex,
AssistantExecutionTarget.singleAgent => throw StateError(
'Single Agent target has no OpenClaw gateway profile index.',
),
};
}
}
class _AiGatewayChatException implements Exception {
const _AiGatewayChatException(this.message);
final String message;
@override
String toString() => message;
}
class _AiGatewayAbortException implements Exception {
const _AiGatewayAbortException(this.partialText);
final String partialText;
}