Persist assistant state and add local recovery cleanup

This commit is contained in:
Haitao Pan 2026-03-22 11:59:54 +08:00
parent 25e6d8cb3c
commit 5266aa8cab
13 changed files with 1733 additions and 85 deletions

View File

@ -31,11 +31,21 @@ import '../runtime/multi_agent_orchestrator.dart';
enum CodexCooperationState { notStarted, bridgeOnly, registered }
class AppController extends ChangeNotifier {
static const List<String> _defaultGatewayOnlySkillScanRoots = <String>[
'.codex/skills',
'.workbuddy/skills',
'.claude/skills',
'.gemini/skills',
'.opencode/skills',
'.openclaw/skills',
];
AppController({
SecureConfigStore? store,
RuntimeCoordinator? runtimeCoordinator,
DesktopPlatformService? desktopPlatformService,
UiFeatureManifest? uiFeatureManifest,
List<String>? 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<String> _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<AssistantThreadSkillEntry> assistantDiscoveredSkillsForSession(
String sessionKey,
) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
return _assistantThreadRecords[normalizedSessionKey]?.discoveredSkills ??
const <AssistantThreadSkillEntry>[];
}
List<AssistantThreadSkillEntry> assistantImportedSkillsForSession(
String sessionKey,
) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
return _assistantThreadRecords[normalizedSessionKey]?.importedSkills ??
const <AssistantThreadSkillEntry>[];
}
List<String> assistantSelectedSkillKeysForSession(String sessionKey) {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
final importedKeys = assistantImportedSkillsForSession(
normalizedSessionKey,
).map((item) => item.key).toSet();
final selected =
_assistantThreadRecords[normalizedSessionKey]?.selectedSkillKeys ??
const <String>[];
return selected
.where((item) => importedKeys.contains(item))
.toList(growable: false);
}
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<String> get assistantModelChoices {
if (isAiGatewayOnlyMode) {
return _assistantModelChoicesForSession(currentSessionKey);
}
List<String> _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<void> selectAssistantModel(String modelId) async {
await selectAssistantModelForSession(currentSessionKey, modelId);
}
Future<void> selectAssistantModelForSession(
String sessionKey,
String modelId,
) async {
final trimmed = modelId.trim();
if (trimmed.isEmpty) {
return;
}
final 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<void> discoverGatewayOnlySkillsForSession(String sessionKey) async {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
if (assistantExecutionTargetForSession(normalizedSessionKey) !=
AssistantExecutionTarget.aiGatewayOnly) {
_upsertAssistantThreadRecord(
normalizedSessionKey,
discoveredSkills: const <AssistantThreadSkillEntry>[],
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<void> confirmImportedSkillsForSession(
String sessionKey,
List<String> 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 = <String, AssistantThreadSkillEntry>{
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 = <String>{
...assistantSelectedSkillKeysForSession(normalizedSessionKey),
...requestedKeys.where(importByKey.containsKey),
}.toList(growable: false);
_upsertAssistantThreadRecord(
normalizedSessionKey,
discoveredSkills: nextDiscovered,
importedSkills: nextImported,
selectedSkillKeys: nextSelected,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
_notifyIfActive();
}
Future<void> dismissDiscoveredSkillsForSession(String sessionKey) async {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
if (assistantDiscoveredSkillsForSession(normalizedSessionKey).isEmpty) {
return;
}
_upsertAssistantThreadRecord(
normalizedSessionKey,
discoveredSkills: const <AssistantThreadSkillEntry>[],
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
_notifyIfActive();
}
Future<void> toggleAssistantSkillForSession(
String sessionKey,
String skillKey,
) async {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
final normalizedSkillKey = skillKey.trim();
if (normalizedSkillKey.isEmpty) {
return;
}
final importedKeys = assistantImportedSkillsForSession(
normalizedSessionKey,
).map((item) => item.key).toSet();
if (!importedKeys.contains(normalizedSkillKey)) {
return;
}
final nextSelected = List<String>.from(
assistantSelectedSkillKeysForSession(normalizedSessionKey),
);
if (nextSelected.contains(normalizedSkillKey)) {
nextSelected.remove(normalizedSkillKey);
} else {
nextSelected.add(normalizedSkillKey);
}
_upsertAssistantThreadRecord(
normalizedSessionKey,
selectedSkillKeys: nextSelected,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
_notifyIfActive();
}
@ -1585,6 +1790,30 @@ class AppController extends ChangeNotifier {
notifyListeners();
}
Future<void> 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<void> 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<void> _restoreInitialAssistantSessionSelection() async {
final normalized = _normalizedAssistantSessionKey(
settings.assistantLastSessionKey,
);
final known =
normalized == 'main' ||
_assistantThreadRecords.containsKey(normalized) ||
_assistantThreadMessages.containsKey(normalized);
if (normalized.isEmpty || !known || isAssistantTaskArchived(normalized)) {
return;
}
await _setCurrentAssistantSessionKey(normalized, persistSelection: false);
}
void _handleRuntimeEvent(GatewayPushEvent event) {
@ -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<List<AssistantThreadSkillEntry>>
_scanGatewayOnlySkillCandidates() async {
final home = Platform.environment['HOME']?.trim() ?? '';
if (home.isEmpty &&
_gatewayOnlySkillScanRoots.every((item) => !item.startsWith('/'))) {
return const <AssistantThreadSkillEntry>[];
}
final entries = <AssistantThreadSkillEntry>[];
final seen = <String>{};
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<AssistantThreadSkillEntry> _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<AssistantThreadRecord> 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<AssistantThreadSkillEntry>? discoveredSkills,
List<AssistantThreadSkillEntry>? importedSkills,
List<String>? 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 <AssistantThreadSkillEntry>[];
final importedKeys = nextImportedSkills.map((item) => item.key).toSet();
final nextSelectedSkillKeys =
(selectedSkillKeys ?? existing?.selectedSkillKeys ?? const <String>[])
.where(importedKeys.contains)
.toList(growable: false);
final nextMessages =
messages ??
existing?.messages ??
@ -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 <AssistantThreadSkillEntry>[],
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<void> _setCurrentAssistantSessionKey(
String sessionKey, {
bool persistSelection = true,
}) async {
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
if (normalizedSessionKey.isEmpty) {
return;
}
await _sessionsController.switchSession(normalizedSessionKey);
if (persistSelection) {
await _persistAssistantLastSessionKey(normalizedSessionKey);
}
}
Future<void> _persistAssistantLastSessionKey(String sessionKey) async {
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) {

View File

@ -64,7 +64,6 @@ class _AssistantPageState extends State<AssistantPage> {
<String, _AssistantTaskSeed>{};
final Set<String> _archivedTaskKeys = <String>{};
List<_ComposerAttachment> _attachments = const <_ComposerAttachment>[];
List<String> _selectedSkillKeys = const <String>[];
String? _lastSubmittedPrompt;
String? _lastSubmittedSessionKey;
String? _lastAutoAgentLabel;
@ -395,7 +394,8 @@ class _AssistantPageState extends State<AssistantPage> {
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<AssistantPage> {
.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<AssistantPage> {
}
List<_ComposerSkillOption> _availableSkillOptions(AppController controller) {
if (controller.isAiGatewayOnlyMode) {
return controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.map(_skillOptionFromThreadSkill)
.toList(growable: false);
}
final options = <_ComposerSkillOption>[];
final seenKeys = <String>{};
@ -748,30 +779,30 @@ class _AssistantPageState extends State<AssistantPage> {
return options;
}
List<_ComposerSkillOption> _discoveredSkillOptions(AppController controller) {
return controller
.assistantDiscoveredSkillsForSession(controller.currentSessionKey)
.map(_skillOptionFromThreadSkill)
.toList(growable: false);
}
List<String> _selectedSkillKeysFor(AppController controller) {
return controller.assistantSelectedSkillKeysForSession(
controller.currentSessionKey,
);
}
List<String> _resolveSelectedSkillLabels(AppController controller) {
final optionsByKey = <String, _ComposerSkillOption>{
for (final option in _availableSkillOptions(controller))
option.key: option,
};
return _selectedSkillKeys
return _selectedSkillKeysFor(controller)
.map((key) => optionsByKey[key]?.label)
.whereType<String>()
.toList(growable: false);
}
void _toggleSelectedSkill(String key) {
setState(() {
final selected = List<String>.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<AssistantPage> {
executionTarget: inheritedTarget,
draft: true,
);
_selectedSkillKeys = const <String>[];
});
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<String> modelOptions;
final List<_ComposerAttachment> attachments;
final List<_ComposerSkillOption> availableSkills;
final List<_ComposerSkillOption> discoveredSkills;
final List<String> selectedSkillKeys;
final ValueChanged<_ComposerAttachment> onRemoveAttachment;
final ValueChanged<String> onToggleSkill;
final ValueChanged<List<String>> onConfirmImportedSkills;
final Future<void> Function() onDismissDiscoveredSkills;
final ValueChanged<String> onThinkingChanged;
final Future<void> 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<String> modelOptions;
final List<_ComposerAttachment> attachments;
final List<_ComposerSkillOption> availableSkills;
final List<_ComposerSkillOption> discoveredSkills;
final List<String> selectedSkillKeys;
final ValueChanged<_ComposerAttachment> onRemoveAttachment;
final ValueChanged<String> onToggleSkill;
final ValueChanged<List<String>> onConfirmImportedSkills;
final Future<void> Function() onDismissDiscoveredSkills;
final ValueChanged<String> onThinkingChanged;
final Future<void> 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<void> _showDiscoveredSkillsDialog(BuildContext context) async {
final searchController = TextEditingController();
final selectedKeys = <String>{};
String query = '';
await showDialog<void>(
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<String>(
'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,

View File

@ -1418,6 +1418,40 @@ class _SettingsPageState extends State<SettingsPage> {
),
),
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<SettingsPage> {
);
}
Future<void> _showClearAssistantLocalStateDialog(
BuildContext context,
AppController controller,
) {
var confirmed = false;
return showDialog<void>(
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<void> _showRotatedTokenDialog(
BuildContext context, {
required GatewayPairedDevice device,

View File

@ -45,6 +45,12 @@ class SettingsController extends ChangeNotifier {
notifyListeners();
}
Future<void> resetSnapshot(SettingsSnapshot snapshot) async {
_snapshot = snapshot;
await _reloadDerivedState();
notifyListeners();
}
Future<void> saveGatewaySecrets({
required String token,
required String password,

View File

@ -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<WorkspaceDestination> assistantNavigationDestinations;
final Map<String, String> assistantCustomTaskTitles;
final List<String> assistantArchivedTaskKeys;
final String assistantLastSessionKey;
factory SettingsSnapshot.defaults() {
return SettingsSnapshot(
@ -1059,6 +1061,7 @@ class SettingsSnapshot {
assistantNavigationDestinations: kAssistantNavigationDestinationDefaults,
assistantCustomTaskTitles: const <String, String>{},
assistantArchivedTaskKeys: const <String>[],
assistantLastSessionKey: '',
);
}
@ -1094,6 +1097,7 @@ class SettingsSnapshot {
List<WorkspaceDestination>? assistantNavigationDestinations,
Map<String, String>? assistantCustomTaskTitles,
List<String>? 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<String, dynamic> toJson() {
return {
'key': key,
'label': label,
'description': description,
'sourcePath': sourcePath,
'sourceLabel': sourceLabel,
};
}
factory AssistantThreadSkillEntry.fromJson(Map<String, dynamic> 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 <AssistantThreadSkillEntry>[],
this.importedSkills = const <AssistantThreadSkillEntry>[],
this.selectedSkillKeys = const <String>[],
this.assistantModelId = '',
this.gatewayEntryState,
});
final String sessionKey;
@ -1704,6 +1769,11 @@ class AssistantThreadRecord {
final bool archived;
final AssistantExecutionTarget? executionTarget;
final AssistantMessageViewMode messageViewMode;
final List<AssistantThreadSkillEntry> discoveredSkills;
final List<AssistantThreadSkillEntry> importedSkills;
final List<String> 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<AssistantThreadSkillEntry>? discoveredSkills,
List<AssistantThreadSkillEntry>? importedSkills,
List<String>? 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 <GatewayChatMessage>[];
List<AssistantThreadSkillEntry> normalizeSkillEntries(Object? value) {
if (value is! List) {
return const <AssistantThreadSkillEntry>[];
}
final entries = <AssistantThreadSkillEntry>[];
final seen = <String>{};
for (final item in value.whereType<Map>()) {
final entry = AssistantThreadSkillEntry.fromJson(
item.cast<String, dynamic>(),
);
final normalizedKey = entry.key.trim();
if (normalizedKey.isEmpty || !seen.add(normalizedKey)) {
continue;
}
entries.add(entry);
}
return entries;
}
List<String> normalizeSkillKeys(Object? value) {
if (value is! List) {
return const <String>[];
}
final keys = <String>[];
final seen = <String>{};
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(),
);
}
}

View File

@ -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<SettingsSnapshot> loadSettingsSnapshot() async {
await initialize();
return SettingsSnapshot.fromJsonString(
await _readStoredString(_settingsKey),
);
final state = await _loadAssistantStateFromPrimaryOrBackup();
return state?.settings ?? SettingsSnapshot.defaults();
}
Future<void> saveSettingsSnapshot(SettingsSnapshot snapshot) async {
await initialize();
await _writeStoredString(_settingsKey, snapshot.toJsonString());
await _persistAssistantStateBackup(settings: snapshot);
}
Future<List<AssistantThreadRecord>> loadAssistantThreadRecords() async {
await initialize();
final raw = await _readStoredString(_assistantThreadsKey);
if (raw == null || raw.trim().isEmpty) {
return const <AssistantThreadRecord>[];
}
try {
final decoded = jsonDecode(raw) as List<dynamic>;
return decoded
.whereType<Map>()
.map(
(item) =>
AssistantThreadRecord.fromJson(item.cast<String, dynamic>()),
)
.toList(growable: false);
} catch (_) {
return const <AssistantThreadRecord>[];
}
final state = await _loadAssistantStateFromPrimaryOrBackup();
return state?.assistantThreads ?? const <AssistantThreadRecord>[];
}
Future<void> saveAssistantThreadRecords(
@ -106,6 +95,14 @@ class SecureConfigStore {
_assistantThreadsKey,
jsonEncode(records.map((item) => item.toJson()).toList(growable: false)),
);
await _persistAssistantStateBackup(assistantThreads: records);
}
Future<void> clearAssistantLocalState() async {
await initialize();
await _deleteStoredString(_settingsKey);
await _deleteStoredString(_assistantThreadsKey);
await _deleteAssistantStateBackup();
}
Future<List<SecretAuditEntry>> loadAuditTrail() async {
@ -326,6 +323,7 @@ class SecureConfigStore {
}
await _migrateLegacyPrefEntry(_settingsKey);
await _migrateLegacyPrefEntry(_auditKey);
await _migrateLegacyPrefEntry(_assistantThreadsKey);
}
Future<void> _migrateLegacyPrefEntry(String key) async {
@ -393,6 +391,25 @@ class SecureConfigStore {
return _memoryStore[key];
}
Future<void> _deleteStoredString(String key) async {
if (_database != null) {
try {
_database!.execute(
'DELETE FROM $_databaseTableName WHERE storage_key = ?',
<Object?>[key],
);
} catch (_) {
// Fall through to in-memory cleanup.
}
}
_memoryStore.remove(key);
try {
await _prefs?.remove(key);
} catch (_) {
// Ignore preference cleanup failures.
}
}
Future<void> _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 <AssistantThreadRecord>[],
);
}
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<String, dynamic>;
return SettingsSnapshot.fromJson(decoded);
} catch (_) {
return null;
}
}
List<AssistantThreadRecord>? _decodeAssistantThreadRecords(String? raw) {
if (raw == null || raw.trim().isEmpty) {
return null;
}
try {
final decoded = jsonDecode(raw) as List<dynamic>;
return decoded
.whereType<Map>()
.map(
(item) =>
AssistantThreadRecord.fromJson(item.cast<String, dynamic>()),
)
.toList(growable: false);
} catch (_) {
return null;
}
}
Future<void> _persistAssistantStateBackup({
SettingsSnapshot? settings,
List<AssistantThreadRecord>? 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(<String, dynamic>{
'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<String, dynamic>;
final settings = SettingsSnapshot.fromJson(
(decoded['settings'] as Map?)?.cast<String, dynamic>() ?? const {},
);
final threads = ((decoded['assistantThreads'] as List?) ?? const [])
.whereType<Map>()
.map(
(item) =>
AssistantThreadRecord.fromJson(item.cast<String, dynamic>()),
)
.toList(growable: false);
return _AssistantStateSnapshot(
settings: settings,
assistantThreads: threads,
);
} catch (_) {
return null;
}
}
Future<File?> _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<void> _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<AssistantThreadRecord> assistantThreads;
}

View File

@ -665,6 +665,7 @@ void main() {
Future<AppController> _createControllerWithThreadRecords({
required List<AssistantThreadRecord> records,
bool useFakeGatewayRuntime = false,
List<String>? gatewayOnlySkillScanRoots,
}) async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
@ -695,6 +696,7 @@ Future<AppController> _createControllerWithThreadRecords({
codex: _FakeCodexRuntime(),
)
: null,
gatewayOnlySkillScanRoots: gatewayOnlySkillScanRoots,
);
final deadline = DateTime.now().add(const Duration(seconds: 5));
while (controller.initializing) {

View File

@ -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<FilledButton>(
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<FilledButton>(
confirmButtonFinder,
);
expect(confirmButtonAfter.onPressed, isNotNull);
},
);
testWidgets('SettingsPage detail mode returns to overview', (
WidgetTester tester,
) async {

View File

@ -109,6 +109,23 @@ class _FakeCodexRuntime extends CodexRuntime {
Future<void> stop() async {}
}
Future<void> _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<void>.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(<String, Object>{});
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(<String, Object>{});
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(<String, Object>{});
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);
},
);
}

View File

@ -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(<String, Object>{});
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: <String>[
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(<String, Object>{});
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: <String>[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,
<String>[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<void> _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<void> _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<void>.delayed(const Duration(milliseconds: 20));
}
}

View File

@ -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();
}

View File

@ -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 <String>['main'],
assistantCustomTaskTitles: const <String, String>{'main': '研发任务'},
assistantLastSessionKey: 'main',
);
const records = <AssistantThreadRecord>[
AssistantThreadRecord(
@ -239,6 +241,27 @@ void main() {
archived: true,
executionTarget: AssistantExecutionTarget.remote,
messageViewMode: AssistantMessageViewMode.raw,
discoveredSkills: <AssistantThreadSkillEntry>[
AssistantThreadSkillEntry(
key: '/tmp/discovered-skill',
label: 'Discovered Skill',
description: 'candidate only',
sourcePath: '/tmp/discovered-skill',
sourceLabel: 'codex/discovered',
),
],
importedSkills: <AssistantThreadSkillEntry>[
AssistantThreadSkillEntry(
key: '/tmp/imported-skill',
label: 'Imported Skill',
description: 'confirmed import',
sourcePath: '/tmp/imported-skill',
sourceLabel: 'workbuddy/imported',
),
],
selectedSkillKeys: <String>['/tmp/imported-skill'],
assistantModelId: 'gpt-5.4-mini',
gatewayEntryState: 'ai-gateway-only',
updatedAtMs: 1700000000000,
messages: <GatewayChatMessage>[
GatewayChatMessage(
@ -276,6 +299,7 @@ void main() {
expect(reloadedSnapshot.assistantArchivedTaskKeys, const <String>[
'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 <String>[
'/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(<String, dynamic>{
'sessionKey': 'legacy-thread',
'messages': const <Object>[],
'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(<String, Object>{});
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>[
AssistantThreadRecord(
sessionKey: 'draft:backup-1',
title: '备份线程',
archived: false,
executionTarget: AssistantExecutionTarget.aiGatewayOnly,
messageViewMode: AssistantMessageViewMode.rendered,
updatedAtMs: 1700000000000,
messages: <GatewayChatMessage>[
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(<String, Object>{});
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>[
AssistantThreadRecord(
sessionKey: 'draft:clear-1',
title: '清理线程',
archived: false,
executionTarget: AssistantExecutionTarget.local,
messageViewMode: AssistantMessageViewMode.rendered,
updatedAtMs: 1700000000000,
messages: <GatewayChatMessage>[],
),
];
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 {

View File

@ -14,16 +14,20 @@ Future<AppController> createTestController(
WidgetTester tester, {
DesktopPlatformService? desktopPlatformService,
UiFeatureManifest? uiFeatureManifest,
List<String>? gatewayOnlySkillScanRoots,
}) async {
SharedPreferences.setMockInitialValues(<String, Object>{});
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));