feat(web): complete assistant thread session parity

This commit is contained in:
Haitao Pan 2026-03-24 17:34:14 +08:00
parent 3900ef9327
commit e396d6b176
10 changed files with 3226 additions and 674 deletions

View File

@ -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

View File

@ -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
View 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;
}
}

View File

@ -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',
),
_ => '',
};
}

View File

@ -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,
},

View File

@ -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',
),
_ => '',
};
}

View File

@ -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';
}
}

View 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));
}
}

View File

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