xworkmate-app/lib/features/assistant/assistant_page_message_widgets.dart
Haitao Pan 6fb1441226
refactor: replace super_clipboard with pasteboard (drop cargokit/Rust) (#55)
* 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>

* ci: keep TestFlight package release-only

---------

Co-authored-by: Haitao Pan <manbuzhe2009@qq.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 08:30:26 +08:00

778 lines
26 KiB
Dart

// 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_components.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_task_models.dart';
import 'assistant_page_composer_skill_picker.dart';
import 'assistant_page_composer_clipboard.dart';
import 'assistant_page_components_core.dart';
class MessageBubbleInternal extends StatelessWidget {
const MessageBubbleInternal({
super.key,
required this.label,
required this.text,
required this.alignRight,
required this.tone,
required this.messageViewMode,
this.onRecall,
this.onEdit,
});
final String label;
final String text;
final bool alignRight;
final BubbleToneInternal tone;
final AssistantMessageViewMode messageViewMode;
final VoidCallback? onRecall;
final VoidCallback? onEdit;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final palette = context.palette;
final showLabel = !(alignRight && label == appText('', 'You'));
final backgroundColor = switch (tone) {
BubbleToneInternal.user => palette.surfaceSecondary,
BubbleToneInternal.agent => palette.surfaceTertiary.withValues(
alpha: 0.78,
),
BubbleToneInternal.assistant => palette.surfacePrimary,
};
final labelColor = switch (tone) {
BubbleToneInternal.user => palette.textSecondary,
BubbleToneInternal.agent => palette.success,
BubbleToneInternal.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),
],
MessageBubbleBodyInternal(
text: text.isEmpty ? appText('暂无内容。', 'No content yet.') : text,
renderMarkdown:
messageViewMode == AssistantMessageViewMode.rendered &&
tone != BubbleToneInternal.user,
compactUserMetadata: tone == BubbleToneInternal.user,
),
if (tone == BubbleToneInternal.user &&
(onRecall != null || onEdit != null)) ...[
const SizedBox(height: 8),
Wrap(
spacing: 4,
runSpacing: 4,
children: [
if (onRecall != null)
MessageMetaToggleButtonInternal(
key: const Key('assistant-user-message-recall'),
icon: Icons.undo_rounded,
expanded: false,
tooltip: appText('撤回并放回输入框', 'Recall to composer'),
onTap: onRecall!,
),
if (onEdit != null)
MessageMetaToggleButtonInternal(
key: const Key('assistant-user-message-edit'),
icon: Icons.edit_rounded,
expanded: false,
tooltip: appText('修改这条消息', 'Edit this message'),
onTap: onEdit!,
),
],
),
],
],
),
),
),
);
}
}
class MessageBubbleBodyInternal extends StatefulWidget {
const MessageBubbleBodyInternal({
super.key,
required this.text,
required this.renderMarkdown,
required this.compactUserMetadata,
});
final String text;
final bool renderMarkdown;
final bool compactUserMetadata;
@override
State<MessageBubbleBodyInternal> createState() =>
MessageBubbleBodyStateInternal();
}
class MessageBubbleBodyStateInternal extends State<MessageBubbleBodyInternal> {
bool attachmentsExpandedInternal = false;
bool executionContextExpandedInternal = false;
bool hoveredInternal = false;
@override
void didUpdateWidget(covariant MessageBubbleBodyInternal oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.text != widget.text) {
attachmentsExpandedInternal = false;
executionContextExpandedInternal = 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 = PromptDebugSnapshotInternal.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 =
attachmentsExpandedInternal && parsed.attachmentsBlock != null;
final showExecutionContext =
executionContextExpandedInternal &&
parsed.executionContextBlock != null;
final content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(bodyText, style: messageBodyStyle),
if (hoveredInternal || showAttachments || showExecutionContext) ...[
const SizedBox(height: 6),
Wrap(
spacing: 4,
runSpacing: 4,
children: [
if (parsed.attachmentsBlock != null)
MessageMetaToggleButtonInternal(
key: const Key('assistant-user-meta-attachments-toggle'),
icon: Icons.attach_file_rounded,
expanded: attachmentsExpandedInternal,
tooltip: attachmentsExpandedInternal
? appText('折叠附件信息', 'Collapse attached files')
: appText('展开附件信息', 'Expand attached files'),
onTap: () {
setState(() {
attachmentsExpandedInternal =
!attachmentsExpandedInternal;
});
},
),
if (parsed.executionContextBlock != null)
MessageMetaToggleButtonInternal(
key: const Key('assistant-user-meta-context-toggle'),
icon: Icons.tune_rounded,
expanded: executionContextExpandedInternal,
tooltip: executionContextExpandedInternal
? appText('折叠执行上下文', 'Collapse execution context')
: appText('展开执行上下文', 'Expand execution context'),
onTap: () {
setState(() {
executionContextExpandedInternal =
!executionContextExpandedInternal;
});
},
),
],
),
],
if (showAttachments) ...[
const SizedBox(height: 6),
MessageMetaBlockInternal(
key: const Key('assistant-user-meta-attachments-block'),
content: parsed.attachmentsBlock!,
),
],
if (showExecutionContext) ...[
const SizedBox(height: 6),
MessageMetaBlockInternal(
key: const Key('assistant-user-meta-context-block'),
content: parsed.executionContextBlock!,
),
],
],
);
return MouseRegion(
onEnter: (_) => setState(() => hoveredInternal = true),
onExit: (_) => setState(() => hoveredInternal = 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 PromptDebugSnapshotInternal {
const PromptDebugSnapshotInternal({
required this.bodyText,
this.attachmentsBlock,
this.executionContextBlock,
});
final String bodyText;
final String? attachmentsBlock;
final String? executionContextBlock;
static PromptDebugSnapshotInternal 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 PromptDebugSnapshotInternal(
bodyText: remainder.trim(),
attachmentsBlock: attachments,
executionContextBlock: executionContextParts.isEmpty
? null
: executionContextParts.join('\n\n'),
);
}
}
class MessageMetaToggleButtonInternal extends StatelessWidget {
const MessageMetaToggleButtonInternal({
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 MessageMetaBlockInternal extends StatelessWidget {
const MessageMetaBlockInternal({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 ToolCallTileInternal extends StatefulWidget {
const ToolCallTileInternal({
super.key,
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<ToolCallTileInternal> createState() => ToolCallTileStateInternal();
}
class ToolCallTileStateInternal extends State<ToolCallTileInternal> {
bool expandedInternal = 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 = pillStyleForStatusInternal(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(() => expandedInternal = !expandedInternal),
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),
StatusPillInternal(
label: toolCallStatusLabelInternal(statusLabel),
backgroundColor: statusStyle.backgroundColor,
textColor: statusStyle.foregroundColor,
),
const SizedBox(width: 4),
Icon(
expandedInternal
? 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: expandedInternal
? 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 StatusPillInternal extends StatelessWidget {
const StatusPillInternal({
super.key,
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 ConnectionChipInternal extends StatelessWidget {
const ConnectionChipInternal({super.key, required this.controller});
final AppController controller;
@override
Widget build(BuildContext context) {
final connectionState = controller.currentAssistantConnectionState;
final statusLabel =
'${controller.assistantConnectionStatusLabel} · ${controller.assistantConnectionTargetLabel}';
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 ConnectionStatusChipInternal(
key: const Key('assistant-connection-chip'),
statusLabel: statusLabel,
backgroundColor: color,
);
}
}
class ConnectionStatusChipInternal extends StatelessWidget {
const ConnectionStatusChipInternal({
super.key,
required this.statusLabel,
required this.backgroundColor,
});
final String statusLabel;
final Color backgroundColor;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Tooltip(
message: statusLabel,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xs,
vertical: 5,
),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(AppRadius.chip),
border: Border.all(color: context.palette.strokeSoft),
),
child: Text(
statusLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: false,
style: theme.textTheme.labelMedium,
),
),
);
}
}
class MessageViewModeChipInternal extends StatelessWidget {
const MessageViewModeChipInternal({
super.key,
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,
),
],
),
),
);
}
}