Split desktop app controller responsibilities
This commit is contained in:
parent
de010bab5e
commit
147e2279c9
File diff suppressed because it is too large
Load Diff
166
lib/app/app_controller_desktop_gateway.dart
Normal file
166
lib/app/app_controller_desktop_gateway.dart
Normal file
@ -0,0 +1,166 @@
|
||||
part of 'app_controller_desktop.dart';
|
||||
|
||||
extension AppControllerDesktopGateway on AppController {
|
||||
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> _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();
|
||||
}
|
||||
}
|
||||
245
lib/app/app_controller_desktop_navigation.dart
Normal file
245
lib/app/app_controller_desktop_navigation.dart
Normal file
@ -0,0 +1,245 @@
|
||||
// ignore_for_file: invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member
|
||||
|
||||
part of 'app_controller_desktop.dart';
|
||||
|
||||
extension AppControllerDesktopNavigation on AppController {
|
||||
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();
|
||||
}
|
||||
}
|
||||
184
lib/app/app_controller_desktop_settings.dart
Normal file
184
lib/app/app_controller_desktop_settings.dart
Normal file
@ -0,0 +1,184 @@
|
||||
// ignore_for_file: invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member
|
||||
|
||||
part of 'app_controller_desktop.dart';
|
||||
|
||||
extension AppControllerDesktopSettings on AppController {
|
||||
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(AppController._draftGatewayTokenKey(profileIndex), value);
|
||||
}
|
||||
|
||||
void saveGatewayPasswordDraft(String value, {required int profileIndex}) {
|
||||
_saveSecretDraft(
|
||||
AppController._draftGatewayPasswordKey(profileIndex),
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
void saveAiGatewayApiKeyDraft(String value) {
|
||||
_saveSecretDraft(AppController._draftAiGatewayApiKeyKey, value);
|
||||
}
|
||||
|
||||
void saveVaultTokenDraft(String value) {
|
||||
_saveSecretDraft(AppController._draftVaultTokenKey, value);
|
||||
}
|
||||
|
||||
void saveOllamaCloudApiKeyDraft(String value) {
|
||||
_saveSecretDraft(AppController._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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
682
lib/app/app_controller_desktop_single_agent.dart
Normal file
682
lib/app/app_controller_desktop_single_agent.dart
Normal file
@ -0,0 +1,682 @@
|
||||
part of 'app_controller_desktop.dart';
|
||||
|
||||
extension AppControllerDesktopSingleAgent on AppController {
|
||||
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,
|
||||
availableProviders: configuredSingleAgentProviders,
|
||||
configuredCodexCliPath: configuredCodexCliPath,
|
||||
gatewayToken: gatewayToken,
|
||||
);
|
||||
final provider = resolution.resolvedProvider;
|
||||
if (provider == null) {
|
||||
if (singleAgentUsesAiChatFallbackForSession(sessionKey)) {
|
||||
_appendSingleAgentFallbackStatusMessage(
|
||||
sessionKey,
|
||||
resolution.fallbackReason,
|
||||
);
|
||||
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: _singleAgentRuntimeDebugToolName(
|
||||
provider?.label ?? selection.label,
|
||||
),
|
||||
stopReason: null,
|
||||
pending: false,
|
||||
error: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_appendSingleAgentRuntimeStatusMessage(sessionKey, provider);
|
||||
_singleAgentExternalCliPendingSessionKeys.add(sessionKey);
|
||||
|
||||
final result = await _singleAgentRunner.run(
|
||||
SingleAgentRunRequest(
|
||||
sessionId: sessionKey,
|
||||
provider: provider,
|
||||
prompt: message,
|
||||
model: assistantModelForSession(sessionKey),
|
||||
gatewayToken: gatewayToken,
|
||||
workingDirectory:
|
||||
_resolveLocalAssistantWorkingDirectoryForSession(sessionKey) ??
|
||||
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)) {
|
||||
_appendSingleAgentFallbackStatusMessage(
|
||||
sessionKey,
|
||||
result.fallbackReason ?? result.errorMessage,
|
||||
);
|
||||
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: _singleAgentRuntimeDebugToolName(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? _singleAgentRuntimeDebugToolName(String label) {
|
||||
if (!_showsSingleAgentRuntimeDebugMessages) {
|
||||
return null;
|
||||
}
|
||||
final trimmed = label.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
void _appendSingleAgentRuntimeStatusMessage(
|
||||
String sessionKey,
|
||||
SingleAgentProvider provider,
|
||||
) {
|
||||
if (!_showsSingleAgentRuntimeDebugMessages) {
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _appendSingleAgentFallbackStatusMessage(
|
||||
String sessionKey,
|
||||
String? reason,
|
||||
) {
|
||||
if (!_showsSingleAgentRuntimeDebugMessages) {
|
||||
return;
|
||||
}
|
||||
_appendAssistantThreadMessage(
|
||||
sessionKey,
|
||||
GatewayChatMessage(
|
||||
id: _nextLocalMessageId(),
|
||||
role: 'assistant',
|
||||
text: _singleAgentFallbackLabel(reason),
|
||||
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
toolCallId: null,
|
||||
toolName: 'AI Chat fallback',
|
||||
stopReason: null,
|
||||
pending: false,
|
||||
error: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,420 @@
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:xworkmate/app/app_controller.dart';
|
||||
import 'package:xworkmate/i18n/app_language.dart';
|
||||
import 'package:xworkmate/models/app_models.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'AppController routes LLM API destination through gateway settings and navigateHome restores assistant',
|
||||
() async {
|
||||
final harness = await _DesktopControllerHarness.create();
|
||||
addTearDown(harness.dispose);
|
||||
final controller = harness.controller;
|
||||
|
||||
controller.navigateTo(WorkspaceDestination.tasks);
|
||||
expect(controller.destination, WorkspaceDestination.tasks);
|
||||
|
||||
controller.navigateTo(WorkspaceDestination.aiGateway);
|
||||
|
||||
expect(controller.destination, WorkspaceDestination.settings);
|
||||
expect(controller.settingsTab, SettingsTab.gateway);
|
||||
|
||||
controller.navigateHome();
|
||||
await _waitFor(
|
||||
() => controller.currentSessionKey == 'main',
|
||||
timeout: const Duration(seconds: 2),
|
||||
);
|
||||
|
||||
expect(controller.destination, WorkspaceDestination.assistant);
|
||||
expect(controller.currentSessionKey, 'main');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController connectManual followed by disconnectGateway clears the active runtime connection',
|
||||
() async {
|
||||
final gateway = await _FakeGatewayServer.start();
|
||||
addTearDown(gateway.close);
|
||||
final harness = await _DesktopControllerHarness.create();
|
||||
addTearDown(harness.dispose);
|
||||
final controller = harness.controller;
|
||||
|
||||
await controller.connectManual(
|
||||
host: '127.0.0.1',
|
||||
port: gateway.port,
|
||||
tls: false,
|
||||
mode: RuntimeConnectionMode.local,
|
||||
token: _FakeGatewayServer.sharedToken,
|
||||
);
|
||||
|
||||
expect(controller.connection.status, RuntimeConnectionStatus.connected);
|
||||
expect(gateway.connectAuthToken, _FakeGatewayServer.sharedToken);
|
||||
|
||||
await controller.disconnectGateway();
|
||||
|
||||
expect(controller.connection.status, RuntimeConnectionStatus.offline);
|
||||
expect(controller.chatMessages, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController persists settings drafts before apply and promotes them only after applySettingsDraft',
|
||||
() async {
|
||||
final harness = await _DesktopControllerHarness.create();
|
||||
addTearDown(harness.dispose);
|
||||
final controller = harness.controller;
|
||||
|
||||
final nextSettings = controller.settings.copyWith(
|
||||
appLanguage: AppLanguage.en,
|
||||
);
|
||||
|
||||
await controller.saveSettingsDraft(nextSettings);
|
||||
|
||||
expect(controller.hasSettingsDraftChanges, isTrue);
|
||||
expect(controller.settings.appLanguage, AppLanguage.zh);
|
||||
|
||||
await controller.persistSettingsDraft();
|
||||
|
||||
expect(controller.hasPendingSettingsApply, isTrue);
|
||||
expect(controller.settings.appLanguage, AppLanguage.en);
|
||||
expect(controller.settingsDraft.appLanguage, AppLanguage.en);
|
||||
|
||||
await controller.applySettingsDraft();
|
||||
|
||||
expect(controller.hasPendingSettingsApply, isFalse);
|
||||
expect(controller.settings.appLanguage, AppLanguage.en);
|
||||
expect(controller.settingsDraft.appLanguage, AppLanguage.en);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController keeps AI Gateway model choices when single-agent falls back to AI chat',
|
||||
() async {
|
||||
final harness = await _DesktopControllerHarness.create(
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[],
|
||||
);
|
||||
addTearDown(harness.dispose);
|
||||
final controller = harness.controller;
|
||||
|
||||
await controller.settingsController.saveAiGatewayApiKey('live-key');
|
||||
await controller.saveSettings(
|
||||
controller.settings.copyWith(
|
||||
aiGateway: controller.settings.aiGateway.copyWith(
|
||||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||
availableModels: const <String>['qwen2.5-coder:latest'],
|
||||
selectedModels: const <String>['qwen2.5-coder:latest'],
|
||||
),
|
||||
assistantExecutionTarget: AssistantExecutionTarget.singleAgent,
|
||||
),
|
||||
);
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
|
||||
expect(controller.currentSingleAgentHasResolvedProvider, isFalse);
|
||||
expect(controller.currentSingleAgentUsesAiChatFallback, isTrue);
|
||||
expect(controller.currentSingleAgentShouldShowModelControl, isTrue);
|
||||
expect(controller.assistantModelChoices, const <String>[
|
||||
'qwen2.5-coder:latest',
|
||||
]);
|
||||
expect(controller.resolvedAssistantModel, 'qwen2.5-coder:latest');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _DesktopControllerHarness {
|
||||
_DesktopControllerHarness._(this.rootDirectory, this.store, this.controller);
|
||||
|
||||
final Directory rootDirectory;
|
||||
final SecureConfigStore store;
|
||||
final AppController controller;
|
||||
|
||||
static Future<_DesktopControllerHarness> create({
|
||||
List<SingleAgentProvider>? availableSingleAgentProvidersOverride,
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-app-controller-refactor-',
|
||||
);
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
availableSingleAgentProvidersOverride:
|
||||
availableSingleAgentProvidersOverride,
|
||||
);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
return _DesktopControllerHarness._(tempDirectory, store, controller);
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
controller.dispose();
|
||||
store.dispose();
|
||||
await _deleteDirectoryWithRetry(rootDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeGatewayServer {
|
||||
_FakeGatewayServer._(this._server);
|
||||
|
||||
static const sharedToken = 'shared-token-from-test';
|
||||
|
||||
final HttpServer _server;
|
||||
WebSocket? _socket;
|
||||
String? connectAuthToken;
|
||||
final List<Map<String, dynamic>> _history = <Map<String, dynamic>>[];
|
||||
final String _lastMessagePreview = '';
|
||||
final double _updatedAtMs = DateTime.now().millisecondsSinceEpoch.toDouble();
|
||||
|
||||
int get port => _server.port;
|
||||
|
||||
static Future<_FakeGatewayServer> start() async {
|
||||
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
final fake = _FakeGatewayServer._(server);
|
||||
unawaited(fake._serve());
|
||||
return fake;
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
await _socket?.close();
|
||||
await _server.close(force: true);
|
||||
}
|
||||
|
||||
Future<void> _serve() async {
|
||||
await for (final request in _server) {
|
||||
if (request.uri.path == '/acp/rpc' && request.method == 'POST') {
|
||||
await _serveAcpRpc(request);
|
||||
continue;
|
||||
}
|
||||
if (request.uri.path == '/acp' &&
|
||||
WebSocketTransformer.isUpgradeRequest(request)) {
|
||||
final acpSocket = await WebSocketTransformer.upgrade(request);
|
||||
await acpSocket.close(
|
||||
WebSocketStatus.normalClosure,
|
||||
'test gateway runtime only',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!WebSocketTransformer.isUpgradeRequest(request)) {
|
||||
request.response.statusCode = HttpStatus.notFound;
|
||||
await request.response.close();
|
||||
continue;
|
||||
}
|
||||
final socket = await WebSocketTransformer.upgrade(request);
|
||||
_socket = socket;
|
||||
_send(socket, <String, dynamic>{
|
||||
'type': 'event',
|
||||
'event': 'connect.challenge',
|
||||
'payload': <String, dynamic>{'nonce': 'nonce-1'},
|
||||
});
|
||||
|
||||
await for (final raw in socket) {
|
||||
final frame = jsonDecode(raw as String) as Map<String, dynamic>;
|
||||
if (frame['type'] != 'req') {
|
||||
continue;
|
||||
}
|
||||
final method = frame['method'] as String? ?? '';
|
||||
final id = frame['id'] as String? ?? 'unknown';
|
||||
final params =
|
||||
(frame['params'] as Map?)?.cast<String, dynamic>() ??
|
||||
const <String, dynamic>{};
|
||||
switch (method) {
|
||||
case 'connect':
|
||||
connectAuthToken = ((params['auth'] as Map?)?['token'] as String?)
|
||||
?.trim();
|
||||
_send(socket, <String, dynamic>{
|
||||
'type': 'res',
|
||||
'id': id,
|
||||
'ok': true,
|
||||
'payload': <String, dynamic>{
|
||||
'sessionId': 'main',
|
||||
'server': <String, dynamic>{'host': '127.0.0.1'},
|
||||
'snapshot': <String, dynamic>{
|
||||
'sessionDefaults': <String, dynamic>{
|
||||
'mainSessionKey': 'agent:main:main',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'health':
|
||||
case 'status':
|
||||
_send(socket, <String, dynamic>{
|
||||
'type': 'res',
|
||||
'id': id,
|
||||
'ok': true,
|
||||
'payload': <String, dynamic>{'ok': true},
|
||||
});
|
||||
break;
|
||||
case 'agents.list':
|
||||
_send(socket, <String, dynamic>{
|
||||
'type': 'res',
|
||||
'id': id,
|
||||
'ok': true,
|
||||
'payload': <String, dynamic>{
|
||||
'agents': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'id': 'main', 'name': 'Main'},
|
||||
],
|
||||
'mainKey': 'main',
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'sessions.list':
|
||||
_send(socket, <String, dynamic>{
|
||||
'type': 'res',
|
||||
'id': id,
|
||||
'ok': true,
|
||||
'payload': <String, dynamic>{
|
||||
'sessions': <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'key': 'agent:main:main',
|
||||
'displayName': 'main',
|
||||
'surface': 'assistant',
|
||||
'updatedAt': _updatedAtMs,
|
||||
'derivedTitle': 'main',
|
||||
'lastMessagePreview': _lastMessagePreview,
|
||||
'sessionId': 'sess-main',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'chat.history':
|
||||
_send(socket, <String, dynamic>{
|
||||
'type': 'res',
|
||||
'id': id,
|
||||
'ok': true,
|
||||
'payload': <String, dynamic>{'messages': _history},
|
||||
});
|
||||
break;
|
||||
case 'skills.status':
|
||||
_send(socket, <String, dynamic>{
|
||||
'type': 'res',
|
||||
'id': id,
|
||||
'ok': true,
|
||||
'payload': <String, dynamic>{'skills': const <Object>[]},
|
||||
});
|
||||
break;
|
||||
case 'channels.status':
|
||||
_send(socket, <String, dynamic>{
|
||||
'type': 'res',
|
||||
'id': id,
|
||||
'ok': true,
|
||||
'payload': <String, dynamic>{
|
||||
'channelMeta': const <Object>[],
|
||||
'channelLabels': const <String, dynamic>{},
|
||||
'channelDetailLabels': const <String, dynamic>{},
|
||||
'channelAccounts': const <String, dynamic>{},
|
||||
'channelOrder': const <Object>[],
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'models.list':
|
||||
_send(socket, <String, dynamic>{
|
||||
'type': 'res',
|
||||
'id': id,
|
||||
'ok': true,
|
||||
'payload': <String, dynamic>{
|
||||
'models': <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'id': 'gpt-5.4',
|
||||
'name': 'gpt-5.4',
|
||||
'provider': 'test',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'cron.list':
|
||||
_send(socket, <String, dynamic>{
|
||||
'type': 'res',
|
||||
'id': id,
|
||||
'ok': true,
|
||||
'payload': <String, dynamic>{'jobs': const <Object>[]},
|
||||
});
|
||||
break;
|
||||
case 'system-presence':
|
||||
_send(socket, <String, dynamic>{
|
||||
'type': 'res',
|
||||
'id': id,
|
||||
'ok': true,
|
||||
'payload': const <Object>[],
|
||||
});
|
||||
break;
|
||||
default:
|
||||
_send(socket, <String, dynamic>{
|
||||
'type': 'res',
|
||||
'id': id,
|
||||
'ok': true,
|
||||
'result': const <String, dynamic>{},
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _serveAcpRpc(HttpRequest request) async {
|
||||
final body = await utf8.decodeStream(request);
|
||||
final envelope = (jsonDecode(body) as Map).cast<String, dynamic>();
|
||||
final id = envelope['id'];
|
||||
final response = <String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'id': id,
|
||||
'result': <String, dynamic>{},
|
||||
};
|
||||
request.response.headers.contentType = ContentType.json;
|
||||
request.response.write(jsonEncode(response));
|
||||
await request.response.close();
|
||||
}
|
||||
|
||||
void _send(WebSocket socket, Map<String, dynamic> payload) {
|
||||
socket.add(jsonEncode(payload));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteDirectoryWithRetry(Directory directory) async {
|
||||
if (directory.path.isEmpty) {
|
||||
return;
|
||||
}
|
||||
for (var attempt = 0; attempt < 5; attempt += 1) {
|
||||
if (!await directory.exists()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await directory.delete(recursive: true);
|
||||
return;
|
||||
} on FileSystemException {
|
||||
if (attempt == 4) {
|
||||
rethrow;
|
||||
}
|
||||
await Future<void>.delayed(Duration(milliseconds: 80 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _waitFor(
|
||||
bool Function() condition, {
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (!condition()) {
|
||||
if (DateTime.now().isAfter(deadline)) {
|
||||
throw TimeoutException('condition not met within $timeout');
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user