Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/stream_chat_flutter_ai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@
- `AITranslations.toolCallAwaitingAuthorization`, `toolCallAwaitingYourAuthorization`,
`toolAuthorizationConnect`, `toolAuthorizationConnecting`, `toolAuthorizationDecline`,
`toolAuthorizationNotOpened` and `toolAuthorizationExpired`.
- `AIComponentBuilders.math` and `AIMathProps` — render LaTeX in every `AIMarkdownBody` given no
`mathBuilder` of its own, such as the replies in an `AIConversationView`, which had no way to
render it.

🔄 Changed

Expand Down
23 changes: 18 additions & 5 deletions packages/stream_chat_flutter_ai/lib/src/ai_markdown_body.dart
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,9 @@ class AIMarkdownBody extends StatefulWidget {
/// )
/// ```
///
/// The LaTeX *syntax* is recognised either way, so leaving this null degrades
/// to showing the TeX source rather than dropping it.
/// The LaTeX *syntax* is recognised either way. When this is null, the
/// nearest [AIComponentFactory]'s [AIComponentBuilders.math] renders it, and
/// without one either the TeX source shows rather than being dropped.
final MathBuilder? mathBuilder;

/// Whether `$…$` and `$$…$$` are also treated as LaTeX delimiters, on top of
Expand Down Expand Up @@ -410,9 +411,21 @@ class _MathElementBuilder extends MarkdownElementBuilder {
final inline = element.attributes[kMathDisplayAttribute] != kMathDisplayBlock;

final builder = _builder();
// No math engine wired up — show the source instead of swallowing it.
if (builder == null) return Text(tex, style: style);
if (builder != null) return builder(context, tex, style, inline: inline);
return _Math(AIMathProps(tex: tex, style: style, inline: inline));
}
}

/// LaTeX left to the [AIComponentFactory]: asks for the widget in its own
/// build, like [_Fence], so swapping the builders rebuilds it without a parse.
class _Math extends StatelessWidget {
const _Math(this.props);

return builder(context, tex, style, inline: inline);
final AIMathProps props;

@override
Widget build(BuildContext context) {
// No math engine wired up — show the source instead of swallowing it.
return AIComponentFactory.of(context).math?.call(context, props) ?? Text(props.tex, style: props.style);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:stream_chat_flutter_ai/src/ai_markdown_body.dart';
import 'package:stream_chat_flutter_ai/src/chart/chart_view.dart';
import 'package:stream_chat_flutter_ai/src/chart/uspec.dart';
import 'package:stream_chat_flutter_ai/src/code_block_view.dart';
Expand Down Expand Up @@ -89,6 +90,7 @@ class AIComponentBuilders with Diagnosticable {
const AIComponentBuilders({
this.codeBlock,
this.chart,
this.math,
this.reasoning,
this.toolCall,
this.toolApproval,
Expand All @@ -108,6 +110,22 @@ class AIComponentBuilders with Diagnosticable {
/// [ChartView].
final AIComponentBuilder<AIChartProps>? chart;

/// A LaTeX expression in markdown, used by an [AIMarkdownBody] given no
/// `mathBuilder` of its own. Nothing typesets it by default: this package
/// ships no math engine, so the TeX source shows as text. Set it to reach
/// every reply, such as in an [AIConversationView]:
///
/// ```dart
/// AIComponentBuilders(
/// math: (context, props) => Math.tex(
/// props.tex,
/// textStyle: props.style,
/// mathStyle: props.inline ? MathStyle.text : MathStyle.display,
/// ),
/// )
/// ```
final AIComponentBuilder<AIMathProps>? math;

/// A round of reasoning in [AIMessagePartsView]. Defaults to
/// [StreamingReasoningView.part].
final AIComponentBuilder<AIReasoningProps>? reasoning;
Expand Down Expand Up @@ -191,6 +209,7 @@ class AIComponentBuilders with Diagnosticable {
AIComponentBuilders copyWith({
AIComponentBuilder<AICodeBlockProps>? codeBlock,
AIComponentBuilder<AIChartProps>? chart,
AIComponentBuilder<AIMathProps>? math,
AIComponentBuilder<AIReasoningProps>? reasoning,
AIComponentBuilder<AIToolCallProps>? toolCall,
AIComponentBuilder<AIToolApprovalProps>? toolApproval,
Expand All @@ -204,6 +223,7 @@ class AIComponentBuilders with Diagnosticable {
}) => AIComponentBuilders(
codeBlock: codeBlock ?? this.codeBlock,
chart: chart ?? this.chart,
math: math ?? this.math,
reasoning: reasoning ?? this.reasoning,
toolCall: toolCall ?? this.toolCall,
toolApproval: toolApproval ?? this.toolApproval,
Expand All @@ -223,6 +243,7 @@ class AIComponentBuilders with Diagnosticable {
return copyWith(
codeBlock: other.codeBlock,
chart: other.chart,
math: other.math,
reasoning: other.reasoning,
toolCall: other.toolCall,
toolApproval: other.toolApproval,
Expand All @@ -242,6 +263,7 @@ class AIComponentBuilders with Diagnosticable {
properties
..add(FlagProperty('codeBlock', value: codeBlock != null, ifTrue: 'custom codeBlock'))
..add(FlagProperty('chart', value: chart != null, ifTrue: 'custom chart'))
..add(FlagProperty('math', value: math != null, ifTrue: 'custom math'))
..add(FlagProperty('reasoning', value: reasoning != null, ifTrue: 'custom reasoning'))
..add(FlagProperty('toolCall', value: toolCall != null, ifTrue: 'custom toolCall'))
..add(FlagProperty('toolApproval', value: toolApproval != null, ifTrue: 'custom toolApproval'))
Expand All @@ -260,6 +282,7 @@ class AIComponentBuilders with Diagnosticable {
other is AIComponentBuilders &&
other.codeBlock == codeBlock &&
other.chart == chart &&
other.math == math &&
other.reasoning == reasoning &&
other.toolCall == toolCall &&
other.toolApproval == toolApproval &&
Expand All @@ -275,6 +298,7 @@ class AIComponentBuilders with Diagnosticable {
int get hashCode => Object.hash(
codeBlock,
chart,
math,
reasoning,
toolCall,
toolApproval,
Expand Down Expand Up @@ -317,6 +341,23 @@ final class AIChartProps {
final USpec spec;
}

/// What [AIComponentBuilders.math] gets.
@immutable
final class AIMathProps {
/// Creates an [AIMathProps].
const AIMathProps({required this.tex, this.style, required this.inline});

/// The LaTeX source, without its delimiters.
final String tex;

/// The style of the text around it.
final TextStyle? style;

/// Whether it sits in a line of text, from `\(…\)`, rather than in a block
/// of its own, from `\[…\]`.
final bool inline;
}

/// What [AIComponentBuilders.reasoning] gets.
@immutable
final class AIReasoningProps {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ AIToolApprover _approver({List<AIToolApprovalDecision>? answers}) => AIToolAppro
AIComponentBuilders _custom([String tag = 'custom']) => AIComponentBuilders(
codeBlock: (context, props) => Text('$tag code ${props.language}: ${props.code}'),
chart: (context, props) => Text('$tag chart ${props.spec.kind.name}'),
math: (context, props) => Text('$tag math ${props.inline ? 'inline' : 'block'}: ${props.tex}'),
reasoning: (context, props) => Text('$tag reasoning'),
toolCall: (context, props) => Text('$tag tool ${props.call.name}'),
toolApproval: (context, props) => TextButton(
Expand Down Expand Up @@ -112,6 +113,7 @@ void main() {
const builders = AIComponentBuilders();
expect(builders.codeBlock, isNull);
expect(builders.chart, isNull);
expect(builders.math, isNull);
expect(builders.reasoning, isNull);
expect(builders.toolCall, isNull);
expect(builders.toolApproval, isNull);
Expand All @@ -127,6 +129,10 @@ void main() {
expect(merged.chart, same(custom.chart));
expect(merged.codeBlock, isNull);
expect(merged.copyWith(codeBlock: custom.codeBlock).codeBlock, same(custom.codeBlock));
final withMath = merged.merge(AIComponentBuilders(math: custom.math));
expect(withMath.math, same(custom.math));
expect(withMath.chart, same(custom.chart));
expect(withMath, isNot(merged));
final signIn = merged.merge(const AIComponentBuilders(toolAuthorization: _hidden));
expect(signIn.toolAuthorization, _hidden);
expect(signIn.reasoning, _hidden);
Expand Down Expand Up @@ -214,6 +220,35 @@ void main() {
expect(find.text('second chart bar'), findsOneWidget);
});

testWidgets('AIMarkdownBody renders LaTeX through the factory when it has no mathBuilder', (tester) async {
const body = AIMarkdownBody(data: 'Inline \\(x^2\\) here.\n\n\\[\\sum_i i\\]');
await tester.pumpWidget(_wrap(body));
expect(find.text('x^2'), findsOneWidget);

await tester.pumpWidget(_wrap(body, builders: _custom('first')));
expect(find.text('first math inline: x^2'), findsOneWidget);
expect(find.text(r'first math block: \sum_i i'), findsOneWidget);

// Swapping the builders reaches LaTeX already on screen, without new text.
await tester.pumpWidget(_wrap(body, builders: _custom('second')));
expect(find.text('second math inline: x^2'), findsOneWidget);
});

testWidgets("an AIMarkdownBody's own mathBuilder wins over the factory", (tester) async {
await tester.pumpWidget(
_wrap(
AIMarkdownBody(
data: r'Inline \(x^2\) here.',
mathBuilder: (context, tex, style, {required inline}) => Text('own $tex'),
),
builders: _custom(),
),
);

expect(find.text('own x^2'), findsOneWidget);
expect(find.textContaining('custom math'), findsNothing);
});

testWidgets('AIMessagePartsView builds reasoning, tool calls and unsupported parts through the factory', (
tester,
) async {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,16 @@ void main() {
expect(builds, first + 1);
});

testWidgets("renders a reply's LaTeX through AIComponentBuilders.math", (tester) async {
await _pump(
tester,
[_user('u1'), _reply('r1', text: r'Area: \(\pi r^2\)')],
builders: AIComponentBuilders(math: (context, props) => Text('tex:${props.tex}')),
);

expect(find.text(r'tex:\pi r^2'), findsOneWidget);
});

group('layout', () {
Rect turnOf(WidgetTester tester, String text) =>
tester.getRect(find.ancestor(of: find.text(text), matching: find.byType(ConstrainedBox)).first);
Expand Down
Loading