feat(web): complete assistant thread session parity
This commit is contained in:
parent
3900ef9327
commit
e396d6b176
@ -420,22 +420,22 @@ web:
|
||||
description: Web relay gateway assistant mode
|
||||
ui_surface: web_assistant_page
|
||||
file_attachments:
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
build_modes: []
|
||||
description: Web does not expose file attachments in assistant composer
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Web file attachment action in assistant composer
|
||||
ui_surface: web_assistant_page
|
||||
multi_agent:
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
build_modes: []
|
||||
description: Web does not expose multi-agent assistant toggle
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Web multi-agent toggle in assistant composer
|
||||
ui_surface: web_assistant_page
|
||||
local_gateway:
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
build_modes: []
|
||||
description: Web does not expose local gateway assistant mode
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Web local gateway assistant mode
|
||||
ui_surface: web_assistant_page
|
||||
local_runtime:
|
||||
enabled: false
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -543,22 +543,22 @@ web:
|
||||
description: Web relay gateway assistant mode
|
||||
ui_surface: web_assistant_page
|
||||
file_attachments:
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
build_modes: []
|
||||
description: Web does not expose file attachments in assistant composer
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Web file attachment action in assistant composer
|
||||
ui_surface: web_assistant_page
|
||||
multi_agent:
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
build_modes: []
|
||||
description: Web does not expose multi-agent assistant toggle
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Web multi-agent toggle in assistant composer
|
||||
ui_surface: web_assistant_page
|
||||
local_gateway:
|
||||
enabled: false
|
||||
release_tier: experimental
|
||||
build_modes: []
|
||||
description: Web does not expose local gateway assistant mode
|
||||
enabled: true
|
||||
release_tier: stable
|
||||
build_modes: [debug, profile, release]
|
||||
description: Web local gateway assistant mode
|
||||
ui_surface: web_assistant_page
|
||||
local_runtime:
|
||||
enabled: false
|
||||
|
||||
251
lib/web/web_acp_client.dart
Normal file
251
lib/web/web_acp_client.dart
Normal file
@ -0,0 +1,251 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import '../runtime/runtime_models.dart';
|
||||
|
||||
class WebAcpException implements Exception {
|
||||
const WebAcpException(this.message, {this.code, this.details});
|
||||
|
||||
final String message;
|
||||
final String? code;
|
||||
final Object? details;
|
||||
|
||||
@override
|
||||
String toString() => code == null ? message : '$code: $message';
|
||||
}
|
||||
|
||||
class WebAcpCapabilities {
|
||||
const WebAcpCapabilities({
|
||||
required this.singleAgent,
|
||||
required this.multiAgent,
|
||||
required this.providers,
|
||||
required this.raw,
|
||||
});
|
||||
|
||||
const WebAcpCapabilities.empty()
|
||||
: singleAgent = false,
|
||||
multiAgent = false,
|
||||
providers = const <SingleAgentProvider>{},
|
||||
raw = const <String, dynamic>{};
|
||||
|
||||
final bool singleAgent;
|
||||
final bool multiAgent;
|
||||
final Set<SingleAgentProvider> providers;
|
||||
final Map<String, dynamic> raw;
|
||||
}
|
||||
|
||||
class WebAcpClient {
|
||||
const WebAcpClient();
|
||||
|
||||
static const Duration _defaultTimeout = Duration(seconds: 120);
|
||||
|
||||
Future<WebAcpCapabilities> loadCapabilities({
|
||||
required Uri endpoint,
|
||||
}) async {
|
||||
final response = await request(
|
||||
endpoint: endpoint,
|
||||
method: 'acp.capabilities',
|
||||
params: const <String, dynamic>{},
|
||||
);
|
||||
final result = _asMap(response['result']);
|
||||
final caps = _asMap(result['capabilities']);
|
||||
final providers = <SingleAgentProvider>{};
|
||||
for (final raw in <Object?>[
|
||||
..._asList(result['providers']),
|
||||
..._asList(caps['providers']),
|
||||
]) {
|
||||
if (raw == null) {
|
||||
continue;
|
||||
}
|
||||
final provider = SingleAgentProviderCopy.fromJsonValue(
|
||||
raw.toString().trim().toLowerCase(),
|
||||
);
|
||||
if (provider != SingleAgentProvider.auto) {
|
||||
providers.add(provider);
|
||||
}
|
||||
}
|
||||
final singleAgent =
|
||||
_boolValue(result['singleAgent']) ??
|
||||
_boolValue(caps['single_agent']) ??
|
||||
providers.isNotEmpty;
|
||||
final multiAgent =
|
||||
_boolValue(result['multiAgent']) ??
|
||||
_boolValue(caps['multi_agent']) ??
|
||||
false;
|
||||
return WebAcpCapabilities(
|
||||
singleAgent: singleAgent,
|
||||
multiAgent: multiAgent,
|
||||
providers: providers,
|
||||
raw: result,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> cancelSession({
|
||||
required Uri endpoint,
|
||||
required String sessionId,
|
||||
required String threadId,
|
||||
}) async {
|
||||
await request(
|
||||
endpoint: endpoint,
|
||||
method: 'session.cancel',
|
||||
params: <String, dynamic>{'sessionId': sessionId, 'threadId': threadId},
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> request({
|
||||
required Uri endpoint,
|
||||
required String method,
|
||||
required Map<String, dynamic> params,
|
||||
void Function(Map<String, dynamic> notification)? onNotification,
|
||||
Duration timeout = _defaultTimeout,
|
||||
}) async {
|
||||
final requestId = '${DateTime.now().microsecondsSinceEpoch}-$method';
|
||||
final wsEndpoint = _resolveWebSocketEndpoint(endpoint);
|
||||
if (wsEndpoint == null) {
|
||||
throw const WebAcpException(
|
||||
'Missing ACP endpoint',
|
||||
code: 'ACP_ENDPOINT_MISSING',
|
||||
);
|
||||
}
|
||||
final socket = WebSocketChannel.connect(wsEndpoint);
|
||||
final completer = Completer<Map<String, dynamic>>();
|
||||
late final StreamSubscription<dynamic> subscription;
|
||||
subscription = socket.stream.listen(
|
||||
(raw) {
|
||||
final json = _decodeMap(raw);
|
||||
final id = _stringValue(json['id']);
|
||||
final methodName = _stringValue(json['method']) ?? '';
|
||||
if (id == requestId &&
|
||||
(json.containsKey('result') || json.containsKey('error'))) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(json);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (methodName.isNotEmpty && onNotification != null) {
|
||||
onNotification(json);
|
||||
}
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(
|
||||
WebAcpException(error.toString(), code: 'ACP_WS_RUNTIME_ERROR'),
|
||||
);
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(
|
||||
const WebAcpException(
|
||||
'ACP websocket closed before response',
|
||||
code: 'ACP_WS_EARLY_CLOSE',
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
|
||||
try {
|
||||
await socket.ready;
|
||||
socket.sink.add(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'jsonrpc': '2.0',
|
||||
'id': requestId,
|
||||
'method': method,
|
||||
'params': params,
|
||||
}),
|
||||
);
|
||||
final response = await completer.future.timeout(timeout);
|
||||
_throwIfJsonRpcError(response);
|
||||
return response;
|
||||
} finally {
|
||||
await subscription.cancel();
|
||||
await socket.sink.close();
|
||||
}
|
||||
}
|
||||
|
||||
static Uri? _resolveWebSocketEndpoint(Uri? endpoint) {
|
||||
if (endpoint == null || endpoint.host.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final scheme = endpoint.scheme.trim().toLowerCase();
|
||||
final wsScheme = switch (scheme) {
|
||||
'https' || 'wss' => 'wss',
|
||||
_ => 'ws',
|
||||
};
|
||||
return endpoint.replace(path: '/acp', query: null, fragment: null, scheme: wsScheme);
|
||||
}
|
||||
|
||||
void _throwIfJsonRpcError(Map<String, dynamic> response) {
|
||||
final error = _asMap(response['error']);
|
||||
if (error.isEmpty) {
|
||||
return;
|
||||
}
|
||||
throw WebAcpException(
|
||||
_stringValue(error['message']) ?? 'ACP request failed',
|
||||
code: _stringValue(error['code']),
|
||||
details: error['data'],
|
||||
);
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _decodeMap(Object? raw) {
|
||||
if (raw is Map<String, dynamic>) {
|
||||
return raw;
|
||||
}
|
||||
if (raw is Map) {
|
||||
return raw.cast<String, dynamic>();
|
||||
}
|
||||
if (raw is String) {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return decoded;
|
||||
}
|
||||
if (decoded is Map) {
|
||||
return decoded.cast<String, dynamic>();
|
||||
}
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _asMap(Object? value) {
|
||||
if (value is Map<String, dynamic>) {
|
||||
return value;
|
||||
}
|
||||
if (value is Map) {
|
||||
return value.cast<String, dynamic>();
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
}
|
||||
|
||||
static List<dynamic> _asList(Object? value) {
|
||||
if (value is List<dynamic>) {
|
||||
return value;
|
||||
}
|
||||
if (value is List) {
|
||||
return value.cast<dynamic>();
|
||||
}
|
||||
return const <dynamic>[];
|
||||
}
|
||||
|
||||
static String? _stringValue(Object? value) {
|
||||
final text = value?.toString().trim();
|
||||
return (text == null || text.isEmpty) ? null : text;
|
||||
}
|
||||
|
||||
static bool? _boolValue(Object? value) {
|
||||
if (value is bool) {
|
||||
return value;
|
||||
}
|
||||
final text = value?.toString().trim().toLowerCase();
|
||||
if (text == 'true') {
|
||||
return true;
|
||||
}
|
||||
if (text == 'false') {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:file_selector/file_selector.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app/app_controller_web.dart';
|
||||
@ -24,7 +27,13 @@ class _WebAssistantPageState extends State<WebAssistantPage> {
|
||||
final TextEditingController _inputController = TextEditingController();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
String _query = '';
|
||||
String _thinkingLevel = 'medium';
|
||||
AssistantPermissionLevel _permissionLevel =
|
||||
AssistantPermissionLevel.defaultAccess;
|
||||
bool _useMultiAgent = false;
|
||||
final List<_WebComposerAttachment> _attachments = <_WebComposerAttachment>[];
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@ -41,28 +50,25 @@ class _WebAssistantPageState extends State<WebAssistantPage> {
|
||||
animation: controller,
|
||||
builder: (context, _) {
|
||||
final uiFeatures = controller.featuresFor(UiFeaturePlatform.web);
|
||||
final allDirect = controller.conversationsForTarget(
|
||||
final allSingle = controller.conversationsForTarget(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
final allRelay = controller.conversationsForTarget(
|
||||
final allLocal = controller.conversationsForTarget(
|
||||
AssistantExecutionTarget.local,
|
||||
);
|
||||
final allRemote = controller.conversationsForTarget(
|
||||
AssistantExecutionTarget.remote,
|
||||
);
|
||||
final direct = _filterConversations(allDirect);
|
||||
final relay = _filterConversations(allRelay);
|
||||
final currentTarget = controller.assistantExecutionTarget;
|
||||
final availableTargets = uiFeatures.availableExecutionTargets
|
||||
.where(
|
||||
(target) =>
|
||||
target == AssistantExecutionTarget.singleAgent ||
|
||||
target == AssistantExecutionTarget.remote,
|
||||
)
|
||||
.toList(growable: false);
|
||||
final connected =
|
||||
currentTarget == AssistantExecutionTarget.singleAgent
|
||||
? controller.canUseAiGatewayConversation
|
||||
: controller.connection.status == RuntimeConnectionStatus.connected;
|
||||
final currentMessages = controller.chatMessages;
|
||||
final single = _filterConversations(allSingle);
|
||||
final local = _filterConversations(allLocal);
|
||||
final remote = _filterConversations(allRemote);
|
||||
|
||||
final availableTargets = uiFeatures.availableExecutionTargets;
|
||||
final currentTarget = controller.assistantExecutionTarget;
|
||||
final connectionState = controller.currentAssistantConnectionState;
|
||||
final connected = connectionState.ready;
|
||||
|
||||
final currentMessages = controller.chatMessages;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.jumpTo(
|
||||
@ -71,6 +77,13 @@ class _WebAssistantPageState extends State<WebAssistantPage> {
|
||||
}
|
||||
});
|
||||
|
||||
final selectedSkillKeys = controller.assistantSelectedSkillKeysForSession(
|
||||
controller.currentSessionKey,
|
||||
);
|
||||
final importedSkills = controller.assistantImportedSkillsForSession(
|
||||
controller.currentSessionKey,
|
||||
);
|
||||
|
||||
return DesktopWorkspaceScaffold(
|
||||
breadcrumbs: <AppBreadcrumbItem>[
|
||||
AppBreadcrumbItem(
|
||||
@ -83,8 +96,8 @@ class _WebAssistantPageState extends State<WebAssistantPage> {
|
||||
eyebrow: appText('Web Workspace', 'Web Workspace'),
|
||||
title: appText('助手', 'Assistant'),
|
||||
subtitle: appText(
|
||||
'单机智能体与 Relay Gateway 共用一个入口,左侧保留会话/任务历史。',
|
||||
'Use one Assistant surface for Single Agent and Relay Gateway, with embedded conversation history on the left.',
|
||||
'Web 助手保持任务线程会话隔离,支持 Single Agent / Local / Remote 三种模式。',
|
||||
'Web Assistant keeps per-thread session isolation with Single Agent / Local / Remote modes.',
|
||||
),
|
||||
toolbar: Wrap(
|
||||
spacing: 10,
|
||||
@ -98,8 +111,7 @@ class _WebAssistantPageState extends State<WebAssistantPage> {
|
||||
label: Text(appText('新对话', 'New conversation')),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
controller.openSettings(tab: SettingsTab.gateway),
|
||||
onPressed: () => controller.openSettings(tab: SettingsTab.gateway),
|
||||
icon: const Icon(Icons.tune_rounded),
|
||||
label: Text(appText('连接设置', 'Connection settings')),
|
||||
),
|
||||
@ -116,7 +128,7 @@ class _WebAssistantPageState extends State<WebAssistantPage> {
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final vertical = constraints.maxWidth < 980;
|
||||
final vertical = constraints.maxWidth < 1080;
|
||||
final rail = _ConversationRail(
|
||||
controller: controller,
|
||||
query: _query,
|
||||
@ -128,23 +140,52 @@ class _WebAssistantPageState extends State<WebAssistantPage> {
|
||||
_searchController.clear();
|
||||
setState(() => _query = '');
|
||||
},
|
||||
showDirect: uiFeatures.supportsDirectAi,
|
||||
showRelay: uiFeatures.supportsRelayGateway,
|
||||
direct: direct,
|
||||
relay: relay,
|
||||
showSingle: uiFeatures.supportsDirectAi,
|
||||
showLocal: uiFeatures.supportsLocalGateway,
|
||||
showRemote: uiFeatures.supportsRelayGateway,
|
||||
single: single,
|
||||
local: local,
|
||||
remote: remote,
|
||||
onRename: (sessionKey) => _renameConversation(sessionKey),
|
||||
onArchive: (sessionKey) =>
|
||||
controller.saveAssistantTaskArchived(sessionKey, true),
|
||||
);
|
||||
|
||||
final panel = _ConversationPanel(
|
||||
controller: controller,
|
||||
inputController: _inputController,
|
||||
scrollController: _scrollController,
|
||||
connected: connected,
|
||||
currentMessages: currentMessages,
|
||||
connectionState: connectionState,
|
||||
thinkingLevel: _thinkingLevel,
|
||||
permissionLevel: _permissionLevel,
|
||||
useMultiAgent: _useMultiAgent,
|
||||
importedSkills: importedSkills,
|
||||
selectedSkillKeys: selectedSkillKeys,
|
||||
attachments: _attachments,
|
||||
onThinkingChanged: (value) {
|
||||
setState(() => _thinkingLevel = value);
|
||||
},
|
||||
onPermissionChanged: (value) {
|
||||
setState(() => _permissionLevel = value);
|
||||
},
|
||||
onToggleMultiAgent: (value) {
|
||||
setState(() => _useMultiAgent = value);
|
||||
},
|
||||
onAddAttachment: _pickAttachments,
|
||||
onRemoveAttachment: (index) {
|
||||
setState(() {
|
||||
_attachments.removeAt(index);
|
||||
});
|
||||
},
|
||||
onSubmit: _submitPrompt,
|
||||
);
|
||||
|
||||
if (vertical) {
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(height: 300, child: rail),
|
||||
SizedBox(height: 320, child: rail),
|
||||
const SizedBox(height: 8),
|
||||
Expanded(child: panel),
|
||||
],
|
||||
@ -153,7 +194,7 @@ class _WebAssistantPageState extends State<WebAssistantPage> {
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(width: 320, child: rail),
|
||||
SizedBox(width: 340, child: rail),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: panel),
|
||||
],
|
||||
@ -178,6 +219,129 @@ class _WebAssistantPageState extends State<WebAssistantPage> {
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<void> _renameConversation(String sessionKey) async {
|
||||
final controller = widget.controller;
|
||||
final initial = controller.conversations
|
||||
.firstWhere(
|
||||
(item) => item.sessionKey == sessionKey,
|
||||
orElse: () => WebConversationSummary(
|
||||
sessionKey: sessionKey,
|
||||
title: '',
|
||||
preview: '',
|
||||
updatedAtMs: 0,
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
pending: false,
|
||||
current: false,
|
||||
),
|
||||
)
|
||||
.title;
|
||||
final renameController = TextEditingController(text: initial);
|
||||
final value = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(appText('重命名任务线程', 'Rename task thread')),
|
||||
content: TextField(
|
||||
controller: renameController,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: appText('输入标题', 'Enter a title'),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(appText('取消', 'Cancel')),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(renameController.text),
|
||||
child: Text(appText('保存', 'Save')),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
renameController.dispose();
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
await controller.saveAssistantTaskTitle(sessionKey, value);
|
||||
}
|
||||
|
||||
Future<void> _pickAttachments() async {
|
||||
final controller = widget.controller;
|
||||
final uiFeatures = controller.featuresFor(UiFeaturePlatform.web);
|
||||
if (!uiFeatures.supportsFileAttachments) {
|
||||
return;
|
||||
}
|
||||
final files = await openFiles(
|
||||
acceptedTypeGroups: const <XTypeGroup>[
|
||||
XTypeGroup(
|
||||
label: 'Images',
|
||||
extensions: <String>['png', 'jpg', 'jpeg', 'gif', 'webp'],
|
||||
),
|
||||
XTypeGroup(
|
||||
label: 'Documents',
|
||||
extensions: <String>['txt', 'md', 'json', 'csv', 'pdf', 'yaml', 'yml'],
|
||||
),
|
||||
],
|
||||
);
|
||||
if (!mounted || files.isEmpty) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_attachments.addAll(files.map(_WebComposerAttachment.fromXFile));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _submitPrompt() async {
|
||||
final controller = widget.controller;
|
||||
final value = _inputController.text.trim();
|
||||
if (value.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final payloads = <GatewayChatAttachmentPayload>[];
|
||||
for (final attachment in _attachments) {
|
||||
final bytes = await attachment.file.readAsBytes();
|
||||
payloads.add(
|
||||
GatewayChatAttachmentPayload(
|
||||
type: attachment.mimeType.startsWith('image/') ? 'image' : 'file',
|
||||
mimeType: attachment.mimeType,
|
||||
fileName: attachment.name,
|
||||
content: base64Encode(bytes),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final selectedSkillLabels = controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.where(
|
||||
(item) => controller
|
||||
.assistantSelectedSkillKeysForSession(controller.currentSessionKey)
|
||||
.contains(item.key),
|
||||
)
|
||||
.map((item) => item.label)
|
||||
.where((item) => item.trim().isNotEmpty)
|
||||
.toList(growable: false);
|
||||
|
||||
await controller.sendMessage(
|
||||
value,
|
||||
thinking: _thinkingLevel,
|
||||
attachments: payloads,
|
||||
selectedSkillLabels: selectedSkillLabels,
|
||||
useMultiAgent: _useMultiAgent,
|
||||
);
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_inputController.clear();
|
||||
setState(() {
|
||||
_attachments.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _ConversationRail extends StatelessWidget {
|
||||
@ -187,10 +351,14 @@ class _ConversationRail extends StatelessWidget {
|
||||
required this.searchController,
|
||||
required this.onQueryChanged,
|
||||
required this.onClearQuery,
|
||||
required this.showDirect,
|
||||
required this.showRelay,
|
||||
required this.direct,
|
||||
required this.relay,
|
||||
required this.showSingle,
|
||||
required this.showLocal,
|
||||
required this.showRemote,
|
||||
required this.single,
|
||||
required this.local,
|
||||
required this.remote,
|
||||
required this.onRename,
|
||||
required this.onArchive,
|
||||
});
|
||||
|
||||
final AppController controller;
|
||||
@ -198,10 +366,14 @@ class _ConversationRail extends StatelessWidget {
|
||||
final TextEditingController searchController;
|
||||
final ValueChanged<String> onQueryChanged;
|
||||
final VoidCallback onClearQuery;
|
||||
final bool showDirect;
|
||||
final bool showRelay;
|
||||
final List<WebConversationSummary> direct;
|
||||
final List<WebConversationSummary> relay;
|
||||
final bool showSingle;
|
||||
final bool showLocal;
|
||||
final bool showRemote;
|
||||
final List<WebConversationSummary> single;
|
||||
final List<WebConversationSummary> local;
|
||||
final List<WebConversationSummary> remote;
|
||||
final ValueChanged<String> onRename;
|
||||
final ValueChanged<String> onArchive;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -216,7 +388,7 @@ class _ConversationRail extends StatelessWidget {
|
||||
controller: searchController,
|
||||
onChanged: onQueryChanged,
|
||||
decoration: InputDecoration(
|
||||
hintText: appText('搜索会话', 'Search conversations'),
|
||||
hintText: appText('搜索任务线程', 'Search task threads'),
|
||||
prefixIcon: const Icon(Icons.search_rounded),
|
||||
suffixIcon: query.isEmpty
|
||||
? null
|
||||
@ -230,32 +402,49 @@ class _ConversationRail extends StatelessWidget {
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: [
|
||||
if (showDirect)
|
||||
if (showSingle)
|
||||
_ConversationGroup(
|
||||
title: appText('Single Agent', 'Single Agent'),
|
||||
icon: Icons.hub_rounded,
|
||||
items: direct,
|
||||
items: single,
|
||||
emptyLabel: appText(
|
||||
'还没有单机智能体对话',
|
||||
'No Single Agent conversations yet',
|
||||
'还没有 Single Agent 任务线程',
|
||||
'No Single Agent task threads yet',
|
||||
),
|
||||
onSelect: controller.switchConversation,
|
||||
onRename: onRename,
|
||||
onArchive: onArchive,
|
||||
),
|
||||
if (showDirect && showRelay) const SizedBox(height: 12),
|
||||
if (showRelay)
|
||||
if (showLocal) ...[
|
||||
const SizedBox(height: 12),
|
||||
_ConversationGroup(
|
||||
title: appText(
|
||||
'Relay OpenClaw Gateway',
|
||||
'Relay OpenClaw Gateway',
|
||||
),
|
||||
icon: Icons.cloud_outlined,
|
||||
items: relay,
|
||||
title: appText('Local Gateway', 'Local Gateway'),
|
||||
icon: Icons.lan_rounded,
|
||||
items: local,
|
||||
emptyLabel: appText(
|
||||
'还没有 Relay 对话',
|
||||
'No Relay conversations yet',
|
||||
'还没有 Local Gateway 任务线程',
|
||||
'No Local Gateway task threads yet',
|
||||
),
|
||||
onSelect: controller.switchConversation,
|
||||
onRename: onRename,
|
||||
onArchive: onArchive,
|
||||
),
|
||||
],
|
||||
if (showRemote) ...[
|
||||
const SizedBox(height: 12),
|
||||
_ConversationGroup(
|
||||
title: appText('Remote Gateway', 'Remote Gateway'),
|
||||
icon: Icons.cloud_outlined,
|
||||
items: remote,
|
||||
emptyLabel: appText(
|
||||
'还没有 Remote Gateway 任务线程',
|
||||
'No Remote Gateway task threads yet',
|
||||
),
|
||||
onSelect: controller.switchConversation,
|
||||
onRename: onRename,
|
||||
onArchive: onArchive,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -272,6 +461,8 @@ class _ConversationGroup extends StatelessWidget {
|
||||
required this.items,
|
||||
required this.emptyLabel,
|
||||
required this.onSelect,
|
||||
required this.onRename,
|
||||
required this.onArchive,
|
||||
});
|
||||
|
||||
final String title;
|
||||
@ -279,6 +470,8 @@ class _ConversationGroup extends StatelessWidget {
|
||||
final List<WebConversationSummary> items;
|
||||
final String emptyLabel;
|
||||
final ValueChanged<String> onSelect;
|
||||
final ValueChanged<String> onRename;
|
||||
final ValueChanged<String> onArchive;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -318,40 +511,56 @@ class _ConversationGroup extends StatelessWidget {
|
||||
borderRadius: 10,
|
||||
padding: const EdgeInsets.all(12),
|
||||
color: item.current ? palette.accentMuted : null,
|
||||
child: Row(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
),
|
||||
IconButton(
|
||||
tooltip: appText('重命名', 'Rename'),
|
||||
onPressed: () => onRename(item.sessionKey),
|
||||
icon: const Icon(Icons.drive_file_rename_outline_rounded),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: appText('归档', 'Archive'),
|
||||
onPressed: () => onArchive(item.sessionKey),
|
||||
icon: const Icon(Icons.archive_outlined),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.preview,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: palette.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (item.pending)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 8, top: 2),
|
||||
child: SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
if (item.pending)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 8, top: 2),
|
||||
child: SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -369,6 +578,19 @@ class _ConversationPanel extends StatelessWidget {
|
||||
required this.scrollController,
|
||||
required this.connected,
|
||||
required this.currentMessages,
|
||||
required this.connectionState,
|
||||
required this.thinkingLevel,
|
||||
required this.permissionLevel,
|
||||
required this.useMultiAgent,
|
||||
required this.importedSkills,
|
||||
required this.selectedSkillKeys,
|
||||
required this.attachments,
|
||||
required this.onThinkingChanged,
|
||||
required this.onPermissionChanged,
|
||||
required this.onToggleMultiAgent,
|
||||
required this.onAddAttachment,
|
||||
required this.onRemoveAttachment,
|
||||
required this.onSubmit,
|
||||
});
|
||||
|
||||
final AppController controller;
|
||||
@ -376,47 +598,140 @@ class _ConversationPanel extends StatelessWidget {
|
||||
final ScrollController scrollController;
|
||||
final bool connected;
|
||||
final List<GatewayChatMessage> currentMessages;
|
||||
final AssistantThreadConnectionState connectionState;
|
||||
final String thinkingLevel;
|
||||
final AssistantPermissionLevel permissionLevel;
|
||||
final bool useMultiAgent;
|
||||
final List<AssistantThreadSkillEntry> importedSkills;
|
||||
final List<String> selectedSkillKeys;
|
||||
final List<_WebComposerAttachment> attachments;
|
||||
final ValueChanged<String> onThinkingChanged;
|
||||
final ValueChanged<AssistantPermissionLevel> onPermissionChanged;
|
||||
final ValueChanged<bool> onToggleMultiAgent;
|
||||
final Future<void> Function() onAddAttachment;
|
||||
final ValueChanged<int> onRemoveAttachment;
|
||||
final Future<void> Function() onSubmit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.palette;
|
||||
final currentTarget = controller.assistantExecutionTarget;
|
||||
final targetReady = currentTarget == AssistantExecutionTarget.singleAgent
|
||||
? controller.canUseAiGatewayConversation
|
||||
: controller.connection.status == RuntimeConnectionStatus.connected;
|
||||
final modelChoices = controller.assistantModelChoices;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SurfaceCard(
|
||||
borderRadius: 10,
|
||||
tone: SurfaceCardTone.chrome,
|
||||
child: Row(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
controller.currentConversationTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
controller.currentConversationTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
controller.assistantConnectionTargetLabel,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: palette.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
controller.assistantConnectionTargetLabel,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: palette.textSecondary,
|
||||
),
|
||||
),
|
||||
StatusBadge(
|
||||
status: StatusInfo(
|
||||
controller.assistantConnectionStatusLabel,
|
||||
connected ? StatusTone.success : StatusTone.warning,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
StatusBadge(
|
||||
status: StatusInfo(
|
||||
controller.assistantConnectionStatusLabel,
|
||||
targetReady ? StatusTone.success : StatusTone.warning,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_CompactDropdown<AssistantExecutionTarget>(
|
||||
key: const Key('assistant-target-button'),
|
||||
value: currentTarget,
|
||||
items: controller
|
||||
.featuresFor(UiFeaturePlatform.web)
|
||||
.availableExecutionTargets,
|
||||
labelBuilder: _targetLabel,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
controller.setAssistantExecutionTarget(value);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (currentTarget == AssistantExecutionTarget.singleAgent)
|
||||
_CompactDropdown<SingleAgentProvider>(
|
||||
key: const Key('assistant-single-agent-provider-button'),
|
||||
value: controller.currentSingleAgentProvider,
|
||||
items: controller.singleAgentProviderOptions,
|
||||
labelBuilder: (item) => item.label,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
controller.setSingleAgentProvider(value);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (modelChoices.isNotEmpty)
|
||||
_CompactDropdown<String>(
|
||||
key: const Key('assistant-model-button'),
|
||||
value: controller.resolvedAssistantModel,
|
||||
items: modelChoices,
|
||||
labelBuilder: (item) => item,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
controller.selectAssistantModel(value);
|
||||
}
|
||||
},
|
||||
),
|
||||
_CompactDropdown<AssistantMessageViewMode>(
|
||||
key: const Key('assistant-message-view-mode-button'),
|
||||
value: controller.currentAssistantMessageViewMode,
|
||||
items: AssistantMessageViewMode.values,
|
||||
labelBuilder: (item) => item.label,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
controller.setAssistantMessageViewMode(value);
|
||||
}
|
||||
},
|
||||
),
|
||||
_CompactDropdown<String>(
|
||||
key: const Key('assistant-thinking-button'),
|
||||
value: thinkingLevel,
|
||||
items: const <String>['low', 'medium', 'high'],
|
||||
labelBuilder: _thinkingLabel,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
onThinkingChanged(value);
|
||||
}
|
||||
},
|
||||
),
|
||||
_CompactDropdown<AssistantPermissionLevel>(
|
||||
key: const Key('assistant-permission-button'),
|
||||
value: permissionLevel,
|
||||
items: AssistantPermissionLevel.values,
|
||||
labelBuilder: (item) => item.label,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
onPermissionChanged(value);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -433,19 +748,18 @@ class _ConversationPanel extends StatelessWidget {
|
||||
child: Text(
|
||||
currentTarget == AssistantExecutionTarget.singleAgent
|
||||
? appText(
|
||||
'当前单机智能体配置还不完整,请先在 Settings 中保存 LLM API Endpoint、LLM API Token 和默认模型。',
|
||||
'Single Agent is not ready yet. Save the LLM API Endpoint, LLM API Token, and default model in Settings first.',
|
||||
'当前线程未就绪。请检查 Single Agent 配置,或切换到可连接的 Gateway 目标。',
|
||||
'This thread is not ready. Check Single Agent configuration, or switch to a connected gateway target.',
|
||||
)
|
||||
: appText(
|
||||
'当前 Relay Gateway 尚未连接,请先在 Settings 中保存配置并连接。',
|
||||
'Relay Gateway is offline. Save the relay config and connect from Settings first.',
|
||||
'当前线程目标网关未连接。请先在 Settings 中 Test / Save / Apply。',
|
||||
'The gateway target for this thread is offline. Use Test / Save / Apply in Settings first.',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: () =>
|
||||
controller.openSettings(tab: SettingsTab.gateway),
|
||||
onPressed: () => controller.openSettings(tab: SettingsTab.gateway),
|
||||
child: Text(appText('打开设置', 'Open settings')),
|
||||
),
|
||||
],
|
||||
@ -459,6 +773,28 @@ class _ConversationPanel extends StatelessWidget {
|
||||
tone: SurfaceCardTone.chrome,
|
||||
child: Column(
|
||||
children: [
|
||||
if (importedSkills.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 8),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: importedSkills.map((skill) {
|
||||
final selected = selectedSkillKeys.contains(skill.key);
|
||||
return FilterChip(
|
||||
label: Text(skill.label),
|
||||
selected: selected,
|
||||
onSelected: (_) => controller.toggleAssistantSkillForSession(
|
||||
controller.currentSessionKey,
|
||||
skill.key,
|
||||
),
|
||||
);
|
||||
}).toList(growable: false),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
@ -475,26 +811,40 @@ class _ConversationPanel extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
children: [
|
||||
if (attachments.isNotEmpty)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (var index = 0; index < attachments.length; index++)
|
||||
InputChip(
|
||||
avatar: Icon(attachments[index].icon, size: 16),
|
||||
label: Text(attachments[index].name),
|
||||
onDeleted: () => onRemoveAttachment(index),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (attachments.isNotEmpty) const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: inputController,
|
||||
minLines: 3,
|
||||
maxLines: 6,
|
||||
maxLines: 8,
|
||||
decoration: InputDecoration(
|
||||
hintText: appText(
|
||||
'输入需求、补充上下文、继续追问',
|
||||
'Describe the task, add context, or continue the conversation',
|
||||
'输入任务说明、上下文和期望输出',
|
||||
'Describe the task, context, and expected output',
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) {
|
||||
if (!connected) {
|
||||
return;
|
||||
if (connected) {
|
||||
onSubmit();
|
||||
}
|
||||
final value = inputController.text;
|
||||
inputController.clear();
|
||||
controller.sendMessage(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
@ -503,43 +853,48 @@ class _ConversationPanel extends StatelessWidget {
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: useMultiAgent,
|
||||
onChanged: (value) {
|
||||
onToggleMultiAgent(value ?? false);
|
||||
},
|
||||
),
|
||||
Text(appText('Multi-Agent', 'Multi-Agent')),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
key: const Key('assistant-attachment-menu-button'),
|
||||
tooltip: appText('添加附件', 'Add attachment'),
|
||||
onPressed: onAddAttachment,
|
||||
icon: const Icon(Icons.attach_file_rounded),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
currentTarget ==
|
||||
AssistantExecutionTarget.singleAgent
|
||||
? appText(
|
||||
'Web 端单机智能体只保留纯网络能力,不提供本地文件和 CLI。',
|
||||
'Single Agent on web keeps network-only capabilities and does not expose local files or CLI.',
|
||||
)
|
||||
controller.lastAssistantError?.trim().isNotEmpty == true
|
||||
? controller.lastAssistantError!.trim()
|
||||
: appText(
|
||||
'Web 端 Relay 模式使用远程 OpenClaw Gateway,不区分 local / remote。',
|
||||
'Relay mode on web uses the remote OpenClaw Gateway and does not expose local / remote splits.',
|
||||
'附件仅支持手动选择,单次总量上限 10MB。',
|
||||
'Attachments are explicit user picks only, with a 10MB total limit per send.',
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: palette.textSecondary),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: palette.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: connected
|
||||
? () {
|
||||
final value = inputController.text;
|
||||
inputController.clear();
|
||||
controller.sendMessage(value);
|
||||
}
|
||||
: () => controller.openSettings(
|
||||
tab: SettingsTab.gateway,
|
||||
),
|
||||
icon: Icon(
|
||||
connected
|
||||
? Icons.arrow_upward_rounded
|
||||
: Icons.settings_rounded,
|
||||
),
|
||||
label: Text(
|
||||
connected
|
||||
? appText('提交', 'Submit')
|
||||
: appText('配置', 'Configure'),
|
||||
),
|
||||
onPressed: connected ? onSubmit : null,
|
||||
icon: controller.relayBusy || controller.acpBusy
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.arrow_upward_rounded),
|
||||
label: Text(appText('发送', 'Send')),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -573,7 +928,7 @@ class _MessageBubble extends StatelessWidget {
|
||||
return Align(
|
||||
alignment: assistant ? Alignment.centerLeft : Alignment.centerRight,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 720),
|
||||
constraints: const BoxConstraints(maxWidth: 760),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: DecoratedBox(
|
||||
@ -623,28 +978,118 @@ class _TargetChip extends StatelessWidget {
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
items: targets
|
||||
.map((target) {
|
||||
return DropdownMenuItem<AssistantExecutionTarget>(
|
||||
.map(
|
||||
(target) => DropdownMenuItem<AssistantExecutionTarget>(
|
||||
value: target,
|
||||
child: Text(_targetLabel(target)),
|
||||
);
|
||||
})
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CompactDropdown<T> extends StatelessWidget {
|
||||
const _CompactDropdown({
|
||||
super.key,
|
||||
required this.value,
|
||||
required this.items,
|
||||
required this.labelBuilder,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final T value;
|
||||
final List<T> items;
|
||||
final String Function(T item) labelBuilder;
|
||||
final ValueChanged<T?> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (items.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return DropdownButtonHideUnderline(
|
||||
child: DropdownButton<T>(
|
||||
value: items.contains(value) ? value : items.first,
|
||||
onChanged: onChanged,
|
||||
items: items
|
||||
.map(
|
||||
(item) => DropdownMenuItem<T>(
|
||||
value: item,
|
||||
child: Text(labelBuilder(item)),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WebComposerAttachment {
|
||||
const _WebComposerAttachment({
|
||||
required this.file,
|
||||
required this.name,
|
||||
required this.mimeType,
|
||||
required this.icon,
|
||||
});
|
||||
|
||||
final XFile file;
|
||||
final String name;
|
||||
final String mimeType;
|
||||
final IconData icon;
|
||||
|
||||
factory _WebComposerAttachment.fromXFile(XFile file) {
|
||||
final extension = file.name.split('.').last.toLowerCase();
|
||||
final mimeType = file.mimeType?.trim().isNotEmpty == true
|
||||
? file.mimeType!.trim()
|
||||
: switch (extension) {
|
||||
'png' => 'image/png',
|
||||
'jpg' || 'jpeg' => 'image/jpeg',
|
||||
'gif' => 'image/gif',
|
||||
'webp' => 'image/webp',
|
||||
'json' => 'application/json',
|
||||
'csv' => 'text/csv',
|
||||
'txt' || 'log' || 'md' || 'yaml' || 'yml' => 'text/plain',
|
||||
'pdf' => 'application/pdf',
|
||||
_ => 'application/octet-stream',
|
||||
};
|
||||
final icon = mimeType.startsWith('image/')
|
||||
? Icons.image_outlined
|
||||
: mimeType == 'application/pdf'
|
||||
? Icons.picture_as_pdf_outlined
|
||||
: Icons.insert_drive_file_outlined;
|
||||
return _WebComposerAttachment(
|
||||
file: file,
|
||||
name: file.name,
|
||||
mimeType: mimeType,
|
||||
icon: icon,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _thinkingLabel(String level) {
|
||||
return switch (level) {
|
||||
'low' => appText('低', 'Low'),
|
||||
'medium' => appText('中', 'Medium'),
|
||||
'high' => appText('高', 'High'),
|
||||
_ => level,
|
||||
};
|
||||
}
|
||||
|
||||
String _targetLabel(AssistantExecutionTarget target) {
|
||||
return switch (target) {
|
||||
AssistantExecutionTarget.singleAgent => appText(
|
||||
'Single Agent',
|
||||
'Single Agent',
|
||||
),
|
||||
AssistantExecutionTarget.remote => appText(
|
||||
'Relay OpenClaw Gateway',
|
||||
'Relay OpenClaw Gateway',
|
||||
AssistantExecutionTarget.local => appText(
|
||||
'Local Gateway',
|
||||
'Local Gateway',
|
||||
),
|
||||
AssistantExecutionTarget.remote => appText(
|
||||
'Remote Gateway',
|
||||
'Remote Gateway',
|
||||
),
|
||||
_ => '',
|
||||
};
|
||||
}
|
||||
|
||||
@ -37,7 +37,7 @@ class WebRelayGatewayClient {
|
||||
StreamSubscription<dynamic>? _subscription;
|
||||
int _requestCounter = 0;
|
||||
GatewayConnectionSnapshot _snapshot = GatewayConnectionSnapshot.initial(
|
||||
mode: RuntimeConnectionMode.remote,
|
||||
mode: RuntimeConnectionMode.unconfigured,
|
||||
);
|
||||
|
||||
Stream<GatewayPushEvent> get events => _events.stream;
|
||||
@ -51,11 +51,14 @@ class WebRelayGatewayClient {
|
||||
required String authPassword,
|
||||
}) async {
|
||||
await disconnect();
|
||||
final targetMode = profile.mode == RuntimeConnectionMode.local
|
||||
? RuntimeConnectionMode.local
|
||||
: RuntimeConnectionMode.remote;
|
||||
final endpoint = _resolveEndpoint(profile);
|
||||
if (endpoint == null) {
|
||||
_snapshot =
|
||||
GatewayConnectionSnapshot.initial(
|
||||
mode: RuntimeConnectionMode.remote,
|
||||
mode: targetMode,
|
||||
).copyWith(
|
||||
status: RuntimeConnectionStatus.error,
|
||||
statusText: 'Missing relay endpoint',
|
||||
@ -68,7 +71,7 @@ class WebRelayGatewayClient {
|
||||
final identity = await _identityManager.loadOrCreate(_store);
|
||||
_snapshot =
|
||||
GatewayConnectionSnapshot.initial(
|
||||
mode: RuntimeConnectionMode.remote,
|
||||
mode: targetMode,
|
||||
).copyWith(
|
||||
status: RuntimeConnectionStatus.connecting,
|
||||
statusText: 'Connecting…',
|
||||
@ -136,6 +139,7 @@ class WebRelayGatewayClient {
|
||||
);
|
||||
|
||||
try {
|
||||
await channel.ready;
|
||||
final nonce = await challenge.future.timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () =>
|
||||
@ -159,6 +163,7 @@ class WebRelayGatewayClient {
|
||||
_snapshot = _snapshot.copyWith(
|
||||
status: RuntimeConnectionStatus.connected,
|
||||
statusText: 'Connected',
|
||||
mode: targetMode,
|
||||
serverName: _stringValue(server['host']),
|
||||
remoteAddress: '${endpoint.host}:${endpoint.port}',
|
||||
mainSessionKey:
|
||||
@ -173,6 +178,7 @@ class WebRelayGatewayClient {
|
||||
} catch (error) {
|
||||
await disconnect();
|
||||
_snapshot = _snapshot.copyWith(
|
||||
mode: targetMode,
|
||||
status: RuntimeConnectionStatus.error,
|
||||
statusText: 'Connection failed',
|
||||
lastError: error.toString(),
|
||||
@ -195,6 +201,13 @@ class WebRelayGatewayClient {
|
||||
_subscription = null;
|
||||
await _channel?.sink.close();
|
||||
_channel = null;
|
||||
if (_snapshot.status != RuntimeConnectionStatus.offline) {
|
||||
_snapshot = _snapshot.copyWith(
|
||||
status: RuntimeConnectionStatus.offline,
|
||||
statusText: 'Offline',
|
||||
clearRemoteAddress: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<GatewaySessionSummary>> listSessions({int limit = 50}) async {
|
||||
@ -275,8 +288,15 @@ class WebRelayGatewayClient {
|
||||
required String sessionKey,
|
||||
required String message,
|
||||
required String thinking,
|
||||
List<GatewayChatAttachmentPayload> attachments =
|
||||
const <GatewayChatAttachmentPayload>[],
|
||||
Map<String, dynamic> metadata = const <String, dynamic>{},
|
||||
}) async {
|
||||
final runId = _randomId();
|
||||
final normalizedMetadata = <String, dynamic>{
|
||||
for (final entry in metadata.entries)
|
||||
if (entry.key.trim().isNotEmpty) entry.key: entry.value,
|
||||
};
|
||||
final payload = _asMap(
|
||||
await request(
|
||||
'chat.send',
|
||||
@ -284,6 +304,11 @@ class WebRelayGatewayClient {
|
||||
'sessionKey': sessionKey,
|
||||
'message': message,
|
||||
'thinking': thinking,
|
||||
if (attachments.isNotEmpty)
|
||||
'attachments': attachments
|
||||
.map((item) => item.toJson())
|
||||
.toList(growable: false),
|
||||
if (normalizedMetadata.isNotEmpty) 'metadata': normalizedMetadata,
|
||||
'timeoutMs': 30000,
|
||||
'idempotencyKey': runId,
|
||||
},
|
||||
|
||||
@ -26,16 +26,22 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
|
||||
late final TextEditingController _directBaseUrlController;
|
||||
late final TextEditingController _directProviderController;
|
||||
late final TextEditingController _directApiKeyController;
|
||||
late final TextEditingController _relayHostController;
|
||||
late final TextEditingController _relayPortController;
|
||||
late final TextEditingController _relayTokenController;
|
||||
late final TextEditingController _relayPasswordController;
|
||||
late final TextEditingController _localHostController;
|
||||
late final TextEditingController _localPortController;
|
||||
late final TextEditingController _localTokenController;
|
||||
late final TextEditingController _localPasswordController;
|
||||
late final TextEditingController _remoteHostController;
|
||||
late final TextEditingController _remotePortController;
|
||||
late final TextEditingController _remoteTokenController;
|
||||
late final TextEditingController _remotePasswordController;
|
||||
late final TextEditingController _sessionRemoteBaseUrlController;
|
||||
late final TextEditingController _sessionApiTokenController;
|
||||
late WebSessionPersistenceMode _sessionPersistenceMode;
|
||||
bool _remoteTls = true;
|
||||
|
||||
String _directMessage = '';
|
||||
String _relayMessage = '';
|
||||
String _localGatewayMessage = '';
|
||||
String _remoteGatewayMessage = '';
|
||||
String _sessionPersistenceMessage = '';
|
||||
|
||||
@override
|
||||
@ -45,10 +51,14 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
|
||||
_directBaseUrlController = TextEditingController();
|
||||
_directProviderController = TextEditingController();
|
||||
_directApiKeyController = TextEditingController();
|
||||
_relayHostController = TextEditingController();
|
||||
_relayPortController = TextEditingController();
|
||||
_relayTokenController = TextEditingController();
|
||||
_relayPasswordController = TextEditingController();
|
||||
_localHostController = TextEditingController();
|
||||
_localPortController = TextEditingController();
|
||||
_localTokenController = TextEditingController();
|
||||
_localPasswordController = TextEditingController();
|
||||
_remoteHostController = TextEditingController();
|
||||
_remotePortController = TextEditingController();
|
||||
_remoteTokenController = TextEditingController();
|
||||
_remotePasswordController = TextEditingController();
|
||||
_sessionRemoteBaseUrlController = TextEditingController();
|
||||
_sessionApiTokenController = TextEditingController();
|
||||
_sessionPersistenceMode = widget.controller.webSessionPersistence.mode;
|
||||
@ -67,10 +77,14 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
|
||||
_directBaseUrlController.dispose();
|
||||
_directProviderController.dispose();
|
||||
_directApiKeyController.dispose();
|
||||
_relayHostController.dispose();
|
||||
_relayPortController.dispose();
|
||||
_relayTokenController.dispose();
|
||||
_relayPasswordController.dispose();
|
||||
_localHostController.dispose();
|
||||
_localPortController.dispose();
|
||||
_localTokenController.dispose();
|
||||
_localPasswordController.dispose();
|
||||
_remoteHostController.dispose();
|
||||
_remotePortController.dispose();
|
||||
_remoteTokenController.dispose();
|
||||
_remotePasswordController.dispose();
|
||||
_sessionRemoteBaseUrlController.dispose();
|
||||
_sessionApiTokenController.dispose();
|
||||
super.dispose();
|
||||
@ -78,7 +92,8 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
|
||||
|
||||
void _syncControllers() {
|
||||
final settings = widget.controller.settings;
|
||||
final relayProfile = settings.primaryRemoteGatewayProfile;
|
||||
final localProfile = settings.primaryLocalGatewayProfile;
|
||||
final remoteProfile = settings.primaryRemoteGatewayProfile;
|
||||
_setIfDifferent(_directNameController, settings.aiGateway.name);
|
||||
_setIfDifferent(_directBaseUrlController, settings.aiGateway.baseUrl);
|
||||
_setIfDifferent(_directProviderController, settings.defaultProvider);
|
||||
@ -88,19 +103,46 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
|
||||
? ''
|
||||
: _directApiKeyController.text,
|
||||
);
|
||||
_setIfDifferent(_relayHostController, relayProfile.host);
|
||||
_setIfDifferent(_relayPortController, '${relayProfile.port}');
|
||||
_setIfDifferent(_localHostController, localProfile.host);
|
||||
_setIfDifferent(_localPortController, '${localProfile.port}');
|
||||
_setIfDifferent(_remoteHostController, remoteProfile.host);
|
||||
_setIfDifferent(_remotePortController, '${remoteProfile.port}');
|
||||
_remoteTls = remoteProfile.tls;
|
||||
_setIfDifferent(
|
||||
_relayTokenController,
|
||||
widget.controller.storedRelayTokenMask == null
|
||||
_localTokenController,
|
||||
widget.controller.storedRelayTokenMaskForProfile(
|
||||
kGatewayLocalProfileIndex,
|
||||
) ==
|
||||
null
|
||||
? ''
|
||||
: _relayTokenController.text,
|
||||
: _localTokenController.text,
|
||||
);
|
||||
_setIfDifferent(
|
||||
_relayPasswordController,
|
||||
widget.controller.storedRelayPasswordMask == null
|
||||
_localPasswordController,
|
||||
widget.controller.storedRelayPasswordMaskForProfile(
|
||||
kGatewayLocalProfileIndex,
|
||||
) ==
|
||||
null
|
||||
? ''
|
||||
: _relayPasswordController.text,
|
||||
: _localPasswordController.text,
|
||||
);
|
||||
_setIfDifferent(
|
||||
_remoteTokenController,
|
||||
widget.controller.storedRelayTokenMaskForProfile(
|
||||
kGatewayRemoteProfileIndex,
|
||||
) ==
|
||||
null
|
||||
? ''
|
||||
: _remoteTokenController.text,
|
||||
);
|
||||
_setIfDifferent(
|
||||
_remotePasswordController,
|
||||
widget.controller.storedRelayPasswordMaskForProfile(
|
||||
kGatewayRemoteProfileIndex,
|
||||
) ==
|
||||
null
|
||||
? ''
|
||||
: _remotePasswordController.text,
|
||||
);
|
||||
_sessionPersistenceMode = settings.webSessionPersistence.mode;
|
||||
_setIfDifferent(
|
||||
@ -225,11 +267,6 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
|
||||
final targets = controller
|
||||
.featuresFor(UiFeaturePlatform.web)
|
||||
.availableExecutionTargets
|
||||
.where(
|
||||
(target) =>
|
||||
target == AssistantExecutionTarget.singleAgent ||
|
||||
target == AssistantExecutionTarget.remote,
|
||||
)
|
||||
.toList(growable: false);
|
||||
return [
|
||||
SurfaceCard(
|
||||
@ -271,7 +308,6 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
|
||||
SettingsSnapshot settings,
|
||||
) {
|
||||
final palette = context.palette;
|
||||
final relayProfile = settings.primaryRemoteGatewayProfile;
|
||||
return [
|
||||
SurfaceCard(
|
||||
child: Row(
|
||||
@ -290,6 +326,217 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SurfaceCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
appText('单机智能体', 'Single Agent'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _directNameController,
|
||||
decoration: InputDecoration(labelText: appText('名称', 'Name')),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _directProviderController,
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('Provider 标识', 'Provider label'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _directBaseUrlController,
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('LLM API Endpoint', 'LLM API Endpoint'),
|
||||
hintText: 'https://api.example.com/v1',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _directApiKeyController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('LLM API Token', 'LLM API Token'),
|
||||
helperText: controller.storedAiGatewayApiKeyMask == null
|
||||
? null
|
||||
: '${appText('已保存', 'Stored')}: ${controller.storedAiGatewayApiKeyMask}',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: controller.resolvedAiGatewayModel.isEmpty
|
||||
? null
|
||||
: controller.resolvedAiGatewayModel,
|
||||
items: settings.aiGateway.availableModels
|
||||
.map(
|
||||
(item) => DropdownMenuItem<String>(
|
||||
value: item,
|
||||
child: Text(item),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
controller.selectDirectModel(value);
|
||||
}
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('默认模型', 'Default model'),
|
||||
hintText: appText('先同步模型目录', 'Sync model catalog first'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: controller.aiGatewayBusy
|
||||
? null
|
||||
: () async {
|
||||
final result = await controller.testAiGatewayConnection(
|
||||
baseUrl: _directBaseUrlController.text,
|
||||
apiKey: _directApiKeyController.text,
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() => _directMessage = result.message);
|
||||
},
|
||||
child: Text(appText('Test', 'Test')),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: controller.aiGatewayBusy
|
||||
? null
|
||||
: () async {
|
||||
await controller.saveAiGatewayConfiguration(
|
||||
name: _directNameController.text,
|
||||
baseUrl: _directBaseUrlController.text,
|
||||
provider: _directProviderController.text,
|
||||
apiKey: _directApiKeyController.text,
|
||||
defaultModel: controller.resolvedAiGatewayModel,
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_directMessage = appText(
|
||||
'配置已保存,尚未同步模型目录。',
|
||||
'Configuration saved; model catalog not synced yet.',
|
||||
);
|
||||
});
|
||||
},
|
||||
child: Text(appText('Save', 'Save')),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: controller.aiGatewayBusy
|
||||
? null
|
||||
: () async {
|
||||
await controller.saveAiGatewayConfiguration(
|
||||
name: _directNameController.text,
|
||||
baseUrl: _directBaseUrlController.text,
|
||||
provider: _directProviderController.text,
|
||||
apiKey: _directApiKeyController.text,
|
||||
defaultModel: controller.resolvedAiGatewayModel,
|
||||
);
|
||||
try {
|
||||
await controller.syncAiGatewayModels(
|
||||
name: _directNameController.text,
|
||||
baseUrl: _directBaseUrlController.text,
|
||||
provider: _directProviderController.text,
|
||||
apiKey: _directApiKeyController.text,
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_directMessage =
|
||||
controller.settings.aiGateway.syncMessage;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() => _directMessage = '$error');
|
||||
}
|
||||
},
|
||||
icon: controller.aiGatewayBusy
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.play_circle_outline_rounded),
|
||||
label: Text(appText('Apply', 'Apply')),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_directMessage.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
_directMessage,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: palette.textSecondary),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildGatewayCard(
|
||||
context,
|
||||
controller: controller,
|
||||
title: appText('Local Gateway', 'Local Gateway'),
|
||||
executionTarget: AssistantExecutionTarget.local,
|
||||
profileIndex: kGatewayLocalProfileIndex,
|
||||
hostController: _localHostController,
|
||||
portController: _localPortController,
|
||||
tokenController: _localTokenController,
|
||||
passwordController: _localPasswordController,
|
||||
tokenMask: controller.storedRelayTokenMaskForProfile(
|
||||
kGatewayLocalProfileIndex,
|
||||
),
|
||||
passwordMask: controller.storedRelayPasswordMaskForProfile(
|
||||
kGatewayLocalProfileIndex,
|
||||
),
|
||||
tls: false,
|
||||
onTlsChanged: null,
|
||||
message: _localGatewayMessage,
|
||||
onMessageChanged: (value) {
|
||||
setState(() => _localGatewayMessage = value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildGatewayCard(
|
||||
context,
|
||||
controller: controller,
|
||||
title: appText('Remote Gateway', 'Remote Gateway'),
|
||||
executionTarget: AssistantExecutionTarget.remote,
|
||||
profileIndex: kGatewayRemoteProfileIndex,
|
||||
hostController: _remoteHostController,
|
||||
portController: _remotePortController,
|
||||
tokenController: _remoteTokenController,
|
||||
passwordController: _remotePasswordController,
|
||||
tokenMask: controller.storedRelayTokenMaskForProfile(
|
||||
kGatewayRemoteProfileIndex,
|
||||
),
|
||||
passwordMask: controller.storedRelayPasswordMaskForProfile(
|
||||
kGatewayRemoteProfileIndex,
|
||||
),
|
||||
tls: _remoteTls,
|
||||
onTlsChanged: (value) {
|
||||
setState(() => _remoteTls = value);
|
||||
},
|
||||
message: _remoteGatewayMessage,
|
||||
onMessageChanged: (value) {
|
||||
setState(() => _remoteGatewayMessage = value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SurfaceCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@ -376,7 +623,26 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
|
||||
controller.sessionPersistenceStatusMessage;
|
||||
});
|
||||
},
|
||||
child: Text(appText('保存会话存储', 'Save session store')),
|
||||
child: Text(appText('Save', 'Save')),
|
||||
),
|
||||
FilledButton.tonal(
|
||||
onPressed: () async {
|
||||
await controller.saveWebSessionPersistenceConfiguration(
|
||||
mode: _sessionPersistenceMode,
|
||||
remoteBaseUrl: _sessionRemoteBaseUrlController.text,
|
||||
apiToken: _sessionApiTokenController.text,
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_sessionPersistenceMessage = appText(
|
||||
'会话存储配置已应用到当前浏览器会话。',
|
||||
'Session persistence settings are now applied to this browser session.',
|
||||
);
|
||||
});
|
||||
},
|
||||
child: Text(appText('Apply', 'Apply')),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -395,299 +661,233 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SurfaceCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
appText('单机智能体', 'Single Agent'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildGatewayCard(
|
||||
BuildContext context, {
|
||||
required AppController controller,
|
||||
required String title,
|
||||
required AssistantExecutionTarget executionTarget,
|
||||
required int profileIndex,
|
||||
required TextEditingController hostController,
|
||||
required TextEditingController portController,
|
||||
required TextEditingController tokenController,
|
||||
required TextEditingController passwordController,
|
||||
required String? tokenMask,
|
||||
required String? passwordMask,
|
||||
required bool tls,
|
||||
required ValueChanged<bool>? onTlsChanged,
|
||||
required String message,
|
||||
required ValueChanged<String> onMessageChanged,
|
||||
}) {
|
||||
final expectedMode = executionTarget == AssistantExecutionTarget.local
|
||||
? RuntimeConnectionMode.local
|
||||
: RuntimeConnectionMode.remote;
|
||||
final matchesTarget = controller.connection.mode == expectedMode;
|
||||
final status = matchesTarget
|
||||
? controller.connection.status.label
|
||||
: RuntimeConnectionStatus.offline.label;
|
||||
final endpoint = '${hostController.text.trim()}:${_parsePort(portController.text, fallback: 443)}';
|
||||
final statusEndpoint = matchesTarget
|
||||
? (controller.connection.remoteAddress?.trim().isNotEmpty == true
|
||||
? controller.connection.remoteAddress!.trim()
|
||||
: endpoint)
|
||||
: endpoint;
|
||||
|
||||
return SurfaceCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: hostController,
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('主机或 URL', 'Host or URL'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _directNameController,
|
||||
decoration: InputDecoration(labelText: appText('名称', 'Name')),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _directProviderController,
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('Provider 标识', 'Provider label'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _directBaseUrlController,
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('LLM API Endpoint', 'LLM API Endpoint'),
|
||||
hintText: 'https://api.example.com/v1',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _directApiKeyController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('LLM API Token', 'LLM API Token'),
|
||||
helperText: controller.storedAiGatewayApiKeyMask == null
|
||||
? null
|
||||
: '${appText('已保存', 'Stored')}: ${controller.storedAiGatewayApiKeyMask}',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: controller.resolvedAiGatewayModel.isEmpty
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: portController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(labelText: appText('端口', 'Port')),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: tokenController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('Gateway Token', 'Gateway token'),
|
||||
helperText: tokenMask == null
|
||||
? null
|
||||
: controller.resolvedAiGatewayModel,
|
||||
items: settings.aiGateway.availableModels
|
||||
.map(
|
||||
(item) => DropdownMenuItem<String>(
|
||||
value: item,
|
||||
child: Text(item),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
controller.selectDirectModel(value);
|
||||
}
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('默认模型', 'Default model'),
|
||||
hintText: appText('先同步模型目录', 'Sync model catalog first'),
|
||||
),
|
||||
: '${appText('已保存', 'Stored')}: $tokenMask',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: controller.aiGatewayBusy
|
||||
? null
|
||||
: () async {
|
||||
final result = await controller
|
||||
.testAiGatewayConnection(
|
||||
baseUrl: _directBaseUrlController.text,
|
||||
apiKey: _directApiKeyController.text,
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() => _directMessage = result.message);
|
||||
},
|
||||
child: Text(appText('测试连接', 'Test connection')),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: passwordController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('Gateway Password', 'Gateway password'),
|
||||
helperText: passwordMask == null
|
||||
? null
|
||||
: '${appText('已保存', 'Stored')}: $passwordMask',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${appText('状态', 'Status')}: $status · $statusEndpoint',
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: controller.aiGatewayBusy
|
||||
? null
|
||||
: () async {
|
||||
await controller.saveAiGatewayConfiguration(
|
||||
name: _directNameController.text,
|
||||
baseUrl: _directBaseUrlController.text,
|
||||
provider: _directProviderController.text,
|
||||
apiKey: _directApiKeyController.text,
|
||||
defaultModel: controller.resolvedAiGatewayModel,
|
||||
);
|
||||
try {
|
||||
await controller.syncAiGatewayModels(
|
||||
name: _directNameController.text,
|
||||
baseUrl: _directBaseUrlController.text,
|
||||
provider: _directProviderController.text,
|
||||
apiKey: _directApiKeyController.text,
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_directMessage =
|
||||
controller.settings.aiGateway.syncMessage;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() => _directMessage = '$error');
|
||||
}
|
||||
},
|
||||
icon: controller.aiGatewayBusy
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.check_circle_outline_rounded),
|
||||
label: Text(appText('保存/应用', 'Save / Apply')),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_directMessage.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
_directMessage,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: palette.textSecondary),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SurfaceCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
appText('Relay OpenClaw Gateway', 'Relay OpenClaw Gateway'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _relayHostController,
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('主机或 URL', 'Host or URL'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _relayPortController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(labelText: appText('端口', 'Port')),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _relayTokenController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('Relay Token', 'Relay token'),
|
||||
helperText: controller.storedRelayTokenMask == null
|
||||
? null
|
||||
: '${appText('已保存', 'Stored')}: ${controller.storedRelayTokenMask}',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: _relayPasswordController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('Relay Password', 'Relay password'),
|
||||
helperText: controller.storedRelayPasswordMask == null
|
||||
? null
|
||||
: '${appText('已保存', 'Stored')}: ${controller.storedRelayPasswordMask}',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${appText('状态', 'Status')}: ${controller.connection.status.label} · ${controller.connection.remoteAddress ?? appText('未连接', 'Offline')}',
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: relayProfile.tls,
|
||||
onChanged: (value) => controller.saveRelayConfiguration(
|
||||
host: _relayHostController.text,
|
||||
port: int.tryParse(_relayPortController.text.trim()) ?? 443,
|
||||
tls: value,
|
||||
token: _relayTokenController.text,
|
||||
password: _relayPasswordController.text,
|
||||
),
|
||||
),
|
||||
if (onTlsChanged != null) ...[
|
||||
Switch(value: tls, onChanged: onTlsChanged),
|
||||
Text(appText('TLS', 'TLS')),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed: () => controller.saveRelayConfiguration(
|
||||
host: _relayHostController.text,
|
||||
port: int.tryParse(_relayPortController.text.trim()) ?? 443,
|
||||
tls: relayProfile.tls,
|
||||
token: _relayTokenController.text,
|
||||
password: _relayPasswordController.text,
|
||||
),
|
||||
child: Text(appText('保存', 'Save')),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: controller.relayBusy
|
||||
? null
|
||||
: () async {
|
||||
try {
|
||||
await controller.saveRelayConfiguration(
|
||||
host: _relayHostController.text,
|
||||
port:
|
||||
int.tryParse(
|
||||
_relayPortController.text.trim(),
|
||||
) ??
|
||||
443,
|
||||
tls: relayProfile.tls,
|
||||
token: _relayTokenController.text,
|
||||
password: _relayPasswordController.text,
|
||||
);
|
||||
await controller.connectRelay();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_relayMessage = appText(
|
||||
'Relay 已连接',
|
||||
'Relay connected',
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() => _relayMessage = '$error');
|
||||
}
|
||||
},
|
||||
icon: controller.relayBusy
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.link_rounded),
|
||||
label: Text(appText('连接 Relay', 'Connect relay')),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: controller.relayBusy
|
||||
? null
|
||||
: () async {
|
||||
await controller.disconnectRelay();
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: controller.relayBusy
|
||||
? null
|
||||
: () async {
|
||||
final profile = _gatewayProfileDraft(
|
||||
executionTarget: executionTarget,
|
||||
host: hostController.text,
|
||||
portText: portController.text,
|
||||
tls: tls,
|
||||
);
|
||||
final result = await controller.testGatewayConnectionDraft(
|
||||
profile: profile,
|
||||
executionTarget: executionTarget,
|
||||
tokenOverride: tokenController.text,
|
||||
passwordOverride: passwordController.text,
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
onMessageChanged(
|
||||
'${result.state.toUpperCase()} · ${result.message}',
|
||||
);
|
||||
},
|
||||
child: Text(appText('Test', 'Test')),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: controller.relayBusy
|
||||
? null
|
||||
: () async {
|
||||
await controller.saveRelayConfiguration(
|
||||
profileIndex: profileIndex,
|
||||
host: hostController.text,
|
||||
port: _parsePort(portController.text, fallback: 443),
|
||||
tls: tls,
|
||||
token: tokenController.text,
|
||||
password: passwordController.text,
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
onMessageChanged(
|
||||
appText(
|
||||
'配置已保存,尚未应用到当前线程连接。',
|
||||
'Configuration saved but not applied to active thread connections yet.',
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text(appText('Save', 'Save')),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: controller.relayBusy
|
||||
? null
|
||||
: () async {
|
||||
try {
|
||||
await controller.applyRelayConfiguration(
|
||||
profileIndex: profileIndex,
|
||||
host: hostController.text,
|
||||
port: _parsePort(portController.text, fallback: 443),
|
||||
tls: tls,
|
||||
token: tokenController.text,
|
||||
password: passwordController.text,
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_relayMessage = appText(
|
||||
'Relay 已断开',
|
||||
'Relay disconnected',
|
||||
);
|
||||
});
|
||||
},
|
||||
child: Text(appText('断开', 'Disconnect')),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_relayMessage.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
_relayMessage,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: palette.textSecondary),
|
||||
onMessageChanged(
|
||||
appText(
|
||||
'配置已应用;当前线程目标匹配时将使用新连接。',
|
||||
'Configuration applied. Threads targeting this gateway now use the updated connection.',
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
onMessageChanged('$error');
|
||||
}
|
||||
},
|
||||
icon: controller.relayBusy
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.play_circle_outline_rounded),
|
||||
label: Text(appText('Apply', 'Apply')),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (message.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
message,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: context.palette.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
);
|
||||
}
|
||||
|
||||
GatewayConnectionProfile _gatewayProfileDraft({
|
||||
required AssistantExecutionTarget executionTarget,
|
||||
required String host,
|
||||
required String portText,
|
||||
required bool tls,
|
||||
}) {
|
||||
final mode = executionTarget == AssistantExecutionTarget.local
|
||||
? RuntimeConnectionMode.local
|
||||
: RuntimeConnectionMode.remote;
|
||||
final defaults = executionTarget == AssistantExecutionTarget.local
|
||||
? GatewayConnectionProfile.defaultsLocal()
|
||||
: GatewayConnectionProfile.defaultsRemote();
|
||||
return defaults.copyWith(
|
||||
mode: mode,
|
||||
host: host.trim(),
|
||||
port: _parsePort(portText, fallback: defaults.port),
|
||||
tls: mode == RuntimeConnectionMode.local ? false : tls,
|
||||
useSetupCode: false,
|
||||
setupCode: '',
|
||||
);
|
||||
}
|
||||
|
||||
int _parsePort(String value, {required int fallback}) {
|
||||
final parsed = int.tryParse(value.trim());
|
||||
if (parsed == null || parsed <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
List<Widget> _buildAppearance(
|
||||
@ -786,10 +986,13 @@ String _targetLabel(AssistantExecutionTarget target) {
|
||||
'Single Agent',
|
||||
'Single Agent',
|
||||
),
|
||||
AssistantExecutionTarget.remote => appText(
|
||||
'Relay OpenClaw Gateway',
|
||||
'Relay OpenClaw Gateway',
|
||||
AssistantExecutionTarget.local => appText(
|
||||
'Local Gateway',
|
||||
'Local Gateway',
|
||||
),
|
||||
AssistantExecutionTarget.remote => appText(
|
||||
'Remote Gateway',
|
||||
'Remote Gateway',
|
||||
),
|
||||
_ => '',
|
||||
};
|
||||
}
|
||||
|
||||
@ -10,8 +10,11 @@ class WebStore {
|
||||
static const settingsKey = 'xworkmate.web.settings.snapshot';
|
||||
static const threadsKey = 'xworkmate.web.assistant.threads';
|
||||
static const aiGatewayApiKeyKey = 'xworkmate.web.ai_gateway.api_key';
|
||||
// Legacy remote-only keys (kept for migration fallback).
|
||||
static const relayTokenKey = 'xworkmate.web.relay.token';
|
||||
static const relayPasswordKey = 'xworkmate.web.relay.password';
|
||||
static const relayTokenProfilePrefix = 'xworkmate.web.relay.token.';
|
||||
static const relayPasswordProfilePrefix = 'xworkmate.web.relay.password.';
|
||||
static const relayDeviceIdentityKey = 'xworkmate.web.relay.device_identity';
|
||||
static const sessionClientIdKey = 'xworkmate.web.session.client_id';
|
||||
static const themeModeKey = 'xworkmate.web.theme_mode';
|
||||
@ -72,24 +75,50 @@ class WebStore {
|
||||
await _prefs!.setString(aiGatewayApiKeyKey, value.trim());
|
||||
}
|
||||
|
||||
Future<String> loadRelayToken() async {
|
||||
Future<String> loadRelayToken({int? profileIndex}) async {
|
||||
await initialize();
|
||||
return (_prefs!.getString(relayTokenKey) ?? '').trim();
|
||||
final scopedKey = _relayTokenScopedKey(profileIndex);
|
||||
final scoped = (_prefs!.getString(scopedKey) ?? '').trim();
|
||||
if (scoped.isNotEmpty) {
|
||||
return scoped;
|
||||
}
|
||||
// Backward compatibility: old builds persisted a single remote token.
|
||||
if (profileIndex == null || profileIndex == kGatewayRemoteProfileIndex) {
|
||||
return (_prefs!.getString(relayTokenKey) ?? '').trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
Future<void> saveRelayToken(String value) async {
|
||||
Future<void> saveRelayToken(String value, {int? profileIndex}) async {
|
||||
await initialize();
|
||||
await _prefs!.setString(relayTokenKey, value.trim());
|
||||
final trimmed = value.trim();
|
||||
await _prefs!.setString(_relayTokenScopedKey(profileIndex), trimmed);
|
||||
if (profileIndex == null || profileIndex == kGatewayRemoteProfileIndex) {
|
||||
await _prefs!.setString(relayTokenKey, trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> loadRelayPassword() async {
|
||||
Future<String> loadRelayPassword({int? profileIndex}) async {
|
||||
await initialize();
|
||||
return (_prefs!.getString(relayPasswordKey) ?? '').trim();
|
||||
final scopedKey = _relayPasswordScopedKey(profileIndex);
|
||||
final scoped = (_prefs!.getString(scopedKey) ?? '').trim();
|
||||
if (scoped.isNotEmpty) {
|
||||
return scoped;
|
||||
}
|
||||
// Backward compatibility: old builds persisted a single remote password.
|
||||
if (profileIndex == null || profileIndex == kGatewayRemoteProfileIndex) {
|
||||
return (_prefs!.getString(relayPasswordKey) ?? '').trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
Future<void> saveRelayPassword(String value) async {
|
||||
Future<void> saveRelayPassword(String value, {int? profileIndex}) async {
|
||||
await initialize();
|
||||
await _prefs!.setString(relayPasswordKey, value.trim());
|
||||
final trimmed = value.trim();
|
||||
await _prefs!.setString(_relayPasswordScopedKey(profileIndex), trimmed);
|
||||
if (profileIndex == null || profileIndex == kGatewayRemoteProfileIndex) {
|
||||
await _prefs!.setString(relayPasswordKey, trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> loadOrCreateWebSessionClientId() async {
|
||||
@ -161,4 +190,14 @@ class WebStore {
|
||||
).join();
|
||||
return 'web-$timestamp-$suffix';
|
||||
}
|
||||
|
||||
static String _relayTokenScopedKey(int? profileIndex) {
|
||||
final resolved = profileIndex ?? kGatewayRemoteProfileIndex;
|
||||
return '$relayTokenProfilePrefix$resolved';
|
||||
}
|
||||
|
||||
static String _relayPasswordScopedKey(int? profileIndex) {
|
||||
final resolved = profileIndex ?? kGatewayRemoteProfileIndex;
|
||||
return '$relayPasswordProfilePrefix$resolved';
|
||||
}
|
||||
}
|
||||
|
||||
306
test/web/web_assistant_controller_parity_browser_test.dart
Normal file
306
test/web/web_assistant_controller_parity_browser_test.dart
Normal file
@ -0,0 +1,306 @@
|
||||
@TestOn('browser')
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:xworkmate/app/app_controller_web.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
import 'package:xworkmate/web/web_acp_client.dart';
|
||||
import 'package:xworkmate/web/web_relay_gateway_client.dart';
|
||||
import 'package:xworkmate/web/web_store.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('thread-scoped assistant context persists across reload on web', () async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
|
||||
final fakeRelay = _FakeRelayGatewayClient(WebStore());
|
||||
final fakeAcp = _FakeAcpClient();
|
||||
final controller = AppController(
|
||||
store: WebStore(),
|
||||
relayClient: fakeRelay,
|
||||
acpClient: fakeAcp,
|
||||
);
|
||||
await _waitForReady(controller);
|
||||
|
||||
await controller.saveRelayConfiguration(
|
||||
profileIndex: kGatewayLocalProfileIndex,
|
||||
host: '',
|
||||
port: 18789,
|
||||
tls: false,
|
||||
token: '',
|
||||
password: '',
|
||||
);
|
||||
await controller.saveRelayConfiguration(
|
||||
profileIndex: kGatewayRemoteProfileIndex,
|
||||
host: '',
|
||||
port: 443,
|
||||
tls: true,
|
||||
token: '',
|
||||
password: '',
|
||||
);
|
||||
|
||||
final threadSingle = controller.currentSessionKey;
|
||||
await controller.setSingleAgentProvider(SingleAgentProvider.codex);
|
||||
await controller.setAssistantMessageViewMode(AssistantMessageViewMode.raw);
|
||||
await controller.selectAssistantModelForSession(threadSingle, 'single-model');
|
||||
await controller.saveAssistantTaskTitle(threadSingle, 'Thread Single');
|
||||
|
||||
await controller.createConversation(target: AssistantExecutionTarget.local);
|
||||
final threadLocal = controller.currentSessionKey;
|
||||
await controller.setAssistantExecutionTarget(AssistantExecutionTarget.local);
|
||||
await controller.selectAssistantModelForSession(threadLocal, 'local-model');
|
||||
await controller.saveAssistantTaskTitle(threadLocal, 'Thread Local');
|
||||
|
||||
await controller.createConversation(target: AssistantExecutionTarget.remote);
|
||||
final threadRemote = controller.currentSessionKey;
|
||||
await controller.setAssistantExecutionTarget(AssistantExecutionTarget.remote);
|
||||
await controller.setAssistantMessageViewMode(AssistantMessageViewMode.raw);
|
||||
await controller.selectAssistantModelForSession(threadRemote, 'remote-model');
|
||||
await controller.saveAssistantTaskTitle(threadRemote, 'Thread Remote');
|
||||
await controller.saveAssistantTaskArchived(threadRemote, true);
|
||||
|
||||
expect(
|
||||
controller.assistantExecutionTargetForSession(threadSingle),
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
expect(
|
||||
controller.singleAgentProviderForSession(threadSingle),
|
||||
SingleAgentProvider.codex,
|
||||
);
|
||||
expect(
|
||||
controller.assistantMessageViewModeForSession(threadSingle),
|
||||
AssistantMessageViewMode.raw,
|
||||
);
|
||||
expect(controller.assistantModelForSession(threadSingle), 'single-model');
|
||||
|
||||
expect(controller.assistantModelForSession(threadLocal), 'local-model');
|
||||
|
||||
expect(
|
||||
controller.isAssistantTaskArchived(threadRemote),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
controller.conversations.where((item) => item.sessionKey == threadRemote),
|
||||
isEmpty,
|
||||
);
|
||||
|
||||
controller.dispose();
|
||||
|
||||
final reloaded = AppController(
|
||||
store: WebStore(),
|
||||
relayClient: _FakeRelayGatewayClient(WebStore()),
|
||||
acpClient: fakeAcp,
|
||||
);
|
||||
await _waitForReady(reloaded);
|
||||
|
||||
expect(
|
||||
reloaded.assistantExecutionTargetForSession(threadSingle),
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
expect(
|
||||
reloaded.singleAgentProviderForSession(threadSingle),
|
||||
SingleAgentProvider.codex,
|
||||
);
|
||||
expect(
|
||||
reloaded.assistantMessageViewModeForSession(threadSingle),
|
||||
AssistantMessageViewMode.raw,
|
||||
);
|
||||
expect(reloaded.assistantModelForSession(threadSingle), 'single-model');
|
||||
expect(reloaded.assistantModelForSession(threadLocal), 'local-model');
|
||||
expect(reloaded.isAssistantTaskArchived(threadRemote), isTrue);
|
||||
|
||||
reloaded.dispose();
|
||||
});
|
||||
|
||||
test('gateway Save does not connect but Apply connects current target profile',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
|
||||
final fakeRelay = _FakeRelayGatewayClient(WebStore());
|
||||
final controller = AppController(
|
||||
store: WebStore(),
|
||||
relayClient: fakeRelay,
|
||||
acpClient: _FakeAcpClient(),
|
||||
);
|
||||
await _waitForReady(controller);
|
||||
|
||||
await controller.setAssistantExecutionTarget(AssistantExecutionTarget.remote);
|
||||
fakeRelay.connectCalls = 0;
|
||||
|
||||
await controller.saveRelayConfiguration(
|
||||
profileIndex: kGatewayRemoteProfileIndex,
|
||||
host: 'remote.example.com',
|
||||
port: 443,
|
||||
tls: true,
|
||||
token: 'remote-token',
|
||||
password: '',
|
||||
);
|
||||
expect(fakeRelay.connectCalls, 0);
|
||||
|
||||
await controller.applyRelayConfiguration(
|
||||
profileIndex: kGatewayRemoteProfileIndex,
|
||||
host: 'remote.example.com',
|
||||
port: 443,
|
||||
tls: true,
|
||||
token: 'remote-token',
|
||||
password: '',
|
||||
);
|
||||
|
||||
expect(fakeRelay.connectCalls, greaterThanOrEqualTo(1));
|
||||
expect(fakeRelay.lastConnectMode, RuntimeConnectionMode.remote);
|
||||
|
||||
controller.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeRelayGatewayClient extends WebRelayGatewayClient {
|
||||
_FakeRelayGatewayClient(
|
||||
super.store, {
|
||||
GatewayConnectionSnapshot? initialSnapshot,
|
||||
}) : _snapshot =
|
||||
initialSnapshot ??
|
||||
GatewayConnectionSnapshot.initial(mode: RuntimeConnectionMode.remote);
|
||||
|
||||
final StreamController<GatewayPushEvent> _eventsController =
|
||||
StreamController<GatewayPushEvent>.broadcast();
|
||||
GatewayConnectionSnapshot _snapshot;
|
||||
|
||||
int connectCalls = 0;
|
||||
RuntimeConnectionMode? lastConnectMode;
|
||||
|
||||
@override
|
||||
Stream<GatewayPushEvent> get events => _eventsController.stream;
|
||||
|
||||
@override
|
||||
GatewayConnectionSnapshot get snapshot => _snapshot;
|
||||
|
||||
@override
|
||||
Future<void> connect({
|
||||
required GatewayConnectionProfile profile,
|
||||
required String authToken,
|
||||
required String authPassword,
|
||||
}) async {
|
||||
connectCalls += 1;
|
||||
lastConnectMode = profile.mode;
|
||||
_snapshot = GatewayConnectionSnapshot.initial(mode: profile.mode).copyWith(
|
||||
status: RuntimeConnectionStatus.connected,
|
||||
statusText: 'Connected',
|
||||
remoteAddress: '${profile.host}:${profile.port}',
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disconnect() async {
|
||||
_snapshot = _snapshot.copyWith(
|
||||
status: RuntimeConnectionStatus.offline,
|
||||
statusText: 'Offline',
|
||||
clearRemoteAddress: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<GatewaySessionSummary>> listSessions({int limit = 50}) async {
|
||||
return const <GatewaySessionSummary>[];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<GatewayChatMessage>> loadHistory(
|
||||
String sessionKey, {
|
||||
int limit = 120,
|
||||
}) async {
|
||||
return const <GatewayChatMessage>[];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> sendChat({
|
||||
required String sessionKey,
|
||||
required String message,
|
||||
required String thinking,
|
||||
List<GatewayChatAttachmentPayload> attachments =
|
||||
const <GatewayChatAttachmentPayload>[],
|
||||
Map<String, dynamic> metadata = const <String, dynamic>{},
|
||||
}) async {
|
||||
return 'fake-run';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<GatewayModelSummary>> listModels() async {
|
||||
return const <GatewayModelSummary>[];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<dynamic> request(
|
||||
String method, {
|
||||
Map<String, dynamic>? params,
|
||||
Duration timeout = const Duration(seconds: 15),
|
||||
}) async {
|
||||
if (method == 'skills.status') {
|
||||
return const <String, dynamic>{'skills': <dynamic>[]};
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
await _eventsController.close();
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeAcpClient extends WebAcpClient {
|
||||
@override
|
||||
Future<WebAcpCapabilities> loadCapabilities({required Uri endpoint}) async {
|
||||
return WebAcpCapabilities(
|
||||
singleAgent: true,
|
||||
multiAgent: true,
|
||||
providers: <SingleAgentProvider>{
|
||||
SingleAgentProvider.codex,
|
||||
SingleAgentProvider.opencode,
|
||||
SingleAgentProvider.claude,
|
||||
SingleAgentProvider.gemini,
|
||||
},
|
||||
raw: <String, dynamic>{},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cancelSession({
|
||||
required Uri endpoint,
|
||||
required String sessionId,
|
||||
required String threadId,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> request({
|
||||
required Uri endpoint,
|
||||
required String method,
|
||||
required Map<String, dynamic> params,
|
||||
void Function(Map<String, dynamic> notification)? onNotification,
|
||||
Duration timeout = const Duration(seconds: 120),
|
||||
}) async {
|
||||
return <String, dynamic>{
|
||||
'result': <String, dynamic>{
|
||||
'output': 'ok',
|
||||
'summary': 'ok',
|
||||
'model': params['model']?.toString() ?? 'fake-model',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _waitForReady(
|
||||
AppController controller, {
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (controller.initializing) {
|
||||
if (DateTime.now().isAfter(deadline)) {
|
||||
fail('controller did not initialize before timeout');
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
}
|
||||
@ -24,15 +24,14 @@ void main() {
|
||||
expect(find.text('设置'), findsWidgets);
|
||||
expect(find.text('Tasks'), findsNothing);
|
||||
expect(find.byKey(const Key('assistant-task-rail')), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('assistant-attachment-menu-button')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(find.byKey(const Key('assistant-attachment-menu-button')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('连接设置'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('设置'), findsWidgets);
|
||||
expect(find.textContaining('浏览器本地存储'), findsOneWidget);
|
||||
expect(find.textContaining('Local Gateway'), findsWidgets);
|
||||
expect(find.textContaining('Remote Gateway'), findsWidgets);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user