xworkmate-app/lib/features/assistant/assistant_page_components.dart
Haitao Pan 6e31064cd2 refactor: replace super_clipboard with pasteboard, drop cargokit/Rust
super_clipboard pulled in super_native_extensions (a Rust native layer
built via cargokit), whose precompiled-binary download from GitHub
release assets has been intermittently failing the build ("Connection
closed while receiving data"). It was used for exactly one feature -
reading a clipboard image into the composer - in a single file; the
other 12 imports were dead.

- Swap super_clipboard -> pasteboard (platform-channel, no Rust).
- Rewrite readClipboardImageAsXFileInternal() on Pasteboard.image
  (PNG bytes), collapsing three helpers into one.
- Remove 12 unused super_clipboard imports.
- Regenerated plugin registrants / lockfiles drop super_native_extensions.

Removes the Rust toolchain requirement and the flaky download entirely.
Text copy/paste already used Flutter's built-in Clipboard and is
unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 07:32:58 +08:00

648 lines
24 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.

// ignore_for_file: unused_import, unnecessary_import
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 '../../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/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';
import 'assistant_page_main.dart';
import 'assistant_page_composer_bar.dart';
import 'assistant_page_composer_state_helpers.dart';
import 'assistant_page_composer_support.dart';
import 'assistant_page_tooltip_labels.dart';
import 'assistant_page_message_widgets.dart';
import 'assistant_page_task_models.dart';
import 'assistant_page_composer_skill_picker.dart';
import 'assistant_page_composer_clipboard.dart';
import 'assistant_page_components_core.dart';
class AssistantTaskRailInternal extends StatefulWidget {
const AssistantTaskRailInternal({
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<AssistantTaskEntryInternal> 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(AssistantTaskEntryInternal entry) onRenameTask;
@override
State<AssistantTaskRailInternal> createState() =>
AssistantTaskRailStateInternal();
}
class AssistantTaskRailStateInternal extends State<AssistantTaskRailInternal> {
final Set<AssistantExecutionTarget> expandedGroupsInternal =
<AssistantExecutionTarget>{};
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final palette = context.palette;
final tasks = widget.tasks;
final groupedTasks = groupTasksForRailInternal(
tasks,
widget.controller.visibleAssistantExecutionTargets(
AssistantExecutionTarget.values,
),
);
final activeCount = tasks.where((task) {
final status = normalizedTaskStatusInternal(task.status);
return status == 'running';
}).length;
final openCount = tasks
.where((task) => normalizedTaskStatusInternal(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: [
MetaPillInternal(
label: '${appText('运行中', 'Running')} $activeCount',
icon: Icons.play_circle_outline_rounded,
),
MetaPillInternal(
label: '${appText('当前', 'Open')} $openCount',
icon: Icons.forum_outlined,
),
MetaPillInternal(
label:
'${appText('技能', 'Skills')} ${widget.controller.currentAssistantSkillCount}',
icon: Icons.key_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 = expandedGroupsInternal.contains(
group.executionTarget,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AssistantTaskGroupHeaderInternal(
executionTarget: group.executionTarget,
count: group.items.length,
expanded: expanded,
onTap: () {
setState(() {
if (expanded) {
expandedGroupsInternal.remove(
group.executionTarget,
);
} else {
expandedGroupsInternal.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),
AssistantTaskTileInternal(
entry: group.items[itemIndex],
archiveEnabled:
normalizedTaskStatusInternal(
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<AssistantTaskGroupInternal> groupTasksForRailInternal(
List<AssistantTaskEntryInternal> tasks,
List<AssistantExecutionTarget> visibleExecutionTargets,
) {
final compactTargets = compactAssistantExecutionTargets(
visibleExecutionTargets,
);
final grouped = <AssistantExecutionTarget, List<AssistantTaskEntryInternal>>{
for (final target in compactTargets) target: <AssistantTaskEntryInternal>[],
};
for (final task in tasks) {
final bucket =
grouped[collapseAssistantExecutionTargetForDisplay(
task.executionTarget,
)];
if (bucket == null) {
continue;
}
bucket.add(task);
}
return compactTargets
.map(
(target) => AssistantTaskGroupInternal(
executionTarget: target,
items: grouped[target]!,
),
)
.toList(growable: false);
}
class AssistantTaskTileInternal extends StatelessWidget {
const AssistantTaskTileInternal({
super.key,
required this.entry,
required this.archiveEnabled,
required this.onTap,
required this.onRename,
required this.onArchive,
});
final AssistantTaskEntryInternal 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 = pillStyleForStatusInternal(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
: _taskIsActiveInternal(entry.status)
? Icons.play_arrow_rounded
: normalizedTaskStatusInternal(entry.status) ==
'interrupted'
? Icons.pause_circle_outline_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),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
entry.updatedAtLabel,
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textMuted,
),
),
if (_taskShowsProgressInternal(entry.status)) ...[
const SizedBox(height: 4),
SizedBox(
key: ValueKey<String>(
'assistant-task-progress-${entry.sessionKey}',
),
width: 56,
child: LinearProgressIndicator(
value: _taskProgressValueInternal(entry.status),
minHeight: 3,
color: statusStyle.foregroundColor,
backgroundColor: statusStyle.foregroundColor.withValues(
alpha: 0.16,
),
borderRadius: BorderRadius.circular(999),
),
),
],
],
),
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,
),
),
],
),
),
),
);
}
}
bool _taskIsActiveInternal(String status) {
final normalized = normalizedTaskStatusInternal(status);
return normalized == 'running';
}
bool _taskShowsProgressInternal(String status) {
final normalized = normalizedTaskStatusInternal(status);
return normalized == 'running' || normalized == 'interrupted';
}
double? _taskProgressValueInternal(String status) {
return switch (normalizedTaskStatusInternal(status)) {
'interrupted' => 0.48,
_ => null,
};
}
class AssistantTaskGroupHeaderInternal extends StatelessWidget {
const AssistantTaskGroupHeaderInternal({
super.key,
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.compactLabel,
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 AssistantEmptyStateInternal extends StatelessWidget {
const AssistantEmptyStateInternal({
super.key,
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 connected = connectionState.connected;
final reconnectAvailable = controller.canQuickConnectGateway;
final title = connected
? appText('开始对话或运行任务', 'Start a chat or run a task')
: connectionState.status == RuntimeConnectionStatus.error
? appText('Bridge 连接失败', 'Bridge connection failed')
: appText('先连接 Bridge', 'Connect xworkmate-bridge first');
final description = connected
? appText(
'输入需求后即可开始执行,结果会回到当前会话并同步到任务页。',
'Type a request to start execution. Results return to this session and the Tasks page.',
)
: connectionState.gatewayTokenMissing
? appText(
'首次连接需要共享 Token配对完成后可继续使用本机的 device token。',
'The first connection requires a shared token; after pairing, this device can continue with its device token.',
)
: connectionState.status == RuntimeConnectionStatus.error
? (connectionState.lastError?.trim().isNotEmpty == true
? connectionState.lastError!.trim()
: appText(
'当前 bridge 连接失败。请重试连接;如果问题持续存在,请检查 bridge 运行状态和本机身份材料。',
'The bridge connection failed. Retry the connection, and if it keeps failing, check bridge health and local device identity material.',
))
: !connected
? appText(
'当前 xworkmate-bridge 尚未连接。请先恢复 bridge 连接,再继续当前任务。',
'xworkmate-bridge is not connected yet. Restore the bridge connection, then continue this task.',
)
: (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
: reconnectAvailable
? () async {
await onReconnectGateway();
}
: onOpenGateway,
icon: Icon(
connected
? Icons.edit_rounded
: reconnectAvailable
? Icons.refresh_rounded
: Icons.link_rounded,
),
label: Text(
connected
? appText('开始输入', 'Start typing')
: reconnectAvailable
? appText('重新连接 Bridge', 'Reconnect bridge')
: appText('连接 Bridge', 'Connect xworkmate-bridge'),
),
style: FilledButton.styleFrom(
minimumSize: const Size(0, 28),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
),
],
),
),
),
),
);
}
}