* 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>
778 lines
26 KiB
Dart
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.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,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|