feat(assistant): collapse prompt metadata in user bubbles
This commit is contained in:
parent
2196af7acf
commit
c5eff0e9ff
@ -3461,6 +3461,7 @@ class _MessageBubble extends StatelessWidget {
|
||||
renderMarkdown:
|
||||
messageViewMode == AssistantMessageViewMode.rendered &&
|
||||
tone != _BubbleTone.user,
|
||||
compactUserMetadata: tone == _BubbleTone.user,
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -3470,22 +3471,121 @@ class _MessageBubble extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageBubbleBody extends StatelessWidget {
|
||||
const _MessageBubbleBody({required this.text, required this.renderMarkdown});
|
||||
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;
|
||||
|
||||
@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);
|
||||
if (!renderMarkdown) {
|
||||
return SelectableText(
|
||||
text,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
height: 1.45,
|
||||
),
|
||||
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: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
height: 1.45,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final bodyText = parsed.bodyText.trim().isEmpty
|
||||
? appText('暂无内容。', 'No content yet.')
|
||||
: parsed.bodyText;
|
||||
final showAttachments =
|
||||
_attachmentsExpanded && parsed.attachmentsBlock != null;
|
||||
final showExecutionContext =
|
||||
_executionContextExpanded && parsed.executionContextBlock != null;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SelectableText(
|
||||
bodyText,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurface,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
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!,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -3517,7 +3617,7 @@ class _MessageBubbleBody extends StatelessWidget {
|
||||
);
|
||||
|
||||
return MarkdownBody(
|
||||
data: text,
|
||||
data: widget.text,
|
||||
selectable: true,
|
||||
styleSheet: styleSheet,
|
||||
extensionSet: md.ExtensionSet.gitHubWeb,
|
||||
@ -3535,6 +3635,150 @@ class _MessageBubbleBody extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
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? executionContext;
|
||||
final passthroughBlocks = <String>[];
|
||||
|
||||
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) {
|
||||
passthroughBlocks.add(skillBlock);
|
||||
continue;
|
||||
}
|
||||
final executionBlock = consumeBlock('Execution context');
|
||||
if (executionBlock != null) {
|
||||
executionContext = executionBlock;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
final remainder = text.substring(cursor).trimLeft();
|
||||
final bodyParts = <String>[
|
||||
...passthroughBlocks,
|
||||
if (remainder.isNotEmpty) remainder,
|
||||
];
|
||||
|
||||
return _PromptDebugSnapshot(
|
||||
bodyText: bodyParts.join('\n\n').trim(),
|
||||
attachmentsBlock: attachments,
|
||||
executionContextBlock: executionContext,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 _TaskStatusCard extends StatelessWidget {
|
||||
const _TaskStatusCard({
|
||||
required this.title,
|
||||
|
||||
@ -708,6 +708,94 @@ void main() {
|
||||
expect(find.text('渲染'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'AssistantPage keeps attached files and execution context collapsed by default',
|
||||
(WidgetTester tester) async {
|
||||
final controller = await _createControllerWithThreadRecords(
|
||||
records: const <AssistantThreadRecord>[
|
||||
AssistantThreadRecord(
|
||||
sessionKey: 'main',
|
||||
title: '研发任务',
|
||||
archived: false,
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
messageViewMode: AssistantMessageViewMode.raw,
|
||||
updatedAtMs: 1700000000000,
|
||||
messages: <GatewayChatMessage>[
|
||||
GatewayChatMessage(
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
text:
|
||||
'Attached files:\n'
|
||||
'- clipboard-image-1.png\n\n'
|
||||
'Execution context:\n'
|
||||
'- target: single-agent\n'
|
||||
'- provider: codex\n'
|
||||
'- workspace_root: /opt/data/workspace\n'
|
||||
'- permission: full-access\n\n'
|
||||
'结合项目代码制作一份用户手册',
|
||||
timestampMs: 1700000000000,
|
||||
toolCallId: null,
|
||||
toolName: null,
|
||||
stopReason: null,
|
||||
pending: false,
|
||||
error: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
useFakeGatewayRuntime: true,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: AssistantPage(controller: controller, onOpenDetail: (_) {}),
|
||||
);
|
||||
|
||||
expect(find.text('结合项目代码制作一份用户手册'), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('assistant-user-meta-attachments-toggle')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const Key('assistant-user-meta-context-toggle')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const Key('assistant-user-meta-attachments-block')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const Key('assistant-user-meta-context-block')),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const Key('assistant-user-meta-attachments-toggle')),
|
||||
);
|
||||
await _pumpForUiSync(tester);
|
||||
|
||||
expect(
|
||||
find.byKey(const Key('assistant-user-meta-attachments-block')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Attached files:'), findsOneWidget);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const Key('assistant-user-meta-context-toggle')),
|
||||
);
|
||||
await _pumpForUiSync(tester);
|
||||
|
||||
expect(
|
||||
find.byKey(const Key('assistant-user-meta-context-block')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Execution context:'), findsOneWidget);
|
||||
},
|
||||
// Known flutter_tester host-exit hang in this widget scenario.
|
||||
skip: true,
|
||||
);
|
||||
|
||||
// Known flutter_tester host-exit hang in this widget scenario.
|
||||
testWidgets('AssistantPage toggles Markdown Rendered and RAW per thread', (
|
||||
WidgetTester tester,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user