Persist assistant state and add local recovery cleanup
This commit is contained in:
parent
25e6d8cb3c
commit
5266aa8cab
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
214
test/runtime/app_controller_thread_skills_suite.dart
Normal file
214
test/runtime/app_controller_thread_skills_suite.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
7
test/runtime/app_controller_thread_skills_test.dart
Normal file
7
test/runtime/app_controller_thread_skills_test.dart
Normal 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();
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user