diff --git a/packages/stream_chat_flutter_ai/CHANGELOG.md b/packages/stream_chat_flutter_ai/CHANGELOG.md index 17121a4..ef67fa0 100644 --- a/packages/stream_chat_flutter_ai/CHANGELOG.md +++ b/packages/stream_chat_flutter_ai/CHANGELOG.md @@ -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 diff --git a/packages/stream_chat_flutter_ai/lib/src/ai_markdown_body.dart b/packages/stream_chat_flutter_ai/lib/src/ai_markdown_body.dart index cc9c1bb..8d9bc2b 100644 --- a/packages/stream_chat_flutter_ai/lib/src/ai_markdown_body.dart +++ b/packages/stream_chat_flutter_ai/lib/src/ai_markdown_body.dart @@ -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 @@ -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); } } diff --git a/packages/stream_chat_flutter_ai/lib/src/components/ai_component_factory.dart b/packages/stream_chat_flutter_ai/lib/src/components/ai_component_factory.dart index 0391670..046ffd6 100644 --- a/packages/stream_chat_flutter_ai/lib/src/components/ai_component_factory.dart +++ b/packages/stream_chat_flutter_ai/lib/src/components/ai_component_factory.dart @@ -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'; @@ -89,6 +90,7 @@ class AIComponentBuilders with Diagnosticable { const AIComponentBuilders({ this.codeBlock, this.chart, + this.math, this.reasoning, this.toolCall, this.toolApproval, @@ -108,6 +110,22 @@ class AIComponentBuilders with Diagnosticable { /// [ChartView]. final AIComponentBuilder? 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? math; + /// A round of reasoning in [AIMessagePartsView]. Defaults to /// [StreamingReasoningView.part]. final AIComponentBuilder? reasoning; @@ -191,6 +209,7 @@ class AIComponentBuilders with Diagnosticable { AIComponentBuilders copyWith({ AIComponentBuilder? codeBlock, AIComponentBuilder? chart, + AIComponentBuilder? math, AIComponentBuilder? reasoning, AIComponentBuilder? toolCall, AIComponentBuilder? toolApproval, @@ -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, @@ -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, @@ -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')) @@ -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 && @@ -275,6 +298,7 @@ class AIComponentBuilders with Diagnosticable { int get hashCode => Object.hash( codeBlock, chart, + math, reasoning, toolCall, toolApproval, @@ -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 { diff --git a/packages/stream_chat_flutter_ai/test/src/components/ai_component_factory_test.dart b/packages/stream_chat_flutter_ai/test/src/components/ai_component_factory_test.dart index 9ca25ca..9362698 100644 --- a/packages/stream_chat_flutter_ai/test/src/components/ai_component_factory_test.dart +++ b/packages/stream_chat_flutter_ai/test/src/components/ai_component_factory_test.dart @@ -49,6 +49,7 @@ AIToolApprover _approver({List? 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( @@ -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); @@ -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); @@ -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 { diff --git a/packages/stream_chat_flutter_ai/test/src/conversation/ai_conversation_view_test.dart b/packages/stream_chat_flutter_ai/test/src/conversation/ai_conversation_view_test.dart index 476a80a..3f63748 100644 --- a/packages/stream_chat_flutter_ai/test/src/conversation/ai_conversation_view_test.dart +++ b/packages/stream_chat_flutter_ai/test/src/conversation/ai_conversation_view_test.dart @@ -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);