xworkmate-app/lib/web/web_assistant_page.dart
2026-03-25 14:42:11 +08:00

2037 lines
71 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:convert';
import 'dart:math' as math;
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import '../app/app_controller_web.dart';
import '../app/ui_feature_manifest.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
import '../runtime/runtime_models.dart';
import '../theme/app_palette.dart';
import '../widgets/assistant_artifact_sidebar.dart';
import '../widgets/desktop_workspace_scaffold.dart';
import '../widgets/pane_resize_handle.dart';
import '../widgets/surface_card.dart';
import 'web_focus_panel.dart';
const double _webAssistantSideTabRailWidth = 46;
const double _webAssistantSidePaneMinWidth = 304;
const double _webAssistantSidePaneMaxWidth = 420;
const double _webAssistantMainWorkspaceMinWidth = 700;
const double _webAssistantComposerMinHeight = 164;
const double _webAssistantConversationMinHeight = 200;
const double _webAssistantResizeHandleSize = 10;
const double _webAssistantArtifactPaneMinWidth = 280;
const double _webAssistantArtifactPaneDefaultWidth = 360;
class WebAssistantPage extends StatefulWidget {
const WebAssistantPage({super.key, required this.controller});
final AppController controller;
@override
State<WebAssistantPage> createState() => _WebAssistantPageState();
}
enum _WebAssistantPane { tasks, quick }
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;
bool _workspaceChromeCollapsed = false;
bool _sidePaneCollapsed = false;
double _sidePaneWidth = 344;
bool _artifactPaneCollapsed = true;
double _artifactPaneWidth = _webAssistantArtifactPaneDefaultWidth;
double _composerHeight = 196;
_WebAssistantPane _activePane = _WebAssistantPane.tasks;
final List<_WebComposerAttachment> _attachments = <_WebComposerAttachment>[];
@override
void dispose() {
_inputController.dispose();
_searchController.dispose();
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final controller = widget.controller;
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
final currentMessages = controller.chatMessages;
final connectionState = controller.currentAssistantConnectionState;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollController.hasClients) {
return;
}
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
);
});
return DesktopWorkspaceScaffold(
child: LayoutBuilder(
builder: (context, constraints) {
final maxSidePaneWidth = math.min(
_webAssistantSidePaneMaxWidth,
math.max(
_webAssistantSidePaneMinWidth,
constraints.maxWidth - _webAssistantMainWorkspaceMinWidth,
),
);
final sidePaneWidth = _sidePaneWidth.clamp(
_webAssistantSidePaneMinWidth,
maxSidePaneWidth,
);
final collapsedWidth = _webAssistantSideTabRailWidth;
return Column(
children: [
_AssistantWorkspaceChrome(
controller: controller,
collapsed: _workspaceChromeCollapsed,
onToggleCollapsed: () {
setState(() {
_workspaceChromeCollapsed = !_workspaceChromeCollapsed;
});
},
),
const SizedBox(height: 8),
Expanded(
child: Row(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
width: _sidePaneCollapsed
? collapsedWidth
: sidePaneWidth,
child: _AssistantSidePane(
collapsed: _sidePaneCollapsed,
activePane: _activePane,
controller: controller,
query: _query,
searchController: _searchController,
permissionLevel: _permissionLevel,
onQueryChanged: (value) {
setState(
() => _query = value.trim().toLowerCase(),
);
},
onClearQuery: () {
_searchController.clear();
setState(() => _query = '');
},
onToggleCollapsed: () {
setState(() {
_sidePaneCollapsed = !_sidePaneCollapsed;
});
},
onPaneChanged: (pane) {
setState(() {
_activePane = pane;
_sidePaneCollapsed = false;
});
},
onPermissionChanged: (value) {
setState(() => _permissionLevel = value);
},
onRename: _renameConversation,
onArchive: (sessionKey) => controller
.saveAssistantTaskArchived(sessionKey, true),
onOpenActions: _openConversationActions,
),
),
if (!_sidePaneCollapsed)
SizedBox(
width: 8,
child: PaneResizeHandle(
axis: Axis.horizontal,
onDelta: (delta) {
setState(() {
_sidePaneWidth = (_sidePaneWidth + delta)
.clamp(
_webAssistantSidePaneMinWidth,
maxSidePaneWidth,
)
.toDouble();
});
},
),
),
Expanded(
child: _buildWorkspaceWithArtifacts(
controller: controller,
child: _ConversationWorkspace(
controller: controller,
scrollController: _scrollController,
inputController: _inputController,
currentMessages: currentMessages,
connectionState: connectionState,
thinkingLevel: _thinkingLevel,
permissionLevel: _permissionLevel,
useMultiAgent: _useMultiAgent,
attachments: _attachments,
composerHeight: _composerHeight,
onComposerHeightChanged: (value) {
setState(() => _composerHeight = value);
},
onThinkingChanged: (value) {
setState(() => _thinkingLevel = value);
},
onPermissionChanged: (value) {
setState(() => _permissionLevel = value);
},
onToggleMultiAgent: (value) {
setState(() => _useMultiAgent = value);
},
onAddAttachment: _pickAttachments,
onRemoveAttachment: (index) {
setState(() => _attachments.removeAt(index));
},
onOpenSessionSettings: _openSessionSettings,
onSubmit: _submitPrompt,
),
),
),
],
),
),
],
);
},
),
);
},
);
}
Future<void> _openSessionSettings() async {
if (!mounted) {
return;
}
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) {
return _AssistantSessionSettingsSheet(
controller: widget.controller,
thinkingLevel: _thinkingLevel,
permissionLevel: _permissionLevel,
onThinkingChanged: (value) {
setState(() => _thinkingLevel = value);
},
onPermissionChanged: (value) {
setState(() => _permissionLevel = value);
},
);
},
);
}
Future<void> _openConversationActions(String sessionKey) async {
final controller = widget.controller;
if (!mounted) {
return;
}
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) {
return SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.drive_file_rename_outline_rounded),
title: Text(appText('重命名', 'Rename')),
onTap: () {
Navigator.of(context).pop();
_renameConversation(sessionKey);
},
),
ListTile(
leading: const Icon(Icons.archive_outlined),
title: Text(appText('归档', 'Archive')),
onTap: () async {
Navigator.of(context).pop();
await controller.saveAssistantTaskArchived(sessionKey, true);
},
),
],
),
);
},
);
}
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 showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) {
return Padding(
padding: EdgeInsets.fromLTRB(
16,
0,
16,
MediaQuery.of(context).viewInsets.bottom + 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('重命名任务线程', 'Rename task thread'),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 12),
TextField(
controller: renameController,
autofocus: true,
decoration: InputDecoration(
hintText: appText('输入标题', 'Enter a title'),
),
onSubmitted: (value) => Navigator.of(context).pop(value),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(appText('取消', 'Cancel')),
),
),
const SizedBox(width: 10),
Expanded(
child: 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());
}
Widget _buildWorkspaceWithArtifacts({
required AppController controller,
required Widget child,
}) {
return LayoutBuilder(
builder: (context, constraints) {
final maxPaneWidth = math.min(
520.0,
math.max(
_webAssistantArtifactPaneMinWidth,
constraints.maxWidth * 0.48,
),
);
final paneWidth = _artifactPaneWidth
.clamp(_webAssistantArtifactPaneMinWidth, maxPaneWidth)
.toDouble();
final workspace = Row(
children: [
Expanded(child: child),
if (!_artifactPaneCollapsed) ...[
SizedBox(
key: const Key('assistant-artifact-pane-resize-handle'),
width: 8,
child: PaneResizeHandle(
axis: Axis.horizontal,
onDelta: (delta) {
setState(() {
_artifactPaneWidth = (_artifactPaneWidth - delta)
.clamp(
_webAssistantArtifactPaneMinWidth,
maxPaneWidth,
)
.toDouble();
});
},
),
),
const SizedBox(width: 8),
SizedBox(
width: paneWidth,
child: AssistantArtifactSidebar(
sessionKey: controller.currentSessionKey,
threadTitle: controller.currentConversationTitle,
workspaceRef: controller.assistantWorkspaceRefForSession(
controller.currentSessionKey,
),
workspaceRefKind: controller
.assistantWorkspaceRefKindForSession(
controller.currentSessionKey,
),
onCollapse: () {
setState(() {
_artifactPaneCollapsed = true;
});
},
loadSnapshot: () =>
controller.loadAssistantArtifactSnapshot(),
loadPreview: (entry) =>
controller.loadAssistantArtifactPreview(entry),
),
),
],
],
);
return Stack(
children: [
Positioned.fill(child: workspace),
if (_artifactPaneCollapsed)
Positioned(
right: 8,
top: 12,
child: AssistantArtifactSidebarRevealButton(
onTap: () {
setState(() {
_artifactPaneCollapsed = false;
});
},
),
),
],
);
},
);
}
}
class _AssistantWorkspaceChrome extends StatelessWidget {
const _AssistantWorkspaceChrome({
required this.controller,
required this.collapsed,
required this.onToggleCollapsed,
});
final AppController controller;
final bool collapsed;
final VoidCallback onToggleCollapsed;
@override
Widget build(BuildContext context) {
final connectionState = controller.currentAssistantConnectionState;
return SurfaceCard(
tone: SurfaceCardTone.chrome,
borderRadius: 10,
child: AnimatedSize(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
child: collapsed
? Row(
children: [
const Expanded(child: _ChromeNavigationPills(compact: true)),
_ChromeConnectionChip(state: connectionState, compact: true),
const SizedBox(width: 8),
IconButton(
key: const Key('assistant-workspace-chrome-toggle'),
tooltip: appText('展开顶部导航', 'Expand top navigation'),
onPressed: onToggleCollapsed,
icon: const Icon(Icons.keyboard_arrow_down_rounded),
),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Expanded(child: _ChromeNavigationPills()),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_ChromeConnectionChip(state: connectionState),
const SizedBox(width: 8),
IconButton(
key: const Key('assistant-workspace-chrome-toggle'),
tooltip: appText(
'折叠顶部导航',
'Collapse top navigation',
),
onPressed: onToggleCollapsed,
icon: const Icon(Icons.keyboard_arrow_up_rounded),
),
],
),
],
),
],
),
),
);
}
}
class _ChromeNavigationPills extends StatelessWidget {
const _ChromeNavigationPills({this.compact = false});
final bool compact;
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_ChromePill(
icon: Icons.home_rounded,
label: appText('主页', 'Home'),
compact: compact,
),
_ChromePill(
label: WorkspaceDestination.assistant.label,
emphasized: true,
compact: compact,
),
],
);
}
}
class _ChromeConnectionChip extends StatelessWidget {
const _ChromeConnectionChip({required this.state, this.compact = false});
final AssistantThreadConnectionState state;
final bool compact;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
final tone = switch (state.status) {
RuntimeConnectionStatus.connected => (
palette.success.withValues(alpha: 0.14),
palette.success.withValues(alpha: 0.22),
palette.success,
),
RuntimeConnectionStatus.connecting => (
palette.accentMuted.withValues(alpha: 0.86),
palette.accent.withValues(alpha: 0.18),
palette.accent,
),
RuntimeConnectionStatus.error => (
palette.danger.withValues(alpha: 0.12),
palette.danger.withValues(alpha: 0.18),
palette.textSecondary,
),
RuntimeConnectionStatus.offline => (
palette.warning.withValues(alpha: 0.12),
palette.warning.withValues(alpha: 0.18),
palette.textSecondary,
),
};
final text = [
state.primaryLabel.trim(),
state.detailLabel.trim(),
].where((item) => item.isNotEmpty).join(' · ');
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: compact ? 280 : 360),
child: Container(
key: const Key('assistant-workspace-status-chip'),
padding: EdgeInsets.symmetric(
horizontal: compact ? 10 : 12,
vertical: compact ? 6 : 7,
),
decoration: BoxDecoration(
color: tone.$1,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: tone.$2),
),
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelLarge?.copyWith(
color: tone.$3,
fontWeight: FontWeight.w600,
letterSpacing: 0.02,
),
),
),
);
}
}
class _AssistantSidePane extends StatelessWidget {
const _AssistantSidePane({
required this.collapsed,
required this.activePane,
required this.controller,
required this.query,
required this.searchController,
required this.permissionLevel,
required this.onQueryChanged,
required this.onClearQuery,
required this.onToggleCollapsed,
required this.onPaneChanged,
required this.onPermissionChanged,
required this.onRename,
required this.onArchive,
required this.onOpenActions,
});
final bool collapsed;
final _WebAssistantPane activePane;
final AppController controller;
final String query;
final TextEditingController searchController;
final AssistantPermissionLevel permissionLevel;
final ValueChanged<String> onQueryChanged;
final VoidCallback onClearQuery;
final VoidCallback onToggleCollapsed;
final ValueChanged<_WebAssistantPane> onPaneChanged;
final ValueChanged<AssistantPermissionLevel> onPermissionChanged;
final ValueChanged<String> onRename;
final ValueChanged<String> onArchive;
final ValueChanged<String> onOpenActions;
@override
Widget build(BuildContext context) {
final single = controller.conversationsForTarget(
AssistantExecutionTarget.singleAgent,
);
final local = controller.conversationsForTarget(
AssistantExecutionTarget.local,
);
final remote = controller.conversationsForTarget(
AssistantExecutionTarget.remote,
);
final filteredSingle = _filterConversations(single, query);
final filteredLocal = _filterConversations(local, query);
final filteredRemote = _filterConversations(remote, query);
final palette = context.palette;
return Row(
children: [
Container(
key: const Key('assistant-side-pane'),
width: _webAssistantSideTabRailWidth,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
palette.chromeHighlight.withValues(alpha: 0.96),
palette.chromeSurface,
],
),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: palette.chromeStroke),
boxShadow: [palette.chromeShadowAmbient],
),
child: Column(
children: [
const SizedBox(height: 4),
_AssistantSideTabButton(
key: const Key('assistant-side-pane-tab-tasks'),
icon: Icons.checklist_rtl_rounded,
selected: activePane == _WebAssistantPane.tasks,
tooltip: appText('任务', 'Tasks'),
onTap: () => onPaneChanged(_WebAssistantPane.tasks),
),
const SizedBox(height: 4),
_AssistantSideTabButton(
key: const Key('assistant-side-pane-tab-quick'),
icon: Icons.dashboard_customize_outlined,
selected: activePane == _WebAssistantPane.quick,
tooltip: appText('快捷面板', 'Quick panel'),
onTap: () => onPaneChanged(_WebAssistantPane.quick),
),
const Spacer(),
IconButton(
key: const Key('assistant-side-pane-toggle'),
tooltip: collapsed
? appText('展开侧板', 'Expand side pane')
: appText('收起侧板', 'Collapse side pane'),
onPressed: onToggleCollapsed,
style: IconButton.styleFrom(
backgroundColor: palette.chromeSurface,
foregroundColor: palette.textSecondary,
side: BorderSide(color: palette.chromeStroke),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
icon: Icon(
collapsed
? Icons.keyboard_double_arrow_right_rounded
: Icons.keyboard_double_arrow_left_rounded,
size: 18,
),
),
const SizedBox(height: 4),
],
),
),
if (!collapsed) ...[
const SizedBox(width: 6),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
child: KeyedSubtree(
key: ValueKey<String>('assistant-side-pane-${activePane.name}'),
child: activePane == _WebAssistantPane.tasks
? _AssistantTaskPane(
controller: controller,
query: query,
searchController: searchController,
onQueryChanged: onQueryChanged,
onClearQuery: onClearQuery,
showSingle: controller
.featuresFor(UiFeaturePlatform.web)
.supportsDirectAi,
showLocal: controller
.featuresFor(UiFeaturePlatform.web)
.supportsLocalGateway,
showRemote: controller
.featuresFor(UiFeaturePlatform.web)
.supportsRelayGateway,
single: filteredSingle,
local: filteredLocal,
remote: filteredRemote,
onRename: onRename,
onArchive: onArchive,
onOpenActions: onOpenActions,
)
: _AssistantQuickPane(
controller: controller,
permissionLevel: permissionLevel,
onPermissionChanged: onPermissionChanged,
),
),
),
),
],
],
);
}
}
class _AssistantTaskPane extends StatelessWidget {
const _AssistantTaskPane({
required this.controller,
required this.query,
required this.searchController,
required this.onQueryChanged,
required this.onClearQuery,
required this.showSingle,
required this.showLocal,
required this.showRemote,
required this.single,
required this.local,
required this.remote,
required this.onRename,
required this.onArchive,
required this.onOpenActions,
});
final AppController controller;
final String query;
final TextEditingController searchController;
final ValueChanged<String> onQueryChanged;
final VoidCallback onClearQuery;
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;
final ValueChanged<String> onOpenActions;
@override
Widget build(BuildContext context) {
final runningCount = controller.conversations
.where((item) => item.pending)
.length;
final threadCount = controller.conversations.length;
final skillCount = controller.currentAssistantSkillCount;
return SurfaceCard(
key: const Key('assistant-task-rail'),
borderRadius: 10,
tone: SurfaceCardTone.chrome,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: searchController,
onChanged: onQueryChanged,
decoration: InputDecoration(
hintText: appText('搜索任务', 'Search tasks'),
prefixIcon: const Icon(Icons.search_rounded),
suffixIcon: query.isEmpty
? null
: IconButton(
onPressed: onClearQuery,
icon: const Icon(Icons.close_rounded),
),
),
),
const SizedBox(height: 10),
FilledButton.icon(
onPressed: () => controller.createConversation(
target: controller.assistantExecutionTarget,
),
icon: const Icon(Icons.edit_square),
label: Text(appText('新对话', 'New conversation')),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(42),
),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_MetaChip(
icon: Icons.play_circle_outline_rounded,
label: '${appText('运行中', 'Running')} $runningCount',
),
_MetaChip(
icon: Icons.chat_bubble_outline_rounded,
label: '${appText('当前', 'Current')} $threadCount',
),
_MetaChip(
icon: Icons.auto_awesome_rounded,
label: '${appText('技能', 'Skills')} $skillCount',
),
],
),
const SizedBox(height: 12),
Expanded(
child: ListView(
children: [
if (showSingle)
_ConversationGroup(
title: appText('单机智能体', 'Single Agent'),
icon: Icons.hub_rounded,
items: single,
emptyLabel: appText(
'还没有 Single Agent 任务线程',
'No Single Agent task threads yet',
),
onSelect: controller.switchConversation,
onRename: onRename,
onArchive: onArchive,
onOpenActions: onOpenActions,
),
if (showLocal) ...[
const SizedBox(height: 12),
_ConversationGroup(
title: appText('本地 OpenClaw Gateway', 'Local Gateway'),
icon: Icons.laptop_mac_rounded,
items: local,
emptyLabel: appText(
'还没有 Local Gateway 任务线程',
'No Local Gateway task threads yet',
),
onSelect: controller.switchConversation,
onRename: onRename,
onArchive: onArchive,
onOpenActions: onOpenActions,
),
],
if (showRemote) ...[
const SizedBox(height: 12),
_ConversationGroup(
title: appText('远程 OpenClaw 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,
onOpenActions: onOpenActions,
),
],
],
),
),
],
),
);
}
}
class _AssistantQuickPane extends StatelessWidget {
const _AssistantQuickPane({
required this.controller,
required this.permissionLevel,
required this.onPermissionChanged,
});
final AppController controller;
final AssistantPermissionLevel permissionLevel;
final ValueChanged<AssistantPermissionLevel> onPermissionChanged;
@override
Widget build(BuildContext context) {
return WebAssistantFocusPanel(controller: controller);
}
}
class _ConversationGroup extends StatelessWidget {
const _ConversationGroup({
required this.title,
required this.icon,
required this.items,
required this.emptyLabel,
required this.onSelect,
required this.onRename,
required this.onArchive,
required this.onOpenActions,
});
final String title;
final IconData icon;
final List<WebConversationSummary> items;
final String emptyLabel;
final ValueChanged<String> onSelect;
final ValueChanged<String> onRename;
final ValueChanged<String> onArchive;
final ValueChanged<String> onOpenActions;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 18, color: palette.accent),
const SizedBox(width: 8),
Expanded(
child: Text(
'$title ${items.length}',
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
),
),
],
),
const SizedBox(height: 8),
if (items.isEmpty)
Text(
emptyLabel,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: palette.textSecondary),
),
...items.map(
(item) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: SurfaceCard(
onTap: () => onSelect(item.sessionKey),
borderRadius: 10,
padding: const EdgeInsets.all(12),
color: item.current ? palette.accentMuted : null,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(
item.pending
? Icons.play_circle_outline_rounded
: Icons.check_circle_outline_rounded,
size: 18,
color: item.pending
? palette.accent
: palette.success.withValues(alpha: 0.92),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 4),
Text(
item.preview,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: palette.textSecondary),
),
],
),
),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_relativeTimeLabel(item.updatedAtMs),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: palette.textMuted,
),
),
const SizedBox(height: 6),
IconButton(
tooltip: appText('更多操作', 'More actions'),
onPressed: () => onOpenActions(item.sessionKey),
icon: const Icon(Icons.more_horiz_rounded),
),
],
),
],
),
),
),
),
],
);
}
}
class _ConversationWorkspace extends StatelessWidget {
const _ConversationWorkspace({
required this.controller,
required this.scrollController,
required this.inputController,
required this.currentMessages,
required this.connectionState,
required this.thinkingLevel,
required this.permissionLevel,
required this.useMultiAgent,
required this.attachments,
required this.composerHeight,
required this.onComposerHeightChanged,
required this.onThinkingChanged,
required this.onPermissionChanged,
required this.onToggleMultiAgent,
required this.onAddAttachment,
required this.onRemoveAttachment,
required this.onOpenSessionSettings,
required this.onSubmit,
});
final AppController controller;
final ScrollController scrollController;
final TextEditingController inputController;
final List<GatewayChatMessage> currentMessages;
final AssistantThreadConnectionState connectionState;
final String thinkingLevel;
final AssistantPermissionLevel permissionLevel;
final bool useMultiAgent;
final List<_WebComposerAttachment> attachments;
final double composerHeight;
final ValueChanged<double> onComposerHeightChanged;
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() onOpenSessionSettings;
final Future<void> Function() onSubmit;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final palette = context.palette;
final currentTarget = controller.assistantExecutionTarget;
final connected = connectionState.ready;
final maxComposerHeight = math.max(
_webAssistantComposerMinHeight,
constraints.maxHeight -
_webAssistantConversationMinHeight -
_webAssistantResizeHandleSize,
);
final resolvedComposerHeight = composerHeight.clamp(
_webAssistantComposerMinHeight,
maxComposerHeight,
);
return Column(
children: [
if (!connected)
SurfaceCard(
borderRadius: 10,
child: Row(
children: [
const Icon(Icons.info_outline_rounded),
const SizedBox(width: 12),
Expanded(
child: Text(
currentTarget == AssistantExecutionTarget.singleAgent
? appText(
'当前线程未就绪。请检查 Single Agent 配置,或切换到可连接的 Gateway 目标。',
'This thread is not ready. Check Single Agent configuration, or switch to a connected gateway target.',
)
: appText(
'当前线程目标网关未连接。请先在 Settings 中 Test / Save / Apply。',
'The gateway target for this thread is offline. Use Test / Save / Apply in Settings first.',
),
),
),
],
),
),
if (!connected) const SizedBox(height: 8),
Expanded(
child: SurfaceCard(
borderRadius: 10,
padding: EdgeInsets.zero,
tone: SurfaceCardTone.chrome,
child: Column(
children: [
if (controller
.assistantImportedSkillsForSession(
controller.currentSessionKey,
)
.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 8),
child: Align(
alignment: Alignment.centerLeft,
child: Wrap(
spacing: 8,
runSpacing: 8,
children: controller
.assistantImportedSkillsForSession(
controller.currentSessionKey,
)
.map((skill) {
final selected = controller
.assistantSelectedSkillKeysForSession(
controller.currentSessionKey,
)
.contains(skill.key);
return FilterChip(
label: Text(skill.label),
selected: selected,
onSelected: (_) => controller
.toggleAssistantSkillForSession(
controller.currentSessionKey,
skill.key,
),
);
})
.toList(growable: false),
),
),
),
Expanded(
child: currentMessages.isEmpty
? _ConversationEmptyState(controller: controller)
: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.all(16),
itemCount: currentMessages.length,
itemBuilder: (context, index) {
return _MessageBubble(
message: currentMessages[index],
);
},
),
),
],
),
),
),
SizedBox(
height: _webAssistantResizeHandleSize,
child: PaneResizeHandle(
axis: Axis.vertical,
onDelta: (delta) {
onComposerHeightChanged(
(resolvedComposerHeight - delta)
.clamp(
_webAssistantComposerMinHeight,
maxComposerHeight,
)
.toDouble(),
);
},
),
),
SizedBox(
height: resolvedComposerHeight,
child: SurfaceCard(
borderRadius: 10,
tone: SurfaceCardTone.chrome,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (attachments.isNotEmpty) ...[
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),
),
],
),
const SizedBox(height: 10),
],
Expanded(
child: TextField(
controller: inputController,
minLines: null,
maxLines: null,
expands: true,
decoration: InputDecoration(
hintText: appText(
'输入任务说明、补充上下文XWorkmate 会沿用当前任务上下文持续处理。',
'Describe the task and add context. XWorkmate keeps working in the current task context.',
),
alignLabelWithHint: true,
),
textAlignVertical: TextAlignVertical.top,
),
),
const SizedBox(height: 10),
Wrap(
spacing: 10,
runSpacing: 10,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
OutlinedButton.icon(
key: const Key('assistant-session-settings-button'),
onPressed: onOpenSessionSettings,
icon: const Icon(Icons.tune_rounded),
label: Text(appText('会话设置', 'Session settings')),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: useMultiAgent,
onChanged: (value) {
onToggleMultiAgent(value ?? false);
},
),
Text(appText('Multi-Agent', 'Multi-Agent')),
],
),
Container(
decoration: BoxDecoration(
color: palette.surfacePrimary,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: palette.strokeSoft),
),
child: IconButton(
key: const Key('assistant-attachment-menu-button'),
tooltip: appText('添加附件', 'Add attachment'),
onPressed: onAddAttachment,
icon: const Icon(Icons.attach_file_rounded),
),
),
FilledButton.icon(
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')),
),
],
),
const SizedBox(height: 8),
Text(
controller.lastAssistantError?.trim().isNotEmpty == true
? controller.lastAssistantError!.trim()
: appText(
'附件仅支持手动选择,单次总量上限 10MB。',
'Attachments are explicit user picks only, with a 10MB total limit per send.',
),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: palette.textSecondary,
),
),
],
),
),
),
],
);
},
);
}
}
class _AssistantSessionSettingsSheet extends StatefulWidget {
const _AssistantSessionSettingsSheet({
required this.controller,
required this.thinkingLevel,
required this.permissionLevel,
required this.onThinkingChanged,
required this.onPermissionChanged,
});
final AppController controller;
final String thinkingLevel;
final AssistantPermissionLevel permissionLevel;
final ValueChanged<String> onThinkingChanged;
final ValueChanged<AssistantPermissionLevel> onPermissionChanged;
@override
State<_AssistantSessionSettingsSheet> createState() =>
_AssistantSessionSettingsSheetState();
}
class _AssistantSessionSettingsSheetState
extends State<_AssistantSessionSettingsSheet> {
late String _thinkingLevel = widget.thinkingLevel;
late AssistantPermissionLevel _permissionLevel = widget.permissionLevel;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return AnimatedBuilder(
animation: widget.controller,
builder: (context, _) {
final controller = widget.controller;
final currentTarget = controller.assistantExecutionTarget;
final modelChoices = controller.assistantModelChoices;
return SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.fromLTRB(
16,
0,
16,
MediaQuery.of(context).viewInsets.bottom + 16,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('会话设置', 'Session settings'),
key: const Key('assistant-session-settings-sheet-title'),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
appText(
'线程模式、渲染方式和执行参数统一放到底部对话框管理。',
'Manage thread mode, rendering, and execution parameters from this bottom sheet.',
),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: palette.textSecondary,
),
),
const SizedBox(height: 16),
_SessionSettingField(
label: appText('执行目标', 'Execution target'),
child: _HeaderDropdownShell(
child: _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)
Padding(
padding: const EdgeInsets.only(top: 12),
child: _SessionSettingField(
label: appText('Provider', 'Provider'),
child: _HeaderDropdownShell(
child: _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)
Padding(
padding: const EdgeInsets.only(top: 12),
child: _SessionSettingField(
label: appText('模型', 'Model'),
child: _HeaderDropdownShell(
child: _CompactDropdown<String>(
key: const Key('assistant-model-button'),
value: controller.resolvedAssistantModel,
items: modelChoices,
labelBuilder: (item) => item,
onChanged: (value) {
if (value != null) {
controller.selectAssistantModel(value);
}
},
),
),
),
),
Padding(
padding: const EdgeInsets.only(top: 12),
child: _SessionSettingField(
label: appText('消息视图', 'Message view'),
child: _HeaderDropdownShell(
child: _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);
}
},
),
),
),
),
Padding(
padding: const EdgeInsets.only(top: 12),
child: _SessionSettingField(
label: appText('思考强度', 'Thinking level'),
child: _HeaderDropdownShell(
child: _CompactDropdown<String>(
key: const Key('assistant-thinking-button'),
value: _thinkingLevel,
items: const <String>['low', 'medium', 'high'],
labelBuilder: _thinkingLabel,
onChanged: (value) {
if (value != null) {
setState(() => _thinkingLevel = value);
widget.onThinkingChanged(value);
}
},
),
),
),
),
Padding(
padding: const EdgeInsets.only(top: 12),
child: _SessionSettingField(
label: appText('权限', 'Permissions'),
child: _HeaderDropdownShell(
child: _CompactDropdown<AssistantPermissionLevel>(
key: const Key('assistant-permission-button'),
value: _permissionLevel,
items: AssistantPermissionLevel.values,
labelBuilder: (item) => item.label,
onChanged: (value) {
if (value != null) {
setState(() => _permissionLevel = value);
widget.onPermissionChanged(value);
}
},
),
),
),
),
],
),
),
),
);
},
);
}
}
class _ConversationEmptyState extends StatelessWidget {
const _ConversationEmptyState({required this.controller});
final AppController controller;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: palette.surfaceSecondary,
borderRadius: BorderRadius.circular(18),
),
child: Icon(
Icons.chat_bubble_outline_rounded,
color: palette.accent,
),
),
const SizedBox(height: 16),
Text(
appText('开始这个任务线程', 'Start this task thread'),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Text(
appText(
'保持当前线程模式与上下文,在底部 composer 中直接输入需求即可。',
'Keep the current thread mode and context, then start from the composer below.',
),
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: palette.textSecondary),
),
],
),
),
),
);
}
}
class _MessageBubble extends StatelessWidget {
const _MessageBubble({required this.message});
final GatewayChatMessage message;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final assistant = message.role.trim().toLowerCase() == 'assistant';
final color = message.error
? palette.danger.withValues(alpha: 0.14)
: assistant
? palette.surfacePrimary
: palette.accentMuted;
return Align(
alignment: assistant ? Alignment.centerLeft : Alignment.centerRight,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: DecoratedBox(
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: palette.strokeSoft),
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
assistant ? 'Assistant' : 'You',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: palette.textSecondary,
),
),
const SizedBox(height: 6),
Text(message.text),
],
),
),
),
),
),
);
}
}
class _AssistantSideTabButton extends StatefulWidget {
const _AssistantSideTabButton({
super.key,
required this.icon,
required this.selected,
required this.tooltip,
required this.onTap,
});
final IconData icon;
final bool selected;
final String tooltip;
final VoidCallback onTap;
@override
State<_AssistantSideTabButton> createState() =>
_AssistantSideTabButtonState();
}
class _AssistantSideTabButtonState extends State<_AssistantSideTabButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return Tooltip(
message: widget.tooltip,
child: MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: widget.onTap,
child: Container(
width: 34,
height: 34,
decoration: BoxDecoration(
gradient: widget.selected || _hovered
? LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
palette.chromeHighlight.withValues(
alpha: widget.selected ? 0.96 : 0.84,
),
palette.chromeSurfacePressed,
],
)
: null,
color: widget.selected || _hovered ? null : Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: widget.selected
? palette.accent.withValues(alpha: 0.28)
: Colors.transparent,
),
),
child: Icon(
widget.icon,
size: 18,
color: widget.selected ? palette.accent : palette.textSecondary,
),
),
),
),
),
);
}
}
class _ChromePill extends StatelessWidget {
const _ChromePill({
this.icon,
required this.label,
this.emphasized = false,
this.compact = false,
});
final IconData? icon;
final String label;
final bool emphasized;
final bool compact;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 10 : 14,
vertical: compact ? 8 : 10,
),
decoration: BoxDecoration(
color: emphasized ? palette.surfacePrimary : palette.surfaceSecondary,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: palette.strokeSoft),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[Icon(icon, size: 16), const SizedBox(width: 8)],
Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: emphasized ? FontWeight.w700 : FontWeight.w600,
),
),
],
),
);
}
}
class _HeaderDropdownShell extends StatelessWidget {
const _HeaderDropdownShell({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: context.palette.surfacePrimary,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: context.palette.strokeSoft),
),
child: child,
);
}
}
class _SessionSettingField extends StatelessWidget {
const _SessionSettingField({required this.label, required this.child});
final String label;
final Widget child;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 6),
child,
],
);
}
}
class _MetaChip extends StatelessWidget {
const _MetaChip({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: context.palette.surfacePrimary,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: context.palette.strokeSoft),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: context.palette.textSecondary),
const SizedBox(width: 8),
Text(
label,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
),
],
),
);
}
}
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,
);
}
}
List<WebConversationSummary> _filterConversations(
List<WebConversationSummary> items,
String query,
) {
if (query.trim().isEmpty) {
return items;
}
final normalized = query.trim().toLowerCase();
return items
.where((item) {
final haystack = '${item.title}\n${item.preview}'.toLowerCase();
return haystack.contains(normalized);
})
.toList(growable: false);
}
String _relativeTimeLabel(double updatedAtMs) {
final delta = DateTime.now().difference(
DateTime.fromMillisecondsSinceEpoch(updatedAtMs.round()),
);
if (delta.inMinutes < 1) {
return appText('刚刚', 'now');
}
if (delta.inHours < 1) {
return '${delta.inMinutes}m';
}
if (delta.inDays < 1) {
return '${delta.inHours}h';
}
return '${delta.inDays}d';
}
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'),
AssistantExecutionTarget.local => appText(
'本地 OpenClaw Gateway',
'Local Gateway',
),
AssistantExecutionTarget.remote => appText(
'远程 OpenClaw Gateway',
'Remote Gateway',
),
};
}