mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
fix(mobile): hide structured assistant citations in conversation display
Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import '../../../shared/markdown/assistant_citations.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
@@ -34,6 +35,7 @@ class _MessageItemWidget extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isAssistant = item.role == 'assistant';
|
||||
final text = isAssistant ? assistantDisplayContent(item.text) : item.text;
|
||||
final badgeColor = isAssistant
|
||||
? context.colors.primary
|
||||
: context.colors.onSurfaceVariant;
|
||||
@@ -66,9 +68,9 @@ class _MessageItemWidget extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Grid.half),
|
||||
if (item.text.isNotEmpty)
|
||||
if (text.isNotEmpty)
|
||||
GptMarkdown(
|
||||
item.text,
|
||||
text,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
|
||||
@@ -346,6 +346,7 @@ class _MessageBubble extends HookConsumerWidget {
|
||||
animate: typingHandoff,
|
||||
child: MessageContent(
|
||||
key: swipeContentKey,
|
||||
isAssistant: isAgent,
|
||||
content: message.content,
|
||||
bubble: true,
|
||||
hasReactions:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import '../../shared/markdown/assistant_citations.dart';
|
||||
import '../../shared/widgets/ios_media_carousel.dart';
|
||||
import '../../shared/widgets/message_attachment_corners.dart';
|
||||
import 'dart:io';
|
||||
@@ -95,6 +96,9 @@ String _safeDownloadedFilename(String filename) {
|
||||
class MessageContent extends HookConsumerWidget {
|
||||
final String content;
|
||||
|
||||
/// Whether the sender is a known agent; enables display-only metadata hiding.
|
||||
final bool isAssistant;
|
||||
|
||||
/// Paint conversation text bubbles while keeping attachments on the page.
|
||||
final bool bubble;
|
||||
|
||||
@@ -172,6 +176,7 @@ class MessageContent extends HookConsumerWidget {
|
||||
const MessageContent({
|
||||
super.key,
|
||||
required this.content,
|
||||
this.isAssistant = false,
|
||||
this.bubble = false,
|
||||
this.ownMessage = false,
|
||||
this.trimTrailingSpacing = false,
|
||||
@@ -197,6 +202,10 @@ class MessageContent extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final content = useMemoized(
|
||||
() => isAssistant ? assistantDisplayContent(this.content) : this.content,
|
||||
[this.content, isAssistant],
|
||||
);
|
||||
final markdownTheme = GptMarkdownTheme.of(context);
|
||||
final chatColors = ChatColors.of(context);
|
||||
final ownTextColor = chatColors.foreground;
|
||||
|
||||
@@ -312,6 +312,7 @@ class _ThreadMessage extends HookConsumerWidget {
|
||||
),
|
||||
MessageContent(
|
||||
key: swipeContentKey,
|
||||
isAssistant: isAgent,
|
||||
content: message.content,
|
||||
bubble: true,
|
||||
hasReactions:
|
||||
|
||||
66
mobile/lib/shared/markdown/assistant_citations.dart
Normal file
66
mobile/lib/shared/markdown/assistant_citations.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
// Recognize the provider's envelope, not arbitrary XML or angle-bracket text.
|
||||
final _citationBlock = RegExp(
|
||||
r' {0,3}<oai-mem-citation>\s*'
|
||||
r'<citation_entries>[^<>]*</citation_entries>\s*'
|
||||
r'(?:<rollout_ids>[^<>]*</rollout_ids>\s*)?'
|
||||
r'</oai-mem-citation>[ \t]*(?:\r?\n|$)',
|
||||
);
|
||||
final _fence = RegExp(r'^ {0,3}(`{3,}|~{3,})(.*)$');
|
||||
final _ticks = RegExp(r'`+');
|
||||
|
||||
/// Hides complete provider memory citations in assistant prose for display only.
|
||||
///
|
||||
/// The caller must establish assistant authorship. Signed content is untouched.
|
||||
/// Standalone envelopes are recognized regardless of payload or position, while
|
||||
/// literal code, quotes, indented examples, and incomplete/unknown markup stay.
|
||||
String assistantDisplayContent(String content) {
|
||||
if (!content.contains('<oai-mem-citation>')) return content;
|
||||
final output = StringBuffer();
|
||||
var offset = 0;
|
||||
String? fenceCharacter;
|
||||
var fenceLength = 0;
|
||||
var inlineLength = 0;
|
||||
while (offset < content.length) {
|
||||
final newline = content.indexOf('\n', offset);
|
||||
final end = newline < 0 ? content.length : newline + 1;
|
||||
final line = content.substring(offset, end);
|
||||
final fence = _fence.firstMatch(line.trimRight());
|
||||
if (fenceCharacter != null) {
|
||||
if (fence != null &&
|
||||
fence[1]![0] == fenceCharacter &&
|
||||
fence[1]!.length >= fenceLength &&
|
||||
fence[2]!.trim().isEmpty) {
|
||||
fenceCharacter = null;
|
||||
}
|
||||
} else if (inlineLength == 0 && fence != null) {
|
||||
fenceCharacter = fence[1]![0];
|
||||
fenceLength = fence[1]!.length;
|
||||
} else {
|
||||
if (inlineLength == 0) {
|
||||
final citation = _citationBlock.matchAsPrefix(content, offset);
|
||||
if (citation != null) {
|
||||
offset = citation.end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Keep multiline inline-code examples literal too. Conservatively retain
|
||||
// the rest after an unmatched backtick rather than hide possible code.
|
||||
for (final tick in _ticks.allMatches(line)) {
|
||||
var slashes = 0;
|
||||
for (var i = tick.start - 1; i >= 0 && line[i] == r'\'; i--) {
|
||||
slashes++;
|
||||
}
|
||||
if (inlineLength == 0 && slashes.isOdd) continue;
|
||||
final length = tick.end - tick.start;
|
||||
if (inlineLength == 0) {
|
||||
inlineLength = length;
|
||||
} else if (inlineLength == length) {
|
||||
inlineLength = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
output.write(line);
|
||||
offset = end;
|
||||
}
|
||||
return output.toString();
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:buzz/features/channels/message_content.dart';
|
||||
import 'package:buzz/features/channels/agent_activity/transcript_item_widget.dart';
|
||||
import 'package:buzz/features/channels/agent_activity/observer_models.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gpt_markdown/gpt_markdown.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import '../../shared/markdown/assistant_citations_test.dart' show citation;
|
||||
|
||||
Widget host(Widget child) => ProviderScope(
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: AppMarkdownTheme(
|
||||
child: Scaffold(body: SingleChildScrollView(child: child)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('activity filters assistant text only and leaves source intact', (
|
||||
tester,
|
||||
) async {
|
||||
for (final role in ['assistant', 'user']) {
|
||||
final item = MessageItem(
|
||||
id: 'message',
|
||||
role: role,
|
||||
title: 'Message',
|
||||
text: 'Answer\n$citation',
|
||||
timestamp: '2026-10-21T22:32:42Z',
|
||||
);
|
||||
await tester.pumpWidget(host(TranscriptItemWidget(item: item)));
|
||||
final data = tester.widget<GptMarkdown>(find.byType(GptMarkdown)).data;
|
||||
expect(data.contains('oai-mem-citation'), role == 'user');
|
||||
expect(item.text, 'Answer\n$citation');
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'agent reply hides metadata and retains surrounding text and attachment',
|
||||
(tester) async {
|
||||
const image = 'https://example.test/status.png';
|
||||
const source =
|
||||
'Online, Away, and Offline now use filled dots.\n\n$citation\n';
|
||||
await tester.pumpWidget(
|
||||
host(const MessageContent(content: source, isAssistant: true)),
|
||||
);
|
||||
final text = tester
|
||||
.widgetList<RichText>(find.byType(RichText))
|
||||
.map((w) => w.text.toPlainText())
|
||||
.join('\n');
|
||||
expect(text, contains('Online, Away, and Offline now use filled dots.'));
|
||||
expect(text, isNot(contains('oai-mem-citation')));
|
||||
expect(text, isNot(contains('MEMORY.md')));
|
||||
expect(
|
||||
find.byKey(const ValueKey('message-media-image-preview:$image')),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
testWidgets('human and code example content reaches markdown unchanged', (
|
||||
tester,
|
||||
) async {
|
||||
for (final agent in [false, true]) {
|
||||
final source = agent ? '```xml\n$citation\n```' : citation;
|
||||
await tester.pumpWidget(
|
||||
host(MessageContent(content: source, isAssistant: agent)),
|
||||
);
|
||||
expect(tester.widget<GptMarkdown>(find.byType(GptMarkdown)).data, source);
|
||||
}
|
||||
});
|
||||
testWidgets('new content and author classification recompute display', (
|
||||
tester,
|
||||
) async {
|
||||
for (final agent in [false, true, false]) {
|
||||
await tester.pumpWidget(
|
||||
host(MessageContent(content: 'Answer\n$citation', isAssistant: agent)),
|
||||
);
|
||||
final data = tester.widget<GptMarkdown>(find.byType(GptMarkdown)).data;
|
||||
expect(data.contains('oai-mem-citation'), !agent);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -215,6 +215,64 @@ void bubbleLayoutTests() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
for (final surface in ['channel', 'solo', 'thread']) {
|
||||
for (final agent in [false, true]) {
|
||||
testWidgets(
|
||||
'citation metadata on $surface uses agent=$agent classification',
|
||||
(tester) async {
|
||||
const metadata =
|
||||
'<oai-mem-citation>\n<citation_entries>MEMORY.md:1-2|note=[test]</citation_entries>\n</oai-mem-citation>';
|
||||
final events = [
|
||||
_textMsg(
|
||||
id: 'citation-message',
|
||||
pubkey: 'alice',
|
||||
content: 'Visible answer\n\n$metadata',
|
||||
createdAt: 1000,
|
||||
),
|
||||
];
|
||||
final messages = formatTimeline(events);
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: events,
|
||||
personalChat: surface == 'solo',
|
||||
knownAgentPubkeys: agent ? {'alice'} : {},
|
||||
users: const {
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
home: surface == 'thread'
|
||||
? ThreadDetailPage(
|
||||
threadHead: messages.single,
|
||||
allMessages: messages,
|
||||
channelId: _channelId,
|
||||
currentPubkey: 'me',
|
||||
isMember: true,
|
||||
isArchived: false,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final content = tester.widget<MessageContent>(
|
||||
find.byType(MessageContent).first,
|
||||
);
|
||||
expect(content.isAssistant, agent);
|
||||
expect(content.content, contains(metadata));
|
||||
final text = tester
|
||||
.widgetList<RichText>(
|
||||
find.descendant(
|
||||
of: find.byType(MessageContent).first,
|
||||
matching: find.byType(RichText),
|
||||
),
|
||||
)
|
||||
.map((w) => w.text.toPlainText())
|
||||
.join('\n');
|
||||
expect(text, contains('Visible answer'));
|
||||
expect(text.contains('oai-mem-citation'), !agent);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('large activity members build and fetch only visible rows', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
65
mobile/test/shared/markdown/assistant_citations_test.dart
Normal file
65
mobile/test/shared/markdown/assistant_citations_test.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
import 'package:buzz/shared/markdown/assistant_citations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
const citation = '''<oai-mem-citation>
|
||||
<citation_entries>
|
||||
MEMORY.md:34-34|note=[Pinned mobile toolchain guidance verified locally]
|
||||
</citation_entries>
|
||||
<rollout_ids>
|
||||
</rollout_ids>
|
||||
</oai-mem-citation>''';
|
||||
|
||||
void main() {
|
||||
test('removes metadata while preserving all surrounding bytes', () {
|
||||
const image = '';
|
||||
const source = 'Reply.\n\n$citation\n$image';
|
||||
expect(assistantDisplayContent(source), 'Reply.\n\n$image');
|
||||
expect(assistantDisplayContent('$citation\nAnswer\n$citation'), 'Answer\n');
|
||||
});
|
||||
test('accepts varied payloads, whitespace and optional rollout ids', () {
|
||||
for (final block in [
|
||||
citation.replaceAll('\n', '\r\n'),
|
||||
citation
|
||||
.replaceAll('MEMORY.md:34-34', 'memory/other.md:1-99')
|
||||
.replaceAll('<rollout_ids>\n', '<rollout_ids>\nfirst\nsecond\n'),
|
||||
'<oai-mem-citation> <citation_entries>other</citation_entries> </oai-mem-citation>',
|
||||
' $citation',
|
||||
]) {
|
||||
expect(assistantDisplayContent('$block\nAnswer'), 'Answer');
|
||||
}
|
||||
});
|
||||
final literals = <String, String>{
|
||||
'inline': '`$citation`',
|
||||
'double backticks': '``$citation``',
|
||||
'backtick fence': '```xml\n$citation\n```',
|
||||
'tilde fence': '~~~xml\n$citation\n~~~',
|
||||
'long fence': '````\n```\n$citation\n````',
|
||||
'unclosed fence': '```xml\n$citation',
|
||||
'unclosed inline': '`example\n$citation',
|
||||
'indented code': citation.split('\n').map((l) => ' $l').join('\n'),
|
||||
'quoted example': citation.split('\n').map((l) => '> $l').join('\n'),
|
||||
'escaped': '\\$citation',
|
||||
'literal mention': 'The tag is <oai-mem-citation>.',
|
||||
'unknown envelope': '<oai-mem-citation>Useful prose</oai-mem-citation>',
|
||||
'incomplete': citation.replaceAll('</oai-mem-citation>', ''),
|
||||
'unknown inner tag': citation.replaceAll('citation_entries', 'example'),
|
||||
'ordinary HTML': '<details>Useful prose</details>',
|
||||
'same line prose': 'Explain $citation please',
|
||||
};
|
||||
for (final entry in literals.entries) {
|
||||
test('preserves ${entry.key}', () {
|
||||
expect(assistantDisplayContent(entry.value), entry.value);
|
||||
});
|
||||
}
|
||||
test('resumes filtering after code closes', () {
|
||||
final example = '~~~xml\n$citation\n~~~\n';
|
||||
expect(
|
||||
assistantDisplayContent('$example$citation\nAnswer'),
|
||||
'${example}Answer',
|
||||
);
|
||||
});
|
||||
test('does not repeatedly scan nested malformed openers', () {
|
||||
final malformed = '<oai-mem-citation>\n' * 10000;
|
||||
expect(assistantDisplayContent(malformed), malformed);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user