xworkmate-app/lib/features/assistant/assistant_page.dart

5156 lines
178 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:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:markdown/markdown.dart' as md;
import 'package:path_provider/path_provider.dart';
import 'package:super_clipboard/super_clipboard.dart';
import '../../app/app_controller.dart';
import '../../app/app_metadata.dart';
import '../../app/ui_feature_manifest.dart';
import '../../i18n/app_language.dart';
import '../../models/app_models.dart';
import '../../runtime/multi_agent_orchestrator.dart';
import '../../runtime/runtime_models.dart';
import '../../theme/app_palette.dart';
import '../../theme/app_theme.dart';
import '../../widgets/assistant_focus_panel.dart';
import '../../widgets/assistant_artifact_sidebar.dart';
import '../../widgets/desktop_workspace_scaffold.dart';
import '../../widgets/pane_resize_handle.dart';
import '../../widgets/surface_card.dart';
const double _assistantComposerDefaultInputHeight = 78;
const double _assistantWorkspaceMinConversationHeight = 180;
const double _assistantWorkspaceMinLowerPaneHeight = 176;
const double _assistantHorizontalResizeHandleWidth = 6;
const double _assistantHorizontalPaneGap = 2;
const double _assistantVerticalResizeHandleHeight = 10;
const double _assistantArtifactPaneMinWidth = 280;
const double _assistantArtifactPaneDefaultWidth = 360;
const double _assistantCollapsedArtifactToggleClearance = 56;
const double _assistantComposerSafeAreaGap = 8;
const double _assistantComposerBaseHeightCompact = 192;
const double _assistantComposerBaseHeightTall = 216;
const int _assistantTaskActionMaxRetryCount = 5;
typedef AssistantClipboardImageReader = Future<XFile?> Function();
class AssistantPage extends StatefulWidget {
const AssistantPage({
super.key,
required this.controller,
required this.onOpenDetail,
this.navigationPanelBuilder,
this.showStandaloneTaskRail = true,
this.unifiedPaneStartsCollapsed = false,
this.clipboardImageReader,
});
final AppController controller;
final ValueChanged<DetailPanelData> onOpenDetail;
final Widget Function(double contentWidth)? navigationPanelBuilder;
final bool showStandaloneTaskRail;
final bool unifiedPaneStartsCollapsed;
final AssistantClipboardImageReader? clipboardImageReader;
@override
State<AssistantPage> createState() => _AssistantPageState();
}
class _AssistantPageState extends State<AssistantPage> {
static const double _sidePaneMinWidth = 184;
static const double _sidePaneContentMinWidth = 140;
static const double _mainWorkspaceMinWidth = 620;
static const double _sidePaneViewportPadding = 72;
static const double _sideTabRailWidth = 46;
late final TextEditingController _inputController;
late final TextEditingController _threadSearchController;
late final ScrollController _conversationController;
late final FocusNode _composerFocusNode;
final String _mode = 'ask';
String _thinkingLabel = 'high';
double _threadRailWidth = 248;
String _threadQuery = '';
bool _sidePaneCollapsed = false;
_AssistantSidePane _activeSidePane = _AssistantSidePane.tasks;
AssistantFocusEntry? _activeFocusedDestination;
final Map<String, _AssistantTaskSeed> _taskSeeds =
<String, _AssistantTaskSeed>{};
final Set<String> _archivedTaskKeys = <String>{};
List<_ComposerAttachment> _attachments = const <_ComposerAttachment>[];
String? _lastAutoAgentLabel;
String _lastConversationScrollSignature = '';
double _composerInputHeight = _assistantComposerDefaultInputHeight;
double _workspaceLowerPaneHeightAdjustment = 0;
bool _artifactPaneCollapsed = true;
double _artifactPaneWidth = _assistantArtifactPaneDefaultWidth;
@override
void initState() {
super.initState();
_inputController = TextEditingController();
_threadSearchController = TextEditingController();
_conversationController = ScrollController();
_composerFocusNode = FocusNode();
_sidePaneCollapsed = widget.unifiedPaneStartsCollapsed;
}
@override
void didUpdateWidget(covariant AssistantPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.unifiedPaneStartsCollapsed !=
widget.unifiedPaneStartsCollapsed) {
_sidePaneCollapsed = widget.unifiedPaneStartsCollapsed;
}
}
@override
void dispose() {
_inputController.dispose();
_threadSearchController.dispose();
_conversationController.dispose();
_composerFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: widget.controller,
builder: (context, _) {
final controller = widget.controller;
final messages = List<GatewayChatMessage>.from(controller.chatMessages);
final timelineItems = _buildTimelineItems(controller, messages);
final tasks = _buildTaskEntries(controller);
final visibleTasks = _filterTasks(tasks);
final currentTask = _resolveCurrentTask(
tasks,
controller.currentSessionKey,
);
final scrollSignature = messages.isEmpty
? controller.currentSessionKey
: '${controller.currentSessionKey}:${messages.length}:${messages.last.id}:${messages.last.pending}:${messages.last.error}';
if (scrollSignature != _lastConversationScrollSignature) {
_lastConversationScrollSignature = scrollSignature;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_conversationController.hasClients) {
return;
}
_conversationController.animateTo(
_conversationController.position.maxScrollExtent,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
);
});
}
return DesktopWorkspaceScaffold(
padding: EdgeInsets.zero,
child: LayoutBuilder(
builder: (context, constraints) {
final showUnifiedSidePane =
widget.navigationPanelBuilder != null &&
constraints.maxWidth >= 860;
final showThreadRail =
!showUnifiedSidePane &&
widget.showStandaloneTaskRail &&
constraints.maxWidth >= 860;
final mainWorkspace = _buildMainWorkspace(
controller: controller,
timelineItems: timelineItems,
currentTask: currentTask,
);
final workspaceWithArtifacts = _buildWorkspaceWithArtifacts(
controller: controller,
currentTask: currentTask,
child: mainWorkspace,
);
if (!showThreadRail && !showUnifiedSidePane) {
return workspaceWithArtifacts;
}
final maxThreadRailWidth = _resolveMaxSidePaneWidth(
constraints.maxWidth,
);
final threadRailWidth = _threadRailWidth
.clamp(_sidePaneMinWidth, maxThreadRailWidth)
.toDouble();
if (showUnifiedSidePane) {
final favoriteDestinations =
controller.assistantNavigationDestinations;
final activeFocusedDestination = _resolveFocusedDestination(
favoriteDestinations,
);
final effectiveActiveSidePane =
_activeSidePane == _AssistantSidePane.focused &&
activeFocusedDestination == null
? _AssistantSidePane.navigation
: _activeSidePane;
final sidePanelContentWidth =
(threadRailWidth - _sideTabRailWidth - 2)
.clamp(_sidePaneContentMinWidth, threadRailWidth)
.toDouble();
return Row(
children: [
AnimatedContainer(
key: const Key('assistant-unified-side-pane-shell'),
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
width: _sidePaneCollapsed
? _sideTabRailWidth
: threadRailWidth,
child: _AssistantUnifiedSidePane(
activePane: effectiveActiveSidePane,
activeFocusedDestination: activeFocusedDestination,
collapsed: _sidePaneCollapsed,
favoriteDestinations: favoriteDestinations,
taskPanel: _AssistantTaskRail(
key: const Key('assistant-task-rail'),
controller: controller,
tasks: visibleTasks,
query: _threadQuery,
searchController: _threadSearchController,
onQueryChanged: (value) {
setState(() {
_threadQuery = value.trim();
});
},
onClearQuery: () {
_threadSearchController.clear();
setState(() {
_threadQuery = '';
});
},
onRefreshTasks: _refreshTasksWithRetry,
onCreateTask: _createNewThread,
onSelectTask: _switchSessionWithRetry,
onArchiveTask: _archiveTask,
onRenameTask: _renameTask,
),
navigationPanel: widget.navigationPanelBuilder!(
sidePanelContentWidth,
),
focusedPanel: activeFocusedDestination == null
? null
: SingleChildScrollView(
padding: const EdgeInsets.all(6),
child: AssistantFocusDestinationCard(
controller: controller,
destination: activeFocusedDestination,
onOpenPage: () => controller.navigateTo(
activeFocusedDestination.destination ??
WorkspaceDestination.settings,
),
onRemoveFavorite: () async {
await controller
.toggleAssistantNavigationDestination(
activeFocusedDestination,
);
if (!mounted) {
return;
}
setState(() {
_activeFocusedDestination =
_resolveFocusedDestination(
controller
.assistantNavigationDestinations,
);
_activeSidePane =
_activeFocusedDestination == null
? _AssistantSidePane.navigation
: _AssistantSidePane.focused;
});
},
),
),
onSelectPane: (pane) {
setState(() {
final normalizedPane =
pane == _AssistantSidePane.focused
? _AssistantSidePane.navigation
: pane;
if (effectiveActiveSidePane == normalizedPane) {
_sidePaneCollapsed = !_sidePaneCollapsed;
return;
}
_activeSidePane = normalizedPane;
if (normalizedPane != _AssistantSidePane.focused) {
_activeFocusedDestination = null;
}
_sidePaneCollapsed = false;
});
},
onSelectFocusedDestination: (destination) {
setState(() {
final isSameSelection =
effectiveActiveSidePane ==
_AssistantSidePane.focused &&
activeFocusedDestination == destination;
if (isSameSelection) {
_sidePaneCollapsed = !_sidePaneCollapsed;
return;
}
_activeFocusedDestination = destination;
_activeSidePane = _AssistantSidePane.focused;
_sidePaneCollapsed = false;
});
},
onToggleCollapsed: () {
setState(() {
_sidePaneCollapsed = !_sidePaneCollapsed;
});
},
),
),
if (!_sidePaneCollapsed)
SizedBox(
width: _assistantHorizontalResizeHandleWidth,
child: PaneResizeHandle(
axis: Axis.horizontal,
onDelta: (delta) {
setState(() {
_threadRailWidth = (_threadRailWidth + delta)
.clamp(_sidePaneMinWidth, maxThreadRailWidth)
.toDouble();
});
},
),
),
const SizedBox(width: _assistantHorizontalPaneGap),
Expanded(child: workspaceWithArtifacts),
],
);
}
return Row(
children: [
SizedBox(
width: threadRailWidth,
child: _AssistantTaskRail(
key: const Key('assistant-task-rail'),
controller: controller,
tasks: visibleTasks,
query: _threadQuery,
searchController: _threadSearchController,
onQueryChanged: (value) {
setState(() {
_threadQuery = value.trim();
});
},
onClearQuery: () {
_threadSearchController.clear();
setState(() {
_threadQuery = '';
});
},
onRefreshTasks: _refreshTasksWithRetry,
onCreateTask: _createNewThread,
onSelectTask: _switchSessionWithRetry,
onArchiveTask: _archiveTask,
onRenameTask: _renameTask,
),
),
SizedBox(
width: _assistantHorizontalResizeHandleWidth,
child: PaneResizeHandle(
axis: Axis.horizontal,
onDelta: (delta) {
setState(() {
_threadRailWidth = (_threadRailWidth + delta)
.clamp(_sidePaneMinWidth, maxThreadRailWidth)
.toDouble();
});
},
),
),
const SizedBox(width: _assistantHorizontalPaneGap),
Expanded(child: workspaceWithArtifacts),
],
);
},
),
);
},
);
}
Widget _buildMainWorkspace({
required AppController controller,
required List<_TimelineItem> timelineItems,
required _AssistantTaskEntry currentTask,
}) {
return LayoutBuilder(
builder: (context, constraints) {
final palette = context.palette;
final mediaQuery = MediaQuery.of(context);
final composerBottomInset = math.max(
mediaQuery.viewPadding.bottom,
mediaQuery.viewInsets.bottom,
);
final composerBottomSpacing = composerBottomInset > 0
? composerBottomInset + _assistantComposerSafeAreaGap
: _assistantComposerSafeAreaGap;
final baseComposerHeight = constraints.maxHeight >= 900
? _assistantComposerBaseHeightTall
: _assistantComposerBaseHeightCompact;
final composerContentWidth = math.max(240.0, constraints.maxWidth - 32);
final availableWorkspaceHeight = math.max(
0.0,
constraints.maxHeight - _assistantVerticalResizeHandleHeight,
);
final attachmentExtraHeight = _estimatedComposerWrapSectionHeight(
itemCount: _attachments.length,
availableWidth: composerContentWidth,
averageChipWidth: 168,
);
final selectedSkillExtraHeight = _estimatedComposerWrapSectionHeight(
itemCount: _selectedSkillKeysFor(controller).length,
availableWidth: composerContentWidth,
averageChipWidth: 132,
);
final defaultComposerHeight = math.min(
availableWorkspaceHeight,
baseComposerHeight +
math.max(
0.0,
_composerInputHeight - _assistantComposerDefaultInputHeight,
) +
attachmentExtraHeight +
selectedSkillExtraHeight +
composerBottomSpacing,
);
final composerHeightUpperBound = math.min(
availableWorkspaceHeight,
math.max(
_assistantWorkspaceMinLowerPaneHeight + composerBottomSpacing,
availableWorkspaceHeight - _assistantWorkspaceMinConversationHeight,
),
);
final composerHeightLowerBound = math.min(
_assistantWorkspaceMinLowerPaneHeight + composerBottomSpacing,
composerHeightUpperBound,
);
final composerHeight =
(defaultComposerHeight + _workspaceLowerPaneHeightAdjustment)
.clamp(composerHeightLowerBound, composerHeightUpperBound)
.toDouble();
return SurfaceCard(
borderRadius: 0,
padding: EdgeInsets.zero,
tone: SurfaceCardTone.chrome,
child: Column(
children: [
Expanded(
child: KeyedSubtree(
key: const Key('assistant-conversation-shell'),
child: _ConversationArea(
controller: controller,
currentTask: currentTask,
items: timelineItems,
messageViewMode: controller.currentAssistantMessageViewMode,
bottomContentInset: composerBottomSpacing,
topTrailingInset: _artifactPaneCollapsed
? _assistantCollapsedArtifactToggleClearance
: 0,
scrollController: _conversationController,
onOpenDetail: widget.onOpenDetail,
onFocusComposer: _focusComposer,
onOpenGateway: _openGatewaySettings,
onOpenAiGatewaySettings: _openAiGatewaySettings,
onReconnectGateway: _connectFromSavedSettingsOrShowDialog,
onMessageViewModeChanged:
controller.setAssistantMessageViewMode,
),
),
),
ColoredBox(
color: palette.canvas,
child: SizedBox(
key: const Key('assistant-workspace-resize-handle'),
height: _assistantVerticalResizeHandleHeight,
child: PaneResizeHandle(
axis: Axis.vertical,
onDelta: (delta) {
setState(() {
final nextComposerHeight = (composerHeight - delta)
.clamp(
composerHeightLowerBound,
composerHeightUpperBound,
)
.toDouble();
_workspaceLowerPaneHeightAdjustment =
nextComposerHeight - defaultComposerHeight;
});
},
),
),
),
SizedBox(
key: const Key('assistant-composer-shell'),
height: composerHeight,
child: _AssistantLowerPane(
bottomContentInset: composerBottomSpacing,
inputController: _inputController,
focusNode: _composerFocusNode,
thinkingLabel: _thinkingLabel,
showModelControl: !controller.isSingleAgentMode
? true
: controller.currentSingleAgentShouldShowModelControl,
modelLabel: controller.isSingleAgentMode
? controller.currentSingleAgentModelDisplayLabel
: controller.resolvedAssistantModel.isEmpty
? appText('未选择模型', 'No model selected')
: controller.resolvedAssistantModel,
modelOptions: controller.assistantModelChoices,
attachments: _attachments,
availableSkills: _availableSkillOptions(controller),
selectedSkillKeys: _selectedSkillKeysFor(controller),
controller: controller,
onRemoveAttachment: (attachment) {
setState(() {
_attachments = _attachments
.where((item) => item.path != attachment.path)
.toList(growable: false);
});
},
onToggleSkill: (key) {
unawaited(
controller.toggleAssistantSkillForSession(
controller.currentSessionKey,
key,
),
);
_focusComposer();
},
onThinkingChanged: (value) {
setState(() => _thinkingLabel = value);
},
onModelChanged: (modelId) =>
controller.selectAssistantModelForSession(
controller.currentSessionKey,
modelId,
),
onOpenGateway: _openGatewaySettings,
onOpenAiGatewaySettings: _openAiGatewaySettings,
onReconnectGateway: _connectFromSavedSettingsOrShowDialog,
onPickAttachments: _pickAttachments,
onAddAttachment: (attachment) {
setState(() {
_attachments = [..._attachments, attachment];
});
},
onPasteImageAttachment:
widget.clipboardImageReader ?? _readClipboardImageAsXFile,
onComposerInputHeightChanged:
_handleComposerInputHeightChanged,
onSend: _submitPrompt,
),
),
],
),
);
},
);
}
Widget _buildWorkspaceWithArtifacts({
required AppController controller,
required _AssistantTaskEntry currentTask,
required Widget child,
}) {
return LayoutBuilder(
builder: (context, constraints) {
final maxPaneWidth = math.min(
560.0,
math.max(_assistantArtifactPaneMinWidth, constraints.maxWidth * 0.48),
);
final paneWidth = _artifactPaneWidth
.clamp(_assistantArtifactPaneMinWidth, maxPaneWidth)
.toDouble();
final panel = Row(
children: [
Expanded(child: child),
if (!_artifactPaneCollapsed) ...[
SizedBox(
key: const Key('assistant-artifact-pane-resize-handle'),
width: _assistantHorizontalResizeHandleWidth,
child: PaneResizeHandle(
axis: Axis.horizontal,
onDelta: (delta) {
setState(() {
_artifactPaneWidth = (_artifactPaneWidth - delta)
.clamp(_assistantArtifactPaneMinWidth, maxPaneWidth)
.toDouble();
});
},
),
),
const SizedBox(width: _assistantHorizontalPaneGap),
SizedBox(
width: paneWidth,
child: AssistantArtifactSidebar(
sessionKey: controller.currentSessionKey,
threadTitle: currentTask.title,
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: panel),
if (_artifactPaneCollapsed)
Positioned(
right: 0,
top: 0,
child: AssistantArtifactSidebarRevealButton(
onTap: () {
setState(() {
_artifactPaneCollapsed = false;
});
},
),
),
],
);
},
);
}
void _handleComposerInputHeightChanged(double value) {
if (!mounted || value == _composerInputHeight) {
return;
}
setState(() {
_composerInputHeight = value;
});
}
List<_TimelineItem> _buildTimelineItems(
AppController controller,
List<GatewayChatMessage> messages,
) {
final items = <_TimelineItem>[];
final ownerLabel = _conversationOwnerLabel(controller);
for (final message in messages) {
if ((message.toolName ?? '').trim().isNotEmpty) {
items.add(
_TimelineItem.toolCall(
toolName: message.toolName!,
summary: message.text,
pending: message.pending,
error: message.error,
),
);
continue;
}
final role = message.role.toLowerCase();
if (role == 'user') {
items.add(
_TimelineItem.message(
kind: _TimelineItemKind.user,
label: appText('', 'You'),
text: message.text,
pending: message.pending,
error: message.error,
),
);
} else if (role == 'assistant') {
items.add(
_TimelineItem.message(
kind: _TimelineItemKind.assistant,
label: kProductBrandName,
text: message.text,
pending: message.pending,
error: message.error,
),
);
} else {
items.add(
_TimelineItem.message(
kind: _TimelineItemKind.agent,
label: _lastAutoAgentLabel ?? ownerLabel,
text: message.text,
pending: message.pending,
error: message.error,
),
);
}
}
return items;
}
Future<void> _pickAttachments() async {
final uiFeatures = widget.controller.featuresFor(
resolveUiFeaturePlatformFromContext(context),
);
if (!uiFeatures.supportsFileAttachments) {
return;
}
final files = await openFiles(
acceptedTypeGroups: const [
XTypeGroup(
label: 'Images',
extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'],
),
XTypeGroup(label: 'Logs', extensions: ['log', 'txt', 'json', 'csv']),
XTypeGroup(
label: 'Files',
extensions: ['md', 'pdf', 'yaml', 'yml', 'zip'],
),
],
);
if (!mounted || files.isEmpty) {
return;
}
setState(() {
_attachments = [
..._attachments,
...files.map(_ComposerAttachment.fromXFile),
];
});
}
Future<void> _submitPrompt() async {
final controller = widget.controller;
final uiFeatures = controller.featuresFor(
resolveUiFeaturePlatformFromContext(context),
);
final settings = controller.settings;
final executionTarget = controller.assistantExecutionTarget;
final rawPrompt = _inputController.text.trim();
if (rawPrompt.isEmpty) {
return;
}
final shouldUseGatewayAgent =
executionTarget != AssistantExecutionTarget.singleAgent;
final autoAgent = shouldUseGatewayAgent
? _pickAutoAgent(controller, rawPrompt)
: null;
if (autoAgent != null) {
await controller.selectAgent(autoAgent.id);
}
final submittedAttachments = List<_ComposerAttachment>.from(
_attachments,
growable: false,
);
final attachmentNames = submittedAttachments
.map((item) => item.name)
.toList(growable: false);
final selectedSkillLabels = _resolveSelectedSkillLabels(controller);
final connectionState = controller.currentAssistantConnectionState;
final prompt = _composePrompt(
mode: _mode,
prompt: rawPrompt,
attachmentNames: attachmentNames,
selectedSkillLabels: selectedSkillLabels,
executionTarget: executionTarget,
singleAgentProvider: controller.currentSingleAgentProvider,
permissionLevel: settings.assistantPermissionLevel,
workspacePath: settings.workspacePath,
remoteProjectRoot: settings.remoteProjectRoot,
);
setState(() {
_lastAutoAgentLabel =
autoAgent?.name ?? _conversationOwnerLabel(controller);
_attachments = const <_ComposerAttachment>[];
_touchTaskSeed(
sessionKey: controller.currentSessionKey,
title:
_taskSeeds[controller.currentSessionKey]?.title ??
_fallbackSessionTitle(controller.currentSessionKey),
preview: rawPrompt,
status:
controller.hasAssistantPendingRun ||
executionTarget == AssistantExecutionTarget.singleAgent ||
connectionState.connected
? 'running'
: 'queued',
owner: autoAgent?.name ?? _conversationOwnerLabel(controller),
surface: 'Assistant',
executionTarget: executionTarget,
draft: controller.currentSessionKey.trim().startsWith('draft:'),
);
});
_inputController.clear();
try {
if (uiFeatures.supportsMultiAgent &&
controller.settings.multiAgent.enabled) {
final collaborationAttachments = submittedAttachments
.map(
(item) => CollaborationAttachment(
name: item.name,
description: item.mimeType,
path: item.path,
),
)
.toList(growable: false);
await controller.runMultiAgentCollaboration(
rawPrompt: rawPrompt,
composedPrompt: prompt,
attachments: collaborationAttachments,
selectedSkillLabels: selectedSkillLabels,
);
} else {
final attachmentPayloads = await _buildAttachmentPayloads(
submittedAttachments,
);
await controller.sendChatMessage(
prompt,
thinking: _thinkingLabel,
attachments: attachmentPayloads,
localAttachments: submittedAttachments
.map(
(item) => CollaborationAttachment(
name: item.name,
description: item.mimeType,
path: item.path,
),
)
.toList(growable: false),
selectedSkillLabels: selectedSkillLabels,
);
}
} catch (_) {
if (!mounted) {
rethrow;
}
if (_inputController.text.trim().isEmpty) {
_inputController.value = TextEditingValue(
text: rawPrompt,
selection: TextSelection.collapsed(offset: rawPrompt.length),
);
}
if (_attachments.isEmpty && submittedAttachments.isNotEmpty) {
setState(() {
_attachments = submittedAttachments;
});
}
rethrow;
}
}
Future<List<GatewayChatAttachmentPayload>> _buildAttachmentPayloads(
List<_ComposerAttachment> attachments,
) async {
final payloads = <GatewayChatAttachmentPayload>[];
for (final attachment in attachments) {
final file = File(attachment.path);
if (!await file.exists()) {
continue;
}
final bytes = await file.readAsBytes();
final mimeType = attachment.mimeType;
payloads.add(
GatewayChatAttachmentPayload(
type: mimeType.startsWith('image/') ? 'image' : 'file',
mimeType: mimeType,
fileName: attachment.name,
content: base64Encode(bytes),
),
);
}
return payloads;
}
GatewayAgentSummary? _pickAutoAgent(AppController controller, String prompt) {
final text = prompt.toLowerCase();
final agents = controller.agents;
if (agents.isEmpty) {
return null;
}
GatewayAgentSummary? byName(String name) {
for (final agent in agents) {
if (agent.name.toLowerCase().contains(name)) {
return agent;
}
}
return null;
}
if (text.contains('browser') ||
text.contains('search') ||
text.contains('website') ||
text.contains('网页') ||
text.contains('') ||
text.contains('抓取')) {
return byName('browser');
}
if (text.contains('research') ||
text.contains('analyze') ||
text.contains('compare') ||
text.contains('summary') ||
text.contains('研究') ||
text.contains('分析') ||
text.contains('调研')) {
return byName('research');
}
if (text.contains('code') ||
text.contains('deploy') ||
text.contains('build') ||
text.contains('test') ||
text.contains('log') ||
text.contains('bug') ||
text.contains('代码') ||
text.contains('部署') ||
text.contains('日志')) {
return byName('coding');
}
return byName('coding') ?? byName('browser') ?? byName('research');
}
List<_ComposerSkillOption> _availableSkillOptions(AppController controller) {
if (controller.isSingleAgentMode) {
return controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.map(_skillOptionFromThreadSkill)
.toList(growable: false);
}
final options = <_ComposerSkillOption>[];
final seenKeys = <String>{};
void addOption(_ComposerSkillOption option) {
if (seenKeys.add(option.key)) {
options.add(option);
}
}
for (final skill in controller.skills) {
final option = _skillOptionFromGateway(skill);
addOption(option);
}
for (final option in _fallbackSkillOptions) {
addOption(option);
}
return options;
}
List<String> _selectedSkillKeysFor(AppController controller) {
return controller.assistantSelectedSkillKeysForSession(
controller.currentSessionKey,
);
}
List<String> _resolveSelectedSkillLabels(AppController controller) {
final optionsByKey = <String, _ComposerSkillOption>{
for (final option in _availableSkillOptions(controller))
option.key: option,
};
return _selectedSkillKeysFor(controller)
.map((key) => optionsByKey[key]?.label)
.whereType<String>()
.toList(growable: false);
}
String _composePrompt({
required String mode,
required String prompt,
required List<String> attachmentNames,
required List<String> selectedSkillLabels,
required AssistantExecutionTarget executionTarget,
required SingleAgentProvider singleAgentProvider,
required AssistantPermissionLevel permissionLevel,
required String workspacePath,
required String remoteProjectRoot,
}) {
final attachmentBlock = attachmentNames.isEmpty
? ''
: 'Attached files:\n${attachmentNames.map((name) => '- $name').join('\n')}\n\n';
final skillBlock = selectedSkillLabels.isEmpty
? ''
: 'Preferred skills:\n${selectedSkillLabels.map((name) => '- $name').join('\n')}\n\n';
final targetRoot = executionTarget == AssistantExecutionTarget.local
? workspacePath.trim()
: remoteProjectRoot.trim();
final executionContext =
'Execution context:\n'
'- target: ${executionTarget.promptValue}\n'
'${executionTarget == AssistantExecutionTarget.singleAgent ? '- provider: ${singleAgentProvider.providerId}\n' : ''}'
'- workspace_root: ${targetRoot.isEmpty ? 'not-set' : targetRoot}\n'
'- permission: ${permissionLevel.promptValue}\n\n';
return switch (mode) {
'craft' =>
'$attachmentBlock$skillBlock$executionContext'
'Craft a polished result for this request:\n$prompt',
'plan' =>
'$attachmentBlock$skillBlock$executionContext'
'Create a clear execution plan for this task:\n$prompt',
_ => '$attachmentBlock$skillBlock$executionContext$prompt',
};
}
void _openGatewaySettings() {
widget.controller.openSettings(
detail: SettingsDetailPage.gatewayConnection,
navigationContext: SettingsNavigationContext(
rootLabel: appText('助手', 'Assistant'),
destination: WorkspaceDestination.assistant,
sectionLabel: appText('集成', 'Integrations'),
),
);
}
Future<void> _connectFromSavedSettingsOrShowDialog() async {
if (!widget.controller.canQuickConnectGateway) {
_openGatewaySettings();
return;
}
await widget.controller.connectSavedGateway();
}
void _openAiGatewaySettings() {
widget.controller.openSettings(tab: SettingsTab.gateway);
}
void _focusComposer() {
if (!mounted) {
return;
}
_composerFocusNode.requestFocus();
}
Future<bool> _runTaskSessionActionWithRetry(
String label,
Future<void> Function() action,
) async {
Object? lastError;
for (
var attempt = 1;
attempt <= _assistantTaskActionMaxRetryCount;
attempt++
) {
try {
await action();
return true;
} catch (error) {
lastError = error;
if (attempt >= _assistantTaskActionMaxRetryCount) {
break;
}
await Future<void>.delayed(Duration(milliseconds: 240 * attempt));
}
}
if (!mounted) {
return false;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
appText(
'$label 失败,弱网环境下已重试 $_assistantTaskActionMaxRetryCount 次。',
'$label failed after $_assistantTaskActionMaxRetryCount retries on a weak network.',
),
),
),
);
debugPrint('$label failed after retries: $lastError');
return false;
}
Future<void> _refreshTasksWithRetry() async {
await _runTaskSessionActionWithRetry(
appText('刷新任务列表', 'Refresh task list'),
widget.controller.refreshSessions,
);
}
Future<void> _switchSessionWithRetry(String sessionKey) async {
final switched = await _runTaskSessionActionWithRetry(
appText('切换会话', 'Switch session'),
() => widget.controller.switchSession(sessionKey),
);
if (switched) {
_focusComposer();
}
}
Future<void> _createNewThread() async {
final sessionKey = _buildDraftSessionKey(widget.controller);
final inheritedTarget = widget.controller.currentAssistantExecutionTarget;
final inheritedViewMode = widget.controller.currentAssistantMessageViewMode;
setState(() {
_archivedTaskKeys.removeWhere(
(value) => _sessionKeysMatch(value, sessionKey),
);
_taskSeeds[sessionKey] = _AssistantTaskSeed(
sessionKey: sessionKey,
title: appText('新对话', 'New conversation'),
preview: appText(
'等待描述这个任务的第一条消息',
'Waiting for the first message of this task',
),
status: 'queued',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
owner: _conversationOwnerLabel(widget.controller),
surface: 'Assistant',
executionTarget: inheritedTarget,
draft: true,
);
});
widget.controller.initializeAssistantThreadContext(
sessionKey,
title: appText('新对话', 'New conversation'),
executionTarget: inheritedTarget,
messageViewMode: inheritedViewMode,
singleAgentProvider: widget.controller.currentSingleAgentProvider,
);
await _switchSessionWithRetry(sessionKey);
}
List<_AssistantTaskEntry> _buildTaskEntries(AppController controller) {
_archivedTaskKeys
..clear()
..addAll(controller.settings.assistantArchivedTaskKeys);
_synchronizeTaskSeeds(controller);
final entries =
_taskSeeds.values
.where((item) => !_isArchivedTask(item.sessionKey))
.map((item) {
final isCurrent = _sessionKeysMatch(
item.sessionKey,
controller.currentSessionKey,
);
final entry = item.toEntry(isCurrent: isCurrent);
if (!isCurrent) {
return entry;
}
return entry.copyWith(owner: _conversationOwnerLabel(controller));
})
.toList(growable: true)
..sort((left, right) {
if (left.isCurrent != right.isCurrent) {
return left.isCurrent ? -1 : 1;
}
return (right.updatedAtMs ?? 0).compareTo(left.updatedAtMs ?? 0);
});
return entries;
}
List<_AssistantTaskEntry> _filterTasks(List<_AssistantTaskEntry> items) {
final query = _threadQuery.trim().toLowerCase();
if (query.isEmpty) {
return items;
}
return items
.where((item) {
final haystack = '${item.title}\n${item.preview}\n${item.sessionKey}'
.toLowerCase();
return haystack.contains(query);
})
.toList(growable: false);
}
_AssistantTaskEntry _resolveCurrentTask(
List<_AssistantTaskEntry> items,
String sessionKey,
) {
for (final item in items) {
if (_sessionKeysMatch(item.sessionKey, sessionKey)) {
return item;
}
}
return _AssistantTaskEntry(
sessionKey: sessionKey,
title: _resolvedTaskTitle(widget.controller, sessionKey),
preview: '',
status: 'queued',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
owner: _conversationOwnerLabel(widget.controller),
surface: 'Assistant',
executionTarget: widget.controller.currentAssistantExecutionTarget,
isCurrent: true,
draft: true,
);
}
void _synchronizeTaskSeeds(AppController controller) {
for (final session in controller.assistantSessions) {
if (_isArchivedTask(session.key)) {
continue;
}
_taskSeeds[session.key] = _AssistantTaskSeed(
sessionKey: session.key,
title: _resolvedTaskTitle(controller, session.key, session: session),
preview:
_sessionPreview(session) ??
appText('等待继续执行这个任务', 'Waiting to continue this task'),
status: _sessionStatus(
session,
sessionPending: controller.assistantSessionHasPendingRun(session.key),
),
updatedAtMs:
session.updatedAtMs ??
DateTime.now().millisecondsSinceEpoch.toDouble(),
owner: _conversationOwnerLabel(controller),
surface: session.surface ?? session.kind ?? 'Assistant',
executionTarget: controller.assistantExecutionTargetForSession(
session.key,
),
draft: session.key.trim().startsWith('draft:'),
);
}
final currentSeed = _taskSeeds[controller.currentSessionKey];
final currentPreview = _currentTaskPreview(controller.chatMessages);
final currentStatus = _currentTaskStatus(
controller.chatMessages,
controller,
);
if (_isArchivedTask(controller.currentSessionKey)) {
return;
}
_taskSeeds[controller.currentSessionKey] = _AssistantTaskSeed(
sessionKey: controller.currentSessionKey,
title: _resolvedTaskTitle(
controller,
controller.currentSessionKey,
fallbackTitle: currentSeed?.title,
),
preview:
currentPreview ??
currentSeed?.preview ??
appText(
'等待描述这个任务的第一条消息',
'Waiting for the first message of this task',
),
status: currentStatus ?? currentSeed?.status ?? 'queued',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
owner: _conversationOwnerLabel(controller),
surface: currentSeed?.surface ?? 'Assistant',
executionTarget: controller.assistantExecutionTargetForSession(
controller.currentSessionKey,
),
draft: controller.currentSessionKey.trim().startsWith('draft:'),
);
}
GatewaySessionSummary? _sessionByKey(
AppController controller,
String sessionKey,
) {
for (final session in controller.assistantSessions) {
if (_sessionKeysMatch(session.key, sessionKey)) {
return session;
}
}
return null;
}
String _resolvedTaskTitle(
AppController controller,
String sessionKey, {
GatewaySessionSummary? session,
String? fallbackTitle,
}) {
final customTitle = controller.assistantCustomTaskTitle(sessionKey);
if (customTitle.isNotEmpty) {
return customTitle;
}
final resolvedSession = session ?? _sessionByKey(controller, sessionKey);
if (resolvedSession != null) {
return _sessionDisplayTitle(resolvedSession);
}
final fallback = fallbackTitle?.trim() ?? '';
if (fallback.isNotEmpty) {
return fallback;
}
return _fallbackSessionTitle(sessionKey);
}
String _defaultTaskTitle(
AppController controller,
String sessionKey, {
GatewaySessionSummary? session,
}) {
final resolvedSession = session ?? _sessionByKey(controller, sessionKey);
if (resolvedSession != null) {
return _sessionDisplayTitle(resolvedSession);
}
return _fallbackSessionTitle(sessionKey);
}
void _touchTaskSeed({
required String sessionKey,
required String title,
required String preview,
required String status,
required String owner,
required String surface,
required AssistantExecutionTarget executionTarget,
required bool draft,
}) {
_taskSeeds[sessionKey] = _AssistantTaskSeed(
sessionKey: sessionKey,
title: title,
preview: preview,
status: status,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
owner: owner,
surface: surface,
executionTarget: executionTarget,
draft: draft,
);
}
bool _isArchivedTask(String sessionKey) {
for (final archivedKey in _archivedTaskKeys) {
if (_sessionKeysMatch(archivedKey, sessionKey)) {
return true;
}
}
return false;
}
Future<void> _archiveTask(String sessionKey) async {
final isCurrent = _sessionKeysMatch(
sessionKey,
widget.controller.currentSessionKey,
);
if (widget.controller.assistantSessionHasPendingRun(sessionKey)) {
return;
}
final archived = await _runTaskSessionActionWithRetry(
appText('归档任务', 'Archive task'),
() => widget.controller.saveAssistantTaskArchived(sessionKey, true),
);
if (!archived) {
return;
}
setState(() {
_archivedTaskKeys.add(sessionKey);
_taskSeeds.removeWhere((key, _) => _sessionKeysMatch(key, sessionKey));
});
if (!isCurrent) {
return;
}
for (final candidate in _taskSeeds.keys) {
if (_isArchivedTask(candidate) ||
_sessionKeysMatch(candidate, sessionKey)) {
continue;
}
await _switchSessionWithRetry(candidate);
return;
}
await _createNewThread();
}
Future<void> _renameTask(_AssistantTaskEntry entry) async {
final controller = widget.controller;
final input = TextEditingController(text: entry.title);
final renamed = await showDialog<String>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(appText('重命名任务', 'Rename task')),
content: TextField(
key: const Key('assistant-task-rename-input'),
controller: input,
autofocus: true,
maxLines: 1,
decoration: InputDecoration(
labelText: appText('任务名称', 'Task name'),
hintText: appText(
'留空后恢复默认名称',
'Leave empty to restore the default title',
),
),
onSubmitted: (value) => Navigator.of(context).pop(value),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(appText('取消', 'Cancel')),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(input.text),
child: Text(appText('保存', 'Save')),
),
],
);
},
);
if (!mounted || renamed == null) {
return;
}
final normalized = renamed.trim();
final nextTitle = normalized.isNotEmpty
? normalized
: _defaultTaskTitle(controller, entry.sessionKey);
final saved = await _runTaskSessionActionWithRetry(
appText('重命名任务', 'Rename task'),
() => controller.saveAssistantTaskTitle(entry.sessionKey, normalized),
);
if (!saved) {
return;
}
setState(() {
final existing = _taskSeeds[entry.sessionKey];
if (existing != null) {
_taskSeeds[entry.sessionKey] = _AssistantTaskSeed(
sessionKey: existing.sessionKey,
title: nextTitle,
preview: existing.preview,
status: existing.status,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
owner: existing.owner,
surface: existing.surface,
executionTarget: existing.executionTarget,
draft: existing.draft,
);
}
});
}
String _buildDraftSessionKey(AppController controller) {
final stamp = DateTime.now().millisecondsSinceEpoch;
if (controller.isSingleAgentMode) {
return 'draft:$stamp';
}
final selectedAgentId = controller.selectedAgentId.trim();
if (selectedAgentId.isEmpty) {
return 'draft:$stamp';
}
return 'draft:$selectedAgentId:$stamp';
}
AssistantFocusEntry? _resolveFocusedDestination(
List<AssistantFocusEntry> favorites,
) {
if (favorites.isEmpty) {
return null;
}
if (_activeFocusedDestination != null &&
favorites.contains(_activeFocusedDestination)) {
return _activeFocusedDestination;
}
return favorites.first;
}
double _resolveMaxSidePaneWidth(double viewportWidth) {
final maxWidthByViewport =
viewportWidth -
_mainWorkspaceMinWidth -
_sidePaneViewportPadding -
_assistantHorizontalResizeHandleWidth -
_assistantHorizontalPaneGap;
return maxWidthByViewport
.clamp(_sidePaneMinWidth, viewportWidth - _sidePaneViewportPadding)
.toDouble();
}
String _conversationOwnerLabel(AppController controller) {
return controller.assistantConversationOwnerLabel;
}
String? _currentTaskPreview(List<GatewayChatMessage> messages) {
for (final message in messages.reversed) {
final text = message.text.trim();
if (text.isNotEmpty) {
return text;
}
}
return null;
}
String? _currentTaskStatus(
List<GatewayChatMessage> messages,
AppController controller,
) {
if (controller.hasAssistantPendingRun) {
return 'running';
}
if (messages.isEmpty) {
return null;
}
final last = messages.last;
if (last.error) {
return 'failed';
}
if (last.role.trim().toLowerCase() == 'user') {
return 'queued';
}
return 'open';
}
}
enum _AssistantSidePane { tasks, navigation, focused }
class _AssistantUnifiedSidePane extends StatelessWidget {
const _AssistantUnifiedSidePane({
required this.activePane,
required this.activeFocusedDestination,
required this.collapsed,
required this.favoriteDestinations,
required this.taskPanel,
required this.navigationPanel,
required this.focusedPanel,
required this.onSelectPane,
required this.onSelectFocusedDestination,
required this.onToggleCollapsed,
});
final _AssistantSidePane activePane;
final AssistantFocusEntry? activeFocusedDestination;
final bool collapsed;
final List<AssistantFocusEntry> favoriteDestinations;
final Widget taskPanel;
final Widget navigationPanel;
final Widget? focusedPanel;
final ValueChanged<_AssistantSidePane> onSelectPane;
final ValueChanged<AssistantFocusEntry> onSelectFocusedDestination;
final VoidCallback onToggleCollapsed;
@override
Widget build(BuildContext context) {
final sidePaneContent = activePane == _AssistantSidePane.tasks
? taskPanel
: activePane == _AssistantSidePane.focused && focusedPanel != null
? focusedPanel!
: navigationPanel;
return Row(
children: [
_AssistantSideTabRail(
activePane: activePane,
activeFocusedDestination: activeFocusedDestination,
collapsed: collapsed,
favoriteDestinations: favoriteDestinations,
onSelectPane: onSelectPane,
onSelectFocusedDestination: onSelectFocusedDestination,
onToggleCollapsed: onToggleCollapsed,
),
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>(switch (activePane) {
_AssistantSidePane.tasks => 'assistant-side-pane-tasks',
_AssistantSidePane.navigation =>
'assistant-side-pane-navigation',
_AssistantSidePane.focused =>
'assistant-side-pane-focused-${activeFocusedDestination?.name ?? 'none'}',
}),
child: sidePaneContent,
),
),
),
],
],
);
}
}
class _AssistantSideTabRail extends StatelessWidget {
const _AssistantSideTabRail({
required this.activePane,
required this.activeFocusedDestination,
required this.collapsed,
required this.favoriteDestinations,
required this.onSelectPane,
required this.onSelectFocusedDestination,
required this.onToggleCollapsed,
});
final _AssistantSidePane activePane;
final AssistantFocusEntry? activeFocusedDestination;
final bool collapsed;
final List<AssistantFocusEntry> favoriteDestinations;
final ValueChanged<_AssistantSidePane> onSelectPane;
final ValueChanged<AssistantFocusEntry> onSelectFocusedDestination;
final VoidCallback onToggleCollapsed;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return Container(
key: const Key('assistant-side-pane'),
width: 46,
decoration: BoxDecoration(
color: palette.chromeSurface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: palette.strokeSoft),
),
child: Column(
children: [
const SizedBox(height: 4),
_AssistantSideTabButton(
key: const Key('assistant-side-pane-tab-tasks'),
icon: Icons.checklist_rtl_rounded,
selected: activePane == _AssistantSidePane.tasks,
tooltip: appText('任务', 'Tasks'),
onTap: () => onSelectPane(_AssistantSidePane.tasks),
),
const SizedBox(height: 4),
_AssistantSideTabButton(
key: const Key('assistant-side-pane-tab-navigation'),
icon: Icons.dashboard_customize_outlined,
selected: activePane == _AssistantSidePane.navigation,
tooltip: appText('导航', 'Navigation'),
onTap: () => onSelectPane(_AssistantSidePane.navigation),
),
if (favoriteDestinations.isNotEmpty) ...[
const SizedBox(height: 4),
Container(width: 24, height: 1, color: palette.strokeSoft),
const SizedBox(height: 4),
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.zero,
child: Column(
children: favoriteDestinations
.map(
(destination) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: _AssistantSideTabButton(
key: ValueKey<String>(
'assistant-side-pane-tab-focus-${destination.name}',
),
icon: destination.icon,
selected:
activePane == _AssistantSidePane.focused &&
activeFocusedDestination == destination,
tooltip: destination.label,
onTap: () =>
onSelectFocusedDestination(destination),
),
),
)
.toList(growable: false),
),
),
),
] else
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.surfacePrimary,
foregroundColor: palette.textSecondary,
side: BorderSide(color: palette.strokeSoft),
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),
],
),
);
}
}
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(
color: widget.selected
? palette.surfacePrimary
: _hovered
? palette.surfaceSecondary
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: widget.selected || _hovered
? palette.strokeSoft
: Colors.transparent,
),
),
child: Icon(
widget.icon,
size: 18,
color: widget.selected
? palette.textPrimary
: palette.textSecondary,
),
),
),
),
),
);
}
}
class _AssistantLowerPane extends StatelessWidget {
const _AssistantLowerPane({
required this.bottomContentInset,
required this.controller,
required this.inputController,
required this.focusNode,
required this.thinkingLabel,
required this.showModelControl,
required this.modelLabel,
required this.modelOptions,
required this.attachments,
required this.availableSkills,
required this.selectedSkillKeys,
required this.onRemoveAttachment,
required this.onToggleSkill,
required this.onThinkingChanged,
required this.onModelChanged,
required this.onOpenGateway,
required this.onOpenAiGatewaySettings,
required this.onReconnectGateway,
required this.onPickAttachments,
required this.onAddAttachment,
required this.onPasteImageAttachment,
required this.onComposerInputHeightChanged,
required this.onSend,
});
final double bottomContentInset;
final AppController controller;
final TextEditingController inputController;
final FocusNode focusNode;
final String thinkingLabel;
final bool showModelControl;
final String modelLabel;
final List<String> modelOptions;
final List<_ComposerAttachment> attachments;
final List<_ComposerSkillOption> availableSkills;
final List<String> selectedSkillKeys;
final ValueChanged<_ComposerAttachment> onRemoveAttachment;
final ValueChanged<String> onToggleSkill;
final ValueChanged<String> onThinkingChanged;
final Future<void> Function(String modelId) onModelChanged;
final VoidCallback onOpenGateway;
final VoidCallback onOpenAiGatewaySettings;
final Future<void> Function() onReconnectGateway;
final VoidCallback onPickAttachments;
final ValueChanged<_ComposerAttachment> onAddAttachment;
final AssistantClipboardImageReader onPasteImageAttachment;
final ValueChanged<double> onComposerInputHeightChanged;
final Future<void> Function() onSend;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return ColoredBox(
color: palette.canvas,
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.only(bottom: bottomContentInset),
child: _ComposerBar(
controller: controller,
inputController: inputController,
focusNode: focusNode,
thinkingLabel: thinkingLabel,
showModelControl: showModelControl,
modelLabel: modelLabel,
modelOptions: modelOptions,
attachments: attachments,
availableSkills: availableSkills,
selectedSkillKeys: selectedSkillKeys,
onRemoveAttachment: onRemoveAttachment,
onToggleSkill: onToggleSkill,
onThinkingChanged: onThinkingChanged,
onModelChanged: onModelChanged,
onOpenGateway: onOpenGateway,
onOpenAiGatewaySettings: onOpenAiGatewaySettings,
onReconnectGateway: onReconnectGateway,
onPickAttachments: onPickAttachments,
onAddAttachment: onAddAttachment,
onPasteImageAttachment: onPasteImageAttachment,
onInputHeightChanged: onComposerInputHeightChanged,
onSend: onSend,
),
),
);
}
}
class _ConversationArea extends StatelessWidget {
const _ConversationArea({
required this.controller,
required this.currentTask,
required this.items,
required this.messageViewMode,
required this.bottomContentInset,
required this.topTrailingInset,
required this.scrollController,
required this.onOpenDetail,
required this.onFocusComposer,
required this.onOpenGateway,
required this.onOpenAiGatewaySettings,
required this.onReconnectGateway,
required this.onMessageViewModeChanged,
});
final AppController controller;
final _AssistantTaskEntry currentTask;
final List<_TimelineItem> items;
final AssistantMessageViewMode messageViewMode;
final double bottomContentInset;
final double topTrailingInset;
final ScrollController scrollController;
final ValueChanged<DetailPanelData> onOpenDetail;
final VoidCallback onFocusComposer;
final VoidCallback onOpenGateway;
final VoidCallback onOpenAiGatewaySettings;
final Future<void> Function() onReconnectGateway;
final Future<void> Function(AssistantMessageViewMode mode)
onMessageViewModeChanged;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return Column(
children: [
Padding(
padding: EdgeInsets.fromLTRB(10, 8, 10 + topTrailingInset, 8),
child: Align(
alignment: Alignment.centerRight,
child: Wrap(
spacing: 6,
runSpacing: 6,
alignment: WrapAlignment.end,
children: [
_MessageViewModeChip(
value: messageViewMode,
onSelected: onMessageViewModeChanged,
),
_ConnectionChip(controller: controller),
],
),
),
),
Divider(height: 1, color: palette.strokeSoft),
Expanded(
child: Container(
decoration: BoxDecoration(color: palette.canvas),
child: items.isEmpty
? _AssistantEmptyState(
controller: controller,
onFocusComposer: onFocusComposer,
onOpenGateway: onOpenGateway,
onOpenAiGatewaySettings: onOpenAiGatewaySettings,
onReconnectGateway: onReconnectGateway,
)
: ListView.separated(
controller: scrollController,
padding: EdgeInsets.fromLTRB(
10,
8,
10,
8 + bottomContentInset,
),
physics: const BouncingScrollPhysics(),
itemCount: items.length,
separatorBuilder: (_, _) => const SizedBox(height: 6),
itemBuilder: (context, index) {
final item = items[index];
return switch (item.kind) {
_TimelineItemKind.user => _MessageBubble(
label: item.label!,
text: item.text!,
alignRight: true,
tone: _BubbleTone.user,
messageViewMode: messageViewMode,
),
_TimelineItemKind.assistant => _MessageBubble(
label: item.label!,
text: item.text!,
alignRight: false,
tone: _BubbleTone.assistant,
messageViewMode: messageViewMode,
),
_TimelineItemKind.agent => _MessageBubble(
label: item.label!,
text: item.text!,
alignRight: false,
tone: _BubbleTone.agent,
messageViewMode: messageViewMode,
),
_TimelineItemKind.toolCall => _ToolCallTile(
toolName: item.title!,
summary: item.text!,
pending: item.pending,
error: item.error,
onOpenDetail: () => onOpenDetail(
DetailPanelData(
title: item.title!,
subtitle: appText('工具调用', 'Tool Call'),
icon: Icons.build_circle_outlined,
status: StatusInfo(
item.pending
? appText('运行中', 'Running')
: appText('已完成', 'Completed'),
item.error
? StatusTone.danger
: StatusTone.accent,
),
description: item.text ?? '',
meta: [
controller.currentSessionKey,
controller.activeAgentName,
],
actions: [appText('复制', 'Copy')],
sections: const [],
),
),
),
};
},
),
),
),
],
);
}
}
class _AssistantTaskRail extends StatefulWidget {
const _AssistantTaskRail({
super.key,
required this.controller,
required this.tasks,
required this.query,
required this.searchController,
required this.onQueryChanged,
required this.onClearQuery,
required this.onRefreshTasks,
required this.onCreateTask,
required this.onSelectTask,
required this.onArchiveTask,
required this.onRenameTask,
});
final AppController controller;
final List<_AssistantTaskEntry> tasks;
final String query;
final TextEditingController searchController;
final ValueChanged<String> onQueryChanged;
final VoidCallback onClearQuery;
final Future<void> Function() onRefreshTasks;
final Future<void> Function() onCreateTask;
final Future<void> Function(String sessionKey) onSelectTask;
final Future<void> Function(String sessionKey) onArchiveTask;
final Future<void> Function(_AssistantTaskEntry entry) onRenameTask;
@override
State<_AssistantTaskRail> createState() => _AssistantTaskRailState();
}
class _AssistantTaskRailState extends State<_AssistantTaskRail> {
final Set<AssistantExecutionTarget> _expandedGroups =
<AssistantExecutionTarget>{};
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final palette = context.palette;
final tasks = widget.tasks;
final groupedTasks = _groupTasksForRail(tasks);
final runningCount = tasks
.where((task) => _normalizedTaskStatus(task.status) == 'running')
.length;
final openCount = tasks
.where((task) => _normalizedTaskStatus(task.status) == 'open')
.length;
return SurfaceCard(
borderRadius: 0,
padding: EdgeInsets.zero,
tone: SurfaceCardTone.chrome,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: TextField(
key: const Key('assistant-task-search'),
controller: widget.searchController,
onChanged: widget.onQueryChanged,
decoration: InputDecoration(
hintText: appText('搜索任务', 'Search tasks'),
prefixIcon: const Icon(Icons.search_rounded),
suffixIcon: widget.query.isEmpty
? null
: IconButton(
tooltip: appText('清除搜索', 'Clear search'),
onPressed: widget.onClearQuery,
icon: const Icon(Icons.close_rounded),
),
),
),
),
const SizedBox(width: 6),
IconButton(
key: const Key('assistant-task-refresh'),
tooltip: appText('刷新任务', 'Refresh tasks'),
onPressed: () async {
await widget.onRefreshTasks();
},
icon: const Icon(Icons.refresh_rounded),
),
],
),
const SizedBox(height: 6),
SizedBox(
width: double.infinity,
child: FilledButton.tonalIcon(
key: const Key('assistant-new-task-button'),
onPressed: () async {
await widget.onCreateTask();
},
icon: const Icon(Icons.edit_note_rounded),
label: Text(appText('新对话', 'New conversation')),
style: FilledButton.styleFrom(
minimumSize: const Size(0, 32),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
_MetaPill(
label: '${appText('运行中', 'Running')} $runningCount',
icon: Icons.play_circle_outline_rounded,
),
_MetaPill(
label: '${appText('当前', 'Open')} $openCount',
icon: Icons.forum_outlined,
),
_MetaPill(
label:
'${appText('技能', 'Skills')} ${widget.controller.currentAssistantSkillCount}',
icon: Icons.auto_awesome_rounded,
),
],
),
],
),
),
Divider(height: 1, color: palette.strokeSoft),
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 4),
child: Row(
children: [
Text(
appText('任务列表', 'Task list'),
style: theme.textTheme.titleSmall,
),
const SizedBox(width: 6),
Text(
'${tasks.length}',
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textMuted,
),
),
],
),
),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 4),
itemCount: groupedTasks.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final group = groupedTasks[index];
final expanded = _expandedGroups.contains(
group.executionTarget,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_AssistantTaskGroupHeader(
executionTarget: group.executionTarget,
count: group.items.length,
expanded: expanded,
onTap: () {
setState(() {
if (expanded) {
_expandedGroups.remove(group.executionTarget);
} else {
_expandedGroups.add(group.executionTarget);
}
});
},
),
if (expanded) ...[
const SizedBox(height: 4),
if (group.items.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(28, 0, 8, 4),
child: Text(
appText('当前分组没有任务。', 'No tasks in this group.'),
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textMuted,
),
),
),
for (
var itemIndex = 0;
itemIndex < group.items.length;
itemIndex++
) ...[
if (itemIndex > 0) const SizedBox(height: 4),
_AssistantTaskTile(
entry: group.items[itemIndex],
archiveEnabled:
_normalizedTaskStatus(
group.items[itemIndex].status,
) !=
'running',
onTap: () async {
await widget.onSelectTask(
group.items[itemIndex].sessionKey,
);
},
onRename: () async {
await widget.onRenameTask(group.items[itemIndex]);
},
onArchive: () async {
await widget.onArchiveTask(
group.items[itemIndex].sessionKey,
);
},
),
],
],
],
);
},
),
),
],
),
);
}
}
List<_AssistantTaskGroup> _groupTasksForRail(List<_AssistantTaskEntry> tasks) {
final grouped = <AssistantExecutionTarget, List<_AssistantTaskEntry>>{
for (final target in AssistantExecutionTarget.values)
target: <_AssistantTaskEntry>[],
};
for (final task in tasks) {
grouped[task.executionTarget]!.add(task);
}
return AssistantExecutionTarget.values
.map(
(target) => _AssistantTaskGroup(
executionTarget: target,
items: grouped[target]!,
),
)
.toList(growable: false);
}
class _AssistantTaskTile extends StatelessWidget {
const _AssistantTaskTile({
required this.entry,
required this.archiveEnabled,
required this.onTap,
required this.onRename,
required this.onArchive,
});
final _AssistantTaskEntry entry;
final bool archiveEnabled;
final VoidCallback onTap;
final VoidCallback onRename;
final VoidCallback onArchive;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
final statusStyle = _pillStyleForStatus(context, entry.status);
return Material(
color: entry.isCurrent ? palette.surfacePrimary : Colors.transparent,
borderRadius: BorderRadius.circular(8),
child: InkWell(
key: ValueKey<String>('assistant-task-item-${entry.sessionKey}'),
borderRadius: BorderRadius.circular(8),
onTap: onTap,
onLongPress: onRename,
onSecondaryTap: onRename,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 7),
decoration: BoxDecoration(
color: entry.isCurrent
? palette.surfaceSecondary
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: entry.isCurrent ? palette.strokeSoft : Colors.transparent,
),
),
child: Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: statusStyle.backgroundColor,
borderRadius: BorderRadius.circular(6),
),
child: Icon(
entry.draft
? Icons.edit_note_rounded
: _normalizedTaskStatus(entry.status) == 'running'
? Icons.play_arrow_rounded
: Icons.task_alt_rounded,
size: 15,
color: statusStyle.foregroundColor,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
entry.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurface,
fontWeight: entry.isCurrent
? FontWeight.w600
: FontWeight.w500,
),
),
),
const SizedBox(width: 8),
Text(
entry.updatedAtLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textMuted,
),
),
const SizedBox(width: 2),
IconButton(
key: ValueKey<String>(
'assistant-task-archive-${entry.sessionKey}',
),
tooltip: appText('归档任务', 'Archive task'),
visualDensity: VisualDensity.compact,
splashRadius: 12,
onPressed: archiveEnabled ? onArchive : null,
icon: Icon(
Icons.archive_outlined,
size: 18,
color: palette.textMuted,
),
),
],
),
),
),
);
}
}
class _AssistantTaskGroupHeader extends StatelessWidget {
const _AssistantTaskGroupHeader({
required this.executionTarget,
required this.count,
required this.expanded,
required this.onTap,
});
final AssistantExecutionTarget executionTarget;
final int count;
final bool expanded;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
return Material(
color: Colors.transparent,
child: InkWell(
key: ValueKey<String>('assistant-task-group-${executionTarget.name}'),
borderRadius: BorderRadius.circular(8),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 4, 2),
child: Row(
children: [
Icon(
expanded
? Icons.keyboard_arrow_down_rounded
: Icons.keyboard_arrow_right_rounded,
size: 16,
color: palette.textMuted,
),
const SizedBox(width: 4),
Icon(executionTarget.icon, size: 14, color: palette.textMuted),
const SizedBox(width: 6),
Flexible(
child: Text(
executionTarget.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelMedium?.copyWith(
color: palette.textSecondary,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 6),
Text(
'$count',
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textMuted,
),
),
],
),
),
),
);
}
}
class _AssistantEmptyState extends StatelessWidget {
const _AssistantEmptyState({
required this.controller,
required this.onFocusComposer,
required this.onOpenGateway,
required this.onOpenAiGatewaySettings,
required this.onReconnectGateway,
});
final AppController controller;
final VoidCallback onFocusComposer;
final VoidCallback onOpenGateway;
final VoidCallback onOpenAiGatewaySettings;
final Future<void> Function() onReconnectGateway;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final connectionState = controller.currentAssistantConnectionState;
final singleAgent = connectionState.isSingleAgent;
final connected = connectionState.connected;
final singleAgentFallback = controller.currentSingleAgentUsesAiChatFallback;
final singleAgentNeedsAiGateway =
controller.currentSingleAgentNeedsAiGatewayConfiguration;
final singleAgentSuggestsAuto =
controller.currentSingleAgentShouldSuggestAutoSwitch;
final providerLabel = controller.currentSingleAgentProvider.label;
final reconnectAvailable = controller.canQuickConnectGateway;
final title = singleAgent
? connected
? appText('开始单机智能体任务', 'Start a single-agent task')
: singleAgentNeedsAiGateway
? appText('先配置 LLM API', 'Configure LLM API first')
: appText('先准备外部 Agent', 'Prepare the external Agent first')
: connected
? appText('开始对话或运行任务', 'Start a chat or run a task')
: connectionState.status == RuntimeConnectionStatus.error
? appText('Gateway 连接失败', 'Gateway connection failed')
: appText('先连接 Gateway', 'Connect a gateway first');
final description = singleAgent
? connected
? (singleAgentFallback
? appText(
'当前没有可用的外部 Agent ACP 连接,这个线程已降级到 AI Chat fallback不会建立 OpenClaw Gateway 会话。',
'No external Agent ACP connection is available for this thread, so it is running in AI Chat fallback without opening an OpenClaw Gateway session.',
)
: appText(
'当前模式使用单机智能体处理当前任务,不会建立 OpenClaw Gateway 会话。',
'This mode uses a single agent for the current task and does not open an OpenClaw Gateway session.',
))
: singleAgentSuggestsAuto
? appText(
'当前线程固定为 $providerLabel,但它在这台设备上不可用。检测到其他外部 Agent ACP 端点时不会自动切换,可在工具栏里改成 Auto。',
'This thread is pinned to $providerLabel, but it is unavailable on this device. XWorkmate will not switch to another external Agent ACP endpoint automatically. Change the provider to Auto in the toolbar.',
)
: singleAgentNeedsAiGateway
? appText(
'请先在 设置 -> 集成 中配置 LLM API Endpoint、LLM API Token 和默认模型,然后以单机智能体模式继续当前任务。',
'Set the LLM API Endpoint, LLM API Token, and default model in Settings -> Integrations, then continue this task in Single Agent mode.',
)
: appText(
'当前线程的外部 Agent ACP 连接尚未就绪。请先配置 $providerLabel 对应端点,或切换到 Auto。',
'The external Agent ACP connection for this thread is not ready yet. Configure the endpoint for $providerLabel first, or switch to Auto.',
)
: connected
? appText(
'输入需求后即可开始执行,结果会回到当前会话并同步到任务页。',
'Type a request to start execution. Results return to this session and the Tasks page.',
)
: connectionState.pairingRequired
? appText(
'当前设备还没通过 Gateway 配对审批。请先在已授权设备上批准该 pairing request再重新连接。',
'This device has not been approved yet. Approve the pairing request from an authorized device, then reconnect.',
)
: connectionState.gatewayTokenMissing
? appText(
'首次连接需要共享 Token配对完成后可继续使用本机的 device token。',
'The first connection requires a shared token; after pairing, this device can continue with its device token.',
)
: (connectionState.lastError?.trim().isNotEmpty == true
? connectionState.lastError!.trim()
: appText(
'连接后可直接对话、创建任务,并在当前会话查看结果。',
'After connecting, you can chat, create tasks, and read results in this session.',
));
return Align(
alignment: Alignment.topCenter,
child: SingleChildScrollView(
padding: const EdgeInsets.all(8),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Container(
key: const Key('assistant-empty-state-card'),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: context.palette.surfacePrimary.withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: context.palette.strokeSoft),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleMedium),
const SizedBox(height: 6),
Text(description, style: theme.textTheme.bodyMedium),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
FilledButton.icon(
onPressed: connected
? onFocusComposer
: singleAgent
? singleAgentNeedsAiGateway
? onOpenAiGatewaySettings
: onFocusComposer
: reconnectAvailable
? () async {
await onReconnectGateway();
}
: onOpenGateway,
icon: Icon(
connected
? Icons.edit_rounded
: singleAgent
? singleAgentNeedsAiGateway
? Icons.tune_rounded
: Icons.smart_toy_outlined
: reconnectAvailable
? Icons.refresh_rounded
: Icons.link_rounded,
),
label: Text(
connected
? appText('开始输入', 'Start typing')
: singleAgent
? singleAgentNeedsAiGateway
? appText('打开配置中心', 'Open settings')
: appText('查看线程工具栏', 'Open toolbar')
: reconnectAvailable
? appText('重新连接', 'Reconnect')
: appText('连接 Gateway', 'Connect gateway'),
),
style: FilledButton.styleFrom(
minimumSize: const Size(0, 28),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
if (!connected &&
(!singleAgent || singleAgentNeedsAiGateway))
OutlinedButton.icon(
onPressed: singleAgent
? onOpenAiGatewaySettings
: onOpenGateway,
icon: Icon(
singleAgent
? Icons.hub_outlined
: Icons.settings_rounded,
),
label: Text(
singleAgent
? appText('打开设置中心', 'Open settings')
: appText('编辑连接', 'Edit connection'),
),
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 28),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
),
],
),
),
),
),
);
}
}
class _ComposerBar extends StatefulWidget {
const _ComposerBar({
required this.controller,
required this.inputController,
required this.focusNode,
required this.thinkingLabel,
required this.showModelControl,
required this.modelLabel,
required this.modelOptions,
required this.attachments,
required this.availableSkills,
required this.selectedSkillKeys,
required this.onRemoveAttachment,
required this.onToggleSkill,
required this.onThinkingChanged,
required this.onModelChanged,
required this.onOpenGateway,
required this.onOpenAiGatewaySettings,
required this.onReconnectGateway,
required this.onPickAttachments,
required this.onAddAttachment,
required this.onPasteImageAttachment,
required this.onInputHeightChanged,
required this.onSend,
});
final AppController controller;
final TextEditingController inputController;
final FocusNode focusNode;
final String thinkingLabel;
final bool showModelControl;
final String modelLabel;
final List<String> modelOptions;
final List<_ComposerAttachment> attachments;
final List<_ComposerSkillOption> availableSkills;
final List<String> selectedSkillKeys;
final ValueChanged<_ComposerAttachment> onRemoveAttachment;
final ValueChanged<String> onToggleSkill;
final ValueChanged<String> onThinkingChanged;
final Future<void> Function(String modelId) onModelChanged;
final VoidCallback onOpenGateway;
final VoidCallback onOpenAiGatewaySettings;
final Future<void> Function() onReconnectGateway;
final VoidCallback onPickAttachments;
final ValueChanged<_ComposerAttachment> onAddAttachment;
final AssistantClipboardImageReader onPasteImageAttachment;
final ValueChanged<double> onInputHeightChanged;
final Future<void> Function() onSend;
@override
State<_ComposerBar> createState() => _ComposerBarState();
}
class _ComposerBarState extends State<_ComposerBar> {
static const double _minInputHeight = 68;
static const double _defaultInputHeight =
_assistantComposerDefaultInputHeight;
static const double _maxInputHeight = 220;
static const double _skillPickerPreferredMaxHeight = 460;
static const double _skillPickerMinHeight = 220;
static const double _skillPickerVerticalGap = 8;
static const Map<ShortcutActivator, Intent> _pasteShortcuts =
<ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.keyV, meta: true):
AssistantPasteIntent(),
SingleActivator(LogicalKeyboardKey.keyV, control: true):
AssistantPasteIntent(),
};
late double _inputHeight;
final GlobalKey _skillPickerTargetKey = GlobalKey(
debugLabel: 'assistant-skill-picker-target',
);
final LayerLink _skillPickerLayerLink = LayerLink();
final OverlayPortalController _skillPickerPortalController =
OverlayPortalController(debugLabel: 'assistant-skill-picker');
late final TextEditingController _skillPickerSearchController;
late final FocusNode _skillPickerSearchFocusNode;
bool _handlingPasteShortcut = false;
bool _refreshingSingleAgentSkills = false;
String _skillPickerQuery = '';
@override
void initState() {
super.initState();
_inputHeight = _defaultInputHeight;
_skillPickerSearchController = TextEditingController();
_skillPickerSearchFocusNode = FocusNode();
widget.controller.addListener(_handleControllerChanged);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
widget.onInputHeightChanged(_inputHeight);
});
}
@override
void didUpdateWidget(covariant _ComposerBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.controller, widget.controller)) {
oldWidget.controller.removeListener(_handleControllerChanged);
widget.controller.addListener(_handleControllerChanged);
}
}
@override
void dispose() {
widget.controller.removeListener(_handleControllerChanged);
if (_skillPickerPortalController.isShowing) {
_skillPickerPortalController.hide();
}
_skillPickerSearchController.dispose();
_skillPickerSearchFocusNode.dispose();
super.dispose();
}
void _handleControllerChanged() {
if (!mounted || !_skillPickerPortalController.isShowing) {
return;
}
setState(() {});
}
void _resizeInput(double delta) {
final nextHeight = (_inputHeight + delta).clamp(
_minInputHeight,
_maxInputHeight,
);
if (nextHeight == _inputHeight) {
return;
}
setState(() {
_inputHeight = nextHeight;
});
widget.onInputHeightChanged(_inputHeight);
}
Future<void> _handlePasteShortcut() async {
if (_handlingPasteShortcut) {
return;
}
_handlingPasteShortcut = true;
try {
if (widget.controller
.featuresFor(resolveUiFeaturePlatformFromContext(context))
.supportsFileAttachments) {
final imageFile = await widget.onPasteImageAttachment();
if (!mounted) {
return;
}
if (imageFile != null) {
widget.onAddAttachment(_ComposerAttachment.fromXFile(imageFile));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
appText(
'已从剪贴板添加图片附件',
'Added image from clipboard as attachment',
),
),
),
);
return;
}
}
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
final text = clipboardData?.text;
if (!mounted || text == null || text.isEmpty) {
return;
}
_insertTextAtSelection(text);
} finally {
_handlingPasteShortcut = false;
}
}
void _insertTextAtSelection(String text) {
final currentValue = widget.inputController.value;
final selection = currentValue.selection;
final textLength = currentValue.text.length;
final start = selection.isValid
? math.min(selection.start, selection.end).clamp(0, textLength)
: textLength;
final end = selection.isValid
? math.max(selection.start, selection.end).clamp(0, textLength)
: textLength;
final updatedText = currentValue.text.replaceRange(start, end, text);
final cursorOffset = start + text.length;
widget.inputController.value = currentValue.copyWith(
text: updatedText,
selection: TextSelection.collapsed(offset: cursorOffset),
composing: TextRange.empty,
);
}
void _resetSkillPickerSearch() {
_skillPickerSearchController.clear();
_skillPickerQuery = '';
}
void _hideSkillPicker() {
if (_skillPickerPortalController.isShowing) {
_skillPickerPortalController.hide();
}
if (_skillPickerQuery.isNotEmpty ||
_skillPickerSearchController.text.isNotEmpty) {
setState(_resetSkillPickerSearch);
}
}
void _toggleSkillPicker() {
if (_skillPickerPortalController.isShowing) {
_hideSkillPicker();
return;
}
setState(_resetSkillPickerSearch);
_skillPickerPortalController.show();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_skillPickerPortalController.isShowing) {
return;
}
_skillPickerSearchFocusNode.requestFocus();
});
if (widget.controller.isSingleAgentMode) {
unawaited(_refreshSingleAgentSkills());
}
}
Future<void> _refreshSingleAgentSkills() async {
if (_refreshingSingleAgentSkills) {
return;
}
setState(() {
_refreshingSingleAgentSkills = true;
});
try {
await widget.controller.refreshSingleAgentLocalSkillsForSession(
widget.controller.currentSessionKey,
);
} finally {
if (mounted) {
setState(() {
_refreshingSingleAgentSkills = false;
});
}
}
}
List<_ComposerSkillOption> _activeSkillOptions() {
if (widget.controller.isSingleAgentMode) {
return widget.controller
.assistantImportedSkillsForSession(
widget.controller.currentSessionKey,
)
.map(_skillOptionFromThreadSkill)
.toList(growable: false);
}
return widget.availableSkills;
}
List<_ComposerSkillOption> _filteredSkillOptions() {
final normalizedQuery = _skillPickerQuery.trim().toLowerCase();
if (normalizedQuery.isEmpty) {
return _activeSkillOptions();
}
return _activeSkillOptions()
.where((skill) {
final haystack =
'${skill.label}\n${skill.description}\n${skill.sourceLabel}'
.toLowerCase();
return haystack.contains(normalizedQuery);
})
.toList(growable: false);
}
Widget _buildSkillPickerOverlay(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
final targetBox =
_skillPickerTargetKey.currentContext?.findRenderObject() as RenderBox?;
final targetOrigin = targetBox?.localToGlobal(Offset.zero);
final targetSize = targetBox?.size;
final availableBelow = targetOrigin == null || targetSize == null
? _skillPickerPreferredMaxHeight
: mediaQuery.size.height -
mediaQuery.padding.bottom -
(targetOrigin.dy + targetSize.height) -
_skillPickerVerticalGap;
final availableAbove = targetOrigin == null
? _skillPickerPreferredMaxHeight
: targetOrigin.dy - mediaQuery.padding.top - _skillPickerVerticalGap;
final openUpward =
availableBelow < _skillPickerMinHeight &&
availableAbove > availableBelow;
final constrainedHeight = math.max(
_skillPickerMinHeight,
openUpward ? availableAbove : availableBelow,
);
final maxHeight = math.min(
_skillPickerPreferredMaxHeight,
constrainedHeight,
);
return Stack(
children: [
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: _hideSkillPicker,
child: const SizedBox.expand(),
),
),
CompositedTransformFollower(
link: _skillPickerLayerLink,
showWhenUnlinked: false,
targetAnchor: openUpward ? Alignment.topLeft : Alignment.bottomLeft,
followerAnchor: openUpward ? Alignment.bottomLeft : Alignment.topLeft,
offset: Offset(0, openUpward ? -_skillPickerVerticalGap : 8),
child: _SkillPickerPopover(
maxHeight: maxHeight,
searchController: _skillPickerSearchController,
searchFocusNode: _skillPickerSearchFocusNode,
selectedSkillKeys: widget.selectedSkillKeys,
filteredSkills: _filteredSkillOptions(),
isLoading: _refreshingSingleAgentSkills,
hasQuery: _skillPickerQuery.trim().isNotEmpty,
onQueryChanged: (value) {
setState(() {
_skillPickerQuery = value;
});
},
onToggleSkill: (skillKey) => widget.onToggleSkill(skillKey),
),
),
],
);
}
@override
Widget build(BuildContext context) {
final palette = context.palette;
final controller = widget.controller;
final uiFeatures = controller.featuresFor(
resolveUiFeaturePlatformFromContext(context),
);
final connectionState = controller.currentAssistantConnectionState;
final singleAgent = connectionState.isSingleAgent;
final connected = connectionState.connected;
final singleAgentNeedsAiGateway =
controller.currentSingleAgentNeedsAiGatewayConfiguration;
final reconnectAvailable = controller.canQuickConnectGateway;
final connecting = connectionState.connecting;
final executionTarget = controller.assistantExecutionTarget;
final permissionLevel = controller.assistantPermissionLevel;
final selectedSkills = widget.availableSkills
.where((skill) => widget.selectedSkillKeys.contains(skill.key))
.toList(growable: false);
final submitLabel = connected
? appText('提交', 'Submit')
: singleAgent
? singleAgentNeedsAiGateway
? appText('配置 LLM API', 'Configure LLM API')
: appText('查看工具栏', 'Open toolbar')
: connecting
? appText('连接中…', 'Connecting…')
: reconnectAvailable
? appText('重连', 'Reconnect')
: appText('连接', 'Connect');
return Padding(
padding: const EdgeInsets.fromLTRB(10, 8, 10, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (uiFeatures.supportsFileAttachments) ...[
PopupMenuButton<String>(
key: const Key('assistant-attachment-menu-button'),
tooltip: appText('添加文件等', 'Add files'),
offset: const Offset(0, 48),
onSelected: (value) {
switch (value) {
case 'attach':
widget.onPickAttachments();
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem<String>(
value: 'attach',
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.attach_file_rounded),
title: Text('添加照片和文件'),
),
),
],
child: const _ComposerIconButton(icon: Icons.add_rounded),
),
const SizedBox(width: 6),
],
PopupMenuButton<AssistantExecutionTarget>(
key: const Key('assistant-execution-target-button'),
tooltip: appText('任务对话模式', 'Task Dialog Mode'),
onSelected: (value) {
controller.setAssistantExecutionTarget(value);
},
itemBuilder: (context) => uiFeatures.availableExecutionTargets
.map(
(value) => PopupMenuItem<AssistantExecutionTarget>(
value: value,
child: Row(
children: [
Icon(value.icon, size: 18),
const SizedBox(width: 10),
Expanded(child: Text(value.label)),
if (value == executionTarget)
const Icon(Icons.check_rounded, size: 18),
],
),
),
)
.toList(),
child: _ComposerToolbarChip(
icon: executionTarget.icon,
tooltip: _executionTargetTooltip(executionTarget),
showChevron: true,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
),
),
const SizedBox(width: 4),
if (singleAgent) ...[
PopupMenuButton<SingleAgentProvider>(
key: const Key('assistant-single-agent-provider-button'),
tooltip: appText('单机智能体执行器', 'Single Agent Provider'),
onSelected: (value) {
unawaited(controller.setSingleAgentProvider(value));
},
itemBuilder: (context) => controller
.singleAgentProviderOptions
.map(
(value) => PopupMenuItem<SingleAgentProvider>(
value: value,
child: Row(
children: [
_SingleAgentProviderBadge(provider: value),
const SizedBox(width: 10),
Expanded(child: Text(value.label)),
if (value ==
controller.currentSingleAgentProvider)
const Icon(Icons.check_rounded, size: 18),
],
),
),
)
.toList(),
child: _ComposerToolbarChip(
leading: _SingleAgentProviderBadge(
provider: controller.currentSingleAgentProvider,
),
tooltip: _singleAgentProviderTooltip(
controller.currentSingleAgentProvider,
),
showChevron: true,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
),
),
const SizedBox(width: 4),
],
if (widget.showModelControl) ...[
widget.modelOptions.isEmpty
? _ComposerToolbarChip(
key: const Key('assistant-model-button'),
icon: Icons.bolt_rounded,
tooltip: _modelTooltip(widget.modelLabel),
showChevron: false,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
)
: PopupMenuButton<String>(
key: const Key('assistant-model-button'),
tooltip: appText('模型', 'Model'),
onSelected: widget.onModelChanged,
itemBuilder: (context) => widget.modelOptions
.map(
(value) => PopupMenuItem<String>(
value: value,
child: Row(
children: [
Expanded(child: Text(value)),
if (value == widget.modelLabel)
const Icon(Icons.check_rounded, size: 18),
],
),
),
)
.toList(),
child: _ComposerToolbarChip(
icon: Icons.bolt_rounded,
tooltip: _modelTooltip(widget.modelLabel),
showChevron: true,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
),
),
const SizedBox(width: 4),
],
if (uiFeatures.supportsMultiAgent) ...[
Tooltip(
message: appText(
'多 Agent 协作模式Architect 调度/文档 → Lead Engineer 主程 → Worker/Review',
'Multi-Agent Collaboration Mode (Architect docs/scheduler -> Lead Engineer -> Worker/Review)',
),
child: AnimatedBuilder(
animation: controller.multiAgentOrchestrator,
builder: (context, _) {
final collab = controller.multiAgentOrchestrator;
final enabled = collab.config.enabled;
return IconButton(
key: const Key('assistant-collaboration-toggle'),
icon: Icon(
enabled
? Icons.auto_awesome
: Icons.auto_awesome_outlined,
size: 20,
color: enabled ? Colors.orange : null,
),
onPressed:
collab.isRunning ||
controller.isMultiAgentRunPending
? null
: () => unawaited(
controller.saveMultiAgentConfig(
collab.config.copyWith(enabled: !enabled),
),
),
splashRadius: 18,
);
},
),
),
AnimatedBuilder(
animation: controller.multiAgentOrchestrator,
builder: (context, _) {
final collab = controller.multiAgentOrchestrator;
if (!collab.config.enabled) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(left: 4),
child: _ComposerToolbarChip(
icon: Icons.hub_rounded,
tooltip: collab.config.usesAris
? appText('多智能体模式: ARIS', 'Multi-agent mode: ARIS')
: appText('多智能体模式: 原生', 'Multi-agent mode: Native'),
showChevron: false,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 6,
),
),
);
},
),
],
],
),
const SizedBox(height: 8),
if (widget.attachments.isNotEmpty) ...[
Wrap(
spacing: 6,
runSpacing: 6,
children: widget.attachments
.map(
(attachment) => InputChip(
avatar: Icon(attachment.icon, size: 16),
label: Text(attachment.name),
onDeleted: () => widget.onRemoveAttachment(attachment),
),
)
.toList(),
),
const SizedBox(height: 6),
],
SizedBox(
key: const Key('assistant-composer-input-area'),
height: _inputHeight,
child: Shortcuts(
shortcuts: _pasteShortcuts,
child: Actions(
actions: <Type, Action<Intent>>{
AssistantPasteIntent: CallbackAction<AssistantPasteIntent>(
onInvoke: (_) {
unawaited(_handlePasteShortcut());
return null;
},
),
},
child: TextField(
controller: widget.inputController,
focusNode: widget.focusNode,
autofocus: true,
expands: true,
minLines: null,
maxLines: null,
textAlignVertical: TextAlignVertical.top,
decoration: InputDecoration(
isCollapsed: true,
filled: true,
fillColor: palette.surfacePrimary,
contentPadding: const EdgeInsets.fromLTRB(10, 8, 10, 8),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: palette.strokeSoft),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: palette.accent.withValues(alpha: 0.24),
),
),
hintText: appText(
'输入需求、补充上下文XWorkmate 会沿用当前任务上下文持续处理。',
'Describe the task or add context. XWorkmate keeps the current task context.',
),
),
onSubmitted: (_) => widget.onSend(),
),
),
),
),
_ComposerResizeHandle(
key: const Key('assistant-composer-resize-handle'),
onDelta: _resizeInput,
),
if (selectedSkills.isNotEmpty) ...[
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 6,
children: selectedSkills
.map(
(skill) => _ComposerSelectedSkillChip(
key: ValueKey<String>(
'assistant-selected-skill-${skill.key}',
),
option: skill,
onDeleted: () => widget.onToggleSkill(skill.key),
),
)
.toList(growable: false),
),
],
const SizedBox(height: 6),
Row(
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CompositedTransformTarget(
key: _skillPickerTargetKey,
link: _skillPickerLayerLink,
child: OverlayPortal(
controller: _skillPickerPortalController,
overlayChildBuilder: _buildSkillPickerOverlay,
child: InkWell(
key: const Key('assistant-skill-picker-button'),
borderRadius: BorderRadius.circular(AppRadius.chip),
onTap: _toggleSkillPicker,
child: _ComposerToolbarChip(
icon: Icons.auto_awesome_rounded,
tooltip: _skillsTooltip(selectedSkills.length),
showChevron: true,
),
),
),
),
const SizedBox(width: 6),
PopupMenuButton<AssistantPermissionLevel>(
key: const Key('assistant-permission-button'),
tooltip: appText('权限', 'Permissions'),
onSelected: (value) {
controller.setAssistantPermissionLevel(value);
},
itemBuilder: (context) => AssistantPermissionLevel
.values
.map(
(value) =>
PopupMenuItem<AssistantPermissionLevel>(
value: value,
child: Row(
children: [
Icon(value.icon, size: 18),
const SizedBox(width: 10),
Expanded(child: Text(value.label)),
if (value == permissionLevel)
const Icon(
Icons.check_rounded,
size: 18,
),
],
),
),
)
.toList(),
child: _ComposerToolbarChip(
icon: permissionLevel.icon,
tooltip: _permissionTooltip(permissionLevel),
showChevron: true,
),
),
const SizedBox(width: 6),
PopupMenuButton<String>(
key: const Key('assistant-thinking-button'),
tooltip: appText('推理强度', 'Reasoning'),
onSelected: widget.onThinkingChanged,
itemBuilder: (context) =>
const <String>['low', 'medium', 'high', 'max']
.map(
(value) => PopupMenuItem<String>(
value: value,
child: Row(
children: [
Expanded(
child: Text(
_assistantThinkingLabel(value),
),
),
if (value == widget.thinkingLabel)
const Icon(
Icons.check_rounded,
size: 18,
),
],
),
),
)
.toList(),
child: _ComposerToolbarChip(
icon: Icons.psychology_alt_outlined,
tooltip: _thinkingTooltip(widget.thinkingLabel),
showChevron: true,
),
),
],
),
),
),
const SizedBox(width: 8),
Tooltip(
message: submitLabel,
child: FilledButton(
key: const Key('assistant-submit-button'),
onPressed: connecting
? null
: connected
? widget.onSend
: singleAgent
? singleAgentNeedsAiGateway
? widget.onOpenAiGatewaySettings
: () {
widget.focusNode.requestFocus();
}
: reconnectAvailable
? () async {
await widget.onReconnectGateway();
}
: widget.onOpenGateway,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
minimumSize: const Size(64, 28),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
connected
? Icons.arrow_upward_rounded
: singleAgent
? singleAgentNeedsAiGateway
? Icons.hub_outlined
: Icons.smart_toy_outlined
: reconnectAvailable
? Icons.refresh_rounded
: Icons.link_rounded,
size: 18,
),
const SizedBox(width: 4),
Text(submitLabel),
],
),
),
),
],
),
],
),
);
}
}
class _ComposerIconButton extends StatefulWidget {
const _ComposerIconButton({required this.icon});
final IconData icon;
@override
State<_ComposerIconButton> createState() => _ComposerIconButtonState();
}
class _ComposerResizeHandle extends StatefulWidget {
const _ComposerResizeHandle({super.key, required this.onDelta});
final ValueChanged<double> onDelta;
@override
State<_ComposerResizeHandle> createState() => _ComposerResizeHandleState();
}
class _ComposerResizeHandleState extends State<_ComposerResizeHandle> {
bool _hovered = false;
bool _dragging = false;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final highlight = _hovered || _dragging;
return MouseRegion(
cursor: SystemMouseCursors.resizeRow,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onVerticalDragStart: (_) => setState(() => _dragging = true),
onVerticalDragEnd: (_) => setState(() => _dragging = false),
onVerticalDragCancel: () => setState(() => _dragging = false),
onVerticalDragUpdate: (details) => widget.onDelta(details.delta.dy),
child: SizedBox(
height: 12,
width: double.infinity,
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
width: 42,
height: 2,
decoration: BoxDecoration(
color: highlight
? palette.accent.withValues(alpha: 0.72)
: palette.strokeSoft,
borderRadius: BorderRadius.circular(999),
),
),
),
),
),
);
}
}
class _ComposerIconButtonState extends State<_ComposerIconButton> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: _hovered ? palette.surfaceSecondary : palette.surfacePrimary,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: palette.strokeSoft),
),
child: Icon(widget.icon, size: 18, color: palette.textMuted),
),
);
}
}
class _ComposerToolbarChip extends StatefulWidget {
const _ComposerToolbarChip({
super.key,
this.icon,
this.leading,
required this.tooltip,
required this.showChevron,
this.padding = const EdgeInsets.symmetric(
horizontal: AppSpacing.xs,
vertical: 6,
),
});
final IconData? icon;
final Widget? leading;
final String tooltip;
final bool showChevron;
final EdgeInsetsGeometry padding;
@override
State<_ComposerToolbarChip> createState() => _ComposerToolbarChipState();
}
class _ComposerToolbarChipState extends State<_ComposerToolbarChip> {
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: Container(
padding: widget.padding,
decoration: BoxDecoration(
color: _hovered ? palette.surfaceSecondary : palette.surfacePrimary,
borderRadius: BorderRadius.circular(AppRadius.chip),
border: Border.all(color: palette.strokeSoft),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
widget.leading ??
Icon(widget.icon, size: 16, color: palette.textMuted),
if (widget.showChevron) ...[
const SizedBox(width: 1),
Icon(
Icons.keyboard_arrow_down_rounded,
size: 14,
color: palette.textMuted,
),
],
],
),
),
),
);
}
}
extension on AssistantExecutionTarget {
IconData get icon => switch (this) {
AssistantExecutionTarget.singleAgent => Icons.hub_outlined,
AssistantExecutionTarget.local => Icons.computer_outlined,
AssistantExecutionTarget.remote => Icons.cloud_outlined,
};
}
extension on AssistantPermissionLevel {
IconData get icon => switch (this) {
AssistantPermissionLevel.defaultAccess => Icons.verified_user_outlined,
AssistantPermissionLevel.fullAccess => Icons.error_outline_rounded,
};
}
class _SingleAgentProviderBadge extends StatelessWidget {
const _SingleAgentProviderBadge({required this.provider});
final SingleAgentProvider provider;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final candidate = provider.badge.trim().isEmpty
? provider.label
: provider.badge;
final display = candidate.length <= 2
? candidate
: candidate.substring(0, 2);
final isAuto = provider == SingleAgentProvider.auto;
return Container(
width: 18,
height: 18,
alignment: Alignment.center,
decoration: BoxDecoration(
color: isAuto
? palette.accent.withValues(alpha: 0.16)
: palette.surfaceSecondary,
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: isAuto
? palette.accent.withValues(alpha: 0.4)
: palette.strokeSoft,
),
),
child: Text(
display,
maxLines: 1,
overflow: TextOverflow.clip,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: palette.textMuted,
fontWeight: FontWeight.w700,
fontSize: 9,
height: 1,
),
),
);
}
}
String _executionTargetTooltip(AssistantExecutionTarget target) =>
appText('任务对话模式: ${target.label}', 'Task dialog mode: ${target.label}');
String _singleAgentProviderTooltip(SingleAgentProvider provider) => appText(
'单机智能体执行器: ${provider.label}',
'Single-agent provider: ${provider.label}',
);
String _modelTooltip(String modelLabel) =>
appText('模型: $modelLabel', 'Model: $modelLabel');
String _skillsTooltip(int selectedCount) => selectedCount <= 0
? appText('技能', 'Skills')
: appText('技能: 已选 $selectedCount', 'Skills: $selectedCount selected');
String _permissionTooltip(AssistantPermissionLevel level) =>
appText('权限: ${level.label}', 'Permissions: ${level.label}');
String _thinkingTooltip(String level) => appText(
'推理强度: ${_assistantThinkingLabel(level)}',
'Reasoning: ${_assistantThinkingLabel(level)}',
);
class _MessageBubble extends StatelessWidget {
const _MessageBubble({
required this.label,
required this.text,
required this.alignRight,
required this.tone,
required this.messageViewMode,
});
final String label;
final String text;
final bool alignRight;
final _BubbleTone tone;
final AssistantMessageViewMode messageViewMode;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final palette = context.palette;
final showLabel = !(alignRight && label == appText('', 'You'));
final backgroundColor = switch (tone) {
_BubbleTone.user => palette.surfaceSecondary,
_BubbleTone.agent => palette.surfaceTertiary.withValues(alpha: 0.78),
_BubbleTone.assistant => palette.surfacePrimary,
};
final labelColor = switch (tone) {
_BubbleTone.user => palette.textSecondary,
_BubbleTone.agent => palette.success,
_BubbleTone.assistant => palette.textMuted,
};
return Align(
alignment: Alignment.centerLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: Container(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(color: palette.strokeSoft),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showLabel) ...[
Text(
label,
style: theme.textTheme.labelMedium?.copyWith(
color: labelColor,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
],
_MessageBubbleBody(
text: text.isEmpty ? appText('暂无内容。', 'No content yet.') : text,
renderMarkdown:
messageViewMode == AssistantMessageViewMode.rendered &&
tone != _BubbleTone.user,
compactUserMetadata: tone == _BubbleTone.user,
),
],
),
),
),
);
}
}
class _MessageBubbleBody extends StatefulWidget {
const _MessageBubbleBody({
required this.text,
required this.renderMarkdown,
required this.compactUserMetadata,
});
final String text;
final bool renderMarkdown;
final bool compactUserMetadata;
@override
State<_MessageBubbleBody> createState() => _MessageBubbleBodyState();
}
class _MessageBubbleBodyState extends State<_MessageBubbleBody> {
bool _attachmentsExpanded = false;
bool _executionContextExpanded = false;
bool _hovered = false;
@override
void didUpdateWidget(covariant _MessageBubbleBody oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.text != widget.text) {
_attachmentsExpanded = false;
_executionContextExpanded = false;
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final palette = context.palette;
final messageBodyStyle = theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface,
height: 1.5,
);
if (!widget.renderMarkdown) {
final parsed = _PromptDebugSnapshot.fromMessage(widget.text);
final canCompactMetadata =
widget.compactUserMetadata &&
(parsed.attachmentsBlock != null ||
parsed.executionContextBlock != null);
if (!canCompactMetadata) {
return SelectableText(widget.text, style: messageBodyStyle);
}
final bodyText = parsed.bodyText.trim().isEmpty
? appText('暂无内容。', 'No content yet.')
: parsed.bodyText;
final showAttachments =
_attachmentsExpanded && parsed.attachmentsBlock != null;
final showExecutionContext =
_executionContextExpanded && parsed.executionContextBlock != null;
final content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(bodyText, style: messageBodyStyle),
if (_hovered || showAttachments || showExecutionContext) ...[
const SizedBox(height: 6),
Wrap(
spacing: 4,
runSpacing: 4,
children: [
if (parsed.attachmentsBlock != null)
_MessageMetaToggleButton(
key: const Key('assistant-user-meta-attachments-toggle'),
icon: Icons.attach_file_rounded,
expanded: _attachmentsExpanded,
tooltip: _attachmentsExpanded
? appText('折叠附件信息', 'Collapse attached files')
: appText('展开附件信息', 'Expand attached files'),
onTap: () {
setState(() {
_attachmentsExpanded = !_attachmentsExpanded;
});
},
),
if (parsed.executionContextBlock != null)
_MessageMetaToggleButton(
key: const Key('assistant-user-meta-context-toggle'),
icon: Icons.tune_rounded,
expanded: _executionContextExpanded,
tooltip: _executionContextExpanded
? appText('折叠执行上下文', 'Collapse execution context')
: appText('展开执行上下文', 'Expand execution context'),
onTap: () {
setState(() {
_executionContextExpanded = !_executionContextExpanded;
});
},
),
],
),
],
if (showAttachments) ...[
const SizedBox(height: 6),
_MessageMetaBlock(
key: const Key('assistant-user-meta-attachments-block'),
content: parsed.attachmentsBlock!,
),
],
if (showExecutionContext) ...[
const SizedBox(height: 6),
_MessageMetaBlock(
key: const Key('assistant-user-meta-context-block'),
content: parsed.executionContextBlock!,
),
],
],
);
return MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: content,
);
}
final styleSheet = MarkdownStyleSheet.fromTheme(theme).copyWith(
p: messageBodyStyle?.copyWith(height: 1.55),
h1: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w600,
color: palette.textPrimary,
),
h2: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: palette.textPrimary,
),
h3: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: palette.textPrimary,
),
code: theme.textTheme.bodyMedium?.copyWith(
fontFamily: 'Menlo',
height: 1.4,
),
codeblockDecoration: BoxDecoration(
color: context.palette.surfaceSecondary,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: context.palette.strokeSoft),
),
blockquoteDecoration: BoxDecoration(
color: context.palette.surfaceSecondary.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: context.palette.strokeSoft),
),
blockquotePadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
tableBorder: TableBorder.all(color: context.palette.strokeSoft),
tableHead: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
);
return MarkdownBody(
data: widget.text,
selectable: true,
styleSheet: styleSheet,
extensionSet: md.ExtensionSet.gitHubWeb,
sizedImageBuilder: (config) => SelectableText(
config.alt?.trim().isNotEmpty == true
? '![${config.alt!.trim()}](${config.uri.toString()})'
: config.uri.toString(),
style: theme.textTheme.bodyMedium?.copyWith(
color: context.palette.textSecondary,
height: 1.4,
),
),
onTapLink: (text, href, title) {},
);
}
}
class _PromptDebugSnapshot {
const _PromptDebugSnapshot({
required this.bodyText,
this.attachmentsBlock,
this.executionContextBlock,
});
final String bodyText;
final String? attachmentsBlock;
final String? executionContextBlock;
static _PromptDebugSnapshot fromMessage(String text) {
var cursor = 0;
String? attachments;
String? preferredSkills;
String? executionContext;
void skipLeadingNewlines() {
while (cursor < text.length && text[cursor] == '\n') {
cursor++;
}
}
String? consumeBlock(String heading) {
final prefix = '$heading:\n';
if (!text.startsWith(prefix, cursor)) {
return null;
}
final blockStart = cursor;
final divider = text.indexOf('\n\n', blockStart);
if (divider == -1) {
cursor = text.length;
return text.substring(blockStart).trimRight();
}
cursor = divider + 2;
return text.substring(blockStart, divider).trimRight();
}
while (cursor < text.length) {
skipLeadingNewlines();
final attachmentBlock = consumeBlock('Attached files');
if (attachmentBlock != null) {
attachments = attachmentBlock;
continue;
}
final skillBlock = consumeBlock('Preferred skills');
if (skillBlock != null) {
preferredSkills = skillBlock;
continue;
}
final executionBlock = consumeBlock('Execution context');
if (executionBlock != null) {
executionContext = executionBlock;
continue;
}
break;
}
final remainder = text.substring(cursor).trimLeft();
final executionContextParts = <String>[?preferredSkills, ?executionContext];
return _PromptDebugSnapshot(
bodyText: remainder.trim(),
attachmentsBlock: attachments,
executionContextBlock: executionContextParts.isEmpty
? null
: executionContextParts.join('\n\n'),
);
}
}
class _MessageMetaToggleButton extends StatelessWidget {
const _MessageMetaToggleButton({
super.key,
required this.icon,
required this.expanded,
required this.tooltip,
required this.onTap,
});
final IconData icon;
final bool expanded;
final String tooltip;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final iconColor = expanded ? palette.accent : palette.textMuted;
return Tooltip(
message: tooltip,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: expanded
? palette.surfaceSecondary
: palette.surfacePrimary.withValues(alpha: 0.78),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: expanded
? palette.accent.withValues(alpha: 0.34)
: palette.strokeSoft,
),
),
child: Icon(icon, size: 12, color: iconColor),
),
),
);
}
}
class _MessageMetaBlock extends StatelessWidget {
const _MessageMetaBlock({super.key, required this.content});
final String content;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: palette.surfaceSecondary.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: palette.strokeSoft),
),
child: SelectableText(
content,
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textSecondary,
height: 1.35,
),
),
);
}
}
class _ToolCallTile extends StatefulWidget {
const _ToolCallTile({
required this.toolName,
required this.summary,
required this.pending,
required this.error,
required this.onOpenDetail,
});
final String toolName;
final String summary;
final bool pending;
final bool error;
final VoidCallback onOpenDetail;
@override
State<_ToolCallTile> createState() => _ToolCallTileState();
}
class _ToolCallTileState extends State<_ToolCallTile> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final palette = context.palette;
final statusLabel = widget.pending
? 'running'
: (widget.error ? 'error' : 'completed');
final statusStyle = _pillStyleForStatus(context, statusLabel);
final collapsedSummary = widget.summary.trim().isEmpty
? appText('工具调用进行中。', 'Tool call in progress.')
: widget.summary.trim().replaceAll('\n', ' ');
return Align(
alignment: Alignment.centerLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: Container(
decoration: BoxDecoration(
color: palette.surfaceSecondary.withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(color: palette.strokeSoft),
),
child: Column(
children: [
InkWell(
borderRadius: BorderRadius.circular(AppRadius.card),
onTap: () => setState(() => _expanded = !_expanded),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
child: Row(
children: [
Container(
width: 9,
height: 9,
decoration: BoxDecoration(
color: statusStyle.foregroundColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Expanded(
child: RichText(
maxLines: 1,
overflow: TextOverflow.ellipsis,
text: TextSpan(
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textSecondary,
),
children: [
TextSpan(
text: widget.toolName,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurface,
),
),
const TextSpan(text: ' '),
TextSpan(text: collapsedSummary),
],
),
),
),
const SizedBox(width: 8),
_StatusPill(
label: _toolCallStatusLabel(statusLabel),
backgroundColor: statusStyle.backgroundColor,
textColor: statusStyle.foregroundColor,
),
const SizedBox(width: 4),
Icon(
_expanded
? Icons.keyboard_arrow_up_rounded
: Icons.keyboard_arrow_down_rounded,
size: 18,
color: palette.textMuted,
),
],
),
),
),
ClipRect(
child: AnimatedSize(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
child: _expanded
? Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.sm,
0,
AppSpacing.sm,
AppSpacing.xs,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Divider(height: 1, color: palette.strokeSoft),
const SizedBox(height: 6),
Text(
widget.summary.trim().isEmpty
? appText(
'工具调用进行中。',
'Tool call in progress.',
)
: widget.summary.trim(),
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 4),
TextButton(
onPressed: widget.onOpenDetail,
child: Text(appText('打开详情', 'Open detail')),
),
],
),
)
: const SizedBox.shrink(),
),
),
],
),
),
),
);
}
}
class _StatusPill extends StatelessWidget {
const _StatusPill({
required this.label,
this.backgroundColor,
this.textColor,
});
final String label;
final Color? backgroundColor;
final Color? textColor;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color:
backgroundColor ??
Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(AppRadius.badge),
border: Border.all(color: context.palette.strokeSoft),
),
child: Text(
label,
style: Theme.of(
context,
).textTheme.labelMedium?.copyWith(color: textColor),
),
);
}
}
class _ConnectionChip extends StatelessWidget {
const _ConnectionChip({required this.controller});
final AppController controller;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final connectionState = controller.currentAssistantConnectionState;
final color = connectionState.isSingleAgent
? (connectionState.connected
? context.palette.accentMuted
: context.palette.surfaceSecondary)
: switch (connectionState.status) {
RuntimeConnectionStatus.connected => context.palette.accentMuted,
RuntimeConnectionStatus.connecting =>
context.palette.surfaceSecondary,
RuntimeConnectionStatus.error => context.palette.danger.withValues(
alpha: 0.10,
),
RuntimeConnectionStatus.offline => context.palette.surfaceSecondary,
};
return Container(
key: const Key('assistant-connection-chip'),
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xs,
vertical: 5,
),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(AppRadius.chip),
border: Border.all(color: context.palette.strokeSoft),
),
child: Text(
'${controller.assistantConnectionStatusLabel} · ${controller.assistantConnectionTargetLabel}',
style: theme.textTheme.labelMedium,
),
);
}
}
class _MessageViewModeChip extends StatelessWidget {
const _MessageViewModeChip({required this.value, required this.onSelected});
final AssistantMessageViewMode value;
final Future<void> Function(AssistantMessageViewMode mode) onSelected;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
return PopupMenuButton<AssistantMessageViewMode>(
key: const Key('assistant-message-view-mode-button'),
tooltip: appText('消息视图', 'Message view'),
onSelected: (mode) => unawaited(onSelected(mode)),
itemBuilder: (context) => AssistantMessageViewMode.values
.map(
(mode) => PopupMenuItem<AssistantMessageViewMode>(
value: mode,
child: Row(
children: [
Expanded(child: Text(mode.label)),
if (mode == value) const Icon(Icons.check_rounded, size: 18),
],
),
),
)
.toList(growable: false),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xs,
vertical: 5,
),
decoration: BoxDecoration(
color: palette.surfaceSecondary,
borderRadius: BorderRadius.circular(AppRadius.chip),
border: Border.all(color: palette.strokeSoft),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.notes_rounded, size: 14, color: palette.textMuted),
const SizedBox(width: 4),
Text(value.label, style: theme.textTheme.labelMedium),
const SizedBox(width: 2),
Icon(
Icons.keyboard_arrow_down_rounded,
size: 14,
color: palette.textMuted,
),
],
),
),
);
}
}
enum _BubbleTone { user, assistant, agent }
enum _TimelineItemKind { user, assistant, agent, toolCall }
class _TimelineItem {
const _TimelineItem._({
required this.kind,
this.label,
this.text,
this.title,
this.pending = false,
this.error = false,
});
const _TimelineItem.message({
required _TimelineItemKind kind,
required String label,
required String text,
required bool pending,
required bool error,
}) : this._(
kind: kind,
label: label,
text: text,
pending: pending,
error: error,
);
const _TimelineItem.toolCall({
required String toolName,
required String summary,
required bool pending,
required bool error,
}) : this._(
kind: _TimelineItemKind.toolCall,
title: toolName,
text: summary,
pending: pending,
error: error,
);
final _TimelineItemKind kind;
final String? label;
final String? text;
final String? title;
final bool pending;
final bool error;
}
class _AssistantTaskSeed {
const _AssistantTaskSeed({
required this.sessionKey,
required this.title,
required this.preview,
required this.status,
required this.updatedAtMs,
required this.owner,
required this.surface,
required this.executionTarget,
required this.draft,
});
final String sessionKey;
final String title;
final String preview;
final String status;
final double updatedAtMs;
final String owner;
final String surface;
final AssistantExecutionTarget executionTarget;
final bool draft;
_AssistantTaskEntry toEntry({required bool isCurrent}) {
return _AssistantTaskEntry(
sessionKey: sessionKey,
title: title,
preview: preview,
status: status,
updatedAtMs: updatedAtMs,
owner: owner,
surface: surface,
executionTarget: executionTarget,
isCurrent: isCurrent,
draft: draft,
);
}
}
class _AssistantTaskEntry {
const _AssistantTaskEntry({
required this.sessionKey,
required this.title,
required this.preview,
required this.status,
required this.updatedAtMs,
required this.owner,
required this.surface,
required this.executionTarget,
required this.isCurrent,
this.draft = false,
});
final String sessionKey;
final String title;
final String preview;
final String status;
final double? updatedAtMs;
final String owner;
final String surface;
final AssistantExecutionTarget executionTarget;
final bool isCurrent;
final bool draft;
_AssistantTaskEntry copyWith({
String? sessionKey,
String? title,
String? preview,
String? status,
double? updatedAtMs,
String? owner,
String? surface,
AssistantExecutionTarget? executionTarget,
bool? isCurrent,
bool? draft,
}) {
return _AssistantTaskEntry(
sessionKey: sessionKey ?? this.sessionKey,
title: title ?? this.title,
preview: preview ?? this.preview,
status: status ?? this.status,
updatedAtMs: updatedAtMs ?? this.updatedAtMs,
owner: owner ?? this.owner,
surface: surface ?? this.surface,
executionTarget: executionTarget ?? this.executionTarget,
isCurrent: isCurrent ?? this.isCurrent,
draft: draft ?? this.draft,
);
}
String get updatedAtLabel => _sessionUpdatedAtLabel(updatedAtMs);
}
class _AssistantTaskGroup {
const _AssistantTaskGroup({
required this.executionTarget,
required this.items,
});
final AssistantExecutionTarget executionTarget;
final List<_AssistantTaskEntry> items;
}
class _PillStyle {
const _PillStyle({
required this.backgroundColor,
required this.foregroundColor,
});
final Color backgroundColor;
final Color foregroundColor;
}
class _MetaPill extends StatelessWidget {
const _MetaPill({required this.label, required this.icon});
final String label;
final IconData icon;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
if (maxWidth.isFinite && maxWidth < 20) {
return const SizedBox.shrink();
}
final showText = !maxWidth.isFinite || maxWidth >= 52;
final horizontalPadding = showText ? 10.0 : 8.0;
return Container(
padding: EdgeInsets.symmetric(
horizontal: horizontalPadding,
vertical: 6,
),
decoration: BoxDecoration(
color: palette.surfaceSecondary,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: palette.strokeSoft),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: palette.textMuted),
if (showText) ...[
const SizedBox(width: 6),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelMedium?.copyWith(
color: palette.textSecondary,
),
),
),
],
],
),
);
},
);
}
}
_PillStyle _pillStyleForStatus(BuildContext context, String label) {
final theme = Theme.of(context);
final normalized = _normalizedTaskStatus(label);
return switch (normalized) {
'running' => _PillStyle(
backgroundColor: context.palette.accentMuted,
foregroundColor: theme.colorScheme.primary,
),
'queued' => _PillStyle(
backgroundColor: context.palette.surfaceSecondary,
foregroundColor: context.palette.textSecondary,
),
'failed' || 'error' => _PillStyle(
backgroundColor: context.palette.surfacePrimary,
foregroundColor: theme.colorScheme.error,
),
_ => _PillStyle(
backgroundColor: context.palette.surfacePrimary,
foregroundColor: theme.colorScheme.tertiary,
),
};
}
String _normalizedTaskStatus(String status) {
final value = status.trim().toLowerCase();
return switch (value) {
'running' => 'running',
'queued' => 'queued',
'failed' => 'failed',
'error' => 'error',
'open' => 'open',
_ => 'open',
};
}
String _toolCallStatusLabel(String status) =>
switch (_normalizedTaskStatus(status)) {
'running' => appText('运行中', 'Running'),
'failed' || 'error' => appText('错误', 'Error'),
_ => appText('已完成', 'Completed'),
};
String _assistantThinkingLabel(String level) => switch (level) {
'low' => appText('', 'Low'),
'medium' => appText('', 'Medium'),
'max' => appText('超高', 'Max'),
_ => appText('', 'High'),
};
String _sessionDisplayTitle(GatewaySessionSummary session) {
final label = session.label.trim();
if (label.isEmpty || label == session.key) {
return _fallbackSessionTitle(session.key);
}
if ((label == 'main' || label == 'agent:main:main') &&
(session.derivedTitle ?? '').trim().toLowerCase() == 'main') {
return _fallbackSessionTitle(session.key);
}
return label;
}
String _fallbackSessionTitle(String sessionKey) {
final trimmed = sessionKey.trim();
if (trimmed == 'main' || trimmed == 'agent:main:main') {
return appText('默认任务', 'Default task');
}
if (trimmed.startsWith('draft:')) {
return appText('新对话', 'New conversation');
}
final parts = trimmed.split(':');
if (parts.length >= 3 && parts.first == 'agent' && parts.last == 'main') {
return appText('默认任务', 'Default task');
}
return trimmed.isEmpty ? appText('未命名对话', 'Untitled conversation') : trimmed;
}
String? _sessionPreview(GatewaySessionSummary session) {
final preview = session.lastMessagePreview?.trim();
if (preview != null && preview.isNotEmpty) {
return preview;
}
final subject = session.subject?.trim();
if (subject != null && subject.isNotEmpty) {
return subject;
}
return null;
}
String _sessionStatus(
GatewaySessionSummary session, {
required bool sessionPending,
}) {
if (session.abortedLastRun == true) {
return 'failed';
}
if (sessionPending) {
return 'running';
}
if ((session.lastMessagePreview ?? '').trim().isEmpty) {
return 'queued';
}
return 'open';
}
String _sessionUpdatedAtLabel(double? updatedAtMs) {
if (updatedAtMs == null) {
return appText('未知', 'Unknown');
}
final delta = DateTime.now().difference(
DateTime.fromMillisecondsSinceEpoch(updatedAtMs.toInt()),
);
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';
}
double _estimatedComposerWrapSectionHeight({
required int itemCount,
required double availableWidth,
required double averageChipWidth,
}) {
if (itemCount <= 0) {
return 0;
}
final itemsPerRow = math.max(1, (availableWidth / averageChipWidth).floor());
final rows = (itemCount / itemsPerRow).ceil();
const chipHeight = 32.0;
const runSpacing = 6.0;
const sectionSpacing = 6.0;
return sectionSpacing + (rows * chipHeight) + ((rows - 1) * runSpacing);
}
bool _sessionKeysMatch(String incoming, String current) {
final left = incoming.trim().toLowerCase();
final right = current.trim().toLowerCase();
if (left == right) {
return true;
}
return (left == 'agent:main:main' && right == 'main') ||
(left == 'main' && right == 'agent:main:main');
}
const List<_ComposerSkillOption> _fallbackSkillOptions = <_ComposerSkillOption>[
_ComposerSkillOption(
key: '1password',
label: '1password',
description: '安全读取和注入本地凭据。',
sourceLabel: 'Local',
icon: Icons.auto_awesome_rounded,
),
_ComposerSkillOption(
key: 'xlsx',
label: 'xlsx',
description: '读取、整理和生成表格文件。',
sourceLabel: 'Local',
icon: Icons.auto_awesome_rounded,
),
_ComposerSkillOption(
key: 'web-processing',
label: '网页处理',
description: '打开网页、提取内容并完成网页操作。',
sourceLabel: 'Web',
icon: Icons.language_rounded,
),
_ComposerSkillOption(
key: 'apple-reminders',
label: 'apple-reminders',
description: '管理提醒事项和任务提醒。',
sourceLabel: 'Local',
icon: Icons.auto_awesome_rounded,
),
_ComposerSkillOption(
key: 'blogwatcher',
label: 'blogwatcher',
description: '跟踪博客更新并生成摘要。',
sourceLabel: 'Local',
icon: Icons.auto_awesome_rounded,
),
];
_ComposerSkillOption _skillOptionFromGateway(GatewaySkillSummary skill) {
final normalizedKey = skill.skillKey.trim().toLowerCase();
final normalizedName = skill.name.trim().toLowerCase();
final isWebSkill =
normalizedKey.contains('browser') ||
normalizedKey.contains('open-link') ||
normalizedKey.contains('web') ||
normalizedName.contains('browser') ||
normalizedName.contains('网页');
final label = isWebSkill ? '网页处理' : skill.name.trim();
final key = isWebSkill ? 'web-processing' : normalizedKey;
final sourceLabel = skill.source.trim().isEmpty ? 'Gateway' : skill.source;
final description = skill.description.trim().isEmpty
? appText('可在当前任务中调用的技能。', 'Skill available in the current task.')
: skill.description.trim();
return _ComposerSkillOption(
key: key,
label: label,
description: description,
sourceLabel: sourceLabel,
icon: isWebSkill ? Icons.language_rounded : Icons.auto_awesome_rounded,
);
}
_ComposerSkillOption _skillOptionFromThreadSkill(
AssistantThreadSkillEntry skill,
) {
return _ComposerSkillOption(
key: skill.key,
label: skill.label.trim().isEmpty ? skill.key : skill.label.trim(),
description: skill.description.trim().isEmpty
? appText('已绑定到当前线程的本地技能。', 'Local skill bound to this thread.')
: skill.description.trim(),
sourceLabel: skill.sourceLabel.trim().isEmpty
? skill.sourcePath
: skill.sourceLabel.trim(),
icon: Icons.auto_awesome_rounded,
);
}
class _ComposerSkillOption {
const _ComposerSkillOption({
required this.key,
required this.label,
required this.description,
required this.sourceLabel,
required this.icon,
});
final String key;
final String label;
final String description;
final String sourceLabel;
final IconData icon;
}
class _ComposerSelectedSkillChip extends StatelessWidget {
const _ComposerSelectedSkillChip({
super.key,
required this.option,
required this.onDeleted,
});
final _ComposerSkillOption option;
final VoidCallback onDeleted;
@override
Widget build(BuildContext context) {
return Tooltip(
message: _skillOptionTooltip(option),
child: InputChip(
avatar: Icon(option.icon, size: 16, color: context.palette.accent),
label: Text(option.label),
onDeleted: onDeleted,
side: BorderSide.none,
backgroundColor: context.palette.surfaceSecondary,
deleteIconColor: context.palette.textMuted,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.chip),
),
),
);
}
}
class _SkillPickerPopover extends StatelessWidget {
const _SkillPickerPopover({
required this.maxHeight,
required this.searchController,
required this.searchFocusNode,
required this.selectedSkillKeys,
required this.filteredSkills,
required this.isLoading,
required this.hasQuery,
required this.onQueryChanged,
required this.onToggleSkill,
});
final double maxHeight;
final TextEditingController searchController;
final FocusNode searchFocusNode;
final List<String> selectedSkillKeys;
final List<_ComposerSkillOption> filteredSkills;
final bool isLoading;
final bool hasQuery;
final ValueChanged<String> onQueryChanged;
final ValueChanged<String> onToggleSkill;
@override
Widget build(BuildContext context) {
final palette = context.palette;
final theme = Theme.of(context);
return Material(
key: const Key('assistant-skill-picker-popover'),
color: Colors.transparent,
child: ConstrainedBox(
constraints: BoxConstraints(
minWidth: 360,
maxWidth: 480,
maxHeight: maxHeight,
),
child: Container(
decoration: BoxDecoration(
color: palette.surfacePrimary,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: palette.strokeSoft),
boxShadow: [palette.chromeShadowAmbient],
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 12),
child: TextField(
key: const Key('assistant-skill-picker-search'),
controller: searchController,
focusNode: searchFocusNode,
autofocus: true,
onChanged: onQueryChanged,
decoration: InputDecoration(
hintText: appText('搜索技能', 'Search skills'),
prefixIcon: const Icon(Icons.search_rounded),
suffixIcon: searchController.text.trim().isEmpty
? null
: IconButton(
tooltip: appText('清除', 'Clear'),
onPressed: () {
searchController.clear();
onQueryChanged('');
},
icon: const Icon(Icons.close_rounded),
),
),
),
),
Container(height: 1, color: palette.strokeSoft),
Expanded(
child: filteredSkills.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (isLoading) ...[
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: palette.textSecondary,
),
),
const SizedBox(height: 12),
],
Text(
isLoading
? appText('正在加载技能…', 'Loading skills…')
: hasQuery
? appText('没有匹配的技能。', 'No matching skills.')
: appText(
'当前没有已加载技能。',
'No skills are loaded yet.',
),
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: palette.textSecondary,
),
),
],
),
),
)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 12),
itemCount: filteredSkills.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final skill = filteredSkills[index];
return _SkillPickerTile(
key: ValueKey<String>(
'assistant-skill-option-${skill.key}',
),
option: skill,
selected: selectedSkillKeys.contains(skill.key),
onTap: () => onToggleSkill(skill.key),
);
},
),
),
],
),
),
),
);
}
}
class _SkillPickerTile extends StatelessWidget {
const _SkillPickerTile({
super.key,
required this.option,
required this.selected,
required this.onTap,
});
final _ComposerSkillOption option;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final palette = context.palette;
return Tooltip(
message: _skillOptionTooltip(option),
waitDuration: const Duration(milliseconds: 250),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Container(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
decoration: BoxDecoration(
color: selected
? palette.surfaceSecondary
: palette.surfacePrimary,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: palette.strokeSoft),
),
child: Row(
children: [
Expanded(
child: Text(
option.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
),
);
}
}
String _skillOptionTooltip(_ComposerSkillOption option) {
final sourceLabel = option.sourceLabel.trim();
return sourceLabel.isEmpty ? option.label : sourceLabel;
}
class _ComposerAttachment {
const _ComposerAttachment({
required this.name,
required this.path,
required this.icon,
required this.mimeType,
});
final String name;
final String path;
final IconData icon;
final String mimeType;
factory _ComposerAttachment.fromXFile(XFile file) {
final extension = file.name.split('.').last.toLowerCase();
final mimeType = 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',
'zip' => 'application/zip',
_ => 'application/octet-stream',
};
final icon = switch (extension) {
'png' || 'jpg' || 'jpeg' || 'gif' || 'webp' => Icons.image_outlined,
'log' || 'txt' || 'json' || 'csv' => Icons.description_outlined,
_ => Icons.insert_drive_file_outlined,
};
return _ComposerAttachment(
name: file.name,
path: file.path,
icon: icon,
mimeType: mimeType,
);
}
}
class AssistantPasteIntent extends Intent {
const AssistantPasteIntent();
}
Future<XFile?> _readClipboardImageAsXFile() async {
final clipboard = SystemClipboard.instance;
if (clipboard == null) {
return null;
}
final reader = await clipboard.read();
return await _readClipboardImageForFormat(
reader,
format: Formats.png,
extension: 'png',
mimeType: 'image/png',
) ??
await _readClipboardImageForFormat(
reader,
format: Formats.jpeg,
extension: 'jpg',
mimeType: 'image/jpeg',
) ??
await _readClipboardImageForFormat(
reader,
format: Formats.gif,
extension: 'gif',
mimeType: 'image/gif',
) ??
await _readClipboardImageForFormat(
reader,
format: Formats.webp,
extension: 'webp',
mimeType: 'image/webp',
);
}
Future<XFile?> _readClipboardImageForFormat(
ClipboardReader reader, {
required FileFormat format,
required String extension,
required String mimeType,
}) async {
if (!reader.canProvide(format)) {
return null;
}
final bytes = await _readClipboardFileBytes(reader, format);
if (bytes == null || bytes.isEmpty) {
return null;
}
final temporaryDirectory = await _resolveClipboardAttachmentTempDirectory();
final fileName =
'clipboard-image-${DateTime.now().microsecondsSinceEpoch}.$extension';
final file = File('${temporaryDirectory.path}/$fileName');
await file.writeAsBytes(bytes, flush: true);
return XFile(file.path, mimeType: mimeType, name: fileName);
}
Future<Uint8List?> _readClipboardFileBytes(
ClipboardReader reader,
FileFormat format,
) {
final completer = Completer<Uint8List?>();
final progress = reader.getFile(
format,
(file) async {
try {
final bytes = await file.readAll();
if (!completer.isCompleted) {
completer.complete(bytes);
}
} catch (error, stackTrace) {
if (!completer.isCompleted) {
completer.completeError(error, stackTrace);
}
}
},
onError: (error) {
if (!completer.isCompleted) {
completer.completeError(error);
}
},
);
if (progress == null) {
return Future<Uint8List?>.value(null);
}
return completer.future;
}
Future<Directory> _resolveClipboardAttachmentTempDirectory() async {
Directory rootDirectory;
try {
rootDirectory = await getTemporaryDirectory();
} catch (_) {
rootDirectory = Directory.systemTemp;
}
final clipboardDirectory = Directory(
'${rootDirectory.path}/xworkmate-clipboard-attachments',
);
await clipboardDirectory.create(recursive: true);
return clipboardDirectory;
}