From 5266aa8cabb959bed4e458024347f9d27e03f80c Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sun, 22 Mar 2026 11:59:54 +0800 Subject: [PATCH] Persist assistant state and add local recovery cleanup --- lib/app/app_controller_desktop.dart | 424 +++++++++++++++++- lib/features/assistant/assistant_page.dart | 275 +++++++++++- lib/features/settings/settings_page.dart | 97 ++++ lib/runtime/runtime_controllers.dart | 6 + lib/runtime/runtime_models.dart | 131 ++++++ lib/runtime/secure_config_store.dart | 221 ++++++++- test/features/assistant_page_suite.dart | 2 + test/features/settings_page_suite.dart | 38 ++ ...troller_execution_target_switch_suite.dart | 207 +++++++-- .../app_controller_thread_skills_suite.dart | 214 +++++++++ .../app_controller_thread_skills_test.dart | 7 + test/runtime/secure_config_store_suite.dart | 188 ++++++++ test/test_support.dart | 8 +- 13 files changed, 1733 insertions(+), 85 deletions(-) create mode 100644 test/runtime/app_controller_thread_skills_suite.dart create mode 100644 test/runtime/app_controller_thread_skills_test.dart diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index e019638a..b486ec45 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -31,11 +31,21 @@ import '../runtime/multi_agent_orchestrator.dart'; enum CodexCooperationState { notStarted, bridgeOnly, registered } class AppController extends ChangeNotifier { + static const List _defaultGatewayOnlySkillScanRoots = [ + '.codex/skills', + '.workbuddy/skills', + '.claude/skills', + '.gemini/skills', + '.opencode/skills', + '.openclaw/skills', + ]; + AppController({ SecureConfigStore? store, RuntimeCoordinator? runtimeCoordinator, DesktopPlatformService? desktopPlatformService, UiFeatureManifest? uiFeatureManifest, + List? gatewayOnlySkillScanRoots, }) { _store = store ?? SecureConfigStore(); _uiFeatureManifest = uiFeatureManifest ?? UiFeatureManifest.fallback(); @@ -75,6 +85,8 @@ class AppController extends ChangeNotifier { _tasksController = DerivedTasksController(); _desktopPlatformService = desktopPlatformService ?? createDesktopPlatformService(); + _gatewayOnlySkillScanRoots = + gatewayOnlySkillScanRoots ?? _defaultGatewayOnlySkillScanRoots; _arisBundleRepository = ArisBundleRepository(); _arisBridgeLocator = ArisBridgeLocator(); _multiAgentMountManager = MultiAgentMountManager( @@ -110,6 +122,7 @@ class AppController extends ChangeNotifier { late final DevicesController _devicesController; late final DerivedTasksController _tasksController; late final DesktopPlatformService _desktopPlatformService; + late final List _gatewayOnlySkillScanRoots; late final ArisBundleRepository _arisBundleRepository; late final ArisBridgeLocator _arisBridgeLocator; late final MultiAgentMountManager _multiAgentMountManager; @@ -298,7 +311,7 @@ class AppController extends ChangeNotifier { } String get resolvedAssistantModel { - return _resolvedAssistantModelForTarget(currentAssistantExecutionTarget); + return assistantModelForSession(currentSessionKey); } String _resolvedAssistantModelForTarget(AssistantExecutionTarget target) { @@ -312,6 +325,48 @@ class AppController extends ChangeNotifier { return ''; } + List assistantDiscoveredSkillsForSession( + String sessionKey, + ) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + return _assistantThreadRecords[normalizedSessionKey]?.discoveredSkills ?? + const []; + } + + List assistantImportedSkillsForSession( + String sessionKey, + ) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + return _assistantThreadRecords[normalizedSessionKey]?.importedSkills ?? + const []; + } + + List assistantSelectedSkillKeysForSession(String sessionKey) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + final importedKeys = assistantImportedSkillsForSession( + normalizedSessionKey, + ).map((item) => item.key).toSet(); + final selected = + _assistantThreadRecords[normalizedSessionKey]?.selectedSkillKeys ?? + const []; + return selected + .where((item) => importedKeys.contains(item)) + .toList(growable: false); + } + + String assistantModelForSession(String sessionKey) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + final target = assistantExecutionTargetForSession(normalizedSessionKey); + final recordModel = + _assistantThreadRecords[normalizedSessionKey]?.assistantModelId + .trim() ?? + ''; + if (recordModel.isNotEmpty) { + return recordModel; + } + return _resolvedAssistantModelForTarget(target); + } + String get assistantConversationOwnerLabel { if (!isAiGatewayOnlyMode) { return activeAgentName; @@ -546,7 +601,12 @@ class AppController extends ChangeNotifier { } List get assistantModelChoices { - if (isAiGatewayOnlyMode) { + return _assistantModelChoicesForSession(currentSessionKey); + } + + List _assistantModelChoicesForSession(String sessionKey) { + final target = assistantExecutionTargetForSession(sessionKey); + if (target == AssistantExecutionTarget.aiGatewayOnly) { return aiGatewayConversationModelChoices; } final runtimeModels = connectedGatewayModelChoices; @@ -1211,7 +1271,7 @@ class AppController extends ChangeNotifier { _preserveGatewayHistoryForSession(previousSessionKey); } - await _sessionsController.switchSession(nextSessionKey); + await _setCurrentAssistantSessionKey(nextSessionKey); _upsertAssistantThreadRecord( nextSessionKey, executionTarget: nextTarget, @@ -1223,6 +1283,9 @@ class AppController extends ChangeNotifier { sessionKey: nextSessionKey, persistDefaultSelection: false, ); + if (nextTarget == AssistantExecutionTarget.aiGatewayOnly) { + await discoverGatewayOnlySkillsForSession(nextSessionKey); + } _recomputeTasks(); } @@ -1297,6 +1360,15 @@ class AppController extends ChangeNotifier { sessionKey: _sessionsController.currentSessionKey, persistDefaultSelection: true, ); + if (resolvedTarget == AssistantExecutionTarget.aiGatewayOnly) { + await discoverGatewayOnlySkillsForSession( + _sessionsController.currentSessionKey, + ); + } else { + await dismissDiscoveredSkillsForSession( + _sessionsController.currentSessionKey, + ); + } _recomputeTasks(); _notifyIfActive(); } @@ -1342,7 +1414,7 @@ class AppController extends ChangeNotifier { normalizedSessionKey, _sessionsController.currentSessionKey, )) { - await _sessionsController.switchSession(normalizedSessionKey); + await _setCurrentAssistantSessionKey(normalizedSessionKey); } if (persistDefaultSelection && settings.assistantExecutionTarget != resolvedTarget) { @@ -1367,7 +1439,7 @@ class AppController extends ChangeNotifier { } else { _chatController.clear(); } - await _sessionsController.switchSession(normalizedSessionKey); + await _setCurrentAssistantSessionKey(normalizedSessionKey); return; } @@ -1380,7 +1452,7 @@ class AppController extends ChangeNotifier { // Keep the selected execution target even when the immediate reconnect // fails so the user can retry or adjust gateway settings manually. } - await _sessionsController.switchSession(normalizedSessionKey); + await _setCurrentAssistantSessionKey(normalizedSessionKey); await _chatController.loadSession(normalizedSessionKey); } @@ -1396,15 +1468,35 @@ class AppController extends ChangeNotifier { } Future selectAssistantModel(String modelId) async { + await selectAssistantModelForSession(currentSessionKey, modelId); + } + + Future selectAssistantModelForSession( + String sessionKey, + String modelId, + ) async { final trimmed = modelId.trim(); if (trimmed.isEmpty) { return; } - final choices = assistantModelChoices; + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + final choices = matchesSessionKey(normalizedSessionKey, currentSessionKey) + ? assistantModelChoices + : _assistantModelChoicesForSession(normalizedSessionKey); if (choices.isNotEmpty && !choices.contains(trimmed)) { return; } - await selectDefaultModel(trimmed); + if (_assistantThreadRecords[normalizedSessionKey]?.assistantModelId == + trimmed) { + return; + } + _upsertAssistantThreadRecord( + normalizedSessionKey, + assistantModelId: trimmed, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + _recomputeTasks(); + _notifyIfActive(); } String assistantCustomTaskTitle(String sessionKey) { @@ -1423,8 +1515,9 @@ class AppController extends ChangeNotifier { AssistantExecutionTarget? executionTarget, AssistantMessageViewMode? messageViewMode, }) { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); _upsertAssistantThreadRecord( - sessionKey, + normalizedSessionKey, title: title.trim(), executionTarget: executionTarget ?? @@ -1434,6 +1527,118 @@ class AppController extends ChangeNotifier { assistantMessageViewModeForSession(currentSessionKey), updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); + unawaited(_persistAssistantLastSessionKey(normalizedSessionKey)); + _notifyIfActive(); + } + + Future discoverGatewayOnlySkillsForSession(String sessionKey) async { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + if (assistantExecutionTargetForSession(normalizedSessionKey) != + AssistantExecutionTarget.aiGatewayOnly) { + _upsertAssistantThreadRecord( + normalizedSessionKey, + discoveredSkills: const [], + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + return; + } + + final discovered = await _scanGatewayOnlySkillCandidates(); + final importedKeys = assistantImportedSkillsForSession( + normalizedSessionKey, + ).map((item) => item.key).toSet(); + _upsertAssistantThreadRecord( + normalizedSessionKey, + discoveredSkills: discovered + .where((item) => !importedKeys.contains(item.key)) + .toList(growable: false), + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + _notifyIfActive(); + } + + Future confirmImportedSkillsForSession( + String sessionKey, + List skillKeys, + ) async { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + final requestedKeys = skillKeys + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) + .toSet(); + if (requestedKeys.isEmpty) { + return; + } + final discovered = assistantDiscoveredSkillsForSession( + normalizedSessionKey, + ); + final existingImported = assistantImportedSkillsForSession( + normalizedSessionKey, + ); + final importByKey = { + for (final item in existingImported) item.key: item, + for (final item in discovered) + if (requestedKeys.contains(item.key)) item.key: item, + }; + final nextImported = importByKey.values.toList(growable: false); + final nextDiscovered = discovered + .where((item) => !requestedKeys.contains(item.key)) + .toList(growable: false); + final nextSelected = { + ...assistantSelectedSkillKeysForSession(normalizedSessionKey), + ...requestedKeys.where(importByKey.containsKey), + }.toList(growable: false); + _upsertAssistantThreadRecord( + normalizedSessionKey, + discoveredSkills: nextDiscovered, + importedSkills: nextImported, + selectedSkillKeys: nextSelected, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + _notifyIfActive(); + } + + Future dismissDiscoveredSkillsForSession(String sessionKey) async { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + if (assistantDiscoveredSkillsForSession(normalizedSessionKey).isEmpty) { + return; + } + _upsertAssistantThreadRecord( + normalizedSessionKey, + discoveredSkills: const [], + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + _notifyIfActive(); + } + + Future 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.from( + assistantSelectedSkillKeysForSession(normalizedSessionKey), + ); + if (nextSelected.contains(normalizedSkillKey)) { + nextSelected.remove(normalizedSkillKey); + } else { + nextSelected.add(normalizedSkillKey); + } + _upsertAssistantThreadRecord( + normalizedSessionKey, + selectedSkillKeys: nextSelected, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); _notifyIfActive(); } @@ -1585,6 +1790,30 @@ class AppController extends ChangeNotifier { notifyListeners(); } + Future clearAssistantLocalState() async { + await _store.clearAssistantLocalState(); + final defaults = SettingsSnapshot.defaults(); + _assistantThreadRecords.clear(); + _assistantThreadMessages.clear(); + _localSessionMessages.clear(); + _gatewayHistoryCache.clear(); + _aiGatewayStreamingTextBySession.clear(); + _aiGatewayStreamingClients.clear(); + _aiGatewayPendingSessionKeys.clear(); + _aiGatewayAbortedSessionKeys.clear(); + _activeMultiAgentBrokerSessions.clear(); + _multiAgentRunPending = false; + setActiveAppLanguage(defaults.appLanguage); + await _settingsController.resetSnapshot(defaults); + _multiAgentOrchestrator.updateConfig(defaults.multiAgent); + _agentsController.restoreSelection(defaults.gateway.selectedAgentId); + _modelsController.restoreFromSettings(defaults.aiGateway); + await _setCurrentAssistantSessionKey('main', persistSelection: false); + _chatController.clear(); + _recomputeTasks(); + notifyListeners(); + } + Future refreshDesktopIntegration() async { _desktopPlatformBusy = true; notifyListeners(); @@ -1848,7 +2077,11 @@ class AppController extends ChangeNotifier { selectedAgentId: _agentsController.selectedAgentId, defaultAgentId: '', ); + await _restoreInitialAssistantSessionSelection(); await _ensureActiveAssistantThread(); + if (isAiGatewayOnlyMode) { + await discoverGatewayOnlySkillsForSession(currentSessionKey); + } _runtimeEventsSubscription = _runtimeCoordinator.gateway.events.listen( _handleRuntimeEvent, ); @@ -1934,7 +2167,21 @@ class AppController extends ChangeNotifier { lastMessagePreview: null, ), ); - await _sessionsController.switchSession(fallback.key); + await _setCurrentAssistantSessionKey(fallback.key); + } + + Future _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) { @@ -2542,9 +2789,7 @@ class AppController extends ChangeNotifier { inputTokens: null, outputTokens: null, totalTokens: null, - model: _resolvedAssistantModelForTarget( - assistantExecutionTargetForSession(normalizedSessionKey), - ), + model: assistantModelForSession(normalizedSessionKey), contextTokens: null, derivedTitle: title.isEmpty ? null : title, lastMessagePreview: preview, @@ -2565,6 +2810,81 @@ class AppController extends ChangeNotifier { return null; } + String _gatewayEntryStateForTarget(AssistantExecutionTarget target) { + return target.promptValue; + } + + Future> + _scanGatewayOnlySkillCandidates() async { + final home = Platform.environment['HOME']?.trim() ?? ''; + if (home.isEmpty && + _gatewayOnlySkillScanRoots.every((item) => !item.startsWith('/'))) { + return const []; + } + final entries = []; + final seen = {}; + for (final relativeRoot in _gatewayOnlySkillScanRoots) { + final root = Directory( + relativeRoot.startsWith('/') ? relativeRoot : '$home/$relativeRoot', + ); + 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 directory = entity.parent.path; + final normalizedKey = directory.trim(); + if (normalizedKey.isEmpty || !seen.add(normalizedKey)) { + continue; + } + entries.add(await _skillEntryFromFile(entity, root.path)); + } + } + entries.sort((left, right) => left.label.compareTo(right.label)); + return entries; + } + + Future _skillEntryFromFile( + File file, + 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; + return AssistantThreadSkillEntry( + key: directory.path, + label: label, + description: (descriptionMatch?.group(1) ?? '').trim(), + sourcePath: directory.path, + sourceLabel: relativeSource.isEmpty + ? directory.path.split('/').where((item) => item.isNotEmpty).last + : relativeSource, + ); + } + void _restoreAssistantThreads(List records) { _assistantThreadRecords.clear(); _assistantThreadMessages.clear(); @@ -2586,6 +2906,21 @@ class AppController extends ChangeNotifier { 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(), + gatewayEntryState: (record.gatewayEntryState ?? '').trim().isEmpty + ? _gatewayEntryStateForTarget( + record.executionTarget ?? settings.assistantExecutionTarget, + ) + : record.gatewayEntryState, ); _assistantThreadRecords[sessionKey] = normalizedRecord; if (normalizedRecord.messages.isNotEmpty) { @@ -2604,9 +2939,27 @@ class AppController extends ChangeNotifier { bool? archived, AssistantExecutionTarget? executionTarget, AssistantMessageViewMode? messageViewMode, + List? discoveredSkills, + List? importedSkills, + List? selectedSkillKeys, + String? assistantModelId, + String? gatewayEntryState, }) { final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); final existing = _assistantThreadRecords[normalizedSessionKey]; + final nextExecutionTarget = + executionTarget ?? + existing?.executionTarget ?? + settings.assistantExecutionTarget; + final nextImportedSkills = + importedSkills ?? + existing?.importedSkills ?? + const []; + final importedKeys = nextImportedSkills.map((item) => item.key).toSet(); + final nextSelectedSkillKeys = + (selectedSkillKeys ?? existing?.selectedSkillKeys ?? const []) + .where(importedKeys.contains) + .toList(growable: false); final nextMessages = messages ?? existing?.messages ?? @@ -2624,14 +2977,25 @@ class AppController extends ChangeNotifier { archived ?? existing?.archived ?? isAssistantTaskArchived(normalizedSessionKey), - executionTarget: - executionTarget ?? - existing?.executionTarget ?? - settings.assistantExecutionTarget, + executionTarget: nextExecutionTarget, messageViewMode: messageViewMode ?? existing?.messageViewMode ?? AssistantMessageViewMode.rendered, + discoveredSkills: + discoveredSkills ?? + existing?.discoveredSkills ?? + const [], + importedSkills: nextImportedSkills, + selectedSkillKeys: nextSelectedSkillKeys, + assistantModelId: + assistantModelId ?? + existing?.assistantModelId ?? + _resolvedAssistantModelForTarget(nextExecutionTarget), + gatewayEntryState: + gatewayEntryState ?? + existing?.gatewayEntryState ?? + _gatewayEntryStateForTarget(nextExecutionTarget), ); _assistantThreadRecords[normalizedSessionKey] = nextRecord; if (messages != null) { @@ -2645,6 +3009,32 @@ class AppController extends ChangeNotifier { ); } + Future _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 _persistAssistantLastSessionKey(String sessionKey) async { + final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); + if (normalizedSessionKey.isEmpty || + settings.assistantLastSessionKey == normalizedSessionKey) { + return; + } + await saveSettings( + settings.copyWith(assistantLastSessionKey: normalizedSessionKey), + refreshAfterSave: false, + ); + } + void _setAiGatewayStreamingText(String sessionKey, String text) { final key = _normalizedAssistantSessionKey(sessionKey); if (text.trim().isEmpty) { diff --git a/lib/features/assistant/assistant_page.dart b/lib/features/assistant/assistant_page.dart index ba0aed57..56967bb6 100644 --- a/lib/features/assistant/assistant_page.dart +++ b/lib/features/assistant/assistant_page.dart @@ -64,7 +64,6 @@ class _AssistantPageState extends State { {}; final Set _archivedTaskKeys = {}; List<_ComposerAttachment> _attachments = const <_ComposerAttachment>[]; - List _selectedSkillKeys = const []; String? _lastSubmittedPrompt; String? _lastSubmittedSessionKey; String? _lastAutoAgentLabel; @@ -395,7 +394,8 @@ class _AssistantPageState extends State { modelOptions: controller.assistantModelChoices, attachments: _attachments, availableSkills: _availableSkillOptions(controller), - selectedSkillKeys: _selectedSkillKeys, + discoveredSkills: _discoveredSkillOptions(controller), + selectedSkillKeys: _selectedSkillKeysFor(controller), controller: controller, onRemoveAttachment: (attachment) { setState(() { @@ -404,11 +404,36 @@ class _AssistantPageState extends State { .toList(growable: false); }); }, - onToggleSkill: _toggleSelectedSkill, + onToggleSkill: (key) { + unawaited( + controller.toggleAssistantSkillForSession( + controller.currentSessionKey, + key, + ), + ); + _focusComposer(); + }, + onConfirmImportedSkills: (skillKeys) { + unawaited( + controller.confirmImportedSkillsForSession( + controller.currentSessionKey, + skillKeys, + ), + ); + }, + onDismissDiscoveredSkills: () { + return controller.dismissDiscoveredSkillsForSession( + controller.currentSessionKey, + ); + }, onThinkingChanged: (value) { setState(() => _thinkingLabel = value); }, - onModelChanged: controller.selectAssistantModel, + onModelChanged: (modelId) => + controller.selectAssistantModelForSession( + controller.currentSessionKey, + modelId, + ), onOpenGateway: _showConnectDialog, onOpenAiGatewaySettings: _openAiGatewaySettings, onReconnectGateway: _connectFromSavedSettingsOrShowDialog, @@ -727,6 +752,12 @@ class _AssistantPageState extends State { } List<_ComposerSkillOption> _availableSkillOptions(AppController controller) { + if (controller.isAiGatewayOnlyMode) { + return controller + .assistantImportedSkillsForSession(controller.currentSessionKey) + .map(_skillOptionFromThreadSkill) + .toList(growable: false); + } final options = <_ComposerSkillOption>[]; final seenKeys = {}; @@ -748,30 +779,30 @@ class _AssistantPageState extends State { return options; } + List<_ComposerSkillOption> _discoveredSkillOptions(AppController controller) { + return controller + .assistantDiscoveredSkillsForSession(controller.currentSessionKey) + .map(_skillOptionFromThreadSkill) + .toList(growable: false); + } + + List _selectedSkillKeysFor(AppController controller) { + return controller.assistantSelectedSkillKeysForSession( + controller.currentSessionKey, + ); + } + List _resolveSelectedSkillLabels(AppController controller) { final optionsByKey = { for (final option in _availableSkillOptions(controller)) option.key: option, }; - return _selectedSkillKeys + return _selectedSkillKeysFor(controller) .map((key) => optionsByKey[key]?.label) .whereType() .toList(growable: false); } - void _toggleSelectedSkill(String key) { - setState(() { - final selected = List.from(_selectedSkillKeys); - if (selected.contains(key)) { - selected.remove(key); - } else { - selected.add(key); - } - _selectedSkillKeys = selected; - }); - _focusComposer(); - } - String _composePrompt({ required String mode, required String prompt, @@ -859,7 +890,6 @@ class _AssistantPageState extends State { executionTarget: inheritedTarget, draft: true, ); - _selectedSkillKeys = const []; }); widget.controller.initializeAssistantThreadContext( sessionKey, @@ -1514,9 +1544,12 @@ class _AssistantLowerPane extends StatelessWidget { required this.modelOptions, required this.attachments, required this.availableSkills, + required this.discoveredSkills, required this.selectedSkillKeys, required this.onRemoveAttachment, required this.onToggleSkill, + required this.onConfirmImportedSkills, + required this.onDismissDiscoveredSkills, required this.onThinkingChanged, required this.onModelChanged, required this.onOpenGateway, @@ -1534,9 +1567,12 @@ class _AssistantLowerPane extends StatelessWidget { final List modelOptions; final List<_ComposerAttachment> attachments; final List<_ComposerSkillOption> availableSkills; + final List<_ComposerSkillOption> discoveredSkills; final List selectedSkillKeys; final ValueChanged<_ComposerAttachment> onRemoveAttachment; final ValueChanged onToggleSkill; + final ValueChanged> onConfirmImportedSkills; + final Future Function() onDismissDiscoveredSkills; final ValueChanged onThinkingChanged; final Future Function(String modelId) onModelChanged; final VoidCallback onOpenGateway; @@ -1560,9 +1596,12 @@ class _AssistantLowerPane extends StatelessWidget { modelOptions: modelOptions, attachments: attachments, availableSkills: availableSkills, + discoveredSkills: discoveredSkills, selectedSkillKeys: selectedSkillKeys, onRemoveAttachment: onRemoveAttachment, onToggleSkill: onToggleSkill, + onConfirmImportedSkills: onConfirmImportedSkills, + onDismissDiscoveredSkills: onDismissDiscoveredSkills, onThinkingChanged: onThinkingChanged, onModelChanged: onModelChanged, onOpenGateway: onOpenGateway, @@ -2332,9 +2371,12 @@ class _ComposerBar extends StatefulWidget { required this.modelOptions, required this.attachments, required this.availableSkills, + required this.discoveredSkills, required this.selectedSkillKeys, required this.onRemoveAttachment, required this.onToggleSkill, + required this.onConfirmImportedSkills, + required this.onDismissDiscoveredSkills, required this.onThinkingChanged, required this.onModelChanged, required this.onOpenGateway, @@ -2352,9 +2394,12 @@ class _ComposerBar extends StatefulWidget { final List modelOptions; final List<_ComposerAttachment> attachments; final List<_ComposerSkillOption> availableSkills; + final List<_ComposerSkillOption> discoveredSkills; final List selectedSkillKeys; final ValueChanged<_ComposerAttachment> onRemoveAttachment; final ValueChanged onToggleSkill; + final ValueChanged> onConfirmImportedSkills; + final Future Function() onDismissDiscoveredSkills; final ValueChanged onThinkingChanged; final Future Function(String modelId) onModelChanged; final VoidCallback onOpenGateway; @@ -2413,6 +2458,7 @@ class _ComposerBarState extends State<_ComposerBar> { final selectedSkills = widget.availableSkills .where((skill) => widget.selectedSkillKeys.contains(skill.key)) .toList(growable: false); + final discoveredCount = widget.discoveredSkills.length; final submitLabel = connected ? appText('提交', 'Submit') : aiGatewayOnly @@ -2634,6 +2680,23 @@ class _ComposerBarState extends State<_ComposerBar> { child: Row( mainAxisSize: MainAxisSize.min, children: [ + if (aiGatewayOnly && discoveredCount > 0) ...[ + InkWell( + key: const Key('assistant-discovered-skills-button'), + borderRadius: BorderRadius.circular(AppRadius.chip), + onTap: () => _showDiscoveredSkillsDialog(context), + child: _ComposerToolbarChip( + icon: Icons.download_done_rounded, + label: appText( + '候选技能 $discoveredCount', + 'Candidates $discoveredCount', + ), + showChevron: true, + maxLabelWidth: 148, + ), + ), + const SizedBox(width: 6), + ], InkWell( key: const Key('assistant-skill-picker-button'), borderRadius: BorderRadius.circular(AppRadius.chip), @@ -2905,6 +2968,164 @@ class _ComposerBarState extends State<_ComposerBar> { ); searchController.dispose(); } + + Future _showDiscoveredSkillsDialog(BuildContext context) async { + final searchController = TextEditingController(); + final selectedKeys = {}; + String query = ''; + await showDialog( + context: context, + builder: (dialogContext) { + return StatefulBuilder( + builder: (context, setDialogState) { + final filteredSkills = widget.discoveredSkills + .where((skill) { + if (query.trim().isEmpty) { + return true; + } + final haystack = + '${skill.label}\n${skill.description}\n${skill.sourceLabel}' + .toLowerCase(); + return haystack.contains(query.trim().toLowerCase()); + }) + .toList(growable: false); + return Dialog( + key: const Key('assistant-discovered-skills-dialog'), + insetPadding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 32, + ), + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 620, + maxHeight: 560, + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText('确认导入技能', 'Confirm Skill Import'), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + TextField( + key: const Key('assistant-discovered-skills-search'), + controller: searchController, + autofocus: true, + onChanged: (value) { + setDialogState(() { + query = value; + }); + }, + decoration: InputDecoration( + hintText: appText( + '搜索候选技能', + 'Search discovered skills', + ), + prefixIcon: const Icon(Icons.search_rounded), + ), + ), + const SizedBox(height: 12), + Expanded( + child: filteredSkills.isEmpty + ? Center( + child: Text( + appText( + '没有匹配的候选技能。', + 'No matching discovered skills.', + ), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: context.palette.textSecondary, + ), + ), + ) + : ListView.separated( + itemCount: filteredSkills.length, + separatorBuilder: (_, _) => + const SizedBox(height: 8), + itemBuilder: (context, index) { + final skill = filteredSkills[index]; + final selected = selectedKeys.contains( + skill.key, + ); + return CheckboxListTile( + key: ValueKey( + 'assistant-discovered-skill-${skill.key}', + ), + value: selected, + controlAffinity: + ListTileControlAffinity.leading, + title: Text(skill.label), + subtitle: Text( + skill.description.trim().isEmpty + ? skill.sourceLabel + : '${skill.description}\n${skill.sourceLabel}', + ), + onChanged: (_) { + setDialogState(() { + if (selected) { + selectedKeys.remove(skill.key); + } else { + selectedKeys.add(skill.key); + } + }); + }, + ); + }, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + key: const Key( + 'assistant-discovered-skills-dismiss', + ), + onPressed: () async { + await widget.onDismissDiscoveredSkills(); + if (dialogContext.mounted) { + Navigator.of(dialogContext).pop(); + } + }, + child: Text(appText('忽略本次', 'Dismiss')), + ), + const SizedBox(width: 8), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(appText('取消', 'Cancel')), + ), + const SizedBox(width: 8), + FilledButton( + key: const Key( + 'assistant-discovered-skills-confirm', + ), + onPressed: selectedKeys.isEmpty + ? null + : () { + widget.onConfirmImportedSkills( + selectedKeys.toList(growable: false), + ); + Navigator.of(dialogContext).pop(); + }, + child: Text(appText('导入所选', 'Import Selected')), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ); + }, + ); + searchController.dispose(); + } } class _ComposerIconButton extends StatefulWidget { @@ -4148,6 +4369,22 @@ _ComposerSkillOption _skillOptionFromGateway(GatewaySkillSummary skill) { ); } +_ComposerSkillOption _skillOptionFromThreadSkill( + AssistantThreadSkillEntry skill, +) { + return _ComposerSkillOption( + key: skill.key, + label: skill.label.trim().isEmpty ? skill.key : skill.label.trim(), + description: skill.description.trim().isEmpty + ? appText('已导入到当前线程的技能。', 'Skill imported into this thread.') + : skill.description.trim(), + sourceLabel: skill.sourceLabel.trim().isEmpty + ? skill.sourcePath + : skill.sourceLabel.trim(), + icon: Icons.auto_awesome_rounded, + ); +} + class _ComposerSkillOption { const _ComposerSkillOption({ required this.key, diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart index 7b7c9dd4..a4094451 100644 --- a/lib/features/settings/settings_page.dart +++ b/lib/features/settings/settings_page.dart @@ -1418,6 +1418,40 @@ class _SettingsPageState extends State { ), ), const SizedBox(height: 16), + SurfaceCard( + key: const ValueKey('assistant-local-state-card'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText('本地数据清理', 'Local Data Cleanup'), + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + appText( + '删除本机保存的 Assistant 任务线程会话、本地设置快照和恢复备份,不会删除已保存密钥,也不会触碰外部 Codex 全局目录。', + 'Deletes locally saved Assistant threads, settings snapshots, and recovery backups. Stored secrets and the external Codex home stay untouched.', + ), + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Align( + alignment: Alignment.centerLeft, + child: FilledButton.tonalIcon( + key: const ValueKey('assistant-local-state-clear-button'), + onPressed: () => + _showClearAssistantLocalStateDialog(context, controller), + icon: const Icon(Icons.delete_forever_rounded), + label: Text( + appText('清理任务线程与本地配置', 'Clear threads and local config'), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), SurfaceCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -2949,6 +2983,69 @@ class _SettingsPageState extends State { ); } + Future _showClearAssistantLocalStateDialog( + BuildContext context, + AppController controller, + ) { + var confirmed = false; + return showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: Text(appText('清理本地数据', 'Clear Local Data')), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + appText( + '该操作会删除本机保存的 Assistant 任务线程会话、本地设置快照和恢复备份,且无法撤销。', + 'This deletes locally stored Assistant threads, settings snapshots, and recovery backups. This cannot be undone.', + ), + ), + const SizedBox(height: 12), + CheckboxListTile( + key: const ValueKey('assistant-local-state-clear-confirm'), + contentPadding: EdgeInsets.zero, + value: confirmed, + onChanged: (value) { + setDialogState(() { + confirmed = value ?? false; + }); + }, + title: Text( + appText( + '我确认删除本机任务线程会话和本地配置', + 'I confirm deleting local threads and settings', + ), + ), + controlAffinity: ListTileControlAffinity.leading, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: Text(appText('取消', 'Cancel')), + ), + FilledButton( + onPressed: !confirmed + ? null + : () async { + await controller.clearAssistantLocalState(); + if (!dialogContext.mounted) { + return; + } + Navigator.of(dialogContext).pop(); + }, + child: Text(appText('确认清理', 'Confirm Clear')), + ), + ], + ), + ), + ); + } + Future _showRotatedTokenDialog( BuildContext context, { required GatewayPairedDevice device, diff --git a/lib/runtime/runtime_controllers.dart b/lib/runtime/runtime_controllers.dart index 1807b606..ed4160b2 100644 --- a/lib/runtime/runtime_controllers.dart +++ b/lib/runtime/runtime_controllers.dart @@ -45,6 +45,12 @@ class SettingsController extends ChangeNotifier { notifyListeners(); } + Future resetSnapshot(SettingsSnapshot snapshot) async { + _snapshot = snapshot; + await _reloadDerivedState(); + notifyListeners(); + } + Future saveGatewaySecrets({ required String token, required String password, diff --git a/lib/runtime/runtime_models.dart b/lib/runtime/runtime_models.dart index 4f02a876..8245b94b 100644 --- a/lib/runtime/runtime_models.dart +++ b/lib/runtime/runtime_models.dart @@ -992,6 +992,7 @@ class SettingsSnapshot { required this.assistantNavigationDestinations, required this.assistantCustomTaskTitles, required this.assistantArchivedTaskKeys, + required this.assistantLastSessionKey, }); final AppLanguage appLanguage; @@ -1025,6 +1026,7 @@ class SettingsSnapshot { final List assistantNavigationDestinations; final Map assistantCustomTaskTitles; final List assistantArchivedTaskKeys; + final String assistantLastSessionKey; factory SettingsSnapshot.defaults() { return SettingsSnapshot( @@ -1059,6 +1061,7 @@ class SettingsSnapshot { assistantNavigationDestinations: kAssistantNavigationDestinationDefaults, assistantCustomTaskTitles: const {}, assistantArchivedTaskKeys: const [], + assistantLastSessionKey: '', ); } @@ -1094,6 +1097,7 @@ class SettingsSnapshot { List? assistantNavigationDestinations, Map? assistantCustomTaskTitles, List? assistantArchivedTaskKeys, + String? assistantLastSessionKey, }) { return SettingsSnapshot( appLanguage: appLanguage ?? this.appLanguage, @@ -1134,6 +1138,8 @@ class SettingsSnapshot { assistantCustomTaskTitles ?? this.assistantCustomTaskTitles, assistantArchivedTaskKeys: assistantArchivedTaskKeys ?? this.assistantArchivedTaskKeys, + assistantLastSessionKey: + assistantLastSessionKey ?? this.assistantLastSessionKey, ); } @@ -1172,6 +1178,7 @@ class SettingsSnapshot { .toList(growable: false), 'assistantCustomTaskTitles': assistantCustomTaskTitles, 'assistantArchivedTaskKeys': assistantArchivedTaskKeys, + 'assistantLastSessionKey': assistantLastSessionKey, }; } @@ -1299,6 +1306,7 @@ class SettingsSnapshot { assistantArchivedTaskKeys: normalizeTaskKeys( json['assistantArchivedTaskKeys'], ), + assistantLastSessionKey: json['assistantLastSessionKey'] as String? ?? '', ); } @@ -1686,6 +1694,58 @@ class GatewayChatMessage { } } +class AssistantThreadSkillEntry { + const AssistantThreadSkillEntry({ + required this.key, + required this.label, + required this.description, + required this.sourcePath, + required this.sourceLabel, + }); + + final String key; + final String label; + final String description; + final String sourcePath; + final String sourceLabel; + + AssistantThreadSkillEntry copyWith({ + String? key, + String? label, + String? description, + String? sourcePath, + String? sourceLabel, + }) { + return AssistantThreadSkillEntry( + key: key ?? this.key, + label: label ?? this.label, + description: description ?? this.description, + sourcePath: sourcePath ?? this.sourcePath, + sourceLabel: sourceLabel ?? this.sourceLabel, + ); + } + + Map toJson() { + return { + 'key': key, + 'label': label, + 'description': description, + 'sourcePath': sourcePath, + 'sourceLabel': sourceLabel, + }; + } + + factory AssistantThreadSkillEntry.fromJson(Map json) { + return AssistantThreadSkillEntry( + key: json['key']?.toString() ?? '', + label: json['label']?.toString() ?? '', + description: json['description']?.toString() ?? '', + sourcePath: json['sourcePath']?.toString() ?? '', + sourceLabel: json['sourceLabel']?.toString() ?? '', + ); + } +} + class AssistantThreadRecord { const AssistantThreadRecord({ required this.sessionKey, @@ -1695,6 +1755,11 @@ class AssistantThreadRecord { required this.archived, required this.executionTarget, required this.messageViewMode, + this.discoveredSkills = const [], + this.importedSkills = const [], + this.selectedSkillKeys = const [], + this.assistantModelId = '', + this.gatewayEntryState, }); final String sessionKey; @@ -1704,6 +1769,11 @@ class AssistantThreadRecord { final bool archived; final AssistantExecutionTarget? executionTarget; final AssistantMessageViewMode messageViewMode; + final List discoveredSkills; + final List importedSkills; + final List selectedSkillKeys; + final String assistantModelId; + final String? gatewayEntryState; AssistantThreadRecord copyWith({ String? sessionKey, @@ -1714,6 +1784,12 @@ class AssistantThreadRecord { AssistantExecutionTarget? executionTarget, bool clearExecutionTarget = false, AssistantMessageViewMode? messageViewMode, + List? discoveredSkills, + List? importedSkills, + List? selectedSkillKeys, + String? assistantModelId, + String? gatewayEntryState, + bool clearGatewayEntryState = false, }) { return AssistantThreadRecord( sessionKey: sessionKey ?? this.sessionKey, @@ -1725,6 +1801,13 @@ class AssistantThreadRecord { ? null : (executionTarget ?? this.executionTarget), messageViewMode: messageViewMode ?? this.messageViewMode, + discoveredSkills: discoveredSkills ?? this.discoveredSkills, + importedSkills: importedSkills ?? this.importedSkills, + selectedSkillKeys: selectedSkillKeys ?? this.selectedSkillKeys, + assistantModelId: assistantModelId ?? this.assistantModelId, + gatewayEntryState: clearGatewayEntryState + ? null + : (gatewayEntryState ?? this.gatewayEntryState), ); } @@ -1737,6 +1820,15 @@ class AssistantThreadRecord { 'archived': archived, 'executionTarget': executionTarget?.name, 'messageViewMode': messageViewMode.name, + 'discoveredSkills': discoveredSkills + .map((item) => item.toJson()) + .toList(growable: false), + 'importedSkills': importedSkills + .map((item) => item.toJson()) + .toList(growable: false), + 'selectedSkillKeys': selectedSkillKeys, + 'assistantModelId': assistantModelId, + 'gatewayEntryState': gatewayEntryState, }; } @@ -1758,6 +1850,40 @@ class AssistantThreadRecord { ) .toList(growable: false) : const []; + List normalizeSkillEntries(Object? value) { + if (value is! List) { + return const []; + } + final entries = []; + final seen = {}; + for (final item in value.whereType()) { + final entry = AssistantThreadSkillEntry.fromJson( + item.cast(), + ); + final normalizedKey = entry.key.trim(); + if (normalizedKey.isEmpty || !seen.add(normalizedKey)) { + continue; + } + entries.add(entry); + } + return entries; + } + + List normalizeSkillKeys(Object? value) { + if (value is! List) { + return const []; + } + final keys = []; + final seen = {}; + for (final item in value) { + final normalized = item?.toString().trim() ?? ''; + if (normalized.isEmpty || !seen.add(normalized)) { + continue; + } + keys.add(normalized); + } + return keys; + } return AssistantThreadRecord( sessionKey: json['sessionKey']?.toString() ?? '', @@ -1773,6 +1899,11 @@ class AssistantThreadRecord { messageViewMode: AssistantMessageViewModeCopy.fromJsonValue( json['messageViewMode']?.toString(), ), + discoveredSkills: normalizeSkillEntries(json['discoveredSkills']), + importedSkills: normalizeSkillEntries(json['importedSkills']), + selectedSkillKeys: normalizeSkillKeys(json['selectedSkillKeys']), + assistantModelId: json['assistantModelId']?.toString() ?? '', + gatewayEntryState: json['gatewayEntryState']?.toString(), ); } } diff --git a/lib/runtime/secure_config_store.dart b/lib/runtime/secure_config_store.dart index f2957633..ccb8af75 100644 --- a/lib/runtime/secure_config_store.dart +++ b/lib/runtime/secure_config_store.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import '../app/app_metadata.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -22,6 +23,8 @@ class SecureConfigStore { static const _assistantThreadsKey = 'xworkmate.assistant.threads'; static const _databaseFileName = 'config-store.sqlite3'; static const _databaseTableName = 'config_entries'; + static const _stateBackupFileName = 'assistant-state-backup.json'; + static const _backupSchemaVersion = 1; static const _secureStorageTimeout = Duration(milliseconds: 400); static const _gatewayTokenKey = 'xworkmate.gateway.token'; @@ -68,34 +71,20 @@ class SecureConfigStore { Future loadSettingsSnapshot() async { await initialize(); - return SettingsSnapshot.fromJsonString( - await _readStoredString(_settingsKey), - ); + final state = await _loadAssistantStateFromPrimaryOrBackup(); + return state?.settings ?? SettingsSnapshot.defaults(); } Future saveSettingsSnapshot(SettingsSnapshot snapshot) async { await initialize(); await _writeStoredString(_settingsKey, snapshot.toJsonString()); + await _persistAssistantStateBackup(settings: snapshot); } Future> loadAssistantThreadRecords() async { await initialize(); - final raw = await _readStoredString(_assistantThreadsKey); - if (raw == null || raw.trim().isEmpty) { - return const []; - } - try { - final decoded = jsonDecode(raw) as List; - return decoded - .whereType() - .map( - (item) => - AssistantThreadRecord.fromJson(item.cast()), - ) - .toList(growable: false); - } catch (_) { - return const []; - } + final state = await _loadAssistantStateFromPrimaryOrBackup(); + return state?.assistantThreads ?? const []; } Future saveAssistantThreadRecords( @@ -106,6 +95,14 @@ class SecureConfigStore { _assistantThreadsKey, jsonEncode(records.map((item) => item.toJson()).toList(growable: false)), ); + await _persistAssistantStateBackup(assistantThreads: records); + } + + Future clearAssistantLocalState() async { + await initialize(); + await _deleteStoredString(_settingsKey); + await _deleteStoredString(_assistantThreadsKey); + await _deleteAssistantStateBackup(); } Future> loadAuditTrail() async { @@ -326,6 +323,7 @@ class SecureConfigStore { } await _migrateLegacyPrefEntry(_settingsKey); await _migrateLegacyPrefEntry(_auditKey); + await _migrateLegacyPrefEntry(_assistantThreadsKey); } Future _migrateLegacyPrefEntry(String key) async { @@ -393,6 +391,25 @@ class SecureConfigStore { return _memoryStore[key]; } + Future _deleteStoredString(String key) async { + if (_database != null) { + try { + _database!.execute( + 'DELETE FROM $_databaseTableName WHERE storage_key = ?', + [key], + ); + } catch (_) { + // Fall through to in-memory cleanup. + } + } + _memoryStore.remove(key); + try { + await _prefs?.remove(key); + } catch (_) { + // Ignore preference cleanup failures. + } + } + Future _writeStoredString(String key, String value) async { if (_database != null) { try { @@ -405,6 +422,162 @@ class SecureConfigStore { _memoryStore[key] = value; } + Future<_AssistantStateSnapshot?> + _loadAssistantStateFromPrimaryOrBackup() async { + final rawSettings = await _readStoredString(_settingsKey); + final rawThreads = await _readStoredString(_assistantThreadsKey); + final decodedSettings = _decodeSettingsSnapshot(rawSettings); + final decodedThreads = _decodeAssistantThreadRecords(rawThreads); + final primaryHasSettings = rawSettings != null; + final primaryHasThreads = rawThreads != null; + final primaryValid = + decodedSettings != null && + decodedThreads != null && + primaryHasSettings && + primaryHasThreads; + if (primaryValid) { + return _AssistantStateSnapshot( + settings: decodedSettings, + assistantThreads: decodedThreads, + ); + } + final backup = await _readAssistantStateBackup(); + if (backup == null) { + return _AssistantStateSnapshot( + settings: decodedSettings ?? SettingsSnapshot.defaults(), + assistantThreads: decodedThreads ?? const [], + ); + } + await _writeStoredString(_settingsKey, backup.settings.toJsonString()); + await _writeStoredString( + _assistantThreadsKey, + jsonEncode( + backup.assistantThreads + .map((item) => item.toJson()) + .toList(growable: false), + ), + ); + return backup; + } + + SettingsSnapshot? _decodeSettingsSnapshot(String? raw) { + if (raw == null || raw.trim().isEmpty) { + return null; + } + try { + final decoded = jsonDecode(raw) as Map; + return SettingsSnapshot.fromJson(decoded); + } catch (_) { + return null; + } + } + + List? _decodeAssistantThreadRecords(String? raw) { + if (raw == null || raw.trim().isEmpty) { + return null; + } + try { + final decoded = jsonDecode(raw) as List; + return decoded + .whereType() + .map( + (item) => + AssistantThreadRecord.fromJson(item.cast()), + ) + .toList(growable: false); + } catch (_) { + return null; + } + } + + Future _persistAssistantStateBackup({ + SettingsSnapshot? settings, + List? assistantThreads, + }) async { + final resolvedSettings = settings ?? await loadSettingsSnapshot(); + final resolvedThreads = + assistantThreads ?? await loadAssistantThreadRecords(); + final payload = _AssistantStateSnapshot( + settings: resolvedSettings, + assistantThreads: resolvedThreads, + ); + try { + final file = await _assistantStateBackupFile(); + if (file == null) { + return; + } + await file.writeAsString( + jsonEncode({ + 'schemaVersion': _backupSchemaVersion, + 'appVersion': kAppVersion, + 'backupCreatedAtMs': DateTime.now().millisecondsSinceEpoch, + 'settings': payload.settings.toJson(), + 'assistantThreads': payload.assistantThreads + .map((item) => item.toJson()) + .toList(growable: false), + }), + flush: true, + ); + } catch (_) { + return; + } + } + + Future<_AssistantStateSnapshot?> _readAssistantStateBackup() async { + try { + final file = await _assistantStateBackupFile(); + if (file == null || !await file.exists()) { + return null; + } + final decoded = + jsonDecode(await file.readAsString()) as Map; + final settings = SettingsSnapshot.fromJson( + (decoded['settings'] as Map?)?.cast() ?? const {}, + ); + final threads = ((decoded['assistantThreads'] as List?) ?? const []) + .whereType() + .map( + (item) => + AssistantThreadRecord.fromJson(item.cast()), + ) + .toList(growable: false); + return _AssistantStateSnapshot( + settings: settings, + assistantThreads: threads, + ); + } catch (_) { + return null; + } + } + + Future _assistantStateBackupFile() async { + try { + final resolvedPath = await _resolveDatabasePath(); + if (resolvedPath == null || resolvedPath.trim().isEmpty) { + return null; + } + final directory = File(resolvedPath).parent; + if (!await directory.exists()) { + await directory.create(recursive: true); + } + return File('${directory.path}/$_stateBackupFileName'); + } catch (_) { + return null; + } + } + + Future _deleteAssistantStateBackup() async { + try { + final file = await _assistantStateBackupFile(); + if (file == null || !await file.exists()) { + return; + } + await file.delete(); + } catch (_) { + return; + } + } + void _writeStoredStringInternal(String key, String value) { if (_database == null) { _memoryStore[key] = value; @@ -638,3 +811,13 @@ class SecureConfigStore { } } } + +class _AssistantStateSnapshot { + const _AssistantStateSnapshot({ + required this.settings, + required this.assistantThreads, + }); + + final SettingsSnapshot settings; + final List assistantThreads; +} diff --git a/test/features/assistant_page_suite.dart b/test/features/assistant_page_suite.dart index 37869469..19efee22 100644 --- a/test/features/assistant_page_suite.dart +++ b/test/features/assistant_page_suite.dart @@ -665,6 +665,7 @@ void main() { Future _createControllerWithThreadRecords({ required List records, bool useFakeGatewayRuntime = false, + List? gatewayOnlySkillScanRoots, }) async { SharedPreferences.setMockInitialValues({}); final tempDirectory = await Directory.systemTemp.createTemp( @@ -695,6 +696,7 @@ Future _createControllerWithThreadRecords({ codex: _FakeCodexRuntime(), ) : null, + gatewayOnlySkillScanRoots: gatewayOnlySkillScanRoots, ); final deadline = DateTime.now().add(const Duration(seconds: 5)); while (controller.initializing) { diff --git a/test/features/settings_page_suite.dart b/test/features/settings_page_suite.dart index 8890e63d..54704f80 100644 --- a/test/features/settings_page_suite.dart +++ b/test/features/settings_page_suite.dart @@ -236,6 +236,44 @@ void main() { expect(find.text('实验特性'), findsNothing); }); + testWidgets( + 'SettingsPage clears local assistant state with double confirmation', + (WidgetTester tester) async { + final controller = await createTestController(tester); + + await pumpPage(tester, child: SettingsPage(controller: controller)); + + await tester.tap(find.text('诊断')); + await tester.pump(const Duration(milliseconds: 300)); + + expect( + find.byKey(const ValueKey('assistant-local-state-card')), + findsOneWidget, + ); + + await tester.tap( + find.byKey(const ValueKey('assistant-local-state-clear-button')), + ); + await tester.pump(const Duration(milliseconds: 300)); + + final confirmButtonFinder = find.widgetWithText(FilledButton, '确认清理'); + final confirmButtonBefore = tester.widget( + confirmButtonFinder, + ); + expect(confirmButtonBefore.onPressed, isNull); + + await tester.tap( + find.byKey(const ValueKey('assistant-local-state-clear-confirm')), + ); + await tester.pump(const Duration(milliseconds: 300)); + + final confirmButtonAfter = tester.widget( + confirmButtonFinder, + ); + expect(confirmButtonAfter.onPressed, isNotNull); + }, + ); + testWidgets('SettingsPage detail mode returns to overview', ( WidgetTester tester, ) async { diff --git a/test/runtime/app_controller_execution_target_switch_suite.dart b/test/runtime/app_controller_execution_target_switch_suite.dart index 68987565..7374e0d2 100644 --- a/test/runtime/app_controller_execution_target_switch_suite.dart +++ b/test/runtime/app_controller_execution_target_switch_suite.dart @@ -109,6 +109,23 @@ class _FakeCodexRuntime extends CodexRuntime { Future stop() async {} } +Future _deleteDirectoryWithRetry(Directory directory) async { + if (!await directory.exists()) { + return; + } + for (var attempt = 0; attempt < 3; attempt += 1) { + try { + await directory.delete(recursive: true); + return; + } on FileSystemException { + if (attempt == 2) { + rethrow; + } + await Future.delayed(const Duration(milliseconds: 100)); + } + } +} + void main() { test( 'AppController switches gateway connection when assistant execution target changes', @@ -118,9 +135,7 @@ void main() { 'xworkmate-execution-target-switch-', ); addTearDown(() async { - if (await tempDirectory.exists()) { - await tempDirectory.delete(recursive: true); - } + await _deleteDirectoryWithRetry(tempDirectory); }); final store = SecureConfigStore( enableSecureStorage: false, @@ -265,9 +280,7 @@ void main() { 'xworkmate-thread-mode-switch-', ); addTearDown(() async { - if (await tempDirectory.exists()) { - await tempDirectory.delete(recursive: true); - } + await _deleteDirectoryWithRetry(tempDirectory); }); final store = SecureConfigStore( enableSecureStorage: false, @@ -340,21 +353,150 @@ void main() { }, ); + test('AppController persists markdown view mode per thread', () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-thread-view-mode-', + ); + addTearDown(() async { + await _deleteDirectoryWithRetry(tempDirectory); + }); + final store = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => '${tempDirectory.path}/settings.db', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + final controller = AppController( + store: store, + runtimeCoordinator: RuntimeCoordinator( + gateway: _FakeGatewayRuntime(store: store), + codex: _FakeCodexRuntime(), + ), + ); + addTearDown(controller.dispose); + + await _waitFor(() => !controller.initializing); + + controller.initializeAssistantThreadContext( + 'main', + messageViewMode: AssistantMessageViewMode.raw, + ); + controller.initializeAssistantThreadContext( + 'draft:secondary', + messageViewMode: AssistantMessageViewMode.rendered, + ); + + await controller.switchSession('main'); + expect( + controller.currentAssistantMessageViewMode, + AssistantMessageViewMode.raw, + ); + + await controller.switchSession('draft:secondary'); + expect( + controller.currentAssistantMessageViewMode, + AssistantMessageViewMode.rendered, + ); + + await controller.setAssistantMessageViewMode(AssistantMessageViewMode.raw); + expect( + controller.currentAssistantMessageViewMode, + AssistantMessageViewMode.raw, + ); + + final reloaded = await store.loadAssistantThreadRecords(); + final secondary = reloaded.firstWhere( + (item) => item.sessionKey == 'draft:secondary', + ); + expect(secondary.messageViewMode, AssistantMessageViewMode.raw); + }); + test( - 'AppController persists markdown view mode per thread', + 'AppController restores the last active assistant thread across restart', () async { SharedPreferences.setMockInitialValues({}); final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-thread-view-mode-', + 'xworkmate-thread-restart-', ); addTearDown(() async { - if (await tempDirectory.exists()) { - await tempDirectory.delete(recursive: true); - } + await _deleteDirectoryWithRetry(tempDirectory); }); + final databasePath = '${tempDirectory.path}/settings.db'; + final firstStore = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => databasePath, + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + final firstController = AppController( + store: firstStore, + runtimeCoordinator: RuntimeCoordinator( + gateway: _FakeGatewayRuntime(store: firstStore), + codex: _FakeCodexRuntime(), + ), + ); + addTearDown(firstController.dispose); + + await _waitFor(() => !firstController.initializing); + firstController.initializeAssistantThreadContext( + 'draft:alpha', + title: 'Alpha', + executionTarget: AssistantExecutionTarget.aiGatewayOnly, + ); + firstController.initializeAssistantThreadContext( + 'draft:beta', + title: 'Beta', + executionTarget: AssistantExecutionTarget.local, + ); + await firstController.saveAssistantTaskTitle('draft:beta', 'Beta Task'); + await firstController.saveAssistantTaskArchived('draft:alpha', true); + await firstController.switchSession('draft:beta'); + + await _waitFor( + () => firstController.settings.assistantLastSessionKey == 'draft:beta', + ); + + firstController.dispose(); + + final secondStore = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => databasePath, + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + final secondController = AppController( + store: secondStore, + runtimeCoordinator: RuntimeCoordinator( + gateway: _FakeGatewayRuntime(store: secondStore), + codex: _FakeCodexRuntime(), + ), + ); + addTearDown(secondController.dispose); + + await _waitFor(() => !secondController.initializing); + + expect(secondController.currentSessionKey, 'draft:beta'); + expect(secondController.settings.assistantLastSessionKey, 'draft:beta'); + expect( + secondController.assistantCustomTaskTitle('draft:beta'), + 'Beta Task', + ); + expect(secondController.isAssistantTaskArchived('draft:alpha'), isTrue); + }, + ); + + test( + 'AppController clears local assistant state and resets persisted defaults', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-thread-clear-local-', + ); + addTearDown(() async { + await _deleteDirectoryWithRetry(tempDirectory); + }); + final databasePath = '${tempDirectory.path}/settings.db'; final store = SecureConfigStore( enableSecureStorage: false, - databasePathResolver: () async => '${tempDirectory.path}/settings.db', + databasePathResolver: () async => databasePath, fallbackDirectoryPathResolver: () async => tempDirectory.path, ); final controller = AppController( @@ -367,31 +509,40 @@ void main() { addTearDown(controller.dispose); await _waitFor(() => !controller.initializing); - - controller.initializeAssistantThreadContext( - 'main', - messageViewMode: AssistantMessageViewMode.raw, + await controller.saveSettings( + controller.settings.copyWith(accountUsername: 'local-user'), + refreshAfterSave: false, ); controller.initializeAssistantThreadContext( - 'draft:secondary', - messageViewMode: AssistantMessageViewMode.rendered, + 'draft:clear-me', + title: 'Clear Me', ); + await controller.switchSession('draft:clear-me'); - await controller.switchSession('main'); - expect(controller.currentAssistantMessageViewMode, AssistantMessageViewMode.raw); + await controller.clearAssistantLocalState(); - await controller.switchSession('draft:secondary'); + expect(controller.currentSessionKey, 'main'); expect( - controller.currentAssistantMessageViewMode, - AssistantMessageViewMode.rendered, + controller.settings.accountUsername, + SettingsSnapshot.defaults().accountUsername, ); + expect(controller.settings.assistantLastSessionKey, isEmpty); + expect(controller.assistantCustomTaskTitle('draft:clear-me'), isEmpty); - await controller.setAssistantMessageViewMode(AssistantMessageViewMode.raw); - expect(controller.currentAssistantMessageViewMode, AssistantMessageViewMode.raw); + final reloadedStore = SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => databasePath, + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + final reloadedSnapshot = await reloadedStore.loadSettingsSnapshot(); + final reloadedThreads = await reloadedStore.loadAssistantThreadRecords(); - final reloaded = await store.loadAssistantThreadRecords(); - final secondary = reloaded.firstWhere((item) => item.sessionKey == 'draft:secondary'); - expect(secondary.messageViewMode, AssistantMessageViewMode.raw); + expect( + reloadedSnapshot.accountUsername, + SettingsSnapshot.defaults().accountUsername, + ); + expect(reloadedSnapshot.assistantLastSessionKey, isEmpty); + expect(reloadedThreads, isEmpty); }, ); } diff --git a/test/runtime/app_controller_thread_skills_suite.dart b/test/runtime/app_controller_thread_skills_suite.dart new file mode 100644 index 00000000..188f2631 --- /dev/null +++ b/test/runtime/app_controller_thread_skills_suite.dart @@ -0,0 +1,214 @@ +@TestOn('vm') +library; + +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/runtime/runtime_models.dart'; +import 'package:xworkmate/runtime/secure_config_store.dart'; + +void main() { + test( + 'AppController keeps gateway-only discovered skills as candidates until confirmed', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-thread-skills-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + try { + await tempDirectory.delete(recursive: true); + } catch (_) {} + } + }); + final codexRoot = Directory('${tempDirectory.path}/codex-skills'); + final workbuddyRoot = Directory('${tempDirectory.path}/workbuddy-skills'); + await _writeSkill( + codexRoot, + 'idea-discovery', + skillName: 'Idea Discovery', + description: 'Discover ideas', + ); + await _writeSkill( + workbuddyRoot, + 'release-checks', + skillName: 'Release Checks', + description: 'Run release checks', + ); + + final controller = AppController( + store: SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => + '${tempDirectory.path}/settings.sqlite3', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ), + gatewayOnlySkillScanRoots: [ + codexRoot.path, + codexRoot.path, + workbuddyRoot.path, + ], + ); + addTearDown(controller.dispose); + await _waitFor(() => !controller.initializing); + + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.aiGatewayOnly, + ); + + final discoveredBefore = controller.assistantDiscoveredSkillsForSession( + controller.currentSessionKey, + ); + expect(discoveredBefore, hasLength(2)); + expect( + controller.assistantImportedSkillsForSession( + controller.currentSessionKey, + ), + isEmpty, + ); + + await controller.confirmImportedSkillsForSession( + controller.currentSessionKey, + discoveredBefore.map((item) => item.key).toList(growable: false), + ); + + expect( + controller.assistantDiscoveredSkillsForSession( + controller.currentSessionKey, + ), + isEmpty, + ); + expect( + controller.assistantImportedSkillsForSession( + controller.currentSessionKey, + ), + hasLength(2), + ); + expect( + controller.assistantSelectedSkillKeysForSession( + controller.currentSessionKey, + ), + hasLength(2), + ); + }, + ); + + test( + 'AppController keeps imported skills and model choices isolated per thread', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-thread-isolation-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + try { + await tempDirectory.delete(recursive: true); + } catch (_) {} + } + }); + final codexRoot = Directory('${tempDirectory.path}/codex-skills'); + await _writeSkill( + codexRoot, + 'analysis', + skillName: 'Analysis', + description: 'Analyze tasks', + ); + + final controller = AppController( + store: SecureConfigStore( + enableSecureStorage: false, + databasePathResolver: () async => + '${tempDirectory.path}/settings.sqlite3', + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ), + gatewayOnlySkillScanRoots: [codexRoot.path], + ); + addTearDown(controller.dispose); + await _waitFor(() => !controller.initializing); + + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.aiGatewayOnly, + ); + final firstSessionKey = controller.currentSessionKey; + final discovered = controller.assistantDiscoveredSkillsForSession( + firstSessionKey, + ); + await controller.confirmImportedSkillsForSession( + firstSessionKey, + [discovered.single.key], + ); + await controller.selectAssistantModelForSession( + firstSessionKey, + 'model-a', + ); + + controller.initializeAssistantThreadContext( + 'draft:thread-2', + title: 'Thread 2', + executionTarget: AssistantExecutionTarget.aiGatewayOnly, + messageViewMode: AssistantMessageViewMode.rendered, + ); + await controller.switchSession('draft:thread-2'); + await controller.selectAssistantModelForSession( + controller.currentSessionKey, + 'model-b', + ); + + expect( + controller.assistantImportedSkillsForSession( + controller.currentSessionKey, + ), + isEmpty, + ); + expect( + controller.assistantSelectedSkillKeysForSession( + controller.currentSessionKey, + ), + isEmpty, + ); + expect( + controller.assistantModelForSession(controller.currentSessionKey), + 'model-b', + ); + + await controller.switchSession(firstSessionKey); + + expect( + controller.assistantImportedSkillsForSession(firstSessionKey), + hasLength(1), + ); + expect( + controller.assistantSelectedSkillKeysForSession(firstSessionKey), + hasLength(1), + ); + expect(controller.assistantModelForSession(firstSessionKey), 'model-a'); + }, + ); +} + +Future _writeSkill( + Directory root, + String folderName, { + required String description, + required String skillName, +}) async { + final directory = Directory('${root.path}/$folderName'); + await directory.create(recursive: true); + await File( + '${directory.path}/SKILL.md', + ).writeAsString('---\nname: $skillName\ndescription: $description\n---\n'); +} + +Future _waitFor(bool Function() predicate) async { + final deadline = DateTime.now().add(const Duration(seconds: 5)); + while (!predicate()) { + if (DateTime.now().isAfter(deadline)) { + fail('Timed out waiting for condition'); + } + await Future.delayed(const Duration(milliseconds: 20)); + } +} diff --git a/test/runtime/app_controller_thread_skills_test.dart b/test/runtime/app_controller_thread_skills_test.dart new file mode 100644 index 00000000..8af82858 --- /dev/null +++ b/test/runtime/app_controller_thread_skills_test.dart @@ -0,0 +1,7 @@ +import '../test_suite_stub.dart' + if (dart.library.io) 'app_controller_thread_skills_suite.dart' + as suite; + +void main() { + suite.main(); +} diff --git a/test/runtime/secure_config_store_suite.dart b/test/runtime/secure_config_store_suite.dart index 6c5d5397..fbaa7757 100644 --- a/test/runtime/secure_config_store_suite.dart +++ b/test/runtime/secure_config_store_suite.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqlite3/sqlite3.dart' as sqlite; import 'package:xworkmate/models/app_models.dart'; import 'package:xworkmate/runtime/secure_config_store.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; @@ -231,6 +232,7 @@ void main() { final snapshot = SettingsSnapshot.defaults().copyWith( assistantArchivedTaskKeys: const ['main'], assistantCustomTaskTitles: const {'main': '研发任务'}, + assistantLastSessionKey: 'main', ); const records = [ AssistantThreadRecord( @@ -239,6 +241,27 @@ void main() { archived: true, executionTarget: AssistantExecutionTarget.remote, messageViewMode: AssistantMessageViewMode.raw, + discoveredSkills: [ + AssistantThreadSkillEntry( + key: '/tmp/discovered-skill', + label: 'Discovered Skill', + description: 'candidate only', + sourcePath: '/tmp/discovered-skill', + sourceLabel: 'codex/discovered', + ), + ], + importedSkills: [ + AssistantThreadSkillEntry( + key: '/tmp/imported-skill', + label: 'Imported Skill', + description: 'confirmed import', + sourcePath: '/tmp/imported-skill', + sourceLabel: 'workbuddy/imported', + ), + ], + selectedSkillKeys: ['/tmp/imported-skill'], + assistantModelId: 'gpt-5.4-mini', + gatewayEntryState: 'ai-gateway-only', updatedAtMs: 1700000000000, messages: [ GatewayChatMessage( @@ -276,6 +299,7 @@ void main() { expect(reloadedSnapshot.assistantArchivedTaskKeys, const [ 'main', ]); + expect(reloadedSnapshot.assistantLastSessionKey, 'main'); expect(reloadedSnapshot.assistantCustomTaskTitles['main'], '研发任务'); expect(reloadedRecords, hasLength(1)); expect(reloadedRecords.first.sessionKey, 'main'); @@ -289,11 +313,175 @@ void main() { reloadedRecords.first.messageViewMode, AssistantMessageViewMode.raw, ); + expect(reloadedRecords.first.discoveredSkills, hasLength(1)); + expect(reloadedRecords.first.importedSkills, hasLength(1)); + expect(reloadedRecords.first.selectedSkillKeys, const [ + '/tmp/imported-skill', + ]); + expect(reloadedRecords.first.assistantModelId, 'gpt-5.4-mini'); + expect(reloadedRecords.first.gatewayEntryState, 'ai-gateway-only'); expect(reloadedRecords.first.messages, hasLength(2)); expect(reloadedRecords.first.messages.last.text, '第一条回复'); }, ); + test('SettingsSnapshot encodes and decodes assistantLastSessionKey', () { + final snapshot = SettingsSnapshot.defaults().copyWith( + assistantLastSessionKey: 'draft:session-1', + ); + + final decoded = SettingsSnapshot.fromJsonString(snapshot.toJsonString()); + + expect(decoded.assistantLastSessionKey, 'draft:session-1'); + }); + + test( + 'AssistantThreadRecord keeps compatibility with legacy json payloads', + () { + final decoded = AssistantThreadRecord.fromJson({ + 'sessionKey': 'legacy-thread', + 'messages': const [], + 'updatedAtMs': 1700000000000, + 'title': 'Legacy', + 'archived': false, + 'executionTarget': 'local', + 'messageViewMode': 'rendered', + }); + + expect(decoded.discoveredSkills, isEmpty); + expect(decoded.importedSkills, isEmpty); + expect(decoded.selectedSkillKeys, isEmpty); + expect(decoded.assistantModelId, isEmpty); + expect(decoded.gatewayEntryState, isNull); + }, + ); + + test( + 'SecureConfigStore restores assistant state from backup when primary storage is missing', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-config-store-backup-restore-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + final databasePath = '${tempDirectory.path}/settings.sqlite3'; + final store = SecureConfigStore( + databasePathResolver: () async => databasePath, + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + final snapshot = SettingsSnapshot.defaults().copyWith( + accountUsername: 'backup-user', + assistantLastSessionKey: 'draft:backup-1', + ); + const records = [ + AssistantThreadRecord( + sessionKey: 'draft:backup-1', + title: '备份线程', + archived: false, + executionTarget: AssistantExecutionTarget.aiGatewayOnly, + messageViewMode: AssistantMessageViewMode.rendered, + updatedAtMs: 1700000000000, + messages: [ + GatewayChatMessage( + id: 'assistant-1', + role: 'assistant', + text: 'backup message', + timestampMs: 1700000001000, + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + ], + ), + ]; + + await store.saveSettingsSnapshot(snapshot); + await store.saveAssistantThreadRecords(records); + + final database = sqlite.sqlite3.open(databasePath); + addTearDown(database.dispose); + database.execute('DELETE FROM config_entries'); + + final recoveredStore = SecureConfigStore( + databasePathResolver: () async => databasePath, + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + final recoveredSnapshot = await recoveredStore.loadSettingsSnapshot(); + final recoveredRecords = await recoveredStore + .loadAssistantThreadRecords(); + + expect(recoveredSnapshot.accountUsername, 'backup-user'); + expect(recoveredSnapshot.assistantLastSessionKey, 'draft:backup-1'); + expect(recoveredRecords, hasLength(1)); + expect(recoveredRecords.first.sessionKey, 'draft:backup-1'); + expect(recoveredRecords.first.messages.single.text, 'backup message'); + }, + ); + + test( + 'SecureConfigStore clears assistant local state without deleting secure refs', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-config-store-clear-local-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + final databasePath = '${tempDirectory.path}/settings.sqlite3'; + final store = SecureConfigStore( + databasePathResolver: () async => databasePath, + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + final snapshot = SettingsSnapshot.defaults().copyWith( + accountUsername: 'clear-me', + assistantLastSessionKey: 'draft:clear-1', + ); + const records = [ + AssistantThreadRecord( + sessionKey: 'draft:clear-1', + title: '清理线程', + archived: false, + executionTarget: AssistantExecutionTarget.local, + messageViewMode: AssistantMessageViewMode.rendered, + updatedAtMs: 1700000000000, + messages: [], + ), + ]; + + await store.saveSettingsSnapshot(snapshot); + await store.saveAssistantThreadRecords(records); + await store.saveGatewayToken('token-secret'); + + await store.clearAssistantLocalState(); + + final clearedSnapshot = await store.loadSettingsSnapshot(); + final clearedRecords = await store.loadAssistantThreadRecords(); + + expect( + clearedSnapshot.accountUsername, + SettingsSnapshot.defaults().accountUsername, + ); + expect(clearedSnapshot.assistantLastSessionKey, isEmpty); + expect(clearedRecords, isEmpty); + expect(await store.loadGatewayToken(), 'token-secret'); + expect( + await File( + '${tempDirectory.path}/assistant-state-backup.json', + ).exists(), + isFalse, + ); + }, + ); + test( 'SecureConfigStore dispose closes sqlite handle and allows reopening the same database path', () async { diff --git a/test/test_support.dart b/test/test_support.dart index 6ab85312..e0679428 100644 --- a/test/test_support.dart +++ b/test/test_support.dart @@ -14,16 +14,20 @@ Future createTestController( WidgetTester tester, { DesktopPlatformService? desktopPlatformService, UiFeatureManifest? uiFeatureManifest, + List? gatewayOnlySkillScanRoots, }) async { SharedPreferences.setMockInitialValues({}); + final testRoot = + '${Directory.systemTemp.path}/xworkmate-widget-tests-${DateTime.now().microsecondsSinceEpoch}'; final controller = AppController( store: SecureConfigStore( enableSecureStorage: false, - fallbackDirectoryPathResolver: () async => - '${Directory.systemTemp.path}/xworkmate-widget-tests', + databasePathResolver: () async => '$testRoot/settings.sqlite3', + fallbackDirectoryPathResolver: () async => testRoot, ), desktopPlatformService: desktopPlatformService, uiFeatureManifest: uiFeatureManifest, + gatewayOnlySkillScanRoots: gatewayOnlySkillScanRoots, ); addTearDown(controller.dispose); await tester.pump(const Duration(milliseconds: 100));