Synchronize assistant threads and markdown view
This commit is contained in:
parent
f8b32bffb3
commit
50b8a4dc98
@ -177,6 +177,7 @@ class AppController extends ChangeNotifier {
|
||||
List<GatewaySessionSummary> get sessions => isAiGatewayOnlyMode
|
||||
? _assistantSessionSummaries()
|
||||
: _sessionsController.sessions;
|
||||
List<GatewaySessionSummary> get assistantSessions => _assistantSessions();
|
||||
List<GatewayInstanceSummary> get instances => _instancesController.items;
|
||||
List<GatewaySkillSummary> get skills => _skillsController.items;
|
||||
List<GatewayConnectorSummary> get connectors => _connectorsController.items;
|
||||
@ -189,7 +190,11 @@ class AppController extends ChangeNotifier {
|
||||
String? get activeRunId => _chatController.activeRunId;
|
||||
AppLanguage get appLanguage => settings.appLanguage;
|
||||
AssistantExecutionTarget get assistantExecutionTarget =>
|
||||
settings.assistantExecutionTarget;
|
||||
currentAssistantExecutionTarget;
|
||||
AssistantExecutionTarget get currentAssistantExecutionTarget =>
|
||||
assistantExecutionTargetForSession(currentSessionKey);
|
||||
AssistantMessageViewMode get currentAssistantMessageViewMode =>
|
||||
assistantMessageViewModeForSession(currentSessionKey);
|
||||
AssistantPermissionLevel get assistantPermissionLevel =>
|
||||
settings.assistantPermissionLevel;
|
||||
bool get hasStoredGatewayCredential =>
|
||||
@ -206,8 +211,7 @@ class AppController extends ChangeNotifier {
|
||||
bool get hasStoredAiGatewayApiKey =>
|
||||
_settingsController.secureRefs.containsKey('ai_gateway_api_key');
|
||||
bool get isAiGatewayOnlyMode =>
|
||||
settings.assistantExecutionTarget ==
|
||||
AssistantExecutionTarget.aiGatewayOnly;
|
||||
currentAssistantExecutionTarget == AssistantExecutionTarget.aiGatewayOnly;
|
||||
bool get isCodexBridgeBusy => _isCodexBridgeBusy;
|
||||
String? get codexBridgeError => _codexBridgeError;
|
||||
String? get codexRuntimeWarning => _codexRuntimeWarning;
|
||||
@ -265,7 +269,11 @@ class AppController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
String get resolvedAssistantModel {
|
||||
if (isAiGatewayOnlyMode) {
|
||||
return _resolvedAssistantModelForTarget(currentAssistantExecutionTarget);
|
||||
}
|
||||
|
||||
String _resolvedAssistantModelForTarget(AssistantExecutionTarget target) {
|
||||
if (target == AssistantExecutionTarget.aiGatewayOnly) {
|
||||
return resolvedAiGatewayModel;
|
||||
}
|
||||
final resolved = resolvedDefaultModel.trim();
|
||||
@ -621,9 +629,71 @@ class AppController extends ChangeNotifier {
|
||||
return trimmed.isEmpty ? 'main' : trimmed;
|
||||
}
|
||||
|
||||
AssistantExecutionTarget assistantExecutionTargetForSession(
|
||||
String sessionKey,
|
||||
) {
|
||||
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
|
||||
return _assistantThreadRecords[normalizedSessionKey]?.executionTarget ??
|
||||
settings.assistantExecutionTarget;
|
||||
}
|
||||
|
||||
AssistantMessageViewMode assistantMessageViewModeForSession(
|
||||
String sessionKey,
|
||||
) {
|
||||
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
|
||||
return _assistantThreadRecords[normalizedSessionKey]?.messageViewMode ??
|
||||
AssistantMessageViewMode.rendered;
|
||||
}
|
||||
|
||||
List<GatewaySessionSummary> _assistantSessions() {
|
||||
final archivedKeys = settings.assistantArchivedTaskKeys
|
||||
.map(_normalizedAssistantSessionKey)
|
||||
.toSet();
|
||||
final byKey = <String, GatewaySessionSummary>{};
|
||||
|
||||
for (final session in _sessionsController.sessions) {
|
||||
final normalizedSessionKey = _normalizedAssistantSessionKey(session.key);
|
||||
if (archivedKeys.contains(normalizedSessionKey)) {
|
||||
continue;
|
||||
}
|
||||
byKey[normalizedSessionKey] = session;
|
||||
}
|
||||
|
||||
for (final record in _assistantThreadRecords.values) {
|
||||
final normalizedSessionKey = _normalizedAssistantSessionKey(
|
||||
record.sessionKey,
|
||||
);
|
||||
if (normalizedSessionKey.isEmpty ||
|
||||
archivedKeys.contains(normalizedSessionKey) ||
|
||||
record.archived) {
|
||||
continue;
|
||||
}
|
||||
byKey.putIfAbsent(
|
||||
normalizedSessionKey,
|
||||
() => _assistantSessionSummaryFor(
|
||||
normalizedSessionKey,
|
||||
record: record,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final currentKey = _normalizedAssistantSessionKey(currentSessionKey);
|
||||
if (!archivedKeys.contains(currentKey) && !byKey.containsKey(currentKey)) {
|
||||
byKey[currentKey] = _assistantSessionSummaryFor(currentKey);
|
||||
}
|
||||
|
||||
final items = byKey.values.toList(growable: true)
|
||||
..sort(
|
||||
(left, right) =>
|
||||
(right.updatedAtMs ?? 0).compareTo(left.updatedAtMs ?? 0),
|
||||
);
|
||||
return items;
|
||||
}
|
||||
|
||||
bool assistantSessionHasPendingRun(String sessionKey) {
|
||||
final normalized = _normalizedAssistantSessionKey(sessionKey);
|
||||
if (isAiGatewayOnlyMode) {
|
||||
if (assistantExecutionTargetForSession(normalized) ==
|
||||
AssistantExecutionTarget.aiGatewayOnly) {
|
||||
return _aiGatewayPendingSessionKeys.contains(normalized);
|
||||
}
|
||||
return (_chatController.hasPendingRun || _multiAgentRunPending) &&
|
||||
@ -735,20 +805,25 @@ class AppController extends ChangeNotifier {
|
||||
tls: decoded?.tls ?? settings.gateway.tls,
|
||||
mode: _modeFromHost(decoded?.host ?? settings.gateway.host),
|
||||
);
|
||||
final nextTarget = _assistantExecutionTargetForMode(nextProfile.mode);
|
||||
await saveSettings(
|
||||
settings.copyWith(
|
||||
gateway: nextProfile,
|
||||
assistantExecutionTarget: _assistantExecutionTargetForMode(
|
||||
nextProfile.mode,
|
||||
),
|
||||
assistantExecutionTarget: nextTarget,
|
||||
),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
_upsertAssistantThreadRecord(
|
||||
_sessionsController.currentSessionKey,
|
||||
executionTarget: nextTarget,
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
await _connectProfile(
|
||||
nextProfile,
|
||||
authTokenOverride: resolvedToken,
|
||||
authPasswordOverride: resolvedPassword,
|
||||
);
|
||||
await _chatController.loadSession(_sessionsController.currentSessionKey);
|
||||
}
|
||||
|
||||
Future<void> connectManual({
|
||||
@ -778,20 +853,25 @@ class AppController extends ChangeNotifier {
|
||||
port: resolvedPort <= 0 ? 443 : resolvedPort,
|
||||
tls: mode == RuntimeConnectionMode.local ? false : tls,
|
||||
);
|
||||
final nextTarget = _assistantExecutionTargetForMode(nextProfile.mode);
|
||||
await saveSettings(
|
||||
settings.copyWith(
|
||||
gateway: nextProfile,
|
||||
assistantExecutionTarget: _assistantExecutionTargetForMode(
|
||||
nextProfile.mode,
|
||||
),
|
||||
assistantExecutionTarget: nextTarget,
|
||||
),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
_upsertAssistantThreadRecord(
|
||||
_sessionsController.currentSessionKey,
|
||||
executionTarget: nextTarget,
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
await _connectProfile(
|
||||
nextProfile,
|
||||
authTokenOverride: token.trim(),
|
||||
authPasswordOverride: password.trim(),
|
||||
);
|
||||
await _chatController.loadSession(_sessionsController.currentSessionKey);
|
||||
}
|
||||
|
||||
Future<void> disconnectGateway() async {
|
||||
@ -916,8 +996,29 @@ class AppController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> switchSession(String sessionKey) async {
|
||||
await _sessionsController.switchSession(sessionKey);
|
||||
await _chatController.loadSession(_sessionsController.currentSessionKey);
|
||||
final previousSessionKey = _normalizedAssistantSessionKey(
|
||||
_sessionsController.currentSessionKey,
|
||||
);
|
||||
final nextSessionKey = _normalizedAssistantSessionKey(sessionKey);
|
||||
final nextTarget = assistantExecutionTargetForSession(nextSessionKey);
|
||||
final nextViewMode = assistantMessageViewModeForSession(nextSessionKey);
|
||||
|
||||
if (!isAiGatewayOnlyMode) {
|
||||
_preserveGatewayHistoryForSession(previousSessionKey);
|
||||
}
|
||||
|
||||
await _sessionsController.switchSession(nextSessionKey);
|
||||
_upsertAssistantThreadRecord(
|
||||
nextSessionKey,
|
||||
executionTarget: nextTarget,
|
||||
messageViewMode: nextViewMode,
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
await _applyAssistantExecutionTarget(
|
||||
nextTarget,
|
||||
sessionKey: nextSessionKey,
|
||||
persistDefaultSelection: false,
|
||||
);
|
||||
_recomputeTasks();
|
||||
}
|
||||
|
||||
@ -974,46 +1075,43 @@ class AppController extends ChangeNotifier {
|
||||
Future<void> setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget target,
|
||||
) async {
|
||||
if (settings.assistantExecutionTarget == target) {
|
||||
return;
|
||||
}
|
||||
if (target == AssistantExecutionTarget.aiGatewayOnly) {
|
||||
_preserveGatewayHistoryForSession(_sessionsController.currentSessionKey);
|
||||
final nextGatewayProfile = settings.gateway.copyWith(
|
||||
mode: RuntimeConnectionMode.unconfigured,
|
||||
useSetupCode: false,
|
||||
setupCode: '',
|
||||
);
|
||||
await saveSettings(
|
||||
settings.copyWith(
|
||||
assistantExecutionTarget: target,
|
||||
gateway: nextGatewayProfile,
|
||||
),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
await _ensureActiveAssistantThread();
|
||||
if (_runtime.isConnected) {
|
||||
try {
|
||||
await disconnectGateway();
|
||||
} catch (_) {
|
||||
// Preserve the selected AI Gateway-only mode even if the active
|
||||
// gateway session does not close cleanly on the first attempt.
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await saveSettings(
|
||||
settings.copyWith(assistantExecutionTarget: target),
|
||||
refreshAfterSave: false,
|
||||
final currentTarget = assistantExecutionTargetForSession(
|
||||
_sessionsController.currentSessionKey,
|
||||
);
|
||||
final targetProfile = _gatewayProfileForAssistantExecutionTarget(target);
|
||||
try {
|
||||
await _connectProfile(targetProfile);
|
||||
} catch (_) {
|
||||
// Keep the selected execution target even when the immediate reconnect
|
||||
// fails so the user can retry or adjust gateway settings manually.
|
||||
if (currentTarget == target &&
|
||||
settings.assistantExecutionTarget == target) {
|
||||
return;
|
||||
}
|
||||
_upsertAssistantThreadRecord(
|
||||
_sessionsController.currentSessionKey,
|
||||
executionTarget: target,
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
await _applyAssistantExecutionTarget(
|
||||
target,
|
||||
sessionKey: _sessionsController.currentSessionKey,
|
||||
persistDefaultSelection: true,
|
||||
);
|
||||
_recomputeTasks();
|
||||
_notifyIfActive();
|
||||
}
|
||||
|
||||
Future<void> setAssistantMessageViewMode(
|
||||
AssistantMessageViewMode mode,
|
||||
) async {
|
||||
final sessionKey = _normalizedAssistantSessionKey(
|
||||
_sessionsController.currentSessionKey,
|
||||
);
|
||||
if (assistantMessageViewModeForSession(sessionKey) == mode) {
|
||||
return;
|
||||
}
|
||||
_upsertAssistantThreadRecord(
|
||||
sessionKey,
|
||||
messageViewMode: mode,
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
_recomputeTasks();
|
||||
_notifyIfActive();
|
||||
}
|
||||
|
||||
Future<void> setAssistantPermissionLevel(
|
||||
@ -1028,6 +1126,56 @@ class AppController extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _applyAssistantExecutionTarget(
|
||||
AssistantExecutionTarget target, {
|
||||
required String sessionKey,
|
||||
required bool persistDefaultSelection,
|
||||
}) async {
|
||||
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
|
||||
if (!matchesSessionKey(
|
||||
normalizedSessionKey,
|
||||
_sessionsController.currentSessionKey,
|
||||
)) {
|
||||
await _sessionsController.switchSession(normalizedSessionKey);
|
||||
}
|
||||
if (persistDefaultSelection &&
|
||||
settings.assistantExecutionTarget != target) {
|
||||
await saveSettings(
|
||||
settings.copyWith(assistantExecutionTarget: target),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
}
|
||||
|
||||
if (target == AssistantExecutionTarget.aiGatewayOnly) {
|
||||
if (_runtime.isConnected) {
|
||||
_preserveGatewayHistoryForSession(normalizedSessionKey);
|
||||
}
|
||||
await _ensureActiveAssistantThread();
|
||||
if (_runtime.isConnected) {
|
||||
try {
|
||||
await disconnectGateway();
|
||||
} catch (_) {
|
||||
// Preserve the selected thread-bound target even when the active
|
||||
// gateway session does not close cleanly on the first attempt.
|
||||
}
|
||||
} else {
|
||||
_chatController.clear();
|
||||
}
|
||||
await _sessionsController.switchSession(normalizedSessionKey);
|
||||
return;
|
||||
}
|
||||
|
||||
final targetProfile = _gatewayProfileForAssistantExecutionTarget(target);
|
||||
try {
|
||||
await _connectProfile(targetProfile);
|
||||
} catch (_) {
|
||||
// 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 _chatController.loadSession(normalizedSessionKey);
|
||||
}
|
||||
|
||||
Future<void> selectDefaultModel(String modelId) async {
|
||||
final trimmed = modelId.trim();
|
||||
if (trimmed.isEmpty || settings.defaultModel == trimmed) {
|
||||
@ -1061,6 +1209,24 @@ class AppController extends ChangeNotifier {
|
||||
return _assistantThreadRecords[normalizedSessionKey]?.title.trim() ?? '';
|
||||
}
|
||||
|
||||
void initializeAssistantThreadContext(
|
||||
String sessionKey, {
|
||||
String title = '',
|
||||
AssistantExecutionTarget? executionTarget,
|
||||
AssistantMessageViewMode? messageViewMode,
|
||||
}) {
|
||||
_upsertAssistantThreadRecord(
|
||||
sessionKey,
|
||||
title: title.trim(),
|
||||
executionTarget:
|
||||
executionTarget ?? assistantExecutionTargetForSession(currentSessionKey),
|
||||
messageViewMode:
|
||||
messageViewMode ?? assistantMessageViewModeForSession(currentSessionKey),
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
_notifyIfActive();
|
||||
}
|
||||
|
||||
Future<void> saveAssistantTaskTitle(String sessionKey, String title) async {
|
||||
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
|
||||
if (normalizedSessionKey.isEmpty) {
|
||||
@ -2110,7 +2276,9 @@ class AppController extends ChangeNotifier {
|
||||
inputTokens: null,
|
||||
outputTokens: null,
|
||||
totalTokens: null,
|
||||
model: resolvedAssistantModel,
|
||||
model: _resolvedAssistantModelForTarget(
|
||||
assistantExecutionTargetForSession(normalizedSessionKey),
|
||||
),
|
||||
contextTokens: null,
|
||||
derivedTitle: title.isEmpty ? null : title,
|
||||
lastMessagePreview: preview,
|
||||
@ -2149,6 +2317,9 @@ class AppController extends ChangeNotifier {
|
||||
? record.title.trim()
|
||||
: titleFromSettings,
|
||||
archived: record.archived || archivedKeys.contains(sessionKey),
|
||||
executionTarget:
|
||||
record.executionTarget ?? settings.assistantExecutionTarget,
|
||||
messageViewMode: record.messageViewMode,
|
||||
);
|
||||
_assistantThreadRecords[sessionKey] = normalizedRecord;
|
||||
if (normalizedRecord.messages.isNotEmpty) {
|
||||
@ -2165,6 +2336,8 @@ class AppController extends ChangeNotifier {
|
||||
double? updatedAtMs,
|
||||
String? title,
|
||||
bool? archived,
|
||||
AssistantExecutionTarget? executionTarget,
|
||||
AssistantMessageViewMode? messageViewMode,
|
||||
}) {
|
||||
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
|
||||
final existing = _assistantThreadRecords[normalizedSessionKey];
|
||||
@ -2185,6 +2358,14 @@ class AppController extends ChangeNotifier {
|
||||
archived ??
|
||||
existing?.archived ??
|
||||
isAssistantTaskArchived(normalizedSessionKey),
|
||||
executionTarget:
|
||||
executionTarget ??
|
||||
existing?.executionTarget ??
|
||||
settings.assistantExecutionTarget,
|
||||
messageViewMode:
|
||||
messageViewMode ??
|
||||
existing?.messageViewMode ??
|
||||
AssistantMessageViewMode.rendered,
|
||||
);
|
||||
_assistantThreadRecords[normalizedSessionKey] = nextRecord;
|
||||
if (messages != null) {
|
||||
@ -2504,7 +2685,7 @@ class AppController extends ChangeNotifier {
|
||||
return CodeAgentNodeState(
|
||||
selectedAgentId: _agentsController.selectedAgentId,
|
||||
gatewayConnected: _runtime.isConnected,
|
||||
executionTarget: settings.assistantExecutionTarget,
|
||||
executionTarget: currentAssistantExecutionTarget,
|
||||
runtimeMode: effectiveCodeAgentRuntimeMode,
|
||||
bridgeEnabled: _isCodexBridgeEnabled,
|
||||
bridgeState: _codexCooperationState.name,
|
||||
|
||||
@ -4,6 +4,8 @@ import 'dart:io';
|
||||
|
||||
import 'package:file_selector/file_selector.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:markdown/markdown.dart' as md;
|
||||
|
||||
import '../../app/app_controller.dart';
|
||||
import '../../app/app_metadata.dart';
|
||||
@ -368,12 +370,15 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
controller: controller,
|
||||
currentTask: currentTask,
|
||||
items: timelineItems,
|
||||
messageViewMode: controller.currentAssistantMessageViewMode,
|
||||
scrollController: _conversationController,
|
||||
onOpenDetail: widget.onOpenDetail,
|
||||
onFocusComposer: _focusComposer,
|
||||
onOpenGateway: _showConnectDialog,
|
||||
onOpenAiGatewaySettings: _openAiGatewaySettings,
|
||||
onReconnectGateway: _connectFromSavedSettingsOrShowDialog,
|
||||
onMessageViewModeChanged:
|
||||
controller.setAssistantMessageViewMode,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
@ -547,14 +552,14 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
Future<void> _submitPrompt() async {
|
||||
final controller = widget.controller;
|
||||
final settings = controller.settings;
|
||||
final executionTarget = controller.assistantExecutionTarget;
|
||||
final rawPrompt = _inputController.text.trim();
|
||||
if (rawPrompt.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final shouldUseGatewayAgent =
|
||||
settings.assistantExecutionTarget !=
|
||||
AssistantExecutionTarget.aiGatewayOnly;
|
||||
executionTarget != AssistantExecutionTarget.aiGatewayOnly;
|
||||
final autoAgent = shouldUseGatewayAgent
|
||||
? _pickAutoAgent(controller, rawPrompt)
|
||||
: null;
|
||||
@ -571,7 +576,7 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
prompt: rawPrompt,
|
||||
attachmentNames: attachmentNames,
|
||||
selectedSkillLabels: selectedSkillLabels,
|
||||
executionTarget: settings.assistantExecutionTarget,
|
||||
executionTarget: executionTarget,
|
||||
permissionLevel: settings.assistantPermissionLevel,
|
||||
workspacePath: settings.workspacePath,
|
||||
remoteProjectRoot: settings.remoteProjectRoot,
|
||||
@ -591,15 +596,14 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
preview: rawPrompt,
|
||||
status:
|
||||
controller.hasAssistantPendingRun ||
|
||||
settings.assistantExecutionTarget ==
|
||||
AssistantExecutionTarget.aiGatewayOnly ||
|
||||
executionTarget == AssistantExecutionTarget.aiGatewayOnly ||
|
||||
controller.connection.status ==
|
||||
RuntimeConnectionStatus.connected
|
||||
? 'running'
|
||||
: 'queued',
|
||||
owner: autoAgent?.name ?? _conversationOwnerLabel(controller),
|
||||
surface: 'Assistant',
|
||||
executionTarget: settings.assistantExecutionTarget,
|
||||
executionTarget: executionTarget,
|
||||
draft: controller.currentSessionKey.trim().startsWith('draft:'),
|
||||
);
|
||||
});
|
||||
@ -824,6 +828,9 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
|
||||
Future<void> _createNewThread() async {
|
||||
final sessionKey = _buildDraftSessionKey(widget.controller);
|
||||
final inheritedTarget = widget.controller.currentAssistantExecutionTarget;
|
||||
final inheritedViewMode =
|
||||
widget.controller.currentAssistantMessageViewMode;
|
||||
setState(() {
|
||||
_archivedTaskKeys.removeWhere(
|
||||
(value) => _sessionKeysMatch(value, sessionKey),
|
||||
@ -839,11 +846,17 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
owner: _conversationOwnerLabel(widget.controller),
|
||||
surface: 'Assistant',
|
||||
executionTarget: widget.controller.assistantExecutionTarget,
|
||||
executionTarget: inheritedTarget,
|
||||
draft: true,
|
||||
);
|
||||
_selectedSkillKeys = const <String>[];
|
||||
});
|
||||
widget.controller.initializeAssistantThreadContext(
|
||||
sessionKey,
|
||||
title: appText('新对话', 'New conversation'),
|
||||
executionTarget: inheritedTarget,
|
||||
messageViewMode: inheritedViewMode,
|
||||
);
|
||||
await widget.controller.switchSession(sessionKey);
|
||||
_focusComposer();
|
||||
}
|
||||
@ -908,18 +921,17 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
owner: _conversationOwnerLabel(widget.controller),
|
||||
surface: 'Assistant',
|
||||
executionTarget: widget.controller.assistantExecutionTarget,
|
||||
executionTarget: widget.controller.currentAssistantExecutionTarget,
|
||||
isCurrent: true,
|
||||
draft: true,
|
||||
);
|
||||
}
|
||||
|
||||
void _synchronizeTaskSeeds(AppController controller) {
|
||||
for (final session in controller.sessions) {
|
||||
for (final session in controller.assistantSessions) {
|
||||
if (_isArchivedTask(session.key)) {
|
||||
continue;
|
||||
}
|
||||
final existingSeed = _taskSeeds[session.key];
|
||||
_taskSeeds[session.key] = _AssistantTaskSeed(
|
||||
sessionKey: session.key,
|
||||
title: _resolvedTaskTitle(controller, session.key, session: session),
|
||||
@ -935,9 +947,9 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
owner: _conversationOwnerLabel(controller),
|
||||
surface: session.surface ?? session.kind ?? 'Assistant',
|
||||
executionTarget:
|
||||
existingSeed?.executionTarget ??
|
||||
controller.assistantExecutionTarget,
|
||||
executionTarget: controller.assistantExecutionTargetForSession(
|
||||
session.key,
|
||||
),
|
||||
draft: session.key.trim().startsWith('draft:'),
|
||||
);
|
||||
}
|
||||
@ -970,8 +982,9 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
owner: _conversationOwnerLabel(controller),
|
||||
surface: currentSeed?.surface ?? 'Assistant',
|
||||
executionTarget:
|
||||
currentSeed?.executionTarget ?? controller.assistantExecutionTarget,
|
||||
executionTarget: controller.assistantExecutionTargetForSession(
|
||||
controller.currentSessionKey,
|
||||
),
|
||||
draft: controller.currentSessionKey.trim().startsWith('draft:'),
|
||||
);
|
||||
}
|
||||
@ -980,7 +993,7 @@ class _AssistantPageState extends State<AssistantPage> {
|
||||
AppController controller,
|
||||
String sessionKey,
|
||||
) {
|
||||
for (final session in controller.sessions) {
|
||||
for (final session in controller.assistantSessions) {
|
||||
if (_sessionKeysMatch(session.key, sessionKey)) {
|
||||
return session;
|
||||
}
|
||||
@ -1558,23 +1571,28 @@ class _ConversationArea extends StatelessWidget {
|
||||
required this.controller,
|
||||
required this.currentTask,
|
||||
required this.items,
|
||||
required this.messageViewMode,
|
||||
required this.scrollController,
|
||||
required this.onOpenDetail,
|
||||
required this.onFocusComposer,
|
||||
required this.onOpenGateway,
|
||||
required this.onOpenAiGatewaySettings,
|
||||
required this.onReconnectGateway,
|
||||
required this.onMessageViewModeChanged,
|
||||
});
|
||||
|
||||
final AppController controller;
|
||||
final _AssistantTaskEntry currentTask;
|
||||
final List<_TimelineItem> items;
|
||||
final AssistantMessageViewMode messageViewMode;
|
||||
final ScrollController scrollController;
|
||||
final ValueChanged<DetailPanelData> onOpenDetail;
|
||||
final VoidCallback onFocusComposer;
|
||||
final VoidCallback onOpenGateway;
|
||||
final VoidCallback onOpenAiGatewaySettings;
|
||||
final Future<void> Function() onReconnectGateway;
|
||||
final Future<void> Function(AssistantMessageViewMode mode)
|
||||
onMessageViewModeChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -1634,7 +1652,17 @@ class _ConversationArea extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ConnectionChip(controller: controller),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_MessageViewModeChip(
|
||||
value: messageViewMode,
|
||||
onSelected: onMessageViewModeChanged,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_ConnectionChip(controller: controller),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -1664,18 +1692,21 @@ class _ConversationArea extends StatelessWidget {
|
||||
text: item.text!,
|
||||
alignRight: true,
|
||||
tone: _BubbleTone.user,
|
||||
messageViewMode: messageViewMode,
|
||||
),
|
||||
_TimelineItemKind.assistant => _MessageBubble(
|
||||
label: item.label!,
|
||||
text: item.text!,
|
||||
alignRight: false,
|
||||
tone: _BubbleTone.assistant,
|
||||
messageViewMode: messageViewMode,
|
||||
),
|
||||
_TimelineItemKind.agent => _MessageBubble(
|
||||
label: item.label!,
|
||||
text: item.text!,
|
||||
alignRight: false,
|
||||
tone: _BubbleTone.agent,
|
||||
messageViewMode: messageViewMode,
|
||||
),
|
||||
_TimelineItemKind.toolCall => _ToolCallTile(
|
||||
toolName: item.title!,
|
||||
@ -2964,12 +2995,14 @@ class _MessageBubble extends StatelessWidget {
|
||||
required this.text,
|
||||
required this.alignRight,
|
||||
required this.tone,
|
||||
required this.messageViewMode,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String text;
|
||||
final bool alignRight;
|
||||
final _BubbleTone tone;
|
||||
final AssistantMessageViewMode messageViewMode;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -3003,12 +3036,11 @@ class _MessageBubble extends StatelessWidget {
|
||||
children: [
|
||||
Text(label, style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 4),
|
||||
SelectableText(
|
||||
text.isEmpty ? appText('暂无内容。', 'No content yet.') : text,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
height: 1.45,
|
||||
),
|
||||
_MessageBubbleBody(
|
||||
text: text.isEmpty ? appText('暂无内容。', 'No content yet.') : text,
|
||||
renderMarkdown:
|
||||
messageViewMode == AssistantMessageViewMode.rendered &&
|
||||
tone != _BubbleTone.user,
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -3018,6 +3050,74 @@ class _MessageBubble extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageBubbleBody extends StatelessWidget {
|
||||
const _MessageBubbleBody({
|
||||
required this.text,
|
||||
required this.renderMarkdown,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final bool renderMarkdown;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
if (!renderMarkdown) {
|
||||
return SelectableText(
|
||||
text,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
height: 1.45,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final styleSheet = MarkdownStyleSheet.fromTheme(theme).copyWith(
|
||||
p: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
height: 1.45,
|
||||
),
|
||||
code: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontFamily: 'Menlo',
|
||||
height: 1.4,
|
||||
),
|
||||
codeblockDecoration: BoxDecoration(
|
||||
color: context.palette.surfaceSecondary,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
blockquoteDecoration: BoxDecoration(
|
||||
color: context.palette.surfaceSecondary.withValues(alpha: 0.72),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
blockquotePadding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 8,
|
||||
),
|
||||
tableBorder: TableBorder.all(color: context.palette.strokeSoft),
|
||||
tableHead: theme.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
);
|
||||
|
||||
return MarkdownBody(
|
||||
data: text,
|
||||
selectable: true,
|
||||
styleSheet: styleSheet,
|
||||
extensionSet: md.ExtensionSet.gitHubWeb,
|
||||
sizedImageBuilder: (config) => SelectableText(
|
||||
config.alt?.trim().isNotEmpty == true
|
||||
? '})'
|
||||
: config.uri.toString(),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: context.palette.textSecondary,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
onTapLink: (text, href, title) {},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TaskStatusCard extends StatelessWidget {
|
||||
const _TaskStatusCard({
|
||||
required this.title,
|
||||
@ -3423,6 +3523,72 @@ class _ConnectionChip extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageViewModeChip extends StatelessWidget {
|
||||
const _MessageViewModeChip({
|
||||
required this.value,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final AssistantMessageViewMode value;
|
||||
final Future<void> Function(AssistantMessageViewMode mode) onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.palette;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return PopupMenuButton<AssistantMessageViewMode>(
|
||||
key: const Key('assistant-message-view-mode-button'),
|
||||
tooltip: appText('消息视图', 'Message view'),
|
||||
onSelected: (mode) => unawaited(onSelected(mode)),
|
||||
itemBuilder: (context) => AssistantMessageViewMode.values
|
||||
.map(
|
||||
(mode) => PopupMenuItem<AssistantMessageViewMode>(
|
||||
value: mode,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(mode.label)),
|
||||
if (mode == value) const Icon(Icons.check_rounded, size: 18),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xs,
|
||||
vertical: 5,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: palette.surfaceSecondary,
|
||||
borderRadius: BorderRadius.circular(AppRadius.chip),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: palette.shadow.withValues(alpha: 0.03),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.notes_rounded, size: 14, color: palette.textMuted),
|
||||
const SizedBox(width: 4),
|
||||
Text(value.label, style: theme.textTheme.labelMedium),
|
||||
const SizedBox(width: 2),
|
||||
Icon(
|
||||
Icons.keyboard_arrow_down_rounded,
|
||||
size: 14,
|
||||
color: palette.textMuted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _BubbleTone { user, assistant, agent }
|
||||
|
||||
enum _TimelineItemKind { user, assistant, agent, taskCard, toolCall }
|
||||
|
||||
@ -63,6 +63,22 @@ extension AssistantExecutionTargetCopy on AssistantExecutionTarget {
|
||||
}
|
||||
}
|
||||
|
||||
enum AssistantMessageViewMode { rendered, raw }
|
||||
|
||||
extension AssistantMessageViewModeCopy on AssistantMessageViewMode {
|
||||
String get label => switch (this) {
|
||||
AssistantMessageViewMode.rendered => appText('渲染', 'Rendered'),
|
||||
AssistantMessageViewMode.raw => 'RAW',
|
||||
};
|
||||
|
||||
static AssistantMessageViewMode fromJsonValue(String? value) {
|
||||
return AssistantMessageViewMode.values.firstWhere(
|
||||
(item) => item.name == value,
|
||||
orElse: () => AssistantMessageViewMode.rendered,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum AssistantPermissionLevel { defaultAccess, fullAccess }
|
||||
|
||||
extension AssistantPermissionLevelCopy on AssistantPermissionLevel {
|
||||
@ -1602,6 +1618,8 @@ class AssistantThreadRecord {
|
||||
required this.updatedAtMs,
|
||||
required this.title,
|
||||
required this.archived,
|
||||
required this.executionTarget,
|
||||
required this.messageViewMode,
|
||||
});
|
||||
|
||||
final String sessionKey;
|
||||
@ -1609,6 +1627,8 @@ class AssistantThreadRecord {
|
||||
final double? updatedAtMs;
|
||||
final String title;
|
||||
final bool archived;
|
||||
final AssistantExecutionTarget? executionTarget;
|
||||
final AssistantMessageViewMode messageViewMode;
|
||||
|
||||
AssistantThreadRecord copyWith({
|
||||
String? sessionKey,
|
||||
@ -1616,6 +1636,9 @@ class AssistantThreadRecord {
|
||||
double? updatedAtMs,
|
||||
String? title,
|
||||
bool? archived,
|
||||
AssistantExecutionTarget? executionTarget,
|
||||
bool clearExecutionTarget = false,
|
||||
AssistantMessageViewMode? messageViewMode,
|
||||
}) {
|
||||
return AssistantThreadRecord(
|
||||
sessionKey: sessionKey ?? this.sessionKey,
|
||||
@ -1623,6 +1646,10 @@ class AssistantThreadRecord {
|
||||
updatedAtMs: updatedAtMs ?? this.updatedAtMs,
|
||||
title: title ?? this.title,
|
||||
archived: archived ?? this.archived,
|
||||
executionTarget: clearExecutionTarget
|
||||
? null
|
||||
: (executionTarget ?? this.executionTarget),
|
||||
messageViewMode: messageViewMode ?? this.messageViewMode,
|
||||
);
|
||||
}
|
||||
|
||||
@ -1633,6 +1660,8 @@ class AssistantThreadRecord {
|
||||
'updatedAtMs': updatedAtMs,
|
||||
'title': title,
|
||||
'archived': archived,
|
||||
'executionTarget': executionTarget?.name,
|
||||
'messageViewMode': messageViewMode.name,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1661,6 +1690,14 @@ class AssistantThreadRecord {
|
||||
updatedAtMs: asDouble(json['updatedAtMs']),
|
||||
title: json['title']?.toString() ?? '',
|
||||
archived: json['archived'] as bool? ?? false,
|
||||
executionTarget: json['executionTarget'] == null
|
||||
? null
|
||||
: AssistantExecutionTargetCopy.fromJsonValue(
|
||||
json['executionTarget']?.toString(),
|
||||
),
|
||||
messageViewMode: AssistantMessageViewModeCopy.fromJsonValue(
|
||||
json['messageViewMode']?.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -68,12 +68,20 @@ class _GatewayConnectDialogState extends State<GatewayConnectDialog> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
final profile = widget.controller.settings.gateway;
|
||||
final executionTarget = widget.controller.currentAssistantExecutionTarget;
|
||||
_setupCodeController = TextEditingController(text: profile.setupCode);
|
||||
_hostController = TextEditingController(text: profile.host);
|
||||
_portController = TextEditingController(text: '${profile.port}');
|
||||
_tls = profile.tls;
|
||||
_connectionMode = profile.mode;
|
||||
_mode = profile.useSetupCode ? 'setup' : 'manual';
|
||||
_connectionMode = switch (executionTarget) {
|
||||
AssistantExecutionTarget.aiGatewayOnly =>
|
||||
RuntimeConnectionMode.unconfigured,
|
||||
AssistantExecutionTarget.local => RuntimeConnectionMode.local,
|
||||
AssistantExecutionTarget.remote => RuntimeConnectionMode.remote,
|
||||
};
|
||||
_mode = executionTarget == AssistantExecutionTarget.aiGatewayOnly
|
||||
? 'manual'
|
||||
: (profile.useSetupCode ? 'setup' : 'manual');
|
||||
_loadBootstrapPrefill();
|
||||
}
|
||||
|
||||
@ -428,32 +436,9 @@ class _GatewayConnectDialogState extends State<GatewayConnectDialog> {
|
||||
password: _passwordController.text,
|
||||
);
|
||||
} else if (_connectionMode == RuntimeConnectionMode.unconfigured) {
|
||||
final currentSettings = widget.controller.settings;
|
||||
final currentProfile = currentSettings.gateway;
|
||||
final resolvedHost = _hostController.text.trim().isEmpty
|
||||
? currentProfile.host
|
||||
: _hostController.text.trim();
|
||||
final resolvedPort =
|
||||
int.tryParse(_portController.text.trim()) ?? currentProfile.port;
|
||||
final nextProfile = currentProfile.copyWith(
|
||||
mode: RuntimeConnectionMode.unconfigured,
|
||||
useSetupCode: false,
|
||||
setupCode: '',
|
||||
host: resolvedHost,
|
||||
port: resolvedPort <= 0 ? currentProfile.port : resolvedPort,
|
||||
tls: _tls,
|
||||
await widget.controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.aiGatewayOnly,
|
||||
);
|
||||
await widget.controller.saveSettings(
|
||||
currentSettings.copyWith(
|
||||
gateway: nextProfile,
|
||||
assistantExecutionTarget: AssistantExecutionTarget.aiGatewayOnly,
|
||||
),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
if (widget.controller.connection.status ==
|
||||
RuntimeConnectionStatus.connected) {
|
||||
await widget.controller.disconnectGateway();
|
||||
}
|
||||
} else {
|
||||
await widget.controller.connectManual(
|
||||
host: _hostController.text,
|
||||
|
||||
24
pubspec.lock
24
pubspec.lock
@ -1,6 +1,14 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -208,6 +216,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_markdown:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_markdown
|
||||
sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.7+1"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -364,6 +380,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
markdown:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: markdown
|
||||
sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.3.1"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -20,7 +20,9 @@ dependencies:
|
||||
device_info_plus: ^11.5.0
|
||||
ffi: ^2.1.4
|
||||
file_selector: ^1.0.3
|
||||
flutter_markdown: ^0.7.7+1
|
||||
flutter_secure_storage: ^9.2.4
|
||||
markdown: ^7.3.0
|
||||
package_info_plus: ^8.3.1
|
||||
path_provider: ^2.1.5
|
||||
shared_preferences: ^2.5.3
|
||||
|
||||
@ -1,8 +1,18 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:xworkmate/app/app_controller.dart';
|
||||
import 'package:xworkmate/features/assistant/assistant_page.dart';
|
||||
import 'package:xworkmate/runtime/codex_runtime.dart';
|
||||
import 'package:xworkmate/runtime/device_identity_store.dart';
|
||||
import 'package:xworkmate/runtime/gateway_runtime.dart';
|
||||
import 'package:xworkmate/runtime/runtime_coordinator.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
import 'package:xworkmate/theme/app_theme.dart';
|
||||
|
||||
import '../test_support.dart';
|
||||
@ -154,7 +164,6 @@ void main() {
|
||||
expect(find.text('研发任务'), findsWidgets);
|
||||
});
|
||||
|
||||
// Known flutter_tester host-exit hang in this widget scenario.
|
||||
testWidgets('AssistantPage groups task rows by execution target', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
@ -165,31 +174,24 @@ void main() {
|
||||
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
|
||||
);
|
||||
|
||||
await controller.saveSettings(
|
||||
controller.settings.copyWith(
|
||||
assistantExecutionTarget: AssistantExecutionTarget.aiGatewayOnly,
|
||||
),
|
||||
refreshAfterSave: false,
|
||||
await tester.tap(find.byKey(const Key('assistant-new-task-button')));
|
||||
await _pumpForUiSync(tester);
|
||||
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.aiGatewayOnly,
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
await _pumpForUiSync(tester);
|
||||
|
||||
await tester.tap(find.byKey(const Key('assistant-new-task-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
await _pumpForUiSync(tester);
|
||||
|
||||
await controller.saveSettings(
|
||||
controller.settings.copyWith(
|
||||
assistantExecutionTarget: AssistantExecutionTarget.remote,
|
||||
),
|
||||
refreshAfterSave: false,
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
await _pumpForUiSync(tester);
|
||||
|
||||
await tester.tap(find.byKey(const Key('assistant-new-task-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
await _pumpForUiSync(tester);
|
||||
|
||||
final aiGroup = find.byKey(
|
||||
const ValueKey<String>('assistant-task-group-aiGatewayOnly'),
|
||||
@ -403,6 +405,143 @@ void main() {
|
||||
expect(find.text('网页处理'), findsOneWidget);
|
||||
});
|
||||
|
||||
// Known flutter_tester host-exit hang in this widget scenario.
|
||||
testWidgets(
|
||||
'AssistantPage syncs task selection with execution target menu and connection chip',
|
||||
(WidgetTester tester) async {
|
||||
final controller = await _createControllerWithThreadRecords(
|
||||
records: const <AssistantThreadRecord>[],
|
||||
useFakeGatewayRuntime: true,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const Key('assistant-new-task-button')));
|
||||
await _pumpForUiSync(tester);
|
||||
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.aiGatewayOnly,
|
||||
);
|
||||
await _pumpForUiSync(tester);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey<String>('assistant-task-item-main')),
|
||||
);
|
||||
await _pumpForUiSync(tester);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('assistant-execution-target-button')),
|
||||
matching: find.text('本地 OpenClaw Gateway'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.textContaining('离线 · 未连接目标'), findsOneWidget);
|
||||
|
||||
final aiThreadItem = find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget.key is ValueKey<String> &&
|
||||
(widget.key as ValueKey<String>).value.startsWith(
|
||||
'assistant-task-item-draft:',
|
||||
),
|
||||
);
|
||||
expect(aiThreadItem, findsOneWidget);
|
||||
|
||||
await tester.tap(aiThreadItem);
|
||||
await _pumpForUiSync(tester);
|
||||
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('assistant-execution-target-button')),
|
||||
matching: find.text('仅 AI Gateway'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.textContaining('仅 AI Gateway'), findsWidgets);
|
||||
},
|
||||
skip: true,
|
||||
);
|
||||
|
||||
testWidgets('AssistantPage shows thread-level message view chip', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final controller = await createTestController(tester);
|
||||
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
|
||||
);
|
||||
|
||||
expect(
|
||||
find.byKey(const Key('assistant-message-view-mode-button')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('渲染'), findsOneWidget);
|
||||
});
|
||||
|
||||
// Known flutter_tester host-exit hang in this widget scenario.
|
||||
testWidgets('AssistantPage toggles Markdown Rendered and RAW per thread', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final controller = await _createControllerWithThreadRecords(
|
||||
records: const <AssistantThreadRecord>[
|
||||
AssistantThreadRecord(
|
||||
sessionKey: 'main',
|
||||
title: '研发任务',
|
||||
archived: false,
|
||||
executionTarget: AssistantExecutionTarget.aiGatewayOnly,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
updatedAtMs: 1700000000000,
|
||||
messages: <GatewayChatMessage>[
|
||||
GatewayChatMessage(
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
text: '请看这个清单',
|
||||
timestampMs: 1700000000000,
|
||||
toolCallId: null,
|
||||
toolName: null,
|
||||
stopReason: null,
|
||||
pending: false,
|
||||
error: false,
|
||||
),
|
||||
GatewayChatMessage(
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
text: '## 标题\\n\\n- 第一项\\n- 第二项',
|
||||
timestampMs: 1700000001000,
|
||||
toolCallId: null,
|
||||
toolName: null,
|
||||
stopReason: null,
|
||||
pending: false,
|
||||
error: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
useFakeGatewayRuntime: true,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
|
||||
);
|
||||
|
||||
expect(find.byType(MarkdownBody), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('assistant-message-view-mode-button')));
|
||||
await _pumpForUiSync(tester);
|
||||
await tester.tap(find.text('RAW').last);
|
||||
await _pumpForUiSync(tester);
|
||||
|
||||
expect(controller.currentAssistantMessageViewMode, AssistantMessageViewMode.raw);
|
||||
expect(find.byType(MarkdownBody), findsNothing);
|
||||
}, skip: true);
|
||||
|
||||
// Known flutter_tester host-exit hang in this widget scenario.
|
||||
testWidgets(
|
||||
'AssistantPage shows AI Gateway-only chip and keeps task rows minimal',
|
||||
@ -445,3 +584,146 @@ void main() {
|
||||
skip: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<AppController> _createControllerWithThreadRecords({
|
||||
required List<AssistantThreadRecord> records,
|
||||
bool useFakeGatewayRuntime = false,
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-assistant-page-tests-',
|
||||
);
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
await store.saveSettingsSnapshot(
|
||||
SettingsSnapshot.defaults().copyWith(
|
||||
aiGateway: SettingsSnapshot.defaults().aiGateway.copyWith(
|
||||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||
availableModels: const <String>['qwen2.5-coder:latest'],
|
||||
selectedModels: const <String>['qwen2.5-coder:latest'],
|
||||
),
|
||||
assistantExecutionTarget: AssistantExecutionTarget.aiGatewayOnly,
|
||||
defaultModel: 'qwen2.5-coder:latest',
|
||||
),
|
||||
);
|
||||
await store.saveAssistantThreadRecords(records);
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
runtimeCoordinator: useFakeGatewayRuntime
|
||||
? RuntimeCoordinator(
|
||||
gateway: _FakeGatewayRuntime(store: store),
|
||||
codex: _FakeCodexRuntime(),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (controller.initializing) {
|
||||
if (DateTime.now().isAfter(deadline)) {
|
||||
fail('controller did not finish initializing before timeout');
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
return controller;
|
||||
}
|
||||
|
||||
Future<void> _pumpForUiSync(WidgetTester tester) async {
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
}
|
||||
|
||||
class _FakeGatewayRuntime extends GatewayRuntime {
|
||||
_FakeGatewayRuntime({required super.store})
|
||||
: super(identityStore: DeviceIdentityStore(store));
|
||||
|
||||
GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial();
|
||||
|
||||
@override
|
||||
bool get isConnected => _snapshot.status == RuntimeConnectionStatus.connected;
|
||||
|
||||
@override
|
||||
GatewayConnectionSnapshot get snapshot => _snapshot;
|
||||
|
||||
@override
|
||||
Stream<GatewayPushEvent> get events => const Stream<GatewayPushEvent>.empty();
|
||||
|
||||
@override
|
||||
Future<void> connectProfile(
|
||||
GatewayConnectionProfile profile, {
|
||||
String authTokenOverride = '',
|
||||
String authPasswordOverride = '',
|
||||
}) async {
|
||||
_snapshot = GatewayConnectionSnapshot.initial(mode: profile.mode).copyWith(
|
||||
status: RuntimeConnectionStatus.connected,
|
||||
statusText: 'Connected',
|
||||
remoteAddress: '${profile.host}:${profile.port}',
|
||||
connectAuthMode: 'none',
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect({bool clearDesiredProfile = true}) async {
|
||||
_snapshot = _snapshot.copyWith(
|
||||
status: RuntimeConnectionStatus.offline,
|
||||
statusText: 'Offline',
|
||||
remoteAddress: null,
|
||||
clearLastError: true,
|
||||
clearLastErrorCode: true,
|
||||
clearLastErrorDetailCode: true,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic> request(
|
||||
String method, {
|
||||
Map<String, dynamic>? params,
|
||||
Duration timeout = const Duration(seconds: 30),
|
||||
}) async {
|
||||
switch (method) {
|
||||
case 'health':
|
||||
case 'status':
|
||||
return <String, dynamic>{'ok': true};
|
||||
case 'agents.list':
|
||||
return <String, dynamic>{'agents': const <Object>[], 'mainKey': 'main'};
|
||||
case 'sessions.list':
|
||||
return <String, dynamic>{'sessions': const <Object>[]};
|
||||
case 'chat.history':
|
||||
return <String, dynamic>{'messages': const <Object>[]};
|
||||
case 'skills.status':
|
||||
return <String, dynamic>{'skills': const <Object>[]};
|
||||
case 'channels.status':
|
||||
return <String, dynamic>{
|
||||
'channelMeta': const <Object>[],
|
||||
'channelLabels': const <String, dynamic>{},
|
||||
'channelDetailLabels': const <String, dynamic>{},
|
||||
'channelAccounts': const <String, dynamic>{},
|
||||
'channelOrder': const <Object>[],
|
||||
};
|
||||
case 'models.list':
|
||||
return <String, dynamic>{'models': const <Object>[]};
|
||||
case 'cron.list':
|
||||
return <String, dynamic>{'jobs': const <Object>[]};
|
||||
case 'device.pair.list':
|
||||
return <String, dynamic>{
|
||||
'pending': const <Object>[],
|
||||
'paired': const <Object>[],
|
||||
};
|
||||
case 'system-presence':
|
||||
return const <Object>[];
|
||||
default:
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeCodexRuntime extends CodexRuntime {
|
||||
@override
|
||||
Future<String?> findCodexBinary() async => null;
|
||||
|
||||
@override
|
||||
Future<void> stop() async {}
|
||||
}
|
||||
|
||||
@ -213,12 +213,6 @@ void main() {
|
||||
controller.settings.assistantExecutionTarget,
|
||||
AssistantExecutionTarget.aiGatewayOnly,
|
||||
);
|
||||
expect(
|
||||
controller.settings.gateway.mode,
|
||||
RuntimeConnectionMode.unconfigured,
|
||||
);
|
||||
expect(controller.settings.gateway.useSetupCode, isFalse);
|
||||
expect(controller.settings.gateway.setupCode, isEmpty);
|
||||
expect(
|
||||
controller.settings.gateway.host,
|
||||
'gateway.example.com',
|
||||
@ -227,6 +221,7 @@ void main() {
|
||||
);
|
||||
expect(controller.settings.gateway.port, 9443);
|
||||
expect(controller.settings.gateway.tls, isTrue);
|
||||
expect(controller.settings.gateway.mode, RuntimeConnectionMode.remote);
|
||||
expect(gateway.disconnectCount, 1);
|
||||
expect(controller.assistantConnectionStatusLabel, '仅 AI Gateway');
|
||||
expect(
|
||||
@ -258,6 +253,144 @@ void main() {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController switches runtime state when the selected thread changes',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-thread-mode-switch-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
final gateway = _FakeGatewayRuntime(store: store);
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
runtimeCoordinator: RuntimeCoordinator(
|
||||
gateway: gateway,
|
||||
codex: _FakeCodexRuntime(),
|
||||
),
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await controller.saveSettings(
|
||||
controller.settings.copyWith(
|
||||
assistantExecutionTarget: AssistantExecutionTarget.local,
|
||||
aiGateway: controller.settings.aiGateway.copyWith(
|
||||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||
availableModels: const <String>['qwen2.5-coder:latest'],
|
||||
selectedModels: const <String>['qwen2.5-coder:latest'],
|
||||
),
|
||||
gateway: controller.settings.gateway.copyWith(
|
||||
mode: RuntimeConnectionMode.remote,
|
||||
host: 'gateway.example.com',
|
||||
port: 9443,
|
||||
tls: true,
|
||||
),
|
||||
),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
|
||||
controller.initializeAssistantThreadContext(
|
||||
'main',
|
||||
executionTarget: AssistantExecutionTarget.aiGatewayOnly,
|
||||
);
|
||||
controller.initializeAssistantThreadContext(
|
||||
'remote-thread',
|
||||
executionTarget: AssistantExecutionTarget.remote,
|
||||
);
|
||||
|
||||
await controller.switchSession('remote-thread');
|
||||
|
||||
expect(
|
||||
controller.assistantExecutionTarget,
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
expect(gateway.connectedProfiles.last.mode, RuntimeConnectionMode.remote);
|
||||
expect(
|
||||
controller.settings.assistantExecutionTarget,
|
||||
AssistantExecutionTarget.local,
|
||||
reason: 'Thread switching should not overwrite the new-thread default.',
|
||||
);
|
||||
|
||||
await controller.switchSession('main');
|
||||
|
||||
expect(
|
||||
controller.assistantExecutionTarget,
|
||||
AssistantExecutionTarget.aiGatewayOnly,
|
||||
);
|
||||
expect(gateway.disconnectCount, 1);
|
||||
expect(controller.assistantConnectionStatusLabel, '仅 AI Gateway');
|
||||
expect(
|
||||
controller.settings.assistantExecutionTarget,
|
||||
AssistantExecutionTarget.local,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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 {
|
||||
if (await tempDirectory.exists()) {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _waitFor(bool Function() predicate) async {
|
||||
|
||||
@ -234,6 +234,8 @@ void main() {
|
||||
sessionKey: 'main',
|
||||
title: '研发任务',
|
||||
archived: true,
|
||||
executionTarget: AssistantExecutionTarget.remote,
|
||||
messageViewMode: AssistantMessageViewMode.raw,
|
||||
updatedAtMs: 1700000000000,
|
||||
messages: <GatewayChatMessage>[
|
||||
GatewayChatMessage(
|
||||
@ -276,6 +278,14 @@ void main() {
|
||||
expect(reloadedRecords.first.sessionKey, 'main');
|
||||
expect(reloadedRecords.first.archived, isTrue);
|
||||
expect(reloadedRecords.first.title, '研发任务');
|
||||
expect(
|
||||
reloadedRecords.first.executionTarget,
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
expect(
|
||||
reloadedRecords.first.messageViewMode,
|
||||
AssistantMessageViewMode.raw,
|
||||
);
|
||||
expect(reloadedRecords.first.messages, hasLength(2));
|
||||
expect(reloadedRecords.first.messages.last.text, '第一条回复');
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user