From 4684c3dbc39d49520279542cf9c901ec58061ee6 Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Wed, 22 Jul 2026 15:46:55 +0800 Subject: [PATCH 01/15] fix: prevent duplicate fetchAssignments on startup - Add _loading guard to fetchAssignments() so concurrent calls return immediately instead of duplicating all deadline requests. - Move enableAutoRefetch() into the boot async block, after all initial fetches finish, so listener-triggered fetches don't fire during startup. --- lib/main.dart | 9 +++++---- lib/services/assignment_service.dart | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 2a09331..0065bca 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -171,7 +171,7 @@ Future _realMain(SharedPreferences prefs) async { } if (authService.isLoggedIn || thirdPartyAuthService.boundPlatforms.isNotEmpty) { - unawaited(assignmentService.fetchAssignments()); + await assignmentService.fetchAssignments(); } // Cloud-sync pull: if sync is enabled and this device has a cached master @@ -188,10 +188,11 @@ Future _realMain(SharedPreferences prefs) async { // Network/Casdoor hiccup — next manual sync retries. } } + + // All initial fetches done — now allow listener-triggered auto-refetch + // so subsequent auth/binding changes don't double-fire. + assignmentService.enableAutoRefetch(); }()); - // Allow auto-refetch on subsequent auth / binding changes - // (login, bind, unbind, logout). - assignmentService.enableAutoRefetch(); } // Disabled along with the boot probe above. Restore if the white-screen diff --git a/lib/services/assignment_service.dart b/lib/services/assignment_service.dart index 1d14141..7b0c896 100644 --- a/lib/services/assignment_service.dart +++ b/lib/services/assignment_service.dart @@ -170,6 +170,7 @@ class AssignmentService extends ChangeNotifier { }; Future fetchAssignments() async { + if (_loading) return; // 避免启动时 listener 链触发的并发重复调用 _loading = true; _error = null; _platformErrors.clear(); From c7eefda2a39c45b549e75a66eb350e3dbca769e0 Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Thu, 23 Jul 2026 02:16:33 +0800 Subject: [PATCH 02/15] feat: port flutter_ai element showcase as native demo page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate the flutter_ai demo (../flutter_ai/demo) into techpie as a self-contained scripted feature accessible from the home "应用" card. No real backend, no voice, fully offline — everything runs against DemoChatProvider with bundled bytes. - lib/pages/ai_demo/ — 6 files: AiDemoPage (entry), FeatureSections (6 element demos, voice dropped), GalleryScreen, DemoChatProvider (offline), ToolRunner, code highlighter (highlight package). - lib/models/feature.dart — add ai_demo native entry in featureEntries. - pubspec.yaml — path-depend on flutter_ai_core/client/elements (pure Dart, no OHOS override needed) + highlight; override core/client to path for transitive resolution. Co-Authored-By: Claude Fable 5 --- lib/models/feature.dart | 10 + lib/pages/ai_demo/ai_demo_page.dart | 721 ++++++++++++++++++++++++ lib/pages/ai_demo/code_highlighter.dart | 51 ++ lib/pages/ai_demo/demo_data.dart | 376 ++++++++++++ lib/pages/ai_demo/demo_provider.dart | 302 ++++++++++ lib/pages/ai_demo/demo_tools.dart | 188 ++++++ lib/pages/ai_demo/feature_sections.dart | 576 +++++++++++++++++++ pubspec.lock | 209 ++++--- pubspec.yaml | 18 + 9 files changed, 2361 insertions(+), 90 deletions(-) create mode 100644 lib/pages/ai_demo/ai_demo_page.dart create mode 100644 lib/pages/ai_demo/code_highlighter.dart create mode 100644 lib/pages/ai_demo/demo_data.dart create mode 100644 lib/pages/ai_demo/demo_provider.dart create mode 100644 lib/pages/ai_demo/demo_tools.dart create mode 100644 lib/pages/ai_demo/feature_sections.dart diff --git a/lib/models/feature.dart b/lib/models/feature.dart index 4df06a5..7dfcbf6 100644 --- a/lib/models/feature.dart +++ b/lib/models/feature.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../pages/ai_demo/ai_demo_page.dart'; import '../pages/oa_gym_page.dart'; enum FeatureMode { @@ -59,6 +60,15 @@ final featureEntries = [ ), icon: const Icon(Icons.sports_tennis), ), + Feature( + id: 'ai_demo', + description: 'AI 演示', + mode: FeatureMode.native, + nativeEntry: (context) => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AiDemoPage()), + ), + icon: const Icon(Icons.auto_awesome), + ), ]; final moreFeature = Feature( diff --git a/lib/pages/ai_demo/ai_demo_page.dart b/lib/pages/ai_demo/ai_demo_page.dart new file mode 100644 index 0000000..5b541ae --- /dev/null +++ b/lib/pages/ai_demo/ai_demo_page.dart @@ -0,0 +1,721 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/flutter_ai_elements.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'code_highlighter.dart'; +import 'demo_data.dart'; +import 'demo_provider.dart'; +import 'demo_tools.dart'; +import 'feature_sections.dart'; + +/// A self-contained showcase of the `flutter_ai_elements` widget family, ported +/// from the flutter_ai demo. +/// +/// Everything here is scripted ([DemoChatProvider]) and offline — no live +/// model, no network, no voice. It is surfaced from the home "应用" card as a +/// native feature entry (see `lib/models/feature.dart`). +class AiDemoPage extends StatefulWidget { + /// Creates the AI demo page. + const AiDemoPage({super.key}); + + @override + State createState() => _AiDemoPageState(); +} + +class _AiDemoPageState extends State { + String _modelId = demoModels.first.id; + final UseChatController _controller = UseChatController( + provider: const DemoChatProvider(), + ); + late final ToolRunner _toolRunner = ToolRunner(_controller); + + @override + void initState() { + super.initState(); + _controller.setTools(demoTools); + _toolRunner.addListener(_onToolChange); + } + + void _onToolChange() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _toolRunner.removeListener(_onToolChange); + _toolRunner.dispose(); + _controller.dispose(); + super.dispose(); + } + + void _selectModel(String id) { + setState(() => _modelId = id); + _controller.setOptions(AiRequestOptions(model: id)); + } + + void _openGallery() => unawaited( + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => Scaffold( + appBar: AppBar( + title: const Text('Every element'), + scrolledUnderElevation: 0, + ), + body: const SafeArea(child: GalleryScreen()), + ), + ), + ), + ); + + @override + Widget build(BuildContext context) { + final media = MediaQuery.of(context); + final isWide = media.size.width >= 900; + final heroHeight = (media.size.height * 0.66).clamp(420.0, 620.0); + + // Inject the flutter_ai theme extension into techpie's inherited ThemeData + // for this subtree only, picking light/dark to match the current brightness. + final inherited = Theme.of(context); + final aiExtension = inherited.brightness == Brightness.dark + ? AiThemeExtension.dark() + : AiThemeExtension.fallback(); + + return Theme( + data: inherited.copyWith( + extensions: [...inherited.extensions.values, aiExtension], + ), + child: Scaffold( + appBar: AppBar(title: const Text('AI 演示'), scrolledUnderElevation: 0), + body: SafeArea( + bottom: false, + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: _Centered( + child: _HeroHeader( + modelId: _modelId, + onSelectModel: _selectModel, + onNewChat: _controller.clear, + onOpenGallery: _openGallery, + ), + ), + ), + // Hero: the live, scripted chat. + SliverToBoxAdapter( + child: SizedBox( + height: heroHeight, + child: ChatScreen( + controller: _controller, + toolRunner: _toolRunner, + ), + ), + ), + SliverToBoxAdapter( + child: _Centered( + child: FeatureSections( + isWide: isWide, + onOpenGallery: _openGallery, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Centers its child at the package's reading width on wide screens. +class _Centered extends StatelessWidget { + const _Centered({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final maxWidth = AiThemeExtension.of(context).maxContentWidth; + return Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: child, + ), + ); + } +} + +/// The tight hero header: brand wordmark + value prop + badges. +class _HeroHeader extends StatelessWidget { + const _HeroHeader({ + required this.modelId, + required this.onSelectModel, + required this.onNewChat, + required this.onOpenGallery, + }); + + final String modelId; + final ValueChanged onSelectModel; + final VoidCallback onNewChat; + final VoidCallback onOpenGallery; + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + final subdued = + DefaultTextStyle.of(context).style.color?.withValues(alpha: 0.62); + return Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 12, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const _BrandGlyph(size: 30), + const SizedBox(width: 10), + const Expanded( + child: Text( + 'flutter_ai', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + ), + ), + ), + IconButton( + icon: const Icon(Icons.edit_square), + tooltip: 'New chat', + onPressed: onNewChat, + ), + ], + ), + const SizedBox(height: 8), + Text( + 'A scripted AI chat toolkit showcase — streaming, tools, ' + 'generative UI, citations.', + style: TextStyle(fontSize: 16, height: 1.4, color: subdued), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + AiModelSelector( + models: demoModels, + selectedId: modelId, + onSelected: onSelectModel, + ), + const _Badge(label: '9 packages'), + const _Badge(label: 'pub.dev'), + const _Badge(label: 'zero lock-in'), + _GalleryButton(theme: theme, onTap: onOpenGallery), + ], + ), + const SizedBox(height: 8), + ], + ), + ); + } +} + +/// A small outlined badge chip used in the hero header. +class _Badge extends StatelessWidget { + const _Badge({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + final color = DefaultTextStyle.of(context).style.color; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + border: Border.all(color: theme.borderColor), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w500, + color: color?.withValues(alpha: 0.7), + ), + ), + ); + } +} + +/// A pill button that opens the full element gallery. +class _GalleryButton extends StatelessWidget { + const _GalleryButton({required this.theme, required this.onTap}); + + final AiThemeExtension theme; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: theme.accentColor, + borderRadius: BorderRadius.circular(20), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.grid_view_rounded, + size: 14, color: theme.onAccentColor), + const SizedBox(width: 6), + Text( + 'Every element', + style: TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w600, + color: theme.onAccentColor, + ), + ), + ], + ), + ), + ), + ); + } +} + +/// A live chat backed by a [UseChatController]. +class ChatScreen extends StatefulWidget { + const ChatScreen({ + super.key, + required this.controller, + required this.toolRunner, + }); + + final UseChatController controller; + final ToolRunner toolRunner; + + @override + State createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + UseChatController get controller => widget.controller; + ToolRunner get toolRunner => widget.toolRunner; + + Object? _dismissedError; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + ListenableBuilder( + listenable: controller, + builder: (context, _) { + final messages = controller.messages.length; + if (controller.status != ChatStatus.error) { + _dismissedError = null; + } + final showError = controller.status == ChatStatus.error && + controller.error != _dismissedError; + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: Column( + children: [ + if (messages > 0) + Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 16, 0), + child: AiContextMeter( + usedTokens: 1200 + messages * 850, + totalTokens: 128000, + ), + ), + if (showError) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: AiErrorBanner( + message: '${controller.error}', + onRetry: () => unawaited(controller.regenerate()), + onDismiss: () => setState( + () => _dismissedError = controller.error, + ), + ), + ), + ], + ), + ), + ); + }, + ), + Expanded( + child: AiChat( + controller: controller, + messageBuilder: _buildMessage, + emptyState: _emptyState(), + loadingBuilder: (_) => + const SizedBox(width: 220, child: AiShimmer()), + maxContentWidth: 760, + ), + ), + SafeArea( + top: false, + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: AiPromptInput( + controller: controller, + onPickAttachment: _pickAttachment, + ), + ), + ), + ), + ], + ); + } + + Future> _pickAttachment() async => [ + FilePart( + mediaType: 'image/png', + bytes: sampleImageBytes, + name: 'photo.png', + ), + ]; + + void _snack(BuildContext context, String text) => + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(text), duration: const Duration(seconds: 1)), + ); + + Future _editPrecedingUserMessage( + BuildContext context, + AiMessage assistant, + ) async { + final msgs = controller.messages; + final i = msgs.indexWhere((m) => m.id == assistant.id); + final userIndex = i == -1 + ? -1 + : msgs.sublist(0, i).lastIndexWhere((m) => m.role == AiRole.user); + if (userIndex == -1) return; + final user = msgs[userIndex]; + final edited = await showDialog( + context: context, + builder: (context) => _EditMessageDialog(initialText: user.text), + ); + if (edited != null && edited.trim().isNotEmpty) { + await controller.editMessage(user.id, edited.trim()); + } + } + + AiWidgetRegistry get _genUi => AiWidgetRegistry() + ..register( + 'chain_of_thought', + (context, data) => + AiChainOfThought(initiallyExpanded: true, steps: _steps(data)), + ) + ..register( + 'task', + (context, data) => AiTask( + title: data['title'] as String? ?? 'Task', + items: _taskItems(data), + ), + ) + ..register( + 'confirmation', + (context, data) => AiConfirmation( + title: data['title'] as String? ?? 'Confirm?', + description: data['description'] as String?, + onConfirm: () => _snack(context, 'Done.'), + onDeny: () => _snack(context, 'Cancelled.'), + ), + ); + + Widget _buildMessage(BuildContext context, AiMessage message) { + if (message.role == AiRole.user) return AiMessageBubble(message: message); + if (message.role == AiRole.tool) return const SizedBox.shrink(); + + final results = { + for (final m in controller.messages) + for (final p in m.parts) + if (p is ToolResultPart) p.toolCallId: p, + }; + final sources = message.parts.whereType().toList(); + final toolCalls = message.parts.whereType().toList(); + final subdued = + DefaultTextStyle.of(context).style.color?.withValues(alpha: 0.6); + var toolsRendered = false; + + final children = []; + void add(Widget w) { + if (children.isNotEmpty) children.add(const SizedBox(height: 12)); + children.add(w); + } + + add(Row( + mainAxisSize: MainAxisSize.min, + children: [ + const AiAvatar(role: AiRole.assistant, size: 24), + const SizedBox(width: 8), + Text('flutter_ai', + style: TextStyle( + fontSize: 13, fontWeight: FontWeight.w600, color: subdued)), + ], + )); + + for (final part in message.parts) { + switch (part) { + case ReasoningPart(:final text): + add(AiReasoning(text: text)); + case TextPart(:final text): + add(AiResponse(text: text, codeHighlighter: demoCodeHighlighter)); + case ToolCallPart(): + if (!toolsRendered) { + toolsRendered = true; + add( + toolCalls.length > 1 + ? AiToolGroup(calls: toolCalls, results: results) + : AiToolInvocation( + call: part, result: results[part.toolCallId]), + ); + } + case ToolResultPart(): + break; + case FilePart(): + if (part.mediaType.startsWith('image/')) { + add(SizedBox( + width: 260, + child: AiImage( + url: part.url, bytes: part.bytes, aspectRatio: 16 / 9), + )); + } else { + add(AiAttachment(file: part)); + } + case SourcePart(): + break; + case DataPart(): + add(AiDataView(part: part, registry: _genUi)); + } + } + + for (final call in toolCalls) { + final pending = toolRunner.pending[call.toolCallId]; + if (pending == null) continue; + final info = toolRunner.confirmationFor(pending); + add(AiConfirmation( + title: info.title, + description: info.description, + onConfirm: () => + toolRunner.resolveConfirmation(call.toolCallId, approved: true), + onDeny: () => + toolRunner.resolveConfirmation(call.toolCallId, approved: false), + )); + } + + if (sources.isNotEmpty) { + add(AiSources( + sources: sources, + onTap: (source) => unawaited( + launchUrl(source.url, mode: LaunchMode.externalApplication)), + )); + } + + if (message.status == AiMessageStatus.complete) { + add(Row( + children: [ + AiMessageActions( + message: message, + onGood: () => _snack(context, 'Thanks for the feedback!'), + onBad: () => _snack(context, 'Thanks — we\'ll do better.'), + onShare: () => _snack(context, 'Share sheet would open here.'), + onRegenerate: () => unawaited(controller.regenerate()), + onEdit: () => + unawaited(_editPrecedingUserMessage(context, message)), + ), + const Spacer(), + if (message == controller.messages.last && controller.branchCount > 1) + AiBranch( + index: controller.branchIndex, + total: controller.branchCount, + onPrevious: () => + controller.selectBranch(controller.branchIndex - 1), + onNext: () => controller.selectBranch(controller.branchIndex + 1), + ), + ], + )); + } + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: children, + ), + ); + } + + List _steps(Map data) { + final raw = (data['steps'] as List?) ?? const []; + return raw.map((s) { + final m = (s! as Map).cast(); + return AiThoughtStep( + label: m['label'] as String? ?? '', + detail: m['detail'] as String?, + isActive: m['active'] as bool? ?? false, + ); + }).toList(); + } + + List _taskItems(Map data) { + final raw = (data['items'] as List?) ?? const []; + return raw.map((item) { + final m = (item! as Map).cast(); + return AiTaskItem( + label: m['label'] as String? ?? '', + status: switch (m['status']) { + 'complete' => AiTaskStatus.complete, + 'active' => AiTaskStatus.active, + 'error' => AiTaskStatus.error, + _ => AiTaskStatus.pending, + }, + ); + }).toList(); + } + + void _onSuggestion(String text) { + if (text.startsWith('Summarize')) { + unawaited( + controller.sendText('Summarize this article', attachments: const [ + FilePart(mediaType: 'application/pdf', name: 'article.pdf'), + ]), + ); + } else { + unawaited(controller.sendText(text)); + } + } + + Widget _emptyState() => AiEmptyState( + glyph: const _BrandGlyph(size: 56), + title: 'Ask me anything', + subtitle: 'A live, scripted demo — no API key required.', + suggestions: const [ + 'Plan a weekend in Lisbon', + 'Suggest a dinner recipe', + 'How do I center a widget?', + 'Summarize this article', + ], + onSuggestionTap: _onSuggestion, + ); +} + +/// Edit-message dialog that owns its [TextEditingController]. +class _EditMessageDialog extends StatefulWidget { + const _EditMessageDialog({required this.initialText}); + + final String initialText; + + @override + State<_EditMessageDialog> createState() => _EditMessageDialogState(); +} + +class _EditMessageDialogState extends State<_EditMessageDialog> { + late final TextEditingController _field = + TextEditingController(text: widget.initialText); + + @override + void dispose() { + _field.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Edit message'), + content: TextField(controller: _field, autofocus: true, maxLines: null), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel')), + FilledButton( + onPressed: () => Navigator.pop(context, _field.text), + child: const Text('Save')), + ], + ); + } +} + +/// The `flutter_ai` brand glyph. +class _BrandGlyph extends StatelessWidget { + const _BrandGlyph({this.size = 40}); + + final double size; + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + return Container( + width: size, + height: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(size * 0.28), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + theme.orbColor, + Color.lerp(theme.orbColor, theme.accentColor, 0.5)!, + ], + ), + boxShadow: [ + BoxShadow( + color: theme.orbColor.withValues(alpha: 0.35), + blurRadius: size * 0.35, + spreadRadius: size * 0.02, + ), + ], + ), + child: Icon(Icons.auto_awesome, + size: size * 0.5, color: theme.onAccentColor), + ); + } +} + +/// A scrolling gallery of every element with sample data. +class GalleryScreen extends StatelessWidget { + const GalleryScreen({super.key}); + + @override + Widget build(BuildContext context) { + final items = galleryItems(); + final divider = AiThemeExtension.of(context).borderColor; + return ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + itemCount: items.length, + separatorBuilder: (_, __) => Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Divider(height: 1, color: divider), + ), + itemBuilder: (context, index) { + final item = items[index]; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.title, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF9893A8), + letterSpacing: 0.2)), + const SizedBox(height: 10), + item.child, + ], + ); + }, + ); + } +} diff --git a/lib/pages/ai_demo/code_highlighter.dart b/lib/pages/ai_demo/code_highlighter.dart new file mode 100644 index 0000000..42edf68 --- /dev/null +++ b/lib/pages/ai_demo/code_highlighter.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/flutter_ai_elements.dart'; +import 'package:highlight/highlight.dart' show highlight, Node; + +/// A demo [CodeHighlighter] backed by the `highlight` package, mapping token +/// classes to a VS Code–dark-ish palette. Returns `null` (plain monospace) when +/// the language is unknown or parsing fails, so rendering never breaks. +List? demoCodeHighlighter( + String code, + String? language, + TextStyle base, +) { + try { + final result = (language != null && language.isNotEmpty) + ? highlight.parse(code, language: language) + : highlight.parse(code, autoDetection: true); + final nodes = result.nodes; + if (nodes == null) return null; + return [for (final node in nodes) _span(node, base)]; + } catch (_) { + return null; // unknown grammar → fall back to plain monospace + } +} + +const _tokenColors = { + 'keyword': Color(0xFFC586C0), + 'built_in': Color(0xFF4EC9B0), + 'type': Color(0xFF4EC9B0), + 'class': Color(0xFF4EC9B0), + 'title': Color(0xFFDCDCAA), + 'function': Color(0xFFDCDCAA), + 'string': Color(0xFFCE9178), + 'number': Color(0xFFB5CEA8), + 'symbol': Color(0xFFB5CEA8), + 'literal': Color(0xFF569CD6), + 'comment': Color(0xFF6A9955), + 'meta': Color(0xFF9CDCFE), + 'attr': Color(0xFF9CDCFE), +}; + +TextSpan _span(Node node, TextStyle base) { + final color = node.className == null ? null : _tokenColors[node.className!]; + final style = color == null ? base : base.copyWith(color: color); + final value = node.value; + if (value != null) return TextSpan(text: value, style: style); + final children = node.children ?? const []; + return TextSpan( + style: style, + children: [for (final child in children) _span(child, base)], + ); +} diff --git a/lib/pages/ai_demo/demo_data.dart b/lib/pages/ai_demo/demo_data.dart new file mode 100644 index 0000000..39e1473 --- /dev/null +++ b/lib/pages/ai_demo/demo_data.dart @@ -0,0 +1,376 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/flutter_ai_elements.dart'; + +/// The demo uses the package's modern default skin as-is. +final AiThemeExtension demoTheme = AiThemeExtension.fallback(); + +/// A tiny 1×1 PNG, used to fake a "picked" image and show the AiImage frame +/// without a network round-trip. +final Uint8List sampleImageBytes = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAen6' + '3NgAAAAASUVORK5CYII=', +); + +/// Models offered by the demo's model selector. These are real Gemini model +/// ids, so selecting one drives the live provider when a key is supplied. +const List demoModels = [ + AiModelOption( + id: 'gemini-2.5-flash', + label: 'Gemini 2.5 Flash', + description: 'Fast and capable', + ), + AiModelOption( + id: 'gemini-2.0-flash', + label: 'Gemini 2.0 Flash', + description: 'Fast and economical', + ), + AiModelOption( + id: 'gemini-2.5-pro', + label: 'Gemini 2.5 Pro', + description: 'Best reasoning', + ), +]; + +/// One entry in the element gallery. +typedef GalleryItem = ({String name, String title, Widget child}); + +final Uri _weatherUri = Uri.parse('https://weather.example.com/london'); + +/// A rich assistant message exercising several part types at once. +final AiMessage richAssistantMessage = AiMessage( + id: 'a-rich', + role: AiRole.assistant, + parts: [ + const ReasoningPart( + 'The user wants current weather, so I should call a tool.', + ), + const TextPart('Let me check the weather for you.'), + const ToolCallPart( + toolCallId: 'call_1', + toolName: 'get_weather', + args: {'city': 'London'}, + state: ToolCallState.outputAvailable, + ), + const ToolResultPart( + toolCallId: 'call_1', + result: {'tempC': 18, 'condition': 'Rainy'}, + ), + const TextPart("It's 18°C and rainy in London — bring an umbrella!"), + SourcePart(url: _weatherUri, title: 'weather.example.com'), + ], +); + +/// The elements gallery, each wrapped for display and screenshotting. +List galleryItems() => [ + ( + name: 'message_user', + title: 'AiMessageBubble — user', + child: const AiMessageBubble( + message: AiMessage( + id: 'u1', + role: AiRole.user, + parts: [TextPart('What is the weather in London today?')], + ), + ), + ), + ( + name: 'message_assistant', + title: 'AiMessageBubble — assistant (rich)', + child: AiMessageBubble(message: richAssistantMessage), + ), + (name: 'loader', title: 'AiLoader', child: const AiLoader()), + ( + name: 'reasoning', + title: 'AiReasoning', + child: const AiReasoning( + text: 'First, identify the city. Then call the weather tool and ' + 'summarize the result for the user.', + initiallyExpanded: true, + ), + ), + ( + name: 'tool_invocation', + title: 'AiToolInvocation', + child: const AiToolInvocation( + call: ToolCallPart( + toolCallId: 'c1', + toolName: 'get_weather', + args: {'city': 'London', 'units': 'metric'}, + state: ToolCallState.outputAvailable, + ), + result: ToolResultPart( + toolCallId: 'c1', + result: {'tempC': 18, 'condition': 'Rainy'}, + ), + initiallyExpanded: true, + ), + ), + ( + name: 'tool_group', + title: 'AiToolGroup — parallel calls', + child: const AiToolGroup( + calls: [ + ToolCallPart( + toolCallId: 'c1', + toolName: 'get_weather', + state: ToolCallState.outputAvailable, + ), + ToolCallPart( + toolCallId: 'c2', + toolName: 'web_search', + state: ToolCallState.executing, + ), + ], + results: { + 'c1': ToolResultPart(toolCallId: 'c1', result: {'tempC': 18}), + }, + ), + ), + ( + name: 'tool_error', + title: 'AiToolInvocation — error', + child: const AiToolInvocation( + call: ToolCallPart( + toolCallId: 'e1', + toolName: 'charge_card', + args: {'amountEur': 420}, + state: ToolCallState.error, + ), + result: ToolResultPart( + toolCallId: 'e1', + result: {'message': 'Card declined: insufficient funds'}, + isError: true, + ), + initiallyExpanded: true, + ), + ), + ( + name: 'attachment', + title: 'AiAttachment', + child: const AiAttachment( + file: FilePart(mediaType: 'application/pdf', name: 'itinerary.pdf'), + ), + ), + ( + name: 'sources', + title: 'AiSources', + child: AiSources( + sources: [ + SourcePart(url: _weatherUri, title: 'weather.example.com'), + SourcePart(url: Uri.parse('https://flutter.dev'), title: 'Flutter'), + ], + ), + ), + ( + name: 'code_block', + title: 'AiCodeBlock', + child: const AiCodeBlock( + language: 'dart', + code: "void main() {\n print('Hello, flutter_ai!');\n}", + ), + ), + ( + name: 'response', + title: 'AiResponse — Markdown', + child: const AiResponse( + text: '## Streaming\n\nFold the **event stream** with a reducer:\n\n' + '- rebuild only changed messages\n' + '- stays at `60fps`\n\n' + 'See the [docs](https://docs.flutter.dev/ai).', + ), + ), + ( + name: 'chain_of_thought', + title: 'AiChainOfThought', + child: const AiChainOfThought( + initiallyExpanded: true, + steps: [ + AiThoughtStep( + label: 'Search the web', detail: 'flutter stream tokens'), + AiThoughtStep(label: 'Read top results'), + AiThoughtStep(label: 'Synthesize an answer', isActive: true), + ], + ), + ), + ( + name: 'task', + title: 'AiTask', + child: const AiTask( + title: 'Refactor the controller', + items: [ + AiTaskItem( + label: 'Read use_chat_controller.dart', + status: AiTaskStatus.complete, + ), + AiTaskItem( + label: 'Extract _startStream()', + status: AiTaskStatus.active, + ), + AiTaskItem(label: 'Update tests'), + ], + ), + ), + ( + name: 'inline_citation', + title: 'AiInlineCitation', + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Backed by sources'), + SizedBox(width: 6), + AiInlineCitation(number: 1), + SizedBox(width: 4), + AiInlineCitation(number: 2), + ], + ), + ), + ( + name: 'branch', + title: 'AiBranch — versions (tap ‹ ›)', + child: const _BranchDemo(), + ), + ( + name: 'image', + title: 'AiImage', + child: SizedBox( + width: 200, + child: AiImage(bytes: sampleImageBytes, aspectRatio: 16 / 9), + ), + ), + ( + name: 'model_selector', + title: 'AiModelSelector', + child: AiModelSelector( + models: demoModels, + selectedId: 'gemini-2.5-flash', + onSelected: (_) {}, + ), + ), + ( + name: 'confirmation', + title: 'AiConfirmation', + child: AiConfirmation( + title: 'Send this email to the team?', + description: 'Subject: "Weekend plan in Lisbon"', + onConfirm: () {}, + onDeny: () {}, + ), + ), + ( + name: 'context_meter', + title: 'AiContextMeter', + child: const AiContextMeter(usedTokens: 8200, totalTokens: 128000), + ), + (name: 'shimmer', title: 'AiShimmer', child: const AiShimmer()), + ( + name: 'live_session', + title: 'AiLiveSession — voice mode', + child: SizedBox( + height: 360, + child: AiLiveSession( + status: AiLiveStatus.speaking, + amplitude: 0.6, + transcript: 'Lisbon is sunny, about 24°C this weekend.', + onMute: () {}, + onKeyboard: () {}, + onEnd: () {}, + ), + ), + ), + ( + name: 'suggestions', + title: 'AiSuggestions', + child: AiSuggestions( + suggestions: const [ + 'Summarize this', + 'Translate to French', + 'Explain' + ], + onSelected: (_) {}, + ), + ), + ( + name: 'avatars', + title: 'AiAvatar', + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + AiAvatar(role: AiRole.user), + SizedBox(width: 12), + AiAvatar(role: AiRole.assistant), + ], + ), + ), + ( + name: 'empty_state', + title: 'AiEmptyState', + child: const SizedBox( + height: 220, + child: AiEmptyState( + title: 'Ask me anything', + subtitle: 'Your AI assistant is ready to help.', + ), + ), + ), + ( + name: 'error_banner', + title: 'AiErrorBanner', + child: AiErrorBanner( + message: 'The request timed out.', + onRetry: () {}, + onDismiss: () {}, + ), + ), + ( + name: 'message_actions', + title: 'AiMessageActions', + child: AiMessageActions( + message: const AiMessage( + id: 'm', + role: AiRole.assistant, + parts: [TextPart('hi')], + ), + onRegenerate: () {}, + onEdit: () {}, + ), + ), + ( + name: 'composer_idle', + title: 'AiComposer — attach · mic · live', + child: AiComposer( + onSend: (_) {}, + onAttach: () {}, + onVoice: () {}, + onLive: () {}, + ), + ), + ( + name: 'composer_busy', + title: 'AiComposer — streaming (Stop)', + child: AiComposer(onSend: (_) {}, onStop: () {}, isBusy: true), + ), + ]; + +/// A live [AiBranch] whose arrows actually move between versions. +class _BranchDemo extends StatefulWidget { + const _BranchDemo(); + + @override + State<_BranchDemo> createState() => _BranchDemoState(); +} + +class _BranchDemoState extends State<_BranchDemo> { + int _index = 0; + static const _total = 3; + + @override + Widget build(BuildContext context) => AiBranch( + index: _index, + total: _total, + onPrevious: () => setState(() => _index--), + onNext: () => setState(() => _index++), + ); +} diff --git a/lib/pages/ai_demo/demo_provider.dart b/lib/pages/ai_demo/demo_provider.dart new file mode 100644 index 0000000..dcbd803 --- /dev/null +++ b/lib/pages/ai_demo/demo_provider.dart @@ -0,0 +1,302 @@ +import 'package:flutter_ai_elements/flutter_ai_elements.dart'; + +import 'demo_data.dart'; + +/// A scripted [LlmProvider] with everyday mobile-chat scenarios (trip planning, +/// a recipe, summarizing an article) so the elements appear in realistic use. +/// +/// Structured widgets ride along as `DataPart`s; the demo's `messageBuilder` +/// maps them to elements (a tiny generative-UI catalog). A prompt containing +/// "error" streams a failure to demo the error path. +/// +/// Fully offline: no HTTP, no live model. Images use bundled bytes +/// ([sampleImageBytes]) so nothing leaves the device. +class DemoChatProvider implements LlmProvider { + /// Creates a demo provider with a per-event [delay] for a streaming feel. + const DemoChatProvider({this.delay = const Duration(milliseconds: 95)}); + + /// Delay between emitted events. + final Duration delay; + + @override + Stream send( + AiConversation conversation, { + List? tools, + AiRequestOptions? options, + }) { + final prompt = conversation.lastMessage?.text.toLowerCase() ?? ''; + final id = 'assistant-${conversation.messages.length}'; + if (prompt.contains('error')) return _error(id); + if (prompt.contains('recipe') || + prompt.contains('dinner') || + prompt.contains('cook')) { + return _recipe(id); + } + if (prompt.contains('summar')) return _summary(id); + if (prompt.contains('center') || + prompt.contains('widget') || + prompt.contains('code')) { + return _code(id); + } + return _trip(id); + } + + Future _step(AiStreamEvent event) async { + await Future.delayed(delay); + return event; + } + + Stream _textChunks(String id, String text, + {Duration? chunkDelay}) async* { + final words = text.split(' '); + final d = chunkDelay ?? const Duration(milliseconds: 40); + for (var i = 0; i < words.length; i++) { + final chunk = i == 0 ? words[i] : ' ${words[i]}'; + await Future.delayed(d); + yield TextDelta(messageId: id, delta: chunk); + } + } + + Stream _error(String id) async* { + yield MessageStarted(messageId: id, role: AiRole.assistant); + yield await _step( + ReasoningDelta(messageId: id, delta: 'Attempting the request…'), + ); + yield await _step( + StreamErrorEvent( + error: 'The upstream service timed out. Please try again.', + messageId: id, + ), + ); + } + + Stream _trip(String id) async* { + yield MessageStarted(messageId: id, role: AiRole.assistant); + yield await _step( + PartReceived( + messageId: id, + part: const DataPart( + dataType: 'chain_of_thought', + data: { + 'steps': [ + {'label': 'Check the weekend weather'}, + {'label': 'Find the top sights'}, + {'label': 'Build a 2-day plan', 'active': true}, + ], + }, + ), + ), + ); + yield* _textChunks( + id, + "Lisbon is a great pick — here's a quick weekend plan.", + ); + yield await _step( + PartReceived( + messageId: id, + part: const DataPart( + dataType: 'task', + data: { + 'title': 'Trip checklist', + 'items': [ + {'label': 'Book flights', 'status': 'complete'}, + {'label': 'Reserve a hotel', 'status': 'active'}, + {'label': 'Pack essentials', 'status': 'pending'}, + ], + }, + ), + ), + ); + yield* _tool(id, 'get_weather', '{"city":"Lisbon"}', { + 'tempC': 24, + 'condition': 'Sunny', + }); + yield* _tool( + id, + 'find_hotels', + '{"city":"Lisbon","nights":2}', + { + 'count': 12, + 'topRate': 210, + }, + tag: 't2'); + yield* _textChunks( + id, + '## Day 1\n' + '- Morning: Belém Tower and pastéis de nata\n' + '- Afternoon: wander Alfama and São Jorge Castle\n\n' + '## Day 2\n' + '- Morning: Time Out Market\n' + '- Afternoon: day trip to Sintra\n\n' + 'The forecast is **sunny, ~24°C** — pack light!', + ); + yield await _step( + PartReceived( + messageId: id, + part: const DataPart( + dataType: 'confirmation', + data: { + 'title': 'Reserve Hotel Lisboa for €420?', + 'description': '2 nights · breakfast included · free cancellation', + }, + ), + ), + ); + yield* _image(id, 'lisbon'); + yield* _sources(id, const [ + ('https://www.timeout.com/lisbon', 'timeout.com'), + ('https://www.lonelyplanet.com/portugal/lisbon', 'lonelyplanet.com'), + ]); + yield await _step( + MessageFinished(messageId: id, reason: FinishReason.stop), + ); + } + + Stream _recipe(String id) async* { + yield MessageStarted(messageId: id, role: AiRole.assistant); + yield await _step( + PartReceived( + messageId: id, + part: const DataPart( + dataType: 'chain_of_thought', + data: { + 'steps': [ + {'label': 'Look for something quick'}, + {'label': 'Pick a crowd-pleaser', 'active': true}, + ], + }, + ), + ), + ); + yield* _textChunks( + id, + 'How about one-pan lemon chicken? Ready in about 30 minutes.', + ); + yield await _step( + PartReceived( + messageId: id, + part: const DataPart( + dataType: 'task', + data: { + 'title': 'Ingredients', + 'items': [ + {'label': 'Chicken thighs', 'status': 'complete'}, + {'label': 'Lemon & garlic', 'status': 'complete'}, + {'label': 'Baby spinach', 'status': 'pending'}, + ], + }, + ), + ), + ); + yield* _tool(id, 'search_recipes', '{"q":"30 minute dinner"}', { + 'results': 5, + }); + yield* _textChunks( + id, + '## Steps\n' + '1. Sear the chicken 5 minutes per side\n' + '2. Add garlic, lemon, and a splash of stock\n' + '3. Simmer 10 minutes, then stir in the spinach\n\n' + '**Tip:** serve over rice or with crusty bread.', + ); + yield* _image(id, 'dinner'); + yield* _sources(id, const [ + ('https://www.bbcgoodfood.com', 'bbcgoodfood.com'), + ]); + yield await _step( + MessageFinished(messageId: id, reason: FinishReason.stop), + ); + } + + Stream _summary(String id) async* { + yield MessageStarted(messageId: id, role: AiRole.assistant); + yield await _step( + ReasoningDelta( + messageId: id, + delta: 'Skimming the article for the key points.', + ), + ); + yield* _textChunks( + id, + '**Summary**\n\nThe article makes three points:\n\n' + '- Streaming UIs must batch updates to stay smooth\n' + '- Tool calls should be inspectable, not hidden\n' + '- Citations build user trust\n\n' + 'Overall, a strong case for *structured* AI interfaces.', + ); + yield* _sources(id, const [ + ('https://www.smashingmagazine.com', 'smashingmagazine.com'), + ('https://www.nngroup.com', 'nngroup.com'), + ]); + yield await _step( + MessageFinished(messageId: id, reason: FinishReason.stop), + ); + } + + Stream _tool( + String id, + String name, + String args, + Map result, { + String tag = 't1', + }) async* { + final callId = '$id-$tag'; + yield await _step( + ToolCallStarted(messageId: id, toolCallId: callId, toolName: name), + ); + yield await _step(ToolCallDelta(toolCallId: callId, argumentsDelta: args)); + yield await _step(ToolCallReady(toolCallId: callId)); + yield await _step( + ToolResultReceived(messageId: id, toolCallId: callId, result: result), + ); + } + + Stream _code(String id) async* { + yield MessageStarted(messageId: id, role: AiRole.assistant); + yield await _step( + ReasoningDelta(messageId: id, delta: 'Recalling the idiomatic way.'), + ); + yield* _textChunks( + id, + 'Wrap the child in a `Center`:\n\n' + '```dart\n' + 'Center(\n' + " child: Text('Hi'),\n" + ')\n' + '```\n\n' + 'For finer control, use `Align` with an `alignment`.', + ); + yield await _step( + MessageFinished(messageId: id, reason: FinishReason.stop), + ); + } + + // Offline image: a bundled 1×1 PNG (demo_data.sampleImageBytes), so AiImage + // renders without any network round-trip or INTERNET permission. + Stream _image(String id, String seed) async* { + yield await _step( + PartReceived( + messageId: id, + part: FilePart( + mediaType: 'image/png', + bytes: sampleImageBytes, + name: '$seed.png', + ), + ), + ); + } + + Stream _sources( + String id, + List<(String, String)> sources, + ) async* { + for (final (url, title) in sources) { + yield await _step( + PartReceived( + messageId: id, + part: SourcePart(url: Uri.parse(url), title: title), + ), + ); + } + } +} diff --git a/lib/pages/ai_demo/demo_tools.dart b/lib/pages/ai_demo/demo_tools.dart new file mode 100644 index 0000000..a72dc8c --- /dev/null +++ b/lib/pages/ai_demo/demo_tools.dart @@ -0,0 +1,188 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_ai_elements/flutter_ai_elements.dart'; + +/// Tools advertised to the model. The model decides *if* and *when* to call +/// them; [ToolRunner] executes the calls and feeds results back. +/// +/// Ask the live model things like "What's the weather in Lisbon?" or +/// "Book a hotel in Lisbon for 2 nights" to trigger them. +const List demoTools = [ + ToolDefinition( + name: 'get_weather', + description: 'Get the current weather for a city. Call this whenever the ' + 'user asks about weather.', + parametersSchema: { + 'type': 'object', + 'properties': { + 'city': {'type': 'string', 'description': 'City name, e.g. "Lisbon"'}, + }, + 'required': ['city'], + }, + ), + ToolDefinition( + name: 'book_hotel', + description: 'Book a hotel room in a city for a number of nights. This ' + 'charges the user, so it must be confirmed first.', + parametersSchema: { + 'type': 'object', + 'properties': { + 'city': {'type': 'string'}, + 'nights': {'type': 'integer', 'description': 'Number of nights'}, + }, + 'required': ['city', 'nights'], + }, + ), +]; + +/// Drives the tool-call loop for the demo: when the model emits a tool call, +/// safe tools run immediately while sensitive ones wait for the user to confirm +/// — then results are sent back so the model can finish its answer. Supports +/// multiple rounds (a real agentic loop). +/// +/// Only acts on calls the model leaves *open* (no result yet), so the scripted +/// [DemoChatProvider], whose tool calls already carry inline results, is +/// untouched. This makes the elements genuinely exercised by a live provider. +class ToolRunner extends ChangeNotifier { + /// Watches [controller] and runs its tool calls. + ToolRunner(this._controller) { + _controller.addListener(_pump); + } + + final UseChatController _controller; + + // Tools that require explicit user approval before they run. + static const Set _needsConfirmation = {'book_hotel'}; + + final Map _pending = {}; // awaiting confirmation + final Map _ready = {}; // executed, not yet sent + final Set _dispatched = {}; // results already sent to the model + bool _busy = false; + + /// Tool calls awaiting user confirmation, keyed by tool-call id. + Map get pending => Map.unmodifiable(_pending); + + @override + void dispose() { + _controller.removeListener(_pump); + super.dispose(); + } + + /// Confirms or rejects a sensitive tool call, then continues the loop. + void resolveConfirmation(String toolCallId, {required bool approved}) { + final call = _pending.remove(toolCallId); + if (call == null) return; + _ready[toolCallId] = _execute(call, approved: approved); + notifyListeners(); + _pump(); + } + + /// Human-readable confirmation copy for a pending sensitive call. + ({String title, String description}) confirmationFor(ToolCallPart call) { + final city = call.args['city'] as String? ?? 'the city'; + final nights = (call.args['nights'] as num?)?.toInt() ?? 1; + return ( + title: + 'Book Hotel Lisboa in $city for $nights night${nights == 1 ? '' : 's'}?', + description: 'Estimated total €${nights * 210} · free cancellation', + ); + } + + void _pump() { + if (_busy || _controller.status != ChatStatus.idle) return; + final messages = _controller.messages; + + // Calls already answered anywhere in the transcript (incl. the scripted + // provider's inline results) are left alone. + final resolved = { + for (final m in messages) + for (final p in m.parts) + if (p is ToolResultPart) p.toolCallId, + }; + + final open = []; + for (final m in messages.reversed) { + if (m.role != AiRole.assistant) continue; + final calls = m.parts.whereType(); + if (calls.isEmpty) continue; + open.addAll( + calls.where( + (c) => + !resolved.contains(c.toolCallId) && + !_dispatched.contains(c.toolCallId), + ), + ); + break; // only the most recent assistant turn can have open calls + } + if (open.isEmpty) return; + + // Resolve each: auto-run safe tools, queue sensitive ones for confirmation. + for (final call in open) { + final id = call.toolCallId; + if (_ready.containsKey(id) || _pending.containsKey(id)) continue; + if (_needsConfirmation.contains(call.toolName)) { + _pending[id] = call; + } else { + _ready[id] = _execute(call, approved: true); + } + } + notifyListeners(); + + // Providers need a result for *every* call before continuing, so only send + // once all open calls are ready (none awaiting confirmation). + if (open.every((c) => _ready.containsKey(c.toolCallId))) { + final results = [for (final c in open) _ready.remove(c.toolCallId)!]; + for (final c in open) { + _dispatched.add(c.toolCallId); + } + _busy = true; + unawaited( + _controller.addToolResults(results).whenComplete(() { + _busy = false; + _pump(); // a further tool round may follow + }), + ); + } + } + + // Mock implementations — a real app would call its backend here. + ToolResultPart _execute(ToolCallPart call, {required bool approved}) { + final id = call.toolCallId; + if (!approved) { + return ToolResultPart( + toolCallId: id, + result: {'status': 'declined', 'note': 'User did not approve.'}, + isError: true, + ); + } + switch (call.toolName) { + case 'get_weather': + final city = call.args['city'] as String? ?? 'your city'; + return ToolResultPart( + toolCallId: id, + result: {'city': city, 'tempC': 22, 'condition': 'Sunny'}, + ); + case 'book_hotel': + final city = call.args['city'] as String? ?? 'the city'; + final nights = (call.args['nights'] as num?)?.toInt() ?? 1; + return ToolResultPart( + toolCallId: id, + result: { + 'status': 'booked', + 'hotel': 'Hotel Lisboa', + 'city': city, + 'nights': nights, + 'totalEur': nights * 210, + 'confirmation': 'LSB-${1000 + nights}', + }, + ); + default: + return ToolResultPart( + toolCallId: id, + result: {'status': 'unknown tool: ${call.toolName}'}, + isError: true, + ); + } + } +} diff --git a/lib/pages/ai_demo/feature_sections.dart b/lib/pages/ai_demo/feature_sections.dart new file mode 100644 index 0000000..cea625d --- /dev/null +++ b/lib/pages/ai_demo/feature_sections.dart @@ -0,0 +1,576 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/flutter_ai_elements.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'code_highlighter.dart'; + +/// The scrollable marketing feature sections shown beneath the hero chat. +/// +/// Each section is a short title + one-line blurb + a *live* mini-demo built +/// from the real `flutter_ai_elements` widgets and scripted data — so the page +/// doubles as a guided tour of the package's surface. +class FeatureSections extends StatelessWidget { + /// Creates the feature sections. + const FeatureSections({ + super.key, + required this.isWide, + required this.onOpenGallery, + }); + + /// Whether the viewport is wide enough to lay demos out side-by-side. + final bool isWide; + + /// Opens the full element gallery. + final VoidCallback onOpenGallery; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 40), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionDivider(), + const _Section( + title: 'Streaming & Markdown', + blurb: + 'Tokens stream in with a blur fade-in, then settle into rich ' + 'Markdown — headings, code, lists, quotes and links.', + child: _StreamingDemo(), + ), + const _Section( + title: 'Generative UI', + blurb: + 'The model emits typed data parts; an allowlist registry maps ' + 'each to a real widget — thoughts, tasks, confirmations.', + child: _GenerativeUiDemo(), + ), + const _Section( + title: 'Tool calling', + blurb: 'Inspect what the agent did. Parallel calls group together ' + 'with arguments and results — never hidden.', + child: _ToolCallingDemo(), + ), + const _Section( + title: 'Citations & grounding', + blurb: 'Show where answers came from. Source chips carry favicons, ' + 'index badges and hover — tap to open.', + child: _CitationsDemo(), + ), + _Section( + title: 'Theming', + blurb: 'One AiThemeExtension restyles everything. Here is the same ' + 'answer in light and dark, side by side.', + child: _ThemingDemo(isWide: isWide), + ), + _Section( + title: 'Every element', + blurb: + 'Thirty-plus composable widgets, each themeable and testable. ' + 'Browse the full gallery with sample data.', + child: _GalleryCta(onOpenGallery: onOpenGallery), + ), + ], + ), + ); + } +} + +/// A single feature section: title, blurb, and a live demo card. +class _Section extends StatelessWidget { + const _Section({ + required this.title, + required this.blurb, + required this.child, + }); + + final String title; + final String blurb; + final Widget child; + + @override + Widget build(BuildContext context) { + final color = DefaultTextStyle.of(context).style.color; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + letterSpacing: -0.4, + color: color, + ), + ), + const SizedBox(height: 6), + Text( + blurb, + style: TextStyle( + fontSize: 15, + height: 1.45, + color: color?.withValues(alpha: 0.62), + ), + ), + const SizedBox(height: 16), + _DemoCard(child: child), + const _SectionDivider(), + ], + ), + ); + } +} + +/// A bordered surface that frames a live mini-demo. +class _DemoCard extends StatelessWidget { + const _DemoCard({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + border: Border.all(color: theme.borderColor), + ), + child: child, + ); + } +} + +class _SectionDivider extends StatelessWidget { + const _SectionDivider(); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: + Divider(height: 1, color: AiThemeExtension.of(context).borderColor), + ); +} + +// ---- Streaming & Markdown -------------------------------------------------- + +/// Renders Markdown progressively — text grows chunk by chunk with a short +/// delay per chunk, and [AiResponse] re-parses Markdown on each update so +/// headings, bold, code blocks, etc. appear in real time. +class _StreamingDemo extends StatefulWidget { + const _StreamingDemo(); + + @override + State<_StreamingDemo> createState() => _StreamingDemoState(); +} + +class _StreamingDemoState extends State<_StreamingDemo> { + static const String _markdown = '## Streaming, done right\n\n' + 'Fold the **event stream** with a reducer so only the *changed* ' + 'message rebuilds:\n\n' + '```dart\n' + 'stream.listen((event) {\n' + ' state = reduce(state, event);\n' + '});\n' + '```\n\n' + '- Stays at `60fps` while tokens arrive\n\n' + 'What ships:\n\n' + '- [x] Streaming Markdown\n' + '- [x] Tool calls\n' + '- [ ] Your idea here\n\n' + '> Trust comes from showing the work.\n\n' + 'See the [docs](https://docs.flutter.dev).'; + + /// Chunk size (characters) revealed per tick. + static const int _chunkSize = 3; + + /// Delay between chunks — a simulated token arrival rate. + static const Duration _chunkDelay = Duration(milliseconds: 30); + + String _shownText = ''; + bool _streaming = false; + Timer? _timer; + int _cursor = 0; + + void _replay() { + _timer?.cancel(); + setState(() { + _streaming = true; + _shownText = ''; + _cursor = 0; + }); + _timer = Timer.periodic(_chunkDelay, (_) { + if (_cursor >= _markdown.length) { + _timer?.cancel(); + setState(() => _streaming = false); + return; + } + final end = (_cursor + _chunkSize).clamp(0, _markdown.length); + setState(() { + _shownText = _markdown.substring(0, end); + _cursor = end; + }); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AiResponse( + text: _streaming ? _shownText : _markdown, + codeHighlighter: demoCodeHighlighter, + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: _GhostButton( + icon: Icons.play_arrow_rounded, + label: _streaming ? 'Streaming…' : 'Replay streaming', + onTap: _streaming ? null : _replay, + ), + ), + ], + ); + } +} + +// ---- Generative UI --------------------------------------------------------- + +/// Drives a tiny generative-UI catalog: each [DataPart] dataType resolves to a +/// real widget via [AiWidgetRegistry] — including a danger-tone +/// [AiConfirmation]. +class _GenerativeUiDemo extends StatelessWidget { + const _GenerativeUiDemo(); + + static final AiWidgetRegistry _registry = AiWidgetRegistry() + ..register( + 'chain_of_thought', + (context, data) => const AiChainOfThought( + initiallyExpanded: true, + steps: [ + AiThoughtStep(label: 'Read the request'), + AiThoughtStep(label: 'Draft the email'), + AiThoughtStep(label: 'Await approval', isActive: true), + ], + ), + ) + ..register( + 'task', + (context, data) => const AiTask( + title: 'Send weekly digest', + items: [ + AiTaskItem(label: 'Gather metrics', status: AiTaskStatus.complete), + AiTaskItem(label: 'Compose email', status: AiTaskStatus.active), + AiTaskItem(label: 'Send to list', status: AiTaskStatus.pending), + ], + ), + ) + ..register( + 'confirmation', + (context, data) => AiConfirmation( + tone: AiConfirmationTone.danger, + icon: Icons.delete_outline, + title: 'Delete all 1,284 archived records?', + description: 'This cannot be undone.', + confirmLabel: 'Delete', + onConfirm: () {}, + onDeny: () {}, + ), + ); + + static const List _parts = [ + DataPart(dataType: 'chain_of_thought', data: {}), + DataPart(dataType: 'task', data: {}), + DataPart(dataType: 'confirmation', data: {}), + ]; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final part in _parts) ...[ + AiDataView(part: part, registry: _registry), + if (part != _parts.last) const SizedBox(height: 12), + ], + ], + ); + } +} + +// ---- Tool calling ---------------------------------------------------------- + +/// Shows a parallel tool call rendered with [AiToolGroup]: two calls, one +/// resolved with a result, one still executing. +class _ToolCallingDemo extends StatelessWidget { + const _ToolCallingDemo(); + + @override + Widget build(BuildContext context) { + return const AiToolGroup( + calls: [ + ToolCallPart( + toolCallId: 'c1', + toolName: 'get_weather', + args: {'city': 'Lisbon'}, + state: ToolCallState.outputAvailable, + ), + ToolCallPart( + toolCallId: 'c2', + toolName: 'find_hotels', + args: {'city': 'Lisbon', 'nights': 2}, + state: ToolCallState.executing, + ), + ], + results: { + 'c1': ToolResultPart( + toolCallId: 'c1', + result: {'tempC': 24, 'condition': 'Sunny'}, + ), + }, + ); + } +} + +// ---- Citations & grounding ------------------------------------------------- + +/// A few real-looking sources rendered with favicons enabled, plus a sentence +/// carrying inline [AiInlineCitation] badges. +class _CitationsDemo extends StatelessWidget { + const _CitationsDemo(); + + @override + Widget build(BuildContext context) { + final color = DefaultTextStyle.of(context).style.color; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text.rich( + TextSpan( + style: TextStyle(fontSize: 15.5, height: 1.5, color: color), + children: const [ + TextSpan(text: 'Lisbon is sunny, about 24°C this weekend '), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: AiInlineCitation(number: 1), + ), + TextSpan(text: ' with a Sintra day trip recommended '), + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: AiInlineCitation(number: 2), + ), + TextSpan(text: '.'), + ], + ), + ), + const SizedBox(height: 14), + AiSources( + showFavicons: true, + sources: [ + SourcePart( + url: Uri.parse('https://www.timeout.com/lisbon'), + title: 'timeout.com', + ), + SourcePart( + url: Uri.parse('https://www.lonelyplanet.com/portugal/lisbon'), + title: 'lonelyplanet.com', + ), + SourcePart( + url: Uri.parse('https://flutter.dev'), + title: 'flutter.dev', + ), + ], + onTap: (source) => unawaited( + launchUrl(source.url, mode: LaunchMode.externalApplication), + ), + ), + ], + ); + } +} + +// ---- Theming --------------------------------------------------------------- + +/// The same answer rendered through both [AiThemeExtension.fallback] (light) +/// and [AiThemeExtension.dark], to show that one token set restyles everything. +class _ThemingDemo extends StatelessWidget { + const _ThemingDemo({required this.isWide}); + + final bool isWide; + + static const String _md = + '## Day 1\n- Belém Tower & pastéis de nata\n- Alfama and the castle\n\n' + 'Sunny, **~24°C** — pack light.'; + + @override + Widget build(BuildContext context) { + final light = _Themed( + label: 'Light', + brightness: Brightness.light, + extension: AiThemeExtension.fallback(), + background: Colors.white, + ); + final dark = _Themed( + label: 'Dark', + brightness: Brightness.dark, + extension: AiThemeExtension.dark(), + background: const Color(0xFF131316), + ); + if (isWide) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: light), + const SizedBox(width: 12), + Expanded(child: dark), + ], + ); + } + return Column(children: [light, const SizedBox(height: 12), dark]); + } +} + +/// A small message preview wrapped in its own [Theme] so the [extension] and +/// brightness apply only to this subtree. +class _Themed extends StatelessWidget { + const _Themed({ + required this.label, + required this.brightness, + required this.extension, + required this.background, + }); + + final String label; + final Brightness brightness; + final AiThemeExtension extension; + final Color background; + + @override + Widget build(BuildContext context) { + return Theme( + data: ThemeData( + useMaterial3: true, + brightness: brightness, + extensions: [extension], + ), + child: Builder( + builder: (context) { + final fg = brightness == Brightness.dark + ? const Color(0xFFECECEC) + : const Color(0xFF0D0D0D); + return DefaultTextStyle.merge( + style: TextStyle(color: fg), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: extension.borderColor), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: fg.withValues(alpha: 0.5), + letterSpacing: 0.4, + ), + ), + const SizedBox(height: 10), + const AiMessageBubble( + message: AiMessage( + id: 'u', + role: AiRole.user, + parts: [TextPart('Plan a weekend in Lisbon')], + ), + ), + const SizedBox(height: 8), + const AiResponse(text: _ThemingDemo._md), + ], + ), + ), + ); + }, + ), + ); + } +} + +// ---- Gallery CTA ----------------------------------------------------------- + +class _GalleryCta extends StatelessWidget { + const _GalleryCta({required this.onOpenGallery}); + + final VoidCallback onOpenGallery; + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.centerLeft, + child: _GhostButton( + icon: Icons.grid_view_rounded, + label: 'Browse the full gallery', + onTap: onOpenGallery, + ), + ); + } +} + +// ---- Shared ---------------------------------------------------------------- + +/// A bordered, low-emphasis action button used across the sections. +class _GhostButton extends StatelessWidget { + const _GhostButton({required this.icon, required this.label, this.onTap}); + + final IconData icon; + final String label; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + final color = DefaultTextStyle.of(context).style.color; + return Material( + color: theme.assistantBubbleColor, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 311bc73..b93dd44 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -6,7 +6,7 @@ packages: description: name: animations sha256: d3d6dcfb218225bbe68e87ccf6378bbb2e32a94900722c5f81611dad089911cb - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.0.11" archive: @@ -14,7 +14,7 @@ packages: description: name: archive sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.0.9" args: @@ -22,7 +22,7 @@ packages: description: name: args sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.7.0" async: @@ -30,7 +30,7 @@ packages: description: name: async sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.11.0" boolean_selector: @@ -38,7 +38,7 @@ packages: description: name: boolean_selector sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.1" casdoor_flutter_sdk: @@ -46,7 +46,7 @@ packages: description: name: casdoor_flutter_sdk sha256: b12c450fbcace4fd05bab0733384ab9e895565c7bc62788545d40584aa6a5519 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.11.0" characters: @@ -54,7 +54,7 @@ packages: description: name: characters sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.3.0" checked_yaml: @@ -62,7 +62,7 @@ packages: description: name: checked_yaml sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.0.3" cli_util: @@ -70,7 +70,7 @@ packages: description: name: cli_util sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.4.2" clock: @@ -78,7 +78,7 @@ packages: description: name: clock sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.1" collection: @@ -86,7 +86,7 @@ packages: description: name: collection sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.19.0" crypto: @@ -94,7 +94,7 @@ packages: description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.0.7" cryptography: @@ -102,7 +102,7 @@ packages: description: name: cryptography sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.9.0" cupertino_icons: @@ -110,7 +110,7 @@ packages: description: name: cupertino_icons sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.0.8" desktop_webview_window: @@ -127,7 +127,7 @@ packages: description: name: dynamic_color sha256: eae98052fa6e2826bdac3dd2e921c6ce2903be15c6b7f8b6d8a5d49b5086298d - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.7.0" fake_async: @@ -135,7 +135,7 @@ packages: description: name: fake_async sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.3.1" ffi: @@ -143,7 +143,7 @@ packages: description: name: ffi sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.3" file: @@ -151,7 +151,7 @@ packages: description: name: file sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "7.0.1" flutter: @@ -159,12 +159,33 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_ai_client: + dependency: "direct main" + description: + path: "../flutter_ai/packages/flutter_ai_client" + relative: true + source: path + version: "0.3.0" + flutter_ai_core: + dependency: "direct main" + description: + path: "../flutter_ai/packages/flutter_ai_core" + relative: true + source: path + version: "0.1.14" + flutter_ai_elements: + dependency: "direct main" + description: + path: "../flutter_ai/packages/flutter_ai_elements" + relative: true + source: path + version: "0.2.0" flutter_inappwebview: dependency: transitive description: name: flutter_inappwebview sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "6.1.5" flutter_inappwebview_android: @@ -172,7 +193,7 @@ packages: description: name: flutter_inappwebview_android sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.3" flutter_inappwebview_internal_annotations: @@ -180,7 +201,7 @@ packages: description: name: flutter_inappwebview_internal_annotations sha256: "787171d43f8af67864740b6f04166c13190aa74a1468a1f1f1e9ee5b90c359cd" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.2.0" flutter_inappwebview_ios: @@ -188,7 +209,7 @@ packages: description: name: flutter_inappwebview_ios sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.2" flutter_inappwebview_macos: @@ -196,7 +217,7 @@ packages: description: name: flutter_inappwebview_macos sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.2" flutter_inappwebview_platform_interface: @@ -204,7 +225,7 @@ packages: description: name: flutter_inappwebview_platform_interface sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.3.0+1" flutter_inappwebview_web: @@ -212,7 +233,7 @@ packages: description: name: flutter_inappwebview_web sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.2" flutter_inappwebview_windows: @@ -220,7 +241,7 @@ packages: description: name: flutter_inappwebview_windows sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.6.0" flutter_launcher_icons: @@ -228,7 +249,7 @@ packages: description: name: flutter_launcher_icons sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.14.4" flutter_lints: @@ -236,7 +257,7 @@ packages: description: name: flutter_lints sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "5.0.0" flutter_secure_storage: @@ -244,7 +265,7 @@ packages: description: name: flutter_secure_storage sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "9.2.4" flutter_secure_storage_linux: @@ -252,7 +273,7 @@ packages: description: name: flutter_secure_storage_linux sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.2.3" flutter_secure_storage_macos: @@ -260,7 +281,7 @@ packages: description: name: flutter_secure_storage_macos sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.1.3" flutter_secure_storage_ohos: @@ -277,7 +298,7 @@ packages: description: name: flutter_secure_storage_platform_interface sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.2" flutter_secure_storage_web: @@ -285,7 +306,7 @@ packages: description: name: flutter_secure_storage_web sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.2.1" flutter_secure_storage_windows: @@ -293,7 +314,7 @@ packages: description: name: flutter_secure_storage_windows sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.1.2" flutter_test: @@ -306,12 +327,20 @@ packages: description: flutter source: sdk version: "0.0.0" + highlight: + dependency: "direct main" + description: + name: highlight + sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21" + url: "https://pub.dev" + source: hosted + version: "0.7.0" http: dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.6.0" http_parser: @@ -319,7 +348,7 @@ packages: description: name: http_parser sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.1.2" image: @@ -327,7 +356,7 @@ packages: description: name: image sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.8.0" intl: @@ -335,7 +364,7 @@ packages: description: name: intl sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.20.2" js: @@ -343,7 +372,7 @@ packages: description: name: js sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.6.7" json_annotation: @@ -351,7 +380,7 @@ packages: description: name: json_annotation sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.9.0" jwt_decoder: @@ -359,7 +388,7 @@ packages: description: name: jwt_decoder sha256: "54774aebf83f2923b99e6416b4ea915d47af3bde56884eb622de85feabbc559f" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.0.1" leak_tracker: @@ -367,7 +396,7 @@ packages: description: name: leak_tracker sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "10.0.7" leak_tracker_flutter_testing: @@ -375,7 +404,7 @@ packages: description: name: leak_tracker_flutter_testing sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.0.8" leak_tracker_testing: @@ -383,7 +412,7 @@ packages: description: name: leak_tracker_testing sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.0.1" lints: @@ -391,7 +420,7 @@ packages: description: name: lints sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "5.1.1" matcher: @@ -399,7 +428,7 @@ packages: description: name: matcher sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.12.16+1" material_color_utilities: @@ -407,7 +436,7 @@ packages: description: name: material_color_utilities sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.11.1" meta: @@ -415,7 +444,7 @@ packages: description: name: meta sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.15.0" open_filex: @@ -423,7 +452,7 @@ packages: description: name: open_filex sha256: "9976da61b6a72302cf3b1efbce259200cd40232643a467aac7370addf94d6900" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.7.0" package_info_plus: @@ -440,7 +469,7 @@ packages: description: name: package_info_plus_platform_interface sha256: "9bc8ba46813a4cc42c66ab781470711781940780fd8beddd0c3da62506d3a6c6" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.0.1" path: @@ -448,7 +477,7 @@ packages: description: name: path sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.9.0" path_provider: @@ -456,7 +485,7 @@ packages: description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.5" path_provider_android: @@ -464,7 +493,7 @@ packages: description: name: path_provider_android sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.2.17" path_provider_foundation: @@ -472,7 +501,7 @@ packages: description: name: path_provider_foundation sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.4.1" path_provider_linux: @@ -480,7 +509,7 @@ packages: description: name: path_provider_linux sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.2.1" path_provider_platform_interface: @@ -488,7 +517,7 @@ packages: description: name: path_provider_platform_interface sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.2" path_provider_windows: @@ -496,7 +525,7 @@ packages: description: name: path_provider_windows sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.3.0" petitparser: @@ -504,7 +533,7 @@ packages: description: name: petitparser sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "6.0.2" platform: @@ -512,7 +541,7 @@ packages: description: name: platform sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.1.6" plugin_platform_interface: @@ -520,7 +549,7 @@ packages: description: name: plugin_platform_interface sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.8" posix: @@ -528,7 +557,7 @@ packages: description: name: posix sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "6.5.0" shared_preferences: @@ -536,7 +565,7 @@ packages: description: name: shared_preferences sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.5.3" shared_preferences_android: @@ -544,7 +573,7 @@ packages: description: name: shared_preferences_android sha256: "5bcf0772a761b04f8c6bf814721713de6f3e5d9d89caf8d3fe031b02a342379e" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.4.11" shared_preferences_foundation: @@ -552,7 +581,7 @@ packages: description: name: shared_preferences_foundation sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.5.4" shared_preferences_linux: @@ -560,7 +589,7 @@ packages: description: name: shared_preferences_linux sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.4.1" shared_preferences_ohos: @@ -577,7 +606,7 @@ packages: description: name: shared_preferences_platform_interface sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.4.1" shared_preferences_web: @@ -585,7 +614,7 @@ packages: description: name: shared_preferences_web sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.4.3" shared_preferences_windows: @@ -593,7 +622,7 @@ packages: description: name: shared_preferences_windows sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.4.1" sky_engine: @@ -606,7 +635,7 @@ packages: description: name: source_span sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.10.0" stack_trace: @@ -614,7 +643,7 @@ packages: description: name: stack_trace sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.12.0" stream_channel: @@ -622,7 +651,7 @@ packages: description: name: stream_channel sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.2" string_scanner: @@ -630,7 +659,7 @@ packages: description: name: string_scanner sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.3.0" term_glyph: @@ -638,7 +667,7 @@ packages: description: name: term_glyph sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.2.1" test_api: @@ -646,7 +675,7 @@ packages: description: name: test_api sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.7.3" typed_data: @@ -654,7 +683,7 @@ packages: description: name: typed_data sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.4.0" url_launcher: @@ -662,7 +691,7 @@ packages: description: name: url_launcher sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "6.3.2" url_launcher_android: @@ -670,7 +699,7 @@ packages: description: name: url_launcher_android sha256: "0aedad096a85b49df2e4725fa32118f9fa580f3b14af7a2d2221896a02cd5656" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "6.3.17" url_launcher_ios: @@ -678,7 +707,7 @@ packages: description: name: url_launcher_ios sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "6.3.3" url_launcher_linux: @@ -686,7 +715,7 @@ packages: description: name: url_launcher_linux sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.2.1" url_launcher_macos: @@ -694,7 +723,7 @@ packages: description: name: url_launcher_macos sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.2.2" url_launcher_ohos: @@ -711,7 +740,7 @@ packages: description: name: url_launcher_platform_interface sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.3.2" url_launcher_web: @@ -719,7 +748,7 @@ packages: description: name: url_launcher_web sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.4.1" url_launcher_windows: @@ -727,7 +756,7 @@ packages: description: name: url_launcher_windows sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.1.4" vector_math: @@ -735,7 +764,7 @@ packages: description: name: vector_math sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.4" vm_service: @@ -743,7 +772,7 @@ packages: description: name: vm_service sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "14.3.0" web: @@ -751,7 +780,7 @@ packages: description: name: web sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.1" webview_flutter: @@ -804,7 +833,7 @@ packages: description: name: win32 sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "5.10.1" xdg_directories: @@ -812,7 +841,7 @@ packages: description: name: xdg_directories sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.0" xml: @@ -820,7 +849,7 @@ packages: description: name: xml sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "6.5.0" yaml: @@ -828,7 +857,7 @@ packages: description: name: yaml sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.1.3" sdks: diff --git a/pubspec.yaml b/pubspec.yaml index 8aed25c..52127ae 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -56,6 +56,17 @@ dependencies: url: https://gitcode.com/openharmony-tpc/flutter_packages.git path: packages/webview_flutter/webview_flutter ref: br_webview_flutter-v4.13.0_ohos + # flutter_ai element showcase (ported from ../flutter_ai demo). All three + # packages are pure Dart/Flutter (no platform channels) so they run on every + # techpie target incl. OHOS with no extra override. `highlight` is used by the + # demo's code_highlighter.dart and is also pure Dart. + flutter_ai_core: + path: ../flutter_ai/packages/flutter_ai_core + flutter_ai_client: + path: ../flutter_ai/packages/flutter_ai_client + flutter_ai_elements: + path: ../flutter_ai/packages/flutter_ai_elements + highlight: ^0.7.0 dependency_overrides: # OpenHarmony-SIG forks add OHOS platform implementations for plugins @@ -78,6 +89,13 @@ dependency_overrides: url: https://gitcode.com/openharmony-tpc/flutter_packages.git path: packages/webview_flutter/webview_flutter_platform_interface ref: br_webview_flutter-v4.13.0_ohos + # The flutter_ai packages declare transitive deps on flutter_ai_core/client + # as hosted (pub.dev) ranges, but we consume them from a local path checkout. + # Force every transitive reference to the local path so version solving agrees. + flutter_ai_core: + path: ../flutter_ai/packages/flutter_ai_core + flutter_ai_client: + path: ../flutter_ai/packages/flutter_ai_client # flutter_secure_storage: # git: # url: https://gitcode.com/openharmony-sig/fluttertpc_flutter_secure_storage.git From 88cb6cf3952e952aa95eb5fe5e9eea9b875ac30b Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Sun, 26 Jul 2026 07:13:54 +0800 Subject: [PATCH 03/15] feat(ai): integrate flutter_ai library for chat UI + streaming markdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the self-written chat rendering layer with the flutter_ai library family (path deps from the sibling repo), so the conversation UI aligns with the library's part-rendering pipeline (TextPart/ReasoningPart/ ToolCallPart/SourcePart/...) — groundwork for upcoming workflow/cite controls. - Wire flutter_ai_elements + flutter_ai_provider_anthropic + highlight; drop flutter_markdown. - AiService wraps UseChatController + AnthropicProvider, exposing the controller for the UI to bind; keeps TechPie's persistence, config, and conversation-list ownership. DeepSeek's x-api-key auth is supported (verified), with baseUrl normalization to the /v1 root. - Chat page uses library AiChat + AiPromptInput + AiErrorBanner; only the platform-adaptive top bar, config banner, and empty state stay TechPie's. - StreamingMarkdownRenderer renders AiResponse (markdown + syntax- highlighted code blocks) live during streaming, not just on completion. - Delete self-written ai_message_bubble.dart and ai_composer.dart. - Migrate to the library's parts-based AiMessage; AiThread wraps it with TechPie's title/updatedAt metadata. - Register AiThemeExtension on light/dark ThemeData so library widgets match the active color scheme. flutter analyze: 0 errors/warnings. flutter test: 31 passing. Co-Authored-By: Claude --- lib/main.dart | 52 ++- lib/models/ai_chat.dart | 275 +++++++++++++++ lib/models/feature.dart | 10 + lib/pages/ai_assistant_page.dart | 315 +++++++++++++++++ lib/pages/ai_config_page.dart | 430 ++++++++++++++++++++++++ lib/pages/ai_gallery_page.dart | 75 +++++ lib/pages/ai_history_page.dart | 263 +++++++++++++++ lib/services/ai_service.dart | 395 ++++++++++++++++++++++ lib/services/service_provider.dart | 9 +- lib/services/storage_service.dart | 94 ++++-- lib/widgets/ai/ai_code_highlighter.dart | 56 +++ lib/widgets/ai/ai_text_renderer.dart | 29 ++ pubspec.lock | 54 +++ pubspec.yaml | 8 + test/ai_service_test.dart | 168 +++++++++ test/widget_test.dart | 5 + 16 files changed, 2203 insertions(+), 35 deletions(-) create mode 100644 lib/models/ai_chat.dart create mode 100644 lib/pages/ai_assistant_page.dart create mode 100644 lib/pages/ai_config_page.dart create mode 100644 lib/pages/ai_gallery_page.dart create mode 100644 lib/pages/ai_history_page.dart create mode 100644 lib/services/ai_service.dart create mode 100644 lib/widgets/ai/ai_code_highlighter.dart create mode 100644 lib/widgets/ai/ai_text_renderer.dart create mode 100644 test/ai_service_test.dart diff --git a/lib/main.dart b/lib/main.dart index 0065bca..3e61184 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,12 +1,13 @@ import 'dart:async'; -import 'package:desktop_webview_window/desktop_webview_window.dart' - show runWebViewTitleBarWidget; +import 'package:desktop_webview_window/desktop_webview_window.dart' show runWebViewTitleBarWidget; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/flutter_ai_elements.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:techpie/utils/platform.dart'; import 'models/third_party_account.dart'; +import 'services/ai_service.dart'; import 'services/assignment_service.dart'; import 'services/auth_service.dart'; import 'services/debug_logger.dart'; @@ -21,6 +22,7 @@ import 'services/third_party_auth_service.dart'; import 'services/uni_auth_service.dart'; import 'widgets/adaptive_feedback.dart'; import 'widgets/app_shell/app_shell.dart'; + void main(List args) async { // If this Flutter engine is a desktop_webview_window title bar (secondary // engine inside the webview popup), render the navigation controls and @@ -79,6 +81,7 @@ Future _realMain(SharedPreferences prefs) async { scheduleService, ); final syncService = SyncService(authService, thirdPartyAuthService, storageService); + final aiService = AiService(storageService); authService.onLogout = () async { // Third-party bindings persist across logouts — they will be used by the @@ -113,6 +116,7 @@ Future _realMain(SharedPreferences prefs) async { await syncService.loadCachedKey(); assignmentService.loadCached(); await scheduleService.loadCachedData(); + await aiService.initialize(); runApp( TechPieApp( @@ -126,6 +130,7 @@ Future _realMain(SharedPreferences prefs) async { oaGymService: oaGymService, uniAuthService: uniAuthService, syncService: syncService, + aiService: aiService, ), ); @@ -138,9 +143,7 @@ Future _realMain(SharedPreferences prefs) async { // refresh token (legacy session) this is a no-op and returns false — // that is NOT a "login expired" condition, only an actual renewal // failure is. - final renewMain = authService.isLoggedIn - ? authService.tryRenewSession() - : Future.value(true); + final renewMain = authService.isLoggedIn ? authService.tryRenewSession() : Future.value(true); final renewThirdParty = thirdPartyAuthService.autoRenewIfNeeded(); final results = await Future.wait([renewMain, renewThirdParty]); @@ -149,9 +152,7 @@ Future _realMain(SharedPreferences prefs) async { // Only surface a renewal failure when we actually had a refresh token // to try (a no-op returning false is not an expiry). - if (!mainOk && - authService.session?.geekpieRefreshToken != null && - !isIos()) { + if (!mainOk && authService.session?.geekpieRefreshToken != null && !isIos()) { showAdaptiveFeedback( message: '登录已过期,请重新登录', style: AdaptiveFeedbackStyle.error, @@ -169,8 +170,7 @@ Future _realMain(SharedPreferences prefs) async { if (thirdPartyAuthService.hasEgateBinding) { await scheduleService.fetchAll(); } - if (authService.isLoggedIn || - thirdPartyAuthService.boundPlatforms.isNotEmpty) { + if (authService.isLoggedIn || thirdPartyAuthService.boundPlatforms.isNotEmpty) { await assignmentService.fetchAssignments(); } @@ -231,6 +231,7 @@ class TechPieApp extends StatefulWidget { final OaGymService oaGymService; final UniAuthService uniAuthService; final SyncService syncService; + final AiService aiService; const TechPieApp({ super.key, @@ -244,6 +245,7 @@ class TechPieApp extends StatefulWidget { required this.oaGymService, required this.uniAuthService, required this.syncService, + required this.aiService, }); @override @@ -277,11 +279,12 @@ class _TechPieAppState extends State { oaGymService: widget.oaGymService, uniAuthService: widget.uniAuthService, syncService: widget.syncService, + aiService: widget.aiService, child: MaterialApp( scaffoldMessengerKey: rootMessengerKey, title: 'TechPie', - theme: widget.themeService.lightTheme, - darkTheme: widget.themeService.darkTheme, + theme: _withAiExtension(widget.themeService.lightTheme), + darkTheme: _withAiExtension(widget.themeService.darkTheme), themeMode: widget.themeService.themeMode, home: const AppShell(), ), @@ -289,3 +292,28 @@ class _TechPieAppState extends State { ); } } + +/// Registers an [AiThemeExtension] on the TechPie theme so the flutter_ai +/// widgets (AiResponse markdown, AiCodeBlock, etc.) pick up the active color +/// scheme instead of their built-in defaults. Without this they fall back to +/// `AiThemeExtension.fallback()`, whose dark code-block background clashes +/// with TechPie's surfaces. +ThemeData _withAiExtension(ThemeData base) { + final cs = base.colorScheme; + final isDark = base.brightness == Brightness.dark; + final ai = (isDark ? AiThemeExtension.dark() : AiThemeExtension.fallback()) + .copyWith( + // Prose follows the surface text color; code block sits on a surface + // container tint with a contrasting foreground. + assistantTextColor: cs.onSurface, + userTextColor: cs.onSurface, + codeBackgroundColor: + isDark ? cs.surfaceContainerHighest : const Color(0xFF1E1E1E), + codeForegroundColor: + isDark ? cs.onSurface : const Color(0xFFE6E6E6), + linkColor: cs.primary, + accentColor: cs.primary, + onAccentColor: cs.onPrimary, + ); + return base.copyWith(extensions: [...base.extensions.values, ai]); +} diff --git a/lib/models/ai_chat.dart b/lib/models/ai_chat.dart new file mode 100644 index 0000000..fb46811 --- /dev/null +++ b/lib/models/ai_chat.dart @@ -0,0 +1,275 @@ +import 'package:flutter/material.dart'; + +// Re-export the library's chat-domain types so the rest of TechPie keeps a +// single import (`ai_chat.dart`) for everything AI — the parts-based +// AiMessage/AiRole/... come from flutter_ai_core, while TechPie-specific +// AiConfig / AiPromptTemplate / AiThread / gallery stay below. +export 'package:flutter_ai_core/flutter_ai_core.dart' + show + AiMessage, + AiConversation, + AiRole, + AiMessageStatus, + AiPart, + TextPart; + +import 'package:flutter_ai_core/flutter_ai_core.dart'; + +/// A persisted conversation thread. +/// +/// The library's [AiConversation] only carries `{id, messages}` — it has no +/// title or timestamp. TechPie's history page needs both, so this wrapper adds +/// them and bundles the library conversation as the message payload. It is the +/// unit persisted by [StorageService] and exchanged with [AiService]. +class AiThread { + final String id; + final String title; + final List messages; + final DateTime updatedAt; + + const AiThread({ + required this.id, + required this.title, + required this.messages, + required this.updatedAt, + }); + + /// The underlying library conversation (messages only, no metadata). + AiConversation get conversation => + AiConversation(id: id, messages: messages); + + AiThread copyWith({ + String? title, + List? messages, + DateTime? updatedAt, + }) { + return AiThread( + id: id, + title: title ?? this.title, + messages: messages ?? this.messages, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + Map toJson() => { + 'id': id, + 'title': title, + 'messages': [for (final m in messages) m.toJson()], + 'updatedAt': updatedAt.toIso8601String(), + }; + + factory AiThread.fromJson(Map json) { + final rawMessages = (json['messages'] as List? ?? const []) + .map((e) => AiMessage.fromJson((e as Map).cast())) + .where((m) => m.text.isNotEmpty || m.role == AiRole.system) + .toList(); + return AiThread( + id: json['id'] as String, + title: json['title'] as String? ?? '新对话', + messages: rawMessages, + updatedAt: + DateTime.tryParse(json['updatedAt'] as String? ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0), + ); + } +} + + +/// API configuration for the Anthropic-format endpoint. +/// +/// Defaults are seeded for DeepSeek's Anthropic-compatible endpoint so the +/// assistant works out of the box. The user can override base URL / model / +/// key in the config page. +/// +/// Note: [baseUrl] should point at the API *root* **including** the `/v1` +/// segment — the underlying `AnthropicProvider` appends `/messages`. See +/// [normalizeAiBaseUrl], which massages whatever the user typed into that form. +class AiConfig { + /// `ANTHROPIC_BASE_URL` — the endpoint root, e.g. + /// `https://api.deepseek.com/anthropic/v1`. `AnthropicProvider` appends + /// `/messages`, so this MUST include the version segment. + final String baseUrl; + + /// `ANTHROPIC_AUTH_TOKEN` — sent as `x-api-key` by `AnthropicProvider`. + final String authToken; + + /// `ANTHROPIC_MODEL` — e.g. `deepseek-v4-flash`. + final String model; + + /// Optional system prompt prepended to every request (injected as a leading + /// `AiRole.system` message, which the provider folds into the top-level + /// `system` field). + final String systemPrompt; + + /// Sampling temperature, 0.0–2.0. Null = server default. + final double? temperature; + + /// Max output tokens per turn. + final int maxTokens; + + const AiConfig({ + required this.baseUrl, + required this.authToken, + required this.model, + this.systemPrompt = _defaultSystemPrompt, + this.temperature, + this.maxTokens = 2048, + }); + + /// Defaults taken from `tech-atlas/.env.local` (DeepSeek's Anthropic-compat + /// endpoint). The `/v1` segment is included because `AnthropicProvider` + /// appends `/messages` itself. + static const defaultBaseUrl = 'https://api.deepseek.com/anthropic/v1'; + static const defaultModel = 'deepseek-v4-flash'; + static const _defaultSystemPrompt = '你是 TechPie AI 助手,为上海科技大学师生提供帮助。请用中文简洁、准确地回答问题。'; + + factory AiConfig.defaults() => const AiConfig( + baseUrl: defaultBaseUrl, + authToken: '', + model: defaultModel, + ); + + bool get hasAuthToken => authToken.isNotEmpty; + + AiConfig copyWith({ + String? baseUrl, + String? authToken, + String? model, + String? systemPrompt, + double? temperature, + int? maxTokens, + }) { + return AiConfig( + baseUrl: baseUrl ?? this.baseUrl, + authToken: authToken ?? this.authToken, + model: model ?? this.model, + systemPrompt: systemPrompt ?? this.systemPrompt, + temperature: temperature ?? this.temperature, + maxTokens: maxTokens ?? this.maxTokens, + ); + } + + Map toJson() => { + 'baseUrl': baseUrl, + 'authToken': authToken, + 'model': model, + 'systemPrompt': systemPrompt, + 'temperature': temperature, + 'maxTokens': maxTokens, + }; + + factory AiConfig.fromJson(Map json) { + return AiConfig( + baseUrl: normalizeAiBaseUrl(json['baseUrl'] as String? ?? defaultBaseUrl), + authToken: json['authToken'] as String? ?? '', + model: json['model'] as String? ?? defaultModel, + systemPrompt: json['systemPrompt'] as String? ?? _defaultSystemPrompt, + temperature: (json['temperature'] as num?)?.toDouble(), + maxTokens: json['maxTokens'] as int? ?? 2048, + ); + } +} + +/// Normalizes a user-entered base URL into the form `AnthropicProvider` +/// expects: a root ending in `/v1` (it appends `/messages` itself). +/// +/// Tolerates the legacy default (no `/v1`), a fully-qualified +/// `/v1/messages` URL, and trailing slashes — so existing saved configs and +/// hand-typed endpoints keep working after the switch to the library provider. +String normalizeAiBaseUrl(String raw) { + var url = raw.trim(); + while (url.endsWith('/')) { + url = url.substring(0, url.length - 1); + } + if (url.isEmpty) return AiConfig.defaultBaseUrl; + if (url.endsWith('/v1/messages')) { + return url.substring(0, url.length - '/messages'.length); + } + if (url.endsWith('/messages')) { + return url.substring(0, url.length - '/messages'.length); + } + if (url.endsWith('/v1')) { + return url; + } + return '$url/v1'; +} + +/// A reusable prompt template shown in the gallery. Tapping one starts a new +/// conversation with the template text pre-filled (and optionally a custom +/// system prompt scoped to that category). +class AiPromptTemplate { + final String id; + final String title; + final String subtitle; + final String prompt; + final IconData icon; + + const AiPromptTemplate({ + required this.id, + required this.title, + required this.subtitle, + required this.prompt, + required this.icon, + }); +} + +/// Built-in gallery of prompt templates. Hard-coded (not user-editable) to +/// keep the feature simple. +const List aiPromptGallery = [ + AiPromptTemplate( + id: 'translate-en-zh', + title: '翻译 英文 → 中文', + subtitle: '把一段英文翻译成通顺的中文', + prompt: '请把下面这段英文翻译成自然流畅的中文:\n\n', + icon: Icons.translate, + ), + AiPromptTemplate( + id: 'translate-zh-en', + title: '翻译 中文 → 英文', + subtitle: '把一段中文翻译成地道的英文', + prompt: 'Please translate the following Chinese text into natural English:\n\n', + icon: Icons.translate_outlined, + ), + AiPromptTemplate( + id: 'summarize', + title: '总结摘要', + subtitle: '提取一段文字的要点', + prompt: '请用中文为以下内容生成一份要点摘要,使用无序列表:\n\n', + icon: Icons.summarize, + ), + AiPromptTemplate( + id: 'polish', + title: '润色改写', + subtitle: '让文字更通顺、更专业', + prompt: '请润色下面这段文字,使其更通顺、专业,并保留原意:\n\n', + icon: Icons.auto_fix_high, + ), + AiPromptTemplate( + id: 'explain-code', + title: '解释代码', + subtitle: '逐行讲解一段代码', + prompt: '请用中文逐行解释下面这段代码的作用,并指出潜在问题:\n\n```\n\n```', + icon: Icons.code, + ), + AiPromptTemplate( + id: 'study-plan', + title: '制定学习计划', + subtitle: '为某个主题安排学习路径', + prompt: '我想学习', + icon: Icons.school, + ), + AiPromptTemplate( + id: 'email-draft', + title: '撰写邮件', + subtitle: '起草一封正式邮件', + prompt: '请帮我起草一封邮件。背景:\n收件人:\n主要目的:\n语气:正式\n\n', + icon: Icons.mail, + ), + AiPromptTemplate( + id: 'brainstorm', + title: '头脑风暴', + subtitle: '为一个问题发散想法', + prompt: '请围绕以下主题,给我 10 个有创意的点子,每个一句话:\n\n', + icon: Icons.lightbulb, + ), +]; diff --git a/lib/models/feature.dart b/lib/models/feature.dart index 4df06a5..fcc75d1 100644 --- a/lib/models/feature.dart +++ b/lib/models/feature.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../pages/ai_assistant_page.dart'; import '../pages/oa_gym_page.dart'; enum FeatureMode { @@ -59,6 +60,15 @@ final featureEntries = [ ), icon: const Icon(Icons.sports_tennis), ), + Feature( + id: 'ai_assistant', + description: 'AI 助手', + mode: FeatureMode.native, + nativeEntry: (context) => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AiAssistantPage()), + ), + icon: const Icon(Icons.smart_toy_outlined), + ), ]; final moreFeature = Feature( diff --git a/lib/pages/ai_assistant_page.dart b/lib/pages/ai_assistant_page.dart new file mode 100644 index 0000000..d2ac719 --- /dev/null +++ b/lib/pages/ai_assistant_page.dart @@ -0,0 +1,315 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/flutter_ai_elements.dart'; + +import '../models/ai_chat.dart'; +import '../services/ai_service.dart'; +import '../services/service_provider.dart'; +import '../utils/platform.dart'; +import '../widgets/ai/ai_text_renderer.dart'; +import '../widgets/blurred_app_bar.dart'; +import '../widgets/ios_liquid/ios_native_navigation_bar.dart'; +import 'ai_config_page.dart'; +import 'ai_gallery_page.dart'; +import 'ai_history_page.dart'; + +/// The AI Assistant chat page — entered from the Home "应用" card. +/// +/// Layout mirrors other TechPie feature pages: a platform-adaptive app bar +/// (Liquid Glass on iOS 26+, blurred bar elsewhere) over a body composed of +/// the flutter_ai library's chat widgets — [AiChat] for the transcript, +/// [AiPromptInput] for the composer, [AiErrorBanner] for failures. Only the +/// top bar, the not-configured banner, and the empty state are TechPie's own; +/// the conversation UI itself is the library's so that markdown, code blocks, +/// and future workflow/cite parts render through the library's part pipeline. +class AiAssistantPage extends StatefulWidget { + const AiAssistantPage({super.key, this.seedPrompt}); + + /// Optional prompt pre-filled from the gallery. Written into the composer's + /// text controller on first build. + final String? seedPrompt; + + @override + State createState() => _AiAssistantPageState(); +} + +class _AiAssistantPageState extends State { + /// Owns the composer's text so the gallery can pre-fill it. Passed to + /// AiPromptInput.textController. + final TextEditingController _textController = TextEditingController(); + + @override + void initState() { + super.initState(); + if (widget.seedPrompt != null && widget.seedPrompt!.isNotEmpty) { + _textController.text = widget.seedPrompt!; + } + } + + @override + void dispose() { + _textController.dispose(); + super.dispose(); + } + + Future _openGallery() async { + final AiPromptTemplate? picked = await Navigator.of(context).push< + AiPromptTemplate + >( + MaterialPageRoute( + builder: (_) => const AiGalleryPage(), + ), + ); + if (picked != null && mounted) { + _textController.text = picked.prompt; + _textController.selection = TextSelection.collapsed( + offset: picked.prompt.length, + ); + } + } + + Future _openHistory(AiService aiService) async { + final String? selectedId = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AiHistoryPage(aiService: aiService), + ), + ); + if (selectedId != null && mounted) { + aiService.selectConversation(selectedId); + } + } + + Future _openConfig() async { + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AiConfigPage()), + ); + } + + @override + Widget build(BuildContext context) { + final sp = ServiceProvider.of(context); + final aiService = sp.aiService; + final useIosChrome = isIos(); + final useLegacyIosChrome = usesLegacyIosChrome(); + final topInset = + useIosChrome || useLegacyIosChrome + ? 0.0 + : adaptiveTopBarHeight() + MediaQuery.viewPaddingOf(context).top; + + return Scaffold( + extendBodyBehindAppBar: !useIosChrome && !useLegacyIosChrome, + appBar: useIosChrome + ? IosNativeNavigationBar( + title: 'AI 助手', + leadingItems: const [ + IosNativeNavigationBarItem( + id: 'back', + title: 'Home', + sfSymbol: 'chevron.left', + accessibilityLabel: '返回 Home', + placementGroup: 'leading-main', + ), + ], + trailingItems: const [ + IosNativeNavigationBarItem( + id: 'gallery', + title: 'Gallery', + sfSymbol: 'square.grid.2x2', + accessibilityLabel: '提示词画廊', + placementGroup: 'trailing-main', + ), + IosNativeNavigationBarItem( + id: 'history', + sfSymbol: 'clock.arrow.circlepath', + accessibilityLabel: '历史会话', + placementGroup: 'trailing-main', + ), + IosNativeNavigationBarItem( + id: 'config', + sfSymbol: 'gearshape', + accessibilityLabel: 'API 设置', + placementGroup: 'trailing-main', + ), + ], + onItemPressed: (id) { + switch (id) { + case 'back': + unawaited(Navigator.maybePop(context)); + case 'gallery': + unawaited(_openGallery()); + case 'history': + unawaited(_openHistory(aiService)); + case 'config': + unawaited(_openConfig()); + } + }, + ) + : BlurredAppBar( + title: const Text('AI 助手'), + actions: [ + IconButton( + tooltip: '提示词画廊', + icon: const Icon(Icons.auto_awesome_outlined), + onPressed: _openGallery, + ), + IconButton( + tooltip: '历史会话', + icon: const Icon(Icons.history), + onPressed: () => unawaited(_openHistory(aiService)), + ), + IconButton( + tooltip: 'API 设置', + icon: const Icon(Icons.settings_outlined), + onPressed: _openConfig, + ), + ], + ), + body: ListenableBuilder( + listenable: aiService, + builder: (context, _) { + final notConfigured = !aiService.isConfigured; + final error = aiService.streamingError; + + return Column( + children: [ + SizedBox(height: topInset), + if (notConfigured) _configBanner(context, aiService), + Expanded( + child: AiChat( + controller: aiService.controller, + textRenderer: const StreamingMarkdownRenderer(), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + emptyState: _emptyState(context, notConfigured), + ), + ), + if (error != null) + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: AiErrorBanner( + message: error, + onRetry: aiService.isStreaming ? null : () => _retry(aiService), + onDismiss: () => _dismissError(aiService), + ), + ), + // The composer only appears once configured; before that the + // config banner + empty state guide the user to set a token. + if (!notConfigured) + AiPromptInput( + controller: aiService.controller, + hintText: '输入消息…', + textController: _textController, + ) + else + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(16), + child: FilledButton.tonalIcon( + onPressed: _openConfig, + icon: const Icon(Icons.key), + label: const Text('前往配置 API 令牌'), + ), + ), + ), + ], + ); + }, + ), + ); + } + + Future _retry(AiService aiService) async { + // Re-send an empty turn to regenerate the last assistant reply. + await aiService.send(''); + } + + void _dismissError(AiService aiService) { + // Clearing the transcript's error requires a fresh controller state; the + // simplest portable way is to reload the current conversation, which + // resets status to idle and drops the error. + final conv = aiService.currentConversation; + if (conv != null) { + aiService.controller.stop(); + } + } + + Widget _configBanner(BuildContext context, AiService aiService) { + final theme = Theme.of(context); + return Material( + color: theme.colorScheme.tertiaryContainer, + child: InkWell( + onTap: _openConfig, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + children: [ + Icon( + Icons.key, + size: 18, + color: theme.colorScheme.onTertiaryContainer, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + '尚未配置 API 令牌,点此填写以开始对话', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onTertiaryContainer, + ), + ), + ), + Icon( + Icons.chevron_right, + color: theme.colorScheme.onTertiaryContainer, + ), + ], + ), + ), + ), + ); + } + + Widget _emptyState(BuildContext context, bool notConfigured) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.smart_toy_outlined, + size: 56, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + notConfigured ? '欢迎使用 AI 助手' : '开始一段新对话', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + notConfigured + ? '先配置 API 令牌,然后即可开始对话。也可以从画廊挑选一个提示词模板。' + : '在下方输入你的问题,或从画廊挑一个提示词模板快速开始。', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 20), + FilledButton.tonalIcon( + onPressed: _openGallery, + icon: const Icon(Icons.auto_awesome_outlined), + label: const Text('浏览提示词画廊'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/ai_config_page.dart b/lib/pages/ai_config_page.dart new file mode 100644 index 0000000..909b36f --- /dev/null +++ b/lib/pages/ai_config_page.dart @@ -0,0 +1,430 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_core/flutter_ai_core.dart'; +import 'package:flutter_ai_provider_anthropic/flutter_ai_provider_anthropic.dart'; + +import '../models/ai_chat.dart'; +import '../services/ai_service.dart'; +import '../services/service_provider.dart'; +import '../utils/platform.dart'; +import '../widgets/adaptive_feedback.dart'; +import '../widgets/blurred_app_bar.dart'; +import '../widgets/ios_liquid/ios_native_navigation_bar.dart'; + +/// Edit the AI assistant's API configuration. +/// +/// Fields: base URL, auth token (obscured), model, system prompt, temperature, +/// max tokens. A "测试连接" button sends a trivial non-streaming probe so the +/// user can confirm their key/endpoint before chatting. +class AiConfigPage extends StatefulWidget { + const AiConfigPage({super.key}); + + @override + State createState() => _AiConfigPageState(); +} + +class _AiConfigPageState extends State { + late final TextEditingController _baseUrlCtrl; + late final TextEditingController _tokenCtrl; + late final TextEditingController _modelCtrl; + late final TextEditingController _systemCtrl; + late final TextEditingController _maxTokensCtrl; + late final TextEditingController _tempCtrl; + + bool _obscureToken = true; + bool _testing = false; + bool _seeded = false; + + @override + void initState() { + super.initState(); + // Controllers created empty; populated in didChangeDependencies where + // ServiceProvider (an InheritedWidget) is safe to access. + _baseUrlCtrl = TextEditingController(); + _tokenCtrl = TextEditingController(); + _modelCtrl = TextEditingController(); + _systemCtrl = TextEditingController(); + _maxTokensCtrl = TextEditingController(); + _tempCtrl = TextEditingController(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_seeded) return; + _seeded = true; + final config = ServiceProvider.of(context).aiService.config; + _baseUrlCtrl.text = config.baseUrl; + _tokenCtrl.text = config.authToken; + _modelCtrl.text = config.model; + _systemCtrl.text = config.systemPrompt; + _maxTokensCtrl.text = config.maxTokens.toString(); + _tempCtrl.text = config.temperature?.toString() ?? ''; + } + + @override + void dispose() { + _baseUrlCtrl.dispose(); + _tokenCtrl.dispose(); + _modelCtrl.dispose(); + _systemCtrl.dispose(); + _maxTokensCtrl.dispose(); + _tempCtrl.dispose(); + super.dispose(); + } + + AiConfig _buildConfig() { + final maxTokens = int.tryParse(_maxTokensCtrl.text.trim()) ?? 2048; + final temp = double.tryParse(_tempCtrl.text.trim()); + return AiConfig( + baseUrl: _baseUrlCtrl.text.trim().isEmpty + ? AiConfig.defaultBaseUrl + : _baseUrlCtrl.text.trim(), + authToken: _tokenCtrl.text.trim(), + model: _modelCtrl.text.trim().isEmpty + ? AiConfig.defaultModel + : _modelCtrl.text.trim(), + systemPrompt: _systemCtrl.text, + temperature: temp, + maxTokens: maxTokens, + ); + } + + Future _save() async { + final aiService = ServiceProvider.of(context).aiService; + await aiService.saveConfig(_buildConfig()); + if (mounted) { + showAdaptiveFeedback(message: '已保存', style: AdaptiveFeedbackStyle.success); + } + } + + Future _testConnection() async { + if (_testing) return; + setState(() => _testing = true); + final config = _buildConfig(); + AnthropicProvider? provider; + StreamSubscription? sub; + try { + if (!config.hasAuthToken) { + showAdaptiveFeedback( + message: '请先填写 API 令牌', + style: AdaptiveFeedbackStyle.error, + ); + return; + } + // Build a one-off provider + a trivial "ping" turn and listen for the + // first text delta — that confirms auth + endpoint end-to-end. + provider = AnthropicProvider( + apiKey: config.authToken, + baseUrl: Uri.parse(normalizeAiBaseUrl(config.baseUrl)), + defaultModel: config.model, + defaultMaxTokens: config.maxTokens, + timeout: const Duration(seconds: 30), + ); + final systemMsg = config.systemPrompt.trim().isEmpty + ? null + : AiMessage( + id: 'probe-system', + role: AiRole.system, + parts: [TextPart(config.systemPrompt)], + ); + final conv = AiConversation( + id: 'probe', + messages: [ + if (systemMsg != null) systemMsg, + AiMessage.text( + id: 'probe-user', + role: AiRole.user, + text: 'ping', + ), + ], + ); + final got = Completer(); + final buffer = StringBuffer(); + sub = provider.send(conv, options: _probeOptions(config)).listen( + (event) { + if (event is TextDelta) { + buffer.write(event.delta); + if (buffer.length > 8 && !got.isCompleted) got.complete(); + } else if (event is StreamErrorEvent) { + if (!got.isCompleted) got.completeError(event.error); + } + }, + onError: (Object e) { + if (!got.isCompleted) got.completeError(e); + }, + ); + await got.future.timeout(const Duration(seconds: 30)); + await sub.cancel(); + if (mounted) { + showAdaptiveFeedback( + message: '连接成功,模型已回复', + style: AdaptiveFeedbackStyle.success, + ); + } + } on LlmException catch (e) { + if (mounted) { + showAdaptiveFeedback( + message: '请求失败(${e.statusCode}):${e.body.isEmpty ? e.toString() : e.body}', + style: AdaptiveFeedbackStyle.error, + duration: const Duration(seconds: 5), + ); + } + } on TimeoutException { + if (mounted) { + showAdaptiveFeedback( + message: '请求超时,请稍后重试', + style: AdaptiveFeedbackStyle.error, + ); + } + } catch (e) { + if (mounted) { + showAdaptiveFeedback( + message: '连接失败:$e', + style: AdaptiveFeedbackStyle.error, + duration: const Duration(seconds: 5), + ); + } + } finally { + await sub?.cancel(); + provider?.close(); + if (mounted) setState(() => _testing = false); + } + } + + AiRequestOptions _probeOptions(AiConfig config) => AiRequestOptions( + model: config.model, + temperature: config.temperature, + maxOutputTokens: config.maxTokens, + ); + + Future _clearAll(AiService aiService) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('清空所有会话?'), + content: const Text('此操作不可撤销,将删除全部本地对话记录。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('清空'), + ), + ], + ), + ); + if (confirmed == true) { + await aiService.clearAllConversations(); + if (mounted) { + showAdaptiveFeedback(message: '已清空', style: AdaptiveFeedbackStyle.success); + } + } + } + + @override + Widget build(BuildContext context) { + final useIosChrome = isIos(); + final useLegacyIosChrome = usesLegacyIosChrome(); + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Scaffold( + extendBodyBehindAppBar: !useIosChrome && !useLegacyIosChrome, + appBar: useIosChrome + ? IosNativeNavigationBar( + title: 'AI 设置', + leadingItems: const [ + IosNativeNavigationBarItem( + id: 'back', + title: 'Chat', + sfSymbol: 'chevron.left', + accessibilityLabel: '返回对话', + placementGroup: 'leading-main', + ), + ], + trailingItems: const [ + IosNativeNavigationBarItem( + id: 'save', + title: '保存', + placementGroup: 'trailing-main', + ), + ], + onItemPressed: (id) { + switch (id) { + case 'back': + unawaited(Navigator.maybePop(context)); + case 'save': + unawaited(_save()); + } + }, + ) + : BlurredAppBar( + title: const Text('AI 设置'), + actions: [ + TextButton( + onPressed: _save, + child: const Text('保存'), + ), + ], + ), + body: ListView( + padding: EdgeInsets.only( + top: useIosChrome || useLegacyIosChrome + ? 8 + : adaptiveTopBarHeight() + MediaQuery.viewPaddingOf(context).top + 8, + bottom: 32, + ), + children: [ + _section(theme, '接口', [ + _field( + theme: theme, + controller: _baseUrlCtrl, + label: 'Base URL', + hint: 'https://api.deepseek.com/anthropic', + keyboardType: TextInputType.url, + ), + _tokenField(theme, colorScheme), + _field( + theme: theme, + controller: _modelCtrl, + label: '模型', + hint: 'deepseek-v4-flash', + ), + ]), + _section(theme, '生成', [ + _field( + theme: theme, + controller: _tempCtrl, + label: '温度 (temperature, 留空使用默认)', + hint: '0.7', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + ), + _field( + theme: theme, + controller: _maxTokensCtrl, + label: '最大输出 tokens', + hint: '2048', + keyboardType: TextInputType.number, + ), + ]), + _section(theme, '系统提示词', [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TextField( + controller: _systemCtrl, + minLines: 3, + maxLines: 8, + decoration: const InputDecoration( + alignLabelWithHint: true, + border: OutlineInputBorder(), + ), + ), + ), + ]), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: _testing ? null : _testConnection, + icon: _testing + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.network_check), + label: const Text('测试连接'), + ), + ), + ], + ), + ), + ListTile( + leading: const Icon(Icons.delete_sweep_outlined, color: Colors.red), + title: const Text('清空所有会话'), + subtitle: const Text('删除本地全部对话记录'), + onTap: () => + unawaited(_clearAll(ServiceProvider.of(context).aiService)), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Text( + '默认值取自 tech-atlas/.env.local(DeepSeek 的 Anthropic 兼容端点)。' + '令牌仅保存在设备安全存储中。', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ); + } + + Widget _section(ThemeData theme, String title, List children) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text( + title, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colorScheme.primary, + ), + ), + ), + ...children, + ], + ); + } + + Widget _field({ + required ThemeData theme, + required TextEditingController controller, + required String label, + required String hint, + TextInputType keyboardType = TextInputType.text, + }) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TextField( + controller: controller, + keyboardType: keyboardType, + decoration: InputDecoration( + labelText: label, + hintText: hint, + border: const OutlineInputBorder(), + ), + ), + ); + } + + Widget _tokenField(ThemeData theme, ColorScheme colorScheme) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: TextField( + controller: _tokenCtrl, + obscureText: _obscureToken, + keyboardType: TextInputType.visiblePassword, + decoration: InputDecoration( + labelText: 'API 令牌 (auth token)', + hintText: 'sk-…', + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: Icon( + _obscureToken ? Icons.visibility_off : Icons.visibility, + ), + onPressed: () => setState(() => _obscureToken = !_obscureToken), + ), + ), + ), + ); + } +} diff --git a/lib/pages/ai_gallery_page.dart b/lib/pages/ai_gallery_page.dart new file mode 100644 index 0000000..be256a2 --- /dev/null +++ b/lib/pages/ai_gallery_page.dart @@ -0,0 +1,75 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../models/ai_chat.dart'; +import '../utils/platform.dart'; +import '../widgets/blurred_app_bar.dart'; +import '../widgets/ios_liquid/ios_native_navigation_bar.dart'; + +/// A gallery of built-in prompt templates. Tapping one pops the page and +/// returns the chosen [AiPromptTemplate] to the chat page, which pre-fills it +/// into the composer. +class AiGalleryPage extends StatelessWidget { + const AiGalleryPage({super.key}); + + @override + Widget build(BuildContext context) { + final useIosChrome = isIos(); + final useLegacyIosChrome = usesLegacyIosChrome(); + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Scaffold( + extendBodyBehindAppBar: !useIosChrome && !useLegacyIosChrome, + appBar: useIosChrome + ? IosNativeNavigationBar( + title: '提示词画廊', + leadingItems: const [ + IosNativeNavigationBarItem( + id: 'back', + title: 'Chat', + sfSymbol: 'chevron.left', + accessibilityLabel: '返回对话', + placementGroup: 'leading-main', + ), + ], + onItemPressed: (id) { + if (id == 'back') unawaited(Navigator.maybePop(context)); + }, + ) + : const BlurredAppBar(title: Text('提示词画廊')), + body: ListView.separated( + padding: EdgeInsets.only( + top: useIosChrome || useLegacyIosChrome + ? 0 + : adaptiveTopBarHeight() + MediaQuery.viewPaddingOf(context).top + 8, + bottom: 16, + ), + itemCount: aiPromptGallery.length, + separatorBuilder: (context, _) => const Divider(height: 1, indent: 72), + itemBuilder: (context, i) { + final t = aiPromptGallery[i]; + return ListTile( + leading: CircleAvatar( + backgroundColor: colorScheme.primaryContainer, + foregroundColor: colorScheme.onPrimaryContainer, + child: Icon(t.icon), + ), + title: Text(t.title, style: theme.textTheme.titleSmall), + subtitle: Text( + t.subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () => Navigator.of(context).pop(t), + ); + }, + ), + ); + } +} diff --git a/lib/pages/ai_history_page.dart b/lib/pages/ai_history_page.dart new file mode 100644 index 0000000..f252988 --- /dev/null +++ b/lib/pages/ai_history_page.dart @@ -0,0 +1,263 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../models/ai_chat.dart'; +import '../services/ai_service.dart'; +import '../utils/platform.dart'; +import '../widgets/adaptive_feedback.dart'; +import '../widgets/blurred_app_bar.dart'; +import '../widgets/ios_liquid/ios_native_navigation_bar.dart'; + +/// Lists past conversations. Tap to open (returns the id), swipe/trailing to +/// delete, rename via the overflow menu. A "新对话" action in the app bar +/// creates a fresh thread. +class AiHistoryPage extends StatelessWidget { + const AiHistoryPage({super.key, required this.aiService}); + + final AiService aiService; + + @override + Widget build(BuildContext context) { + final useIosChrome = isIos(); + final useLegacyIosChrome = usesLegacyIosChrome(); + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Scaffold( + extendBodyBehindAppBar: !useIosChrome && !useLegacyIosChrome, + appBar: useIosChrome + ? IosNativeNavigationBar( + title: '历史会话', + leadingItems: const [ + IosNativeNavigationBarItem( + id: 'back', + title: 'Chat', + sfSymbol: 'chevron.left', + accessibilityLabel: '返回对话', + placementGroup: 'leading-main', + ), + ], + trailingItems: const [ + IosNativeNavigationBarItem( + id: 'new', + title: '新对话', + sfSymbol: 'square.and.pencil', + placementGroup: 'trailing-main', + ), + ], + onItemPressed: (id) { + switch (id) { + case 'back': + unawaited(Navigator.maybePop(context)); + case 'new': + final conv = aiService.newConversation(); + Navigator.of(context).pop(conv.id); + } + }, + ) + : BlurredAppBar( + title: const Text('历史会话'), + actions: [ + IconButton( + tooltip: '新对话', + icon: const Icon(Icons.add_comment_outlined), + onPressed: () { + final conv = aiService.newConversation(); + Navigator.of(context).pop(conv.id); + }, + ), + ], + ), + body: ListenableBuilder( + listenable: aiService, + builder: (context, _) { + final conversations = aiService.conversations; + if (conversations.isEmpty) { + return _empty(context, theme, colorScheme); + } + final currentId = aiService.currentConversation?.id; + return ListView.separated( + padding: EdgeInsets.only( + top: useIosChrome || useLegacyIosChrome + ? 8 + : adaptiveTopBarHeight() + + MediaQuery.viewPaddingOf(context).top + + 8, + bottom: 16, + ), + itemCount: conversations.length, + separatorBuilder: (context, _) => const Divider(height: 1, indent: 72), + itemBuilder: (context, i) { + final c = conversations[i]; + final isCurrent = c.id == currentId; + return Dismissible( + key: ValueKey(c.id), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 24), + color: colorScheme.error, + child: Icon(Icons.delete, color: colorScheme.onError), + ), + confirmDismiss: (_) async { + return await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('删除该会话?'), + content: Text(c.title), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('删除'), + ), + ], + ), + ); + }, + onDismissed: (_) async { + await aiService.deleteConversation(c.id); + if (context.mounted) { + showAdaptiveFeedback( + message: '已删除', + style: AdaptiveFeedbackStyle.success, + ); + } + }, + child: ListTile( + leading: CircleAvatar( + backgroundColor: isCurrent + ? colorScheme.primary + : colorScheme.surfaceContainerHigh, + foregroundColor: isCurrent + ? colorScheme.onPrimary + : colorScheme.onSurfaceVariant, + child: const Icon(Icons.chat_bubble_outline), + ), + title: Text( + c.title, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: isCurrent ? FontWeight.bold : null, + ), + ), + subtitle: Text( + _subtitle(c), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + trailing: PopupMenuButton( + itemBuilder: (context) => const [ + PopupMenuItem(value: 'rename', child: Text('重命名')), + PopupMenuItem(value: 'delete', child: Text('删除')), + ], + onSelected: (value) { + switch (value) { + case 'rename': + unawaited(_rename(context, c)); + case 'delete': + unawaited(_deleteWithConfirm(context, c)); + } + }, + ), + onTap: () => Navigator.of(context).pop(c.id), + ), + ); + }, + ); + }, + ), + ); + } + + String _subtitle(AiThread c) { + final count = c.messages.length; + final date = '${c.updatedAt.month}/${c.updatedAt.day}'; + return '$date · $count 条消息'; + } + + Widget _empty( + BuildContext context, + ThemeData theme, + ColorScheme colorScheme, + ) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.history, + size: 48, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text('暂无历史会话', style: theme.textTheme.bodyLarge), + ], + ), + ); + } + + Future _rename(BuildContext context, AiThread c) async { + final controller = TextEditingController(text: c.title); + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('重命名会话'), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration(border: OutlineInputBorder()), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, controller.text.trim()), + child: const Text('确定'), + ), + ], + ), + ); + controller.dispose(); + if (result != null && result.isNotEmpty) { + await aiService.renameConversation(c.id, result); + } + } + + Future _deleteWithConfirm(BuildContext context, AiThread c) async { + final ok = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('删除该会话?'), + content: Text(c.title), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('删除'), + ), + ], + ), + ); + if (ok == true) { + await aiService.deleteConversation(c.id); + if (context.mounted) { + showAdaptiveFeedback( + message: '已删除', + style: AdaptiveFeedbackStyle.success, + ); + } + } + } +} diff --git a/lib/services/ai_service.dart b/lib/services/ai_service.dart new file mode 100644 index 0000000..44409c2 --- /dev/null +++ b/lib/services/ai_service.dart @@ -0,0 +1,395 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_ai_client/flutter_ai_client.dart'; +import 'package:flutter_ai_core/flutter_ai_core.dart'; +import 'package:flutter_ai_provider_anthropic/flutter_ai_provider_anthropic.dart'; + +import '../models/ai_chat.dart'; +import 'storage_service.dart'; + +/// Central state for the AI Assistant feature. +/// +/// Wraps the library's [UseChatController] (the streaming state machine) and an +/// [AnthropicProvider], exposing TechPie's own chat API so the rest of the app +/// — UI, config page, history, gallery — binds to a single `ChangeNotifier` +/// exactly as before. The controller's notifications are forwarded to this +/// service's listeners, so `ListenableBuilder(listenable: aiService, ...)` +/// keeps working. +/// +/// Responsibilities split as: the controller owns the live transcript + stream +/// state (in library [AiConversation] form); this service owns the [AiConfig] +/// (persisted), the list of persisted [AiThread]s (TechPie's metadata + +/// messages), the current-thread pointer, and serializing the controller's +/// transcript back to [StorageService] when a turn settles. +class AiService extends ChangeNotifier { + final StorageService _storage; + + AiConfig _config = AiConfig.defaults(); + List _conversations = const []; + String? _currentConversationId; + + /// The library controller. Lazily created once config is available so an + /// unconfigured app doesn't spin up a provider with an empty key. + UseChatController? _controller; + AnthropicProvider? _provider; + + /// True while the controller's forward-listener is active, to avoid + /// re-entrant notifyListeners churn. + bool _forwarding = false; + + /// A service-level error surfaced ahead of any network call (e.g. the app + /// isn't configured yet). Cleared when a real turn starts or config is saved. + String? _userError; + + /// Debounced persistence timer. + Timer? _persistTimer; + + AiService(this._storage); + + // ---- Accessors ---- + + AiConfig get config => _config; + + bool get isConfigured => _config.hasAuthToken; + + bool get isStreaming { + final c = _controller; + return c != null && c.status == ChatStatus.streaming; + } + + /// The error from the last failed turn, surfaced as a string for the UI + /// banner. Returns null when idle/healthy. Prefers a service-level user + /// error (e.g. "not configured") over the controller's last network error. + String? get streamingError { + if (_userError != null) return _userError; + final c = _controller; + if (c == null) return null; + final err = c.error; + if (err == null) return null; + if (err is LlmException) { + // LlmException carries a status code + body, not a plain message. Map + // auth failures to a friendlier hint; otherwise surface the body. + if (err is LlmAuthException) { + return '认证失败(${err.statusCode}):请检查 API 令牌与端点。'; + } + final body = err.body; + final detail = + body.isEmpty ? err.toString() : (body.length > 200 ? '${body.substring(0, 200)}…' : body); + return '请求失败(${err.statusCode}):$detail'; + } + return '发生错误:$err'; + } + + List get conversations { + final list = [..._conversations]; + list.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + return list; + } + + AiThread? get currentConversation { + final id = _currentConversationId; + if (id == null) return null; + for (final c in _conversations) { + if (c.id == id) return c; + } + return null; + } + + /// The library chat controller the UI binds to (AiChat / AiPromptInput). + /// + /// Lazily built on first access. The controller owns the live transcript + + /// streaming state; this service mirrors its transcript back into the + /// persisted [AiThread] list (see [_onControllerChanged]) and owns config / + /// conversation metadata. Thread switches happen via [selectConversation] / + /// [newConversation] (which call `controller.load`), NOT here — re-loading + /// on every build would tear down an in-flight stream. + UseChatController get controller => _ensureController(forCurrent: false); + + // ---- Lifecycle ---- + + Future initialize() async { + _config = await _storage.loadAiConfig(); + _conversations = _storage.loadAiConversations(); + if (_conversations.isEmpty) { + // Start the user with one fresh conversation so the chat is never empty. + final fresh = _newConversation(); + _conversations = [fresh]; + _currentConversationId = fresh.id; + } else { + _currentConversationId = conversations.first.id; + } + _ensureController(forCurrent: true); + notifyListeners(); + } + + // ---- Config ---- + + Future saveConfig(AiConfig config) async { + _config = config; + // Token goes to secure storage; the rest to prefs (token stripped inside). + await _storage.saveAiAuthToken(config.authToken); + await _storage.saveAiConfig(config); + // Re-point the controller's provider/options to the new config. + _ensureController(forCurrent: true); + _controller?.setProvider(_buildProvider()); + _controller?.setOptions(_buildOptions()); + // A fresh config clears any "not configured" prompt. + _userError = null; + notifyListeners(); + } + + // ---- Conversations ---- + + /// Create and switch to a new conversation, optionally seeded with a prompt + /// (used by the gallery). + AiThread newConversation({String? seedPrompt}) { + final conv = _newConversation(); + _conversations = [..._conversations, conv]; + _currentConversationId = conv.id; + _ensureController(forCurrent: true); + _schedulePersist(); + notifyListeners(); + return conv; + } + + void selectConversation(String id) { + if (_currentConversationId == id) return; + final conv = _conversationById(id); + if (conv == null) return; + _controller?.stop(); + _currentConversationId = id; + _ensureController(forCurrent: true); + notifyListeners(); + } + + Future deleteConversation(String id) async { + if (_currentConversationId == id) { + _controller?.stop(); + } + _conversations = _conversations.where((c) => c.id != id).toList(); + if (_currentConversationId == id) { + _currentConversationId = + _conversations.isEmpty ? null : conversations.first.id; + if (_conversations.isEmpty) { + final fresh = _newConversation(); + _conversations = [fresh]; + _currentConversationId = fresh.id; + } + _ensureController(forCurrent: true); + } + _schedulePersist(); + notifyListeners(); + } + + Future clearAllConversations() async { + _controller?.stop(); + await _storage.clearAiConversations(); + _conversations = [_newConversation()]; + _currentConversationId = _conversations.first.id; + _ensureController(forCurrent: true); + notifyListeners(); + } + + Future renameConversation(String id, String title) async { + _conversations = [ + for (final c in _conversations) + if (c.id == id) c.copyWith(title: title) else c, + ]; + _schedulePersist(); + notifyListeners(); + } + + // ---- Sending / streaming ---- + + /// Send [text] as a user turn and stream the assistant reply. If [text] is + /// empty, re-streams the last user turn (regenerate). Errors are captured + /// into [streamingError] (via the controller's `error`), not thrown. + Future send(String text, {String? conversationId}) async { + final c = _ensureController(forCurrent: true); + if (!_config.hasAuthToken) { + _userError = '请先在设置中填写 API 令牌'; + notifyListeners(); + return; + } + if (c.status == ChatStatus.streaming) return; + // Starting a real turn — clear any prior service-level error. + if (_userError != null) { + _userError = null; + notifyListeners(); + } + + // Auto-title from the first user message if untitled. + final convId = conversationId ?? _currentConversationId; + if (convId != null && _conversationById(convId)?.title == '新对话') { + final firstUserText = text.trim().isNotEmpty ? text : _lastUserText(c); + if (firstUserText != null) { + unawaited(renameConversation(convId, _deriveTitle(firstUserText))); + } + } + + if (text.trim().isNotEmpty) { + await c.sendText(text); + } else { + // Regenerate: re-run from the last user message. + await c.regenerate(); + } + } + + /// Stop an in-flight stream. The partial assistant text is kept. + void stop() => _controller?.stop(); + + // ---- Controller plumbing ---- + + /// Lazily build (or re-seat) the controller for the current conversation. + /// When [forCurrent] is true and the controller already exists, the current + /// conversation is loaded into it (thread switch). Returns the controller. + UseChatController _ensureController({required bool forCurrent}) { + if (_controller == null) { + _controller = UseChatController( + provider: _buildProvider(), + options: _buildOptions(), + // The controller defaults to scheduleMicrotask for notification + // coalescing, so a fast token stream collapses into one notify per + // microtask — plenty smooth, and matches the flutter_ai demo. + ); + _controller!.addListener(_onControllerChanged); + } + if (forCurrent) { + final conv = currentConversation; + if (conv != null) { + _controller!.load(_withSystemMessage(conv.conversation)); + } + } + return _controller!; + } + + void _onControllerChanged() { + if (_forwarding) return; + _forwarding = true; + try { + // Mirror the controller's live transcript back into our persisted thread + // so currentConversation reflects streamed tokens, then forward the notify. + _syncCurrentFromController(); + notifyListeners(); + // Persist once the turn settles (status leaves streaming). + if (_controller?.status != ChatStatus.streaming) { + _schedulePersist(); + } + } finally { + _forwarding = false; + } + } + + /// Copy the controller's transcript into the persisted thread record. + void _syncCurrentFromController() { + final id = _currentConversationId; + final c = _controller; + if (id == null || c == null) return; + // Strip the leading system message before persisting — it's re-injected + // from config on load, so we don't want it duplicated across saves. + final messages = + c.conversation.messages.where((m) => m.role != AiRole.system).toList(); + _conversations = [ + for (final conv in _conversations) + if (conv.id == id) + conv.copyWith(messages: messages, updatedAt: DateTime.now()) + else + conv, + ]; + } + + /// The conversation with the configured system prompt prepended (the + /// AnthropicProvider folds `AiRole.system` messages into the request's + /// top-level `system` field). + AiConversation _withSystemMessage(AiConversation conv) { + final prompt = _config.systemPrompt; + if (prompt.trim().isEmpty) return conv; + if (conv.messages.any((m) => m.role == AiRole.system)) return conv; + final systemMsg = AiMessage( + id: 'system-${conv.id}', + role: AiRole.system, + parts: [TextPart(prompt)], + ); + return conv.copyWith(messages: [systemMsg, ...conv.messages]); + } + + AnthropicProvider _buildProvider() { + // Reuse an existing provider when auth + endpoint are unchanged; otherwise + // build fresh and close the old one. (baseUrl is private on the provider, + // so we track the resolved URL ourselves for the reuse check.) + final previous = _provider; + final resolved = normalizeAiBaseUrl(_config.baseUrl); + if (previous != null && + previous.apiKey == _config.authToken && + _resolvedBaseUrl == resolved) { + return previous; + } + previous?.close(); + final provider = AnthropicProvider( + apiKey: _config.authToken.isEmpty ? 'unset' : _config.authToken, + baseUrl: Uri.parse(resolved), + defaultModel: _config.model, + defaultMaxTokens: _config.maxTokens, + timeout: const Duration(seconds: 90), + ); + _provider = provider; + _resolvedBaseUrl = resolved; + return provider; + } + + String? _resolvedBaseUrl; + + AiRequestOptions? _buildOptions() { + return AiRequestOptions( + model: _config.model, + temperature: _config.temperature, + maxOutputTokens: _config.maxTokens, + ); + } + + String? _lastUserText(UseChatController c) { + for (final m in c.conversation.messages.reversed) { + if (m.role == AiRole.user) return m.text; + } + return null; + } + + void _schedulePersist() { + _persistTimer?.cancel(); + _persistTimer = Timer(const Duration(seconds: 1), () { + unawaited(_storage.saveAiConversations(_conversations)); + }); + } + + AiThread _newConversation() => AiThread( + id: _id(), + title: '新对话', + messages: const [], + updatedAt: DateTime.now(), + ); + + AiThread? _conversationById(String id) { + for (final c in _conversations) { + if (c.id == id) return c; + } + return null; + } + + String _deriveTitle(String text) { + final t = text.trim().replaceAll(RegExp(r'\s+'), ' '); + return t.isEmpty ? '新对话' : (t.length > 20 ? '${t.substring(0, 20)}…' : t); + } + + String _id() => 'm${DateTime.now().microsecondsSinceEpoch}-${_counter++}'; + int _counter = 0; + + @override + void dispose() { + _persistTimer?.cancel(); + _controller?.removeListener(_onControllerChanged); + _controller?.dispose(); + _provider?.close(); + super.dispose(); + } +} diff --git a/lib/services/service_provider.dart b/lib/services/service_provider.dart index c802068..4631022 100644 --- a/lib/services/service_provider.dart +++ b/lib/services/service_provider.dart @@ -1,5 +1,6 @@ import 'package:flutter/widgets.dart'; +import 'ai_service.dart'; import 'assignment_service.dart'; import 'auth_service.dart'; import 'debug_logger.dart'; @@ -22,6 +23,7 @@ class ServiceProvider extends InheritedWidget { final OaGymService oaGymService; final UniAuthService uniAuthService; final SyncService syncService; + final AiService aiService; const ServiceProvider({ super.key, @@ -35,12 +37,12 @@ class ServiceProvider extends InheritedWidget { required this.oaGymService, required this.uniAuthService, required this.syncService, + required this.aiService, required super.child, }); static ServiceProvider of(BuildContext context) { - final result = - context.dependOnInheritedWidgetOfExactType(); + final result = context.dependOnInheritedWidgetOfExactType(); assert(result != null, 'No ServiceProvider found in context'); return result!; } @@ -56,5 +58,6 @@ class ServiceProvider extends InheritedWidget { thirdPartyAuthService != oldWidget.thirdPartyAuthService || oaGymService != oldWidget.oaGymService || uniAuthService != oldWidget.uniAuthService || - syncService != oldWidget.syncService; + syncService != oldWidget.syncService || + aiService != oldWidget.aiService; } diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index ac5d084..6bb0860 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -9,6 +9,7 @@ import 'dart:convert'; import 'package:flutter_secure_storage_ohos/flutter_secure_storage_ohos.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../models/ai_chat.dart'; import '../models/assignment_overrides.dart'; import '../models/course_table.dart'; import '../models/oa_gym.dart'; @@ -97,34 +98,27 @@ class StorageService { Future setDebugMode(bool value) => _prefs.setBool(_debugModeKey, value); String get cachedSchoolName => _prefs.getString(_schoolNameKey) ?? ''; - Future setCachedSchoolName(String name) => - _prefs.setString(_schoolNameKey, name); + Future setCachedSchoolName(String name) => _prefs.setString(_schoolNameKey, name); String get cachedPhone => _prefs.getString(_phoneKey) ?? ''; - Future setCachedPhone(String phone) => - _prefs.setString(_phoneKey, phone); + Future setCachedPhone(String phone) => _prefs.setString(_phoneKey, phone); String get themeMode => _prefs.getString(_themeModeKey) ?? 'system'; - Future setThemeMode(String mode) => - _prefs.setString(_themeModeKey, mode); + Future setThemeMode(String mode) => _prefs.setString(_themeModeKey, mode); String get colorScheme => _prefs.getString(_colorSchemeKey) ?? 'system'; - Future setColorScheme(String scheme) => - _prefs.setString(_colorSchemeKey, scheme); + Future setColorScheme(String scheme) => _prefs.setString(_colorSchemeKey, scheme); bool get useLocalhost => _prefs.getBool(_useLocalhostKey) ?? false; - Future setUseLocalhost(bool value) => - _prefs.setBool(_useLocalhostKey, value); + Future setUseLocalhost(bool value) => _prefs.setBool(_useLocalhostKey, value); // Cloud-sync settings. The master-password-derived key is the one sensitive // piece — it lives in secure storage, never in SharedPreferences. bool get syncEnabled => _prefs.getBool(_syncEnabledKey) ?? false; - Future setSyncEnabled(bool value) => - _prefs.setBool(_syncEnabledKey, value); + Future setSyncEnabled(bool value) => _prefs.setBool(_syncEnabledKey, value); String? get syncLastAt => _prefs.getString(_syncLastAtKey); - Future setSyncLastAt(String iso) => - _prefs.setString(_syncLastAtKey, iso); + Future setSyncLastAt(String iso) => _prefs.setString(_syncLastAtKey, iso); Future loadSyncMasterKey() => _secure.read(key: _syncMasterKeyKey); Future saveSyncMasterKey(String serialized) => @@ -146,8 +140,8 @@ class StorageService { return SemesterInfo.fromJson(jsonDecode(raw) as Map); } - Future saveCourseTable(String semesterId, CourseTable table) => _prefs - .setString('$_courseTablePrefix$semesterId', jsonEncode(table.toJson())); + Future saveCourseTable(String semesterId, CourseTable table) => + _prefs.setString('$_courseTablePrefix$semesterId', jsonEncode(table.toJson())); CourseTable? loadCourseTable(String semesterId) { final raw = _prefs.getString('$_courseTablePrefix$semesterId'); @@ -165,8 +159,7 @@ class StorageService { } String? get selectedSemester => _prefs.getString(_selectedSemesterKey); - Future setSelectedSemester(String id) => - _prefs.setString(_selectedSemesterKey, id); + Future setSelectedSemester(String id) => _prefs.setString(_selectedSemesterKey, id); // Assignments cache (non-sensitive — stored as JSON in SharedPreferences) static const _assignmentsKey = 'cached_assignments'; @@ -205,8 +198,7 @@ class StorageService { } } - Future clearAssignmentOverrides() => - _prefs.remove(_assignmentOverridesKey); + Future clearAssignmentOverrides() => _prefs.remove(_assignmentOverridesKey); // OA gym booking profile. This is non-sensitive contact info used to submit // reservation forms and can be edited by the user. @@ -228,4 +220,66 @@ class StorageService { return const OaBookingProfile(name: '', phone: '', email: ''); } } + + // ---- AI assistant ---- + // The auth token is the one sensitive piece — it lives in secure storage. + // Everything else (config minus the token, conversation history) is plain + // cache in SharedPreferences. + + static const _aiAuthTokenKey = 'ai_auth_token'; // secure storage + static const _aiConfigKey = 'ai_config'; // prefs JSON (token excluded) + static const _aiConversationsKey = 'ai_conversations'; // prefs JSON list + + Future saveAiAuthToken(String token) => _secure.write(key: _aiAuthTokenKey, value: token); + + Future loadAiAuthToken() async => await _secure.read(key: _aiAuthTokenKey) ?? ''; + + Future clearAiAuthToken() => _secure.delete(key: _aiAuthTokenKey); + + /// Persist config with the token stripped out — the token has its own secure + /// slot. We keep the two concerns separate so a prefs export never leaks the + /// secret. + Future saveAiConfig(AiConfig config) => _prefs.setString( + _aiConfigKey, + jsonEncode(config.copyWith(authToken: '').toJson()), + ); + + /// Load config and re-attach the auth token from secure storage. Callers + /// should `await` this so the token is present. + Future loadAiConfig() async { + final token = await loadAiAuthToken(); + final raw = _prefs.getString(_aiConfigKey); + if (raw == null) { + return AiConfig.defaults().copyWith(authToken: token); + } + try { + return AiConfig.fromJson( + jsonDecode(raw) as Map, + ).copyWith(authToken: token); + } catch (_) { + return AiConfig.defaults().copyWith(authToken: token); + } + } + + Future saveAiConversations(List conversations) => _prefs.setString( + _aiConversationsKey, + jsonEncode( + conversations.map((c) => c.toJson()).toList(), + ), + ); + + List loadAiConversations() { + final raw = _prefs.getString(_aiConversationsKey); + if (raw == null) return const []; + try { + final list = jsonDecode(raw) as List; + return list + .map((e) => AiThread.fromJson(e as Map)) + .toList(); + } catch (_) { + return const []; + } + } + + Future clearAiConversations() => _prefs.remove(_aiConversationsKey); } diff --git a/lib/widgets/ai/ai_code_highlighter.dart b/lib/widgets/ai/ai_code_highlighter.dart new file mode 100644 index 0000000..b32a51f --- /dev/null +++ b/lib/widgets/ai/ai_code_highlighter.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:highlight/highlight.dart' show highlight, Node; + +/// Syntax-highlights AI code blocks using the `highlight` package, mapping +/// token classes to a VS Code–dark-ish palette. Returns `null` (plain +/// monospace) when the language is unknown or parsing fails, so rendering +/// never breaks. +/// +/// This matches the `CodeHighlighter` typedef from flutter_ai_elements +/// structurally (so it can be passed to `AiResponse.codeHighlighter`). +/// Ported verbatim from the flutter_ai demo (`demo/lib/code_highlighter.dart`) +/// so TechPie's code blocks match the demo's look exactly. +List? techpieCodeHighlighter( + String code, + String? language, + TextStyle base, +) { + try { + final result = (language != null && language.isNotEmpty) + ? highlight.parse(code, language: language) + : highlight.parse(code, autoDetection: true); + final nodes = result.nodes; + if (nodes == null) return null; + return [for (final node in nodes) _span(node, base)]; + } catch (_) { + return null; // unknown grammar → fall back to plain monospace + } +} + +const _tokenColors = { + 'keyword': Color(0xFFC586C0), + 'built_in': Color(0xFF4EC9B0), + 'type': Color(0xFF4EC9B0), + 'class': Color(0xFF4EC9B0), + 'title': Color(0xFFDCDCAA), + 'function': Color(0xFFDCDCAA), + 'string': Color(0xFFCE9178), + 'number': Color(0xFFB5CEA8), + 'symbol': Color(0xFFB5CEA8), + 'literal': Color(0xFF569CD6), + 'comment': Color(0xFF6A9955), + 'meta': Color(0xFF9CDCFE), + 'attr': Color(0xFF9CDCFE), +}; + +TextSpan _span(Node node, TextStyle base) { + final color = node.className == null ? null : _tokenColors[node.className!]; + final style = color == null ? base : base.copyWith(color: color); + final value = node.value; + if (value != null) return TextSpan(text: value, style: style); + final children = node.children ?? const []; + return TextSpan( + style: style, + children: [for (final child in children) _span(child, base)], + ); +} diff --git a/lib/widgets/ai/ai_text_renderer.dart b/lib/widgets/ai/ai_text_renderer.dart new file mode 100644 index 0000000..5a2264a --- /dev/null +++ b/lib/widgets/ai/ai_text_renderer.dart @@ -0,0 +1,29 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_ai_elements/flutter_ai_elements.dart'; + +import 'ai_code_highlighter.dart'; + +/// A [TextRenderer] that renders Markdown via [AiResponse] **even while +/// streaming**, so markdown and code blocks form up live as tokens arrive. +/// +/// This is the demo's "render Markdown during streaming too" opt-out from the +/// flutter_ai `ChatScreen` — the library's default [MarkdownTextRenderer] +/// switches to [AiAnimatedResponse] (plain-prose blur fade) while streaming, +/// which doesn't render Markdown. We prefer live Markdown here. +/// +/// [AiResponse] caches its parse and only re-parses when the text actually +/// changes, and the controller coalesces notifications, so re-rendering per +/// streamed token stays smooth. +class StreamingMarkdownRenderer implements AiTextRenderer { + const StreamingMarkdownRenderer({this.onLinkTap}); + + /// Called when a link is tapped. If `null`, links render but aren't tappable. + final void Function(Uri url)? onLinkTap; + + @override + Widget render(String text, {required bool isStreaming}) => AiResponse( + text: text, + onLinkTap: onLinkTap, + codeHighlighter: techpieCodeHighlighter, + ); +} diff --git a/pubspec.lock b/pubspec.lock index 311bc73..134fbe1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -138,6 +138,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.3.1" + fetch_api: + dependency: transitive + description: + name: fetch_api + sha256: "24cbd5616f3d4008c335c197bb90bfa0eb43b9e55c6de5c60d1f805092636034" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.1" + fetch_client: + dependency: transitive + description: + name: fetch_client + sha256: "2125ffb6325e0045cc3cbfca1a87e6defe9afa27d85dc14857c1cf3835a9a636" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" ffi: dependency: transitive description: @@ -159,6 +175,36 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_ai_client: + dependency: transitive + description: + name: flutter_ai_client + sha256: e60a3a69c99070952ee203ed8aad709eeb704915cb6b79f94eeb5a4321556033 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.3.0" + flutter_ai_core: + dependency: transitive + description: + name: flutter_ai_core + sha256: b188f83a4bf78f59c1c830dab348cfcd9be40e40ef6401b3d2d21bee1c7c3d04 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.1.14" + flutter_ai_elements: + dependency: "direct main" + description: + path: "../flutter_ai/packages/flutter_ai_elements" + relative: true + source: path + version: "0.2.0" + flutter_ai_provider_anthropic: + dependency: "direct main" + description: + path: "../flutter_ai/packages/flutter_ai_provider_anthropic" + relative: true + source: path + version: "0.1.12" flutter_inappwebview: dependency: transitive description: @@ -306,6 +352,14 @@ packages: description: flutter source: sdk version: "0.0.0" + highlight: + dependency: "direct main" + description: + name: highlight + sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.0" http: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 8aed25c..1a3c694 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,14 @@ dependencies: dynamic_color: ">=1.7.0 <1.8.0" flutter: sdk: flutter + # AI chat UI: the flutter_ai library family, pulled in as path deps from the + # sibling repo. flutter_ai_elements transitively brings client + core. + flutter_ai_elements: + path: ../flutter_ai/packages/flutter_ai_elements + flutter_ai_provider_anthropic: + path: ../flutter_ai/packages/flutter_ai_provider_anthropic + # Syntax highlighting for AI code blocks (port of highlight.js, pure Dart). + highlight: ^0.7.0 flutter_secure_storage: ^9.2.4 flutter_secure_storage_ohos: # HarmonyOS 安全存储 git: diff --git a/test/ai_service_test.dart b/test/ai_service_test.dart new file mode 100644 index 0000000..40db53c --- /dev/null +++ b/test/ai_service_test.dart @@ -0,0 +1,168 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:techpie/models/ai_chat.dart'; +import 'package:techpie/services/ai_service.dart'; +import 'package:techpie/services/storage_service.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // flutter_secure_storage_ohos reads via this method channel; in unit tests + // there's no plugin, so stub it to behave as empty storage. + const channel = MethodChannel('plugins.it_nomads.com/flutter_secure_storage'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + switch (call.method) { + case 'read': + return null; // nothing stored → AiService loads defaults + case 'write': + case 'delete': + return null; + default: + return null; + } + }); + + late StorageService storage; + late AiService ai; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + storage = StorageService(prefs); + ai = AiService(storage); + await ai.initialize(); + }); + + group('AiThread serialization', () { + test('round-trips through JSON with a single text part', () { + final thread = AiThread( + id: 't1', + title: 'hello', + updatedAt: DateTime.utc(2026, 7, 26), + messages: [ + AiMessage( + id: 'm1', + role: AiRole.user, + parts: [TextPart('hi there')], + ), + ], + ); + final json = thread.toJson(); + final restored = AiThread.fromJson(json); + + expect(restored.id, 't1'); + expect(restored.title, 'hello'); + expect(restored.messages, hasLength(1)); + expect(restored.messages.first.role, AiRole.user); + expect(restored.messages.first.text, 'hi there'); + expect(restored.messages.first.parts.first, isA()); + }); + + test('empty-content non-system messages are dropped on load', () { + final raw = { + 'id': 't2', + 'title': 'x', + 'updatedAt': '2026-07-26T00:00:00.000Z', + 'messages': [ + {'id': 'a', 'role': 'user', 'status': 'complete', 'parts': [ + {'type': 'text', 'text': ''}, + ]}, + {'id': 'b', 'role': 'assistant', 'status': 'complete', 'parts': [ + {'type': 'text', 'text': 'real reply'}, + ]}, + ], + }; + final restored = AiThread.fromJson(raw); + expect(restored.messages, hasLength(1)); + expect(restored.messages.first.text, 'real reply'); + }); + }); + + group('AiService lifecycle', () { + test('initialize seeds one fresh conversation', () { + expect(ai.conversations, hasLength(1)); + expect(ai.currentConversation, isNotNull); + expect(ai.currentConversation!.title, '新对话'); + expect(ai.isConfigured, isFalse); + expect(ai.isStreaming, isFalse); + }); + + test('newConversation adds a thread and switches to it', () { + final first = ai.currentConversation!.id; + final created = ai.newConversation(); + expect(ai.conversations, hasLength(2)); + expect(ai.currentConversation!.id, created.id); + expect(created.id, isNot(first)); + }); + + test('renameConversation updates title and is reflected in the list', () { + final id = ai.currentConversation!.id; + ai.renameConversation(id, '我的对话'); + expect(ai.currentConversation!.title, '我的对话'); + }); + + test('deleteConversation on the current thread re-points to another', () { + final a = ai.newConversation(); + final b = ai.newConversation(); + expect(ai.currentConversation!.id, b.id); + ai.deleteConversation(b.id); + expect(ai.conversations.any((c) => c.id == b.id), isFalse); + expect(ai.currentConversation, isNotNull); + expect(ai.currentConversation!.id, isNot(b.id)); + expect(ai.conversations.any((c) => c.id == a.id), isTrue); + }); + + test('clearAllConversations leaves exactly one fresh thread', () async { + ai.newConversation(); + ai.newConversation(); + await ai.clearAllConversations(); + expect(ai.conversations, hasLength(1)); + expect(ai.currentConversation!.title, '新对话'); + }); + + test('selectConversation switches the current pointer', () { + final a = ai.currentConversation!.id; + final b = ai.newConversation().id; + expect(ai.currentConversation!.id, b); + ai.selectConversation(a); + expect(ai.currentConversation!.id, a); + }); + }); + + group('baseUrl normalization', () { + test('appends /v1 when missing', () { + expect( + normalizeAiBaseUrl('https://api.deepseek.com/anthropic'), + 'https://api.deepseek.com/anthropic/v1', + ); + }); + + test('strips a trailing /messages', () { + expect( + normalizeAiBaseUrl('https://api.deepseek.com/anthropic/v1/messages'), + 'https://api.deepseek.com/anthropic/v1', + ); + }); + + test('leaves an already-/v1 root untouched', () { + expect( + normalizeAiBaseUrl('https://api.deepseek.com/anthropic/v1'), + 'https://api.deepseek.com/anthropic/v1', + ); + }); + + test('trims trailing slashes', () { + expect( + normalizeAiBaseUrl('https://api.anthropic.com/v1/'), + 'https://api.anthropic.com/v1', + ); + }); + + test('falls back to the default for empty input', () { + expect(normalizeAiBaseUrl(''), AiConfig.defaultBaseUrl); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index e598270..5890c12 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:techpie/main.dart'; +import 'package:techpie/services/ai_service.dart'; import 'package:techpie/services/assignment_service.dart'; import 'package:techpie/services/auth_service.dart'; import 'package:techpie/services/debug_logger.dart'; @@ -38,6 +39,7 @@ void main() { AssignmentService(storage, http, auth, tpAuth, schedule); final oaGym = OaGymService(auth, storage, tpAuth); final sync = SyncService(auth, tpAuth, storage); + final ai = AiService(storage); await tester.pumpWidget( TechPieApp( @@ -51,6 +53,7 @@ void main() { oaGymService: oaGym, uniAuthService: uniAuth, syncService: sync, + aiService: ai, ), ); @@ -79,6 +82,7 @@ void main() { AssignmentService(storage, http, auth, tpAuth, schedule); final oaGym = OaGymService(auth, storage, tpAuth); final sync = SyncService(auth, tpAuth, storage); + final ai = AiService(storage); await tester.pumpWidget( TechPieApp( @@ -92,6 +96,7 @@ void main() { oaGymService: oaGym, uniAuthService: uniAuth, syncService: sync, + aiService: ai, ), ); From f2772b93a1fcb6e0f1e7fd5116ccd3f27703e31f Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Sun, 26 Jul 2026 07:43:25 +0800 Subject: [PATCH 04/15] feat(ai): custom auto-scroll chat with scroll-to-end button Replace AiChat(autoScroll:false) with a custom _AutoScrollChat wrapper around AiConversationView, driven by our own ScrollController. This keeps the no-bottom-padding fix (no trailingSpace flicker) while restoring scroll behavior: - Scroll to end on send / new message, and follow streaming while the user stays near the bottom (don't yank back when they scroll up to read). - Floating scroll-to-end button, shown only when not at the bottom; hides once at the end. - Jump to the latest message on first open and on conversation switch (detected via first-message-id change, since the controller is a singleton that load()s a new transcript). AiChat's own ScrollController is private, so driving scroll from outside required dropping AiChat for the presentational AiConversationView. Co-Authored-By: Claude --- lib/pages/ai_assistant_page.dart | 303 ++++++++++++++++++++++++++----- lib/services/ai_service.dart | 29 ++- 2 files changed, 286 insertions(+), 46 deletions(-) diff --git a/lib/pages/ai_assistant_page.dart b/lib/pages/ai_assistant_page.dart index d2ac719..d68a3d1 100644 --- a/lib/pages/ai_assistant_page.dart +++ b/lib/pages/ai_assistant_page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_ai_client/flutter_ai_client.dart'; import 'package:flutter_ai_elements/flutter_ai_elements.dart'; import '../models/ai_chat.dart'; @@ -165,46 +166,57 @@ class _AiAssistantPageState extends State { ), ], ), - body: ListenableBuilder( - listenable: aiService, - builder: (context, _) { - final notConfigured = !aiService.isConfigured; - final error = aiService.streamingError; - - return Column( - children: [ - SizedBox(height: topInset), - if (notConfigured) _configBanner(context, aiService), - Expanded( - child: AiChat( - controller: aiService.controller, - textRenderer: const StreamingMarkdownRenderer(), - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - emptyState: _emptyState(context, notConfigured), - ), + // The transcript (AiChat) and composer (AiPromptInput) bind the + // controller DIRECTLY and are NOT wrapped in a ListenableBuilder — + // matching the flutter_ai demo. Wrapping them in ListenableBuilder(aiService) + // rebuilds AiChat on every status change, which races AiChat's top-anchor + // scroll logic (its anchor RenderBox goes missing mid-rebuild → trailingSpace + // oscillates → the bottom padding flickers). Only the mutable banners listen + // to aiService; AiChat listens to the controller itself. + body: Column( + children: [ + SizedBox(height: topInset), + ListenableBuilder( + listenable: aiService, + builder: (context, _) { + final notConfigured = !aiService.isConfigured; + if (!notConfigured) return const SizedBox.shrink(); + return _configBanner(context, aiService); + }, + ), + Expanded( + child: _AutoScrollChat( + controller: aiService.controller, + emptyState: Builder( + builder: (context) => + _emptyState(context, !aiService.isConfigured), ), - if (error != null) - Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), - child: AiErrorBanner( - message: error, - onRetry: aiService.isStreaming ? null : () => _retry(aiService), - onDismiss: () => _dismissError(aiService), - ), + ), + ), + ListenableBuilder( + listenable: aiService, + builder: (context, _) { + final error = aiService.streamingError; + if (error == null) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: AiErrorBanner( + message: error, + onRetry: + aiService.isStreaming ? null : () => _retry(aiService), + onDismiss: () => _dismissError(aiService), ), - // The composer only appears once configured; before that the - // config banner + empty state guide the user to set a token. - if (!notConfigured) - AiPromptInput( - controller: aiService.controller, - hintText: '输入消息…', - textController: _textController, - ) - else - SafeArea( + ); + }, + ), + // Composer appears only once configured; before that a button guides + // the user to set a token. This toggle is the only composer-level + // piece that needs aiService. + ListenableBuilder( + listenable: aiService, + builder: (context, _) { + if (!aiService.isConfigured) { + return SafeArea( top: false, child: Padding( padding: const EdgeInsets.all(16), @@ -214,10 +226,16 @@ class _AiAssistantPageState extends State { label: const Text('前往配置 API 令牌'), ), ), - ), - ], - ); - }, + ); + } + return AiPromptInput( + controller: aiService.controller, + hintText: '输入消息…', + textController: _textController, + ); + }, + ), + ], ), ); } @@ -313,3 +331,204 @@ class _AiAssistantPageState extends State { ); } } + +/// A transcript bound to a [UseChatController] that scrolls to the bottom when a +/// message is sent — WITHOUT the library [AiChat]'s ChatGPT-style top-anchor. +/// +/// Why not just use `AiChat(autoScroll: false)`: AiChat's ScrollController is +/// private, so we can't drive scroll-to-end from outside. This wrapper uses the +/// presentational [AiConversationView] with our own ScrollController, so we +/// control scrolling directly. Crucially it never sets `trailingSpace`, so +/// there is no dynamic bottom-padding (the source of the earlier flicker). +/// +/// Scroll behavior: +/// - On a new message (count grows), animate to the bottom. +/// - While streaming, keep pinned to the bottom ONLY if the user is already +/// near the bottom — so scrolling up to read isn't yanked back down. +class _AutoScrollChat extends StatefulWidget { + const _AutoScrollChat({required this.controller, this.emptyState}); + + final UseChatController controller; + final Widget? emptyState; + + @override + State<_AutoScrollChat> createState() => _AutoScrollChatState(); +} + +class _AutoScrollChatState extends State<_AutoScrollChat> { + final ScrollController _scrollController = ScrollController(); + int _lastCount = 0; + /// First message id of the currently-shown thread. When it changes, the user + /// switched conversations (the controller is a singleton that `load()`s a new + /// transcript) — we jump to the bottom of the new thread. + String? _firstMessageId; + bool _nearBottom = true; + + @override + void initState() { + super.initState(); + _lastCount = widget.controller.messages.length; + _firstMessageId = widget.controller.messages.firstOrNull?.id; + widget.controller.addListener(_onChanged); + _scrollController.addListener(_onScroll); + // On first open (or when re-entering a conversation), jump to the latest + // message so the user lands at the bottom of the transcript. + _jumpToEnd(); + } + + @override + void didUpdateWidget(covariant _AutoScrollChat oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_onChanged); + widget.controller.addListener(_onChanged); + _lastCount = widget.controller.messages.length; + _firstMessageId = widget.controller.messages.firstOrNull?.id; + // Switched controllers — land at the bottom of the new thread. + _jumpToEnd(); + } + } + + @override + void dispose() { + widget.controller.removeListener(_onChanged); + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (!_scrollController.hasClients) return; + final pos = _scrollController.position; + final near = (pos.maxScrollExtent - pos.pixels) <= 80; + if (near != _nearBottom) { + setState(() => _nearBottom = near); + } + } + + void _onChanged() { + final messages = widget.controller.messages; + final count = messages.length; + final firstId = messages.firstOrNull?.id; + // The controller is a singleton; a conversation switch swaps the transcript + // in place via load(). Detect it by the first message id changing. + final switched = firstId != _firstMessageId; + _firstMessageId = firstId; + final grew = count > _lastCount; + _lastCount = count; + if (!mounted) return; + if (switched) { + // New conversation — jump (no animation) to its latest message. + _jumpToEnd(); + return; + } + // Scroll to end when a new message lands (user sent / assistant turn + // started), or while streaming if the user is still pinned near the bottom. + final streaming = widget.controller.status == ChatStatus.streaming; + if (grew || (streaming && _nearBottom)) { + _scrollToEnd(); + } + } + + /// Animated scroll to the bottom — used while streaming / on send. + void _scrollToEnd() { + if (!_scrollController.hasClients) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) return; + final pos = _scrollController.position; + unawaited( + _scrollController.animateTo( + pos.maxScrollExtent, + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + ), + ); + }); + } + + /// Instant jump to the bottom — used on init / conversation switch so the + /// user lands at the latest message without a scroll animation. + void _jumpToEnd() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollController.hasClients) return; + _scrollController.jumpTo(_scrollController.position.maxScrollExtent); + }); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: widget.controller, + builder: (context, _) { + final messages = widget.controller.messages; + if (messages.isEmpty && + !widget.controller.status.isBusy && + widget.emptyState != null) { + return widget.emptyState!; + } + final view = AiConversationView( + messages: messages, + scrollController: _scrollController, + textRenderer: const StreamingMarkdownRenderer(), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + // Show the thinking loader while awaiting the first streamed token, + // matching AiChat's behavior. + showLoader: widget.controller.status == ChatStatus.submitted, + // trailingSpace stays 0 (default) — no bottom padding, no flicker. + ); + // Floating "scroll to end" button, shown only when not already at the + // bottom (and there's content to scroll). Hidden once at the end. + final showJump = !_nearBottom && messages.isNotEmpty; + return Stack( + children: [ + view, + if (showJump) + PositionedDirectional( + bottom: 12, + start: 0, + end: 0, + child: Center(child: _ScrollToEndButton(onTap: _scrollToEnd)), + ), + ], + ); + }, + ); + } +} + +/// A small circular "scroll to end" affordance. Styled to match the library's +/// jump button (AiChat._JumpButton) so it feels native to the chat surface. +class _ScrollToEndButton extends StatelessWidget { + const _ScrollToEndButton({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Semantics( + button: true, + label: '滚动到最新', + child: Material( + color: theme.colorScheme.surfaceContainerHigh, + shape: const CircleBorder(), + elevation: 2, + shadowColor: Colors.black.withValues(alpha: 0.2), + child: InkWell( + customBorder: const CircleBorder(), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + Icons.arrow_downward_rounded, + size: 20, + color: theme.colorScheme.onSurface, + ), + ), + ), + ), + ); + } +} + + diff --git a/lib/services/ai_service.dart b/lib/services/ai_service.dart index 44409c2..f75f374 100644 --- a/lib/services/ai_service.dart +++ b/lib/services/ai_service.dart @@ -42,6 +42,12 @@ class AiService extends ChangeNotifier { /// isn't configured yet). Cleared when a real turn starts or config is saved. String? _userError; + /// Last-seen controller status / error, to detect transitions worth notifying + /// the UI about (streaming start/stop, error appear/clear) without firing on + /// every token. + ChatStatus? _lastStatus; + Object? _lastError; + /// Debounced persistence timer. Timer? _persistTimer; @@ -268,12 +274,27 @@ class AiService extends ChangeNotifier { if (_forwarding) return; _forwarding = true; try { - // Mirror the controller's live transcript back into our persisted thread - // so currentConversation reflects streamed tokens, then forward the notify. + // Mirror the controller's live transcript into the persisted thread so + // currentConversation reflects streamed tokens. Done silently — token + // arrivals must NOT call notifyListeners, or the page rebuilds every + // token and AiChat's scroll/anchor logic fights the rebuild (visible as + // flicker + no smooth auto-scroll). AiChat listens to the controller + // directly for live transcript updates. _syncCurrentFromController(); - notifyListeners(); + final status = _controller?.status; + final error = _controller?.error; + // Only surface a notification when something the UI actually cares about + // changes: streaming started/stopped, or an error appeared/cleared. + // Token-only changes (status stays streaming, same error) are silent. + final statusChanged = status != _lastStatus; + final errorChanged = error != _lastError; + _lastStatus = status; + _lastError = error; + if (statusChanged || errorChanged) { + notifyListeners(); + } // Persist once the turn settles (status leaves streaming). - if (_controller?.status != ChatStatus.streaming) { + if (status != ChatStatus.streaming) { _schedulePersist(); } } finally { From 6562b60aa131945060020e9bbac4363d8e04ad40 Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Sun, 26 Jul 2026 13:47:26 +0800 Subject: [PATCH 05/15] feat(ai): tool calling for schedule/semesters/assignments/time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the flutter_ai tool chain (ToolSpec + ToolRegistry + onToolCalls agent loop) so the assistant can query real campus data via 4 read-only tools: - get_current_time: now / weekday / current teaching week - get_semesters: available terms + current selection (eGate-gated) - get_week_schedule: courses for a semester/week, grouped by weekday (eGate-gated; defaults to current semester + current week) - get_assignments: upcoming assignments + exams across platforms (filterable by platform/kind) Tools are declared as ToolSpecs in lib/services/ai_tools.dart; their execute closures call ScheduleService / AssignmentService / DateTime.now() and return JSON Maps. AiService injects tools + onToolCalls into the UseChatController, enabling the automatic agent loop (multi-round, maxSteps=8; validateToolArgs on). Tool executors auto-ensure data (cache then network) and return a friendly error when eGate isn't bound. Rendering: a custom messageBuilder on the chat view pairs each ToolCallPart with its ToolResultPart across the whole transcript (the agent loop lands results in a separate AiRole.tool message, which the default bubble can't pair) — renders AiToolInvocation / AiToolGroup cards that expand to show args + result. System prompt updated to tell the model when to call each tool and to surface eGate-binding errors to the user. flutter analyze: 0 errors/warnings. flutter test: 31 passing. Co-Authored-By: Claude --- lib/main.dart | 7 +- lib/models/ai_chat.dart | 10 +- lib/pages/ai_assistant_page.dart | 72 +++++++++- lib/services/ai_service.dart | 41 +++++- lib/services/ai_tools.dart | 233 +++++++++++++++++++++++++++++++ pubspec.lock | 7 + pubspec.yaml | 2 + test/ai_service_test.dart | 17 ++- test/widget_test.dart | 4 +- 9 files changed, 383 insertions(+), 10 deletions(-) create mode 100644 lib/services/ai_tools.dart diff --git a/lib/main.dart b/lib/main.dart index 3e61184..998a951 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -81,7 +81,12 @@ Future _realMain(SharedPreferences prefs) async { scheduleService, ); final syncService = SyncService(authService, thirdPartyAuthService, storageService); - final aiService = AiService(storageService); + final aiService = AiService( + storageService, + scheduleService, + assignmentService, + thirdPartyAuthService, + ); authService.onLogout = () async { // Third-party bindings persist across logouts — they will be used by the diff --git a/lib/models/ai_chat.dart b/lib/models/ai_chat.dart index fb46811..bd4267f 100644 --- a/lib/models/ai_chat.dart +++ b/lib/models/ai_chat.dart @@ -121,7 +121,15 @@ class AiConfig { /// appends `/messages` itself. static const defaultBaseUrl = 'https://api.deepseek.com/anthropic/v1'; static const defaultModel = 'deepseek-v4-flash'; - static const _defaultSystemPrompt = '你是 TechPie AI 助手,为上海科技大学师生提供帮助。请用中文简洁、准确地回答问题。'; + static const _defaultSystemPrompt = + '你是 TechPie AI 助手,为上海科技大学师生提供帮助。请用中文简洁、准确地回答问题。\n\n' + '你可以调用以下工具查询校园数据(数据来自上海科技大学校园系统):\n' + '- get_current_time:当前时间、星期几、第几教学周\n' + '- get_semesters:可用学期列表及当前选中学期\n' + '- get_week_schedule:指定学期指定周的课程表(可按学期/周过滤,默认当前)\n' + '- get_assignments:作业与考试截止时间列表(可按平台/类型过滤)\n\n' + '当用户询问时间、课程表、作业、考试、截止日期等信息时,请主动调用相应工具获取真实数据,' + '不要凭空编造。工具返回的 error 字段说明查询失败(如未绑定 eGate),应如实告知用户并引导其在设置中绑定校园账号。'; factory AiConfig.defaults() => const AiConfig( baseUrl: defaultBaseUrl, diff --git a/lib/pages/ai_assistant_page.dart b/lib/pages/ai_assistant_page.dart index d68a3d1..7b3cdcb 100644 --- a/lib/pages/ai_assistant_page.dart +++ b/lib/pages/ai_assistant_page.dart @@ -8,6 +8,7 @@ import '../models/ai_chat.dart'; import '../services/ai_service.dart'; import '../services/service_provider.dart'; import '../utils/platform.dart'; +import '../widgets/ai/ai_code_highlighter.dart'; import '../widgets/ai/ai_text_renderer.dart'; import '../widgets/blurred_app_bar.dart'; import '../widgets/ios_liquid/ios_native_navigation_bar.dart'; @@ -455,6 +456,71 @@ class _AutoScrollChatState extends State<_AutoScrollChat> { }); } + /// Builds a message bubble, pairing tool calls with their results across the + /// whole transcript (the agent loop appends results as a separate + /// AiRole.tool message, so per-message pairing would miss them). + Widget _buildMessage(BuildContext context, AiMessage message) { + // Tool-result messages are folded into the assistant turn's tool cards. + if (message.role == AiRole.tool) return const SizedBox.shrink(); + // User messages render with the default bubble. + if (message.role == AiRole.user) { + return AiMessageBubble(message: message); + } + + // Assistant: gather tool results across the whole transcript so each + // ToolCallPart pairs with its result regardless of which message it's in. + final results = { + for (final m in widget.controller.messages) + for (final p in m.parts) + if (p is ToolResultPart) p.toolCallId: p, + }; + final toolCalls = message.parts.whereType().toList(); + + final children = []; + void add(Widget w) { + if (children.isNotEmpty) children.add(const SizedBox(height: 10)); + children.add(w); + } + + var toolsRendered = false; + for (final part in message.parts) { + switch (part) { + case TextPart(:final text): + if (text.isNotEmpty) { + add(AiResponse(text: text, codeHighlighter: techpieCodeHighlighter)); + } + case ToolCallPart(): + // Render all tool calls once (a group when parallel, else one card), + // each paired with its transcript-wide result. + if (!toolsRendered) { + toolsRendered = true; + add( + toolCalls.length > 1 + ? AiToolGroup(calls: toolCalls, results: results) + : AiToolInvocation( + call: part, + result: results[part.toolCallId], + ), + ); + } + case ToolResultPart(): + break; // rendered within its AiToolInvocation card + case ReasoningPart(:final text): + if (text.isNotEmpty) add(AiReasoning(text: text)); + default: + break; // FilePart/SourcePart/DataPart not used by TechPie tools yet + } + } + + if (children.isEmpty) return const SizedBox.shrink(); + if (children.length == 1) return children.first; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: children, + ); + } + @override Widget build(BuildContext context) { return ListenableBuilder( @@ -474,7 +540,11 @@ class _AutoScrollChatState extends State<_AutoScrollChat> { // Show the thinking loader while awaiting the first streamed token, // matching AiChat's behavior. showLoader: widget.controller.status == ChatStatus.submitted, - // trailingSpace stays 0 (default) — no bottom padding, no flicker. + // Custom builder pairs tool calls with their results across the whole + // transcript (the agent loop lands ToolResultParts in a separate + // AiRole.tool message; the default bubble only pairs within one + // message). Tool-result messages are collapsed into the call card. + messageBuilder: _buildMessage, ); // Floating "scroll to end" button, shown only when not already at the // bottom (and there's content to scroll). Hidden once at the end. diff --git a/lib/services/ai_service.dart b/lib/services/ai_service.dart index f75f374..1336ab2 100644 --- a/lib/services/ai_service.dart +++ b/lib/services/ai_service.dart @@ -4,9 +4,14 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_ai_client/flutter_ai_client.dart'; import 'package:flutter_ai_core/flutter_ai_core.dart'; import 'package:flutter_ai_provider_anthropic/flutter_ai_provider_anthropic.dart'; +import 'package:flutter_ai_tools/flutter_ai_tools.dart'; import '../models/ai_chat.dart'; +import 'ai_tools.dart'; +import 'assignment_service.dart'; +import 'schedule_service.dart'; import 'storage_service.dart'; +import 'third_party_auth_service.dart'; /// Central state for the AI Assistant feature. /// @@ -24,6 +29,16 @@ import 'storage_service.dart'; /// transcript back to [StorageService] when a turn settles. class AiService extends ChangeNotifier { final StorageService _storage; + final ScheduleService _schedule; + final AssignmentService _assignments; + final ThirdPartyAuthService _tpAuth; + + /// The campus-service tools the model can call. Built once at construction. + late final ToolRegistry _tools = buildAiTools( + scheduleService: _schedule, + assignmentService: _assignments, + thirdPartyAuthService: _tpAuth, + ); AiConfig _config = AiConfig.defaults(); List _conversations = const []; @@ -51,7 +66,12 @@ class AiService extends ChangeNotifier { /// Debounced persistence timer. Timer? _persistTimer; - AiService(this._storage); + AiService( + this._storage, + this._schedule, + this._assignments, + this._tpAuth, + ); // ---- Accessors ---- @@ -255,9 +275,22 @@ class AiService extends ChangeNotifier { _controller = UseChatController( provider: _buildProvider(), options: _buildOptions(), - // The controller defaults to scheduleMicrotask for notification - // coalescing, so a fast token stream collapses into one notify per - // microtask — plenty smooth, and matches the flutter_ai demo. + // Campus-service tools (schedule / assignments / time). Providing + // onToolCalls turns the controller into an automatic agent loop: + // after a stream, pending ToolCallParts are executed, results are + // appended as an AiRole.tool message, and the model is re-prompted — + // repeating until no tool calls remain (bounded by maxSteps=8). + tools: _tools.definitions, + onToolCalls: (calls, signal) async { + // Run each call through the registry; it catches per-tool failures + // as isError results so one bad tool doesn't kill the batch. + final results = []; + for (final call in calls) { + if (signal.isCancelled) break; + results.add(await _tools.run(call)); + } + return results; + }, ); _controller!.addListener(_onControllerChanged); } diff --git a/lib/services/ai_tools.dart b/lib/services/ai_tools.dart new file mode 100644 index 0000000..1137d88 --- /dev/null +++ b/lib/services/ai_tools.dart @@ -0,0 +1,233 @@ +import 'package:flutter_ai_tools/flutter_ai_tools.dart'; + +import '../models/course_table.dart'; +import 'assignment_service.dart'; +import 'schedule_service.dart'; +import 'third_party_auth_service.dart'; + +/// Builds the [ToolRegistry] of campus-service tools the AI assistant can call. +/// +/// All four tools are read-only and safe to auto-execute (no confirmation +/// gate). Each executor returns a JSON-encodable Map (best for the model to +/// reason over) and never throws — failures become a `{'error': ...}` map so +/// the model can surface them to the user. eGate-gated tools return a friendly +/// "not bound" error when the user hasn't bound their campus account. +ToolRegistry buildAiTools({ + required ScheduleService scheduleService, + required AssignmentService assignmentService, + required ThirdPartyAuthService thirdPartyAuthService, +}) { + return ToolRegistry([ + ToolSpec( + name: 'get_current_time', + description: + 'Get the current date, time, day of week, and the current academic ' + 'week number (1-based, derived from the semester\'s term-begin date). ' + 'Call this whenever the user asks what time/day/week it is, or when ' + 'a relative date is needed.', + parametersSchema: const { + 'type': 'object', + 'properties': {}, + }, + execute: (args) async { + final now = DateTime.now(); + const weekdayNames = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']; + return { + 'now': now.toIso8601String(), + 'weekday': now.weekday, + 'weekdayName': weekdayNames[now.weekday], + 'currentWeek': scheduleService.currentWeek(), + }; + }, + ), + + ToolSpec( + name: 'get_semesters', + description: + 'List the student\'s available semesters (e.g. "2024-2025 春学期") ' + 'with their IDs, and which semester is currently selected. Requires ' + 'the eGate campus binding. Call when the user asks about available ' + 'terms or which semester is active.', + parametersSchema: const { + 'type': 'object', + 'properties': {}, + }, + execute: (args) async { + if (!thirdPartyAuthService.hasEgateBinding) { + return {'error': '未绑定 eGate 校园账号,请在设置中绑定后再试。'}; + } + await scheduleService.loadCachedData(); + var info = scheduleService.semesterInfo; + if (info == null || info.allSemesters.isEmpty) { + await scheduleService.fetchSemesters(); + info = scheduleService.semesterInfo; + } + if (info == null) { + return {'error': scheduleService.error ?? '获取学期列表失败'}; + } + return { + 'currentSemesterId': scheduleService.selectedSemesterId, + 'currentLabel': + scheduleService.selectedSemesterId == null + ? null + : info.findSemesterLabel(scheduleService.selectedSemesterId!), + 'semesters': [ + for (final e in info.allSemesters) + {'id': e.key, 'label': e.value}, + ], + }; + }, + ), + + ToolSpec( + name: 'get_week_schedule', + description: + 'Get the course schedule for a specific semester and week. Returns ' + 'courses grouped by weekday (周一~周日), each with name, location, ' + 'period range, time range, teachers, and active weeks. Requires the ' + 'eGate campus binding. If semesterId or week are omitted, defaults to ' + 'the current semester and current week. Call when the user asks about ' + 'their timetable / what classes they have.', + parametersSchema: const { + 'type': 'object', + 'properties': { + 'semesterId': { + 'type': 'string', + 'description': + 'Semester ID (from get_semesters). Omit for the current ' + 'semester.', + }, + 'week': { + 'type': 'integer', + 'description': 'Week number (1-based). Omit for the current week.', + 'minimum': 1, + 'maximum': 25, + }, + }, + }, + execute: (args) async { + if (!thirdPartyAuthService.hasEgateBinding) { + return {'error': '未绑定 eGate 校园账号,请在设置中绑定后再试。'}; + } + await scheduleService.loadCachedData(); + final semesterId = + (args['semesterId'] as String?)?.isNotEmpty == true + ? args['semesterId'] as String + : scheduleService.selectedSemesterId; + if (semesterId == null) { + return {'error': '无法确定当前学期,请先调用 get_semesters。'}; + } + // Fetch the course table for the requested semester if not cached. + if (scheduleService.courseTable == null) { + await scheduleService.fetchCourseTable(semesterId); + } + final table = scheduleService.courseTable; + if (table == null) { + return {'error': scheduleService.error ?? '获取课程表失败'}; + } + final week = + args['week'] is int + ? args['week'] as int + : scheduleService.currentWeek(); + final display = eamsToDisplayCourses(table.courses, week); + // Period index → time range, from the table's period definitions. + final periodTimes = {}; + for (final p in table.periods) { + periodTimes[p.index] = p.timeRange; + } + const dayNames = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日']; + // Group by weekday. + final byDay = >>{}; + for (final c in display) { + byDay.putIfAbsent(c.dayOfWeek, () => []).add({ + 'name': c.name, + 'location': c.location, + 'periods': '${c.startPeriod}-${c.endPeriod}', + 'time': + '${periodTimes[c.startPeriod] ?? ''}~${periodTimes[c.endPeriod] ?? ''}', + if (c.teachers != null && c.teachers!.isNotEmpty) + 'teachers': c.teachers, + if (c.weeksText != null && c.weeksText!.isNotEmpty) + 'weeks': c.weeksText, + if (c.isGhost) 'note': '本周无此课(仅供参考)', + }); + } + final days = >[]; + for (var d = 1; d <= 7; d++) { + final courses = byDay[d]; + if (courses == null || courses.isEmpty) continue; + days.add({'day': dayNames[d], 'courses': courses}); + } + final info = scheduleService.semesterInfo; + return { + 'semesterId': semesterId, + 'semesterLabel': info?.findSemesterLabel(semesterId), + 'week': week, + 'days': days, + if (days.isEmpty) 'note': '本周没有课程', + }; + }, + ), + + ToolSpec( + name: 'get_assignments', + description: + 'Get the student\'s upcoming assignments and exams (deadlines) across ' + 'all platforms (Blackboard, exams, Gradescope, Hydro), sorted by due ' + 'date. Each item has title, course, due date, platform, kind ' + '(作业/考试), and status. Optionally filter by platform or kind. Call ' + 'when the user asks about homework, deadlines, or exams.', + parametersSchema: const { + 'type': 'object', + 'properties': { + 'platform': { + 'type': 'string', + 'description': + 'Filter to one platform: blackboard, exam, gradescope, or hydro. ' + 'Omit for all.', + 'enum': ['blackboard', 'exam', 'gradescope', 'hydro'], + }, + 'kind': { + 'type': 'string', + 'description': 'Filter to assignments or exams. Omit for both.', + 'enum': ['assignment', 'exam'], + }, + }, + }, + execute: (args) async { + assignmentService.loadCached(); + if (assignmentService.visibleAssignments.isEmpty) { + await assignmentService.fetchAssignments(); + } + var items = assignmentService.visibleAssignments; + final platform = args['platform'] as String?; + final kind = args['kind'] as String?; + if (platform != null) { + items = items.where((a) => a.platform == platform).toList(); + } + if (kind != null) { + items = items.where((a) => a.kind.id == kind).toList(); + } + final errors = assignmentService.platformErrors; + return { + 'assignments': [ + for (final a in items) + { + 'title': a.title, + 'course': a.course, + 'due': a.due.toIso8601String(), + if (a.lateDue != null) 'lateDue': a.lateDue!.toIso8601String(), + 'platform': a.platform, + 'kind': a.kind.label, + if (a.status != null) 'status': a.status, + 'submitted': a.submitted, + if (a.url != null) 'url': a.url, + }, + ], + if (assignmentService.error != null) 'error': assignmentService.error, + if (errors.isNotEmpty) 'platformErrors': errors, + }; + }, + ), + ]); +} diff --git a/pubspec.lock b/pubspec.lock index 134fbe1..f75d489 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -205,6 +205,13 @@ packages: relative: true source: path version: "0.1.12" + flutter_ai_tools: + dependency: "direct main" + description: + path: "../flutter_ai/packages/flutter_ai_tools" + relative: true + source: path + version: "0.1.4" flutter_inappwebview: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1a3c694..180bdc9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,6 +43,8 @@ dependencies: path: ../flutter_ai/packages/flutter_ai_elements flutter_ai_provider_anthropic: path: ../flutter_ai/packages/flutter_ai_provider_anthropic + flutter_ai_tools: + path: ../flutter_ai/packages/flutter_ai_tools # Syntax highlighting for AI code blocks (port of highlight.js, pure Dart). highlight: ^0.7.0 flutter_secure_storage: ^9.2.4 diff --git a/test/ai_service_test.dart b/test/ai_service_test.dart index 40db53c..ec0b413 100644 --- a/test/ai_service_test.dart +++ b/test/ai_service_test.dart @@ -4,7 +4,14 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:techpie/models/ai_chat.dart'; import 'package:techpie/services/ai_service.dart'; +import 'package:techpie/services/assignment_service.dart'; +import 'package:techpie/services/auth_service.dart'; +import 'package:techpie/services/debug_logger.dart'; +import 'package:techpie/services/http_client.dart'; +import 'package:techpie/services/schedule_service.dart'; import 'package:techpie/services/storage_service.dart'; +import 'package:techpie/services/third_party_auth_service.dart'; +import 'package:techpie/services/uni_auth_service.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -32,7 +39,15 @@ void main() { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); storage = StorageService(prefs); - ai = AiService(storage); + final logger = DebugLogger(); + final http = LoggingHttpClient(logger); + final uniAuth = UniAuthService(); + final auth = AuthService(storage, http, uniAuth); + final tpAuth = ThirdPartyAuthService(storage, http); + final schedule = ScheduleService(storage, http, auth, tpAuth); + final assignments = + AssignmentService(storage, http, auth, tpAuth, schedule); + ai = AiService(storage, schedule, assignments, tpAuth); await ai.initialize(); }); diff --git a/test/widget_test.dart b/test/widget_test.dart index 5890c12..686efb4 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -39,7 +39,7 @@ void main() { AssignmentService(storage, http, auth, tpAuth, schedule); final oaGym = OaGymService(auth, storage, tpAuth); final sync = SyncService(auth, tpAuth, storage); - final ai = AiService(storage); + final ai = AiService(storage, schedule, assignments, tpAuth); await tester.pumpWidget( TechPieApp( @@ -82,7 +82,7 @@ void main() { AssignmentService(storage, http, auth, tpAuth, schedule); final oaGym = OaGymService(auth, storage, tpAuth); final sync = SyncService(auth, tpAuth, storage); - final ai = AiService(storage); + final ai = AiService(storage, schedule, assignments, tpAuth); await tester.pumpWidget( TechPieApp( From 25bd07471cf5c2427a4d092bdde56c08d2e7d646 Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Sun, 26 Jul 2026 16:26:13 +0800 Subject: [PATCH 06/15] feat(ai): cache-first tools with per-semester cache and refresh param MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI tools now read the per-semester storage cache first and only hit the network on a miss (writing back to storage) or when the model passes refresh=true. Fixes the old bug where tools read the in-memory course table — which only holds the selected semester — so querying a past semester returned nothing; and stops every AI query from firing live API requests. Assignment fetch fan-out is factored into _fanOutFetches, shared by the mutating and non-mutating paths. Co-Authored-By: Claude Fable 5 --- lib/services/ai_tools.dart | 124 +++++++++++++++++++------ lib/services/assignment_service.dart | 132 ++++++++++++++++++--------- lib/services/schedule_service.dart | 80 ++++++++++++++++ 3 files changed, 264 insertions(+), 72 deletions(-) diff --git a/lib/services/ai_tools.dart b/lib/services/ai_tools.dart index a4c262d..6710a4a 100644 --- a/lib/services/ai_tools.dart +++ b/lib/services/ai_tools.dart @@ -1,5 +1,6 @@ import 'package:flutter_ai_tools/flutter_ai_tools.dart'; +import '../models/assignment.dart'; import '../models/course_table.dart'; import 'assignment_service.dart'; import 'schedule_service.dart'; @@ -47,23 +48,31 @@ ToolRegistry buildAiTools({ 'List the student\'s available semesters (e.g. "2024-2025 春学期") ' 'with their IDs, and which semester is currently selected. Requires ' 'the eGate campus binding. Call when the user asks about available ' - 'terms or which semester is active.', + 'terms or which semester is active. Served from the local cache; ' + 'pass refresh=true only if the user explicitly wants fresh data.', parametersSchema: const { 'type': 'object', - 'properties': {}, + 'properties': { + 'refresh': { + 'type': 'boolean', + 'description': + 'Force a live fetch, bypassing the local cache. Default false.', + }, + }, }, execute: (args) async { if (!thirdPartyAuthService.hasCpdailyBinding) { return {'error': '未绑定校园账号(eGate),请在设置中绑定后再试。'}; } - await scheduleService.loadCachedData(); - var info = scheduleService.semesterInfo; - if (info == null || info.allSemesters.isEmpty) { - await scheduleService.fetchSemesters(); - info = scheduleService.semesterInfo; - } - if (info == null) { - return {'error': scheduleService.error ?? '获取学期列表失败'}; + // Cache-first: in-memory/storage semester list, live only on miss or + // explicit refresh. Never writes ScheduleService's in-memory state. + final SemesterInfo info; + try { + info = await scheduleService.semestersCachedOrLive( + refresh: args['refresh'] == true, + ); + } catch (e) { + return {'error': '获取学期列表失败:$e'}; } return { 'currentSemesterId': scheduleService.selectedSemesterId, @@ -87,7 +96,10 @@ ToolRegistry buildAiTools({ 'period range, time range, teachers, and active weeks. Requires the ' 'eGate campus binding. If semesterId or week are omitted, defaults to ' 'the current semester and current week. Call when the user asks about ' - 'their timetable / what classes they have.', + 'their timetable / what classes they have. Served from the local ' + 'per-semester cache; pass refresh=true only if the user explicitly ' + 'wants fresh data. When querying a non-current semester, pass an ' + 'explicit week.', parametersSchema: const { 'type': 'object', 'properties': { @@ -103,13 +115,17 @@ ToolRegistry buildAiTools({ 'minimum': 1, 'maximum': 25, }, + 'refresh': { + 'type': 'boolean', + 'description': + 'Force a live fetch, bypassing the local cache. Default false.', + }, }, }, execute: (args) async { if (!thirdPartyAuthService.hasCpdailyBinding) { return {'error': '未绑定校园账号(eGate),请在设置中绑定后再试。'}; } - await scheduleService.loadCachedData(); final semesterId = (args['semesterId'] as String?)?.isNotEmpty == true ? args['semesterId'] as String @@ -117,18 +133,44 @@ ToolRegistry buildAiTools({ if (semesterId == null) { return {'error': '无法确定当前学期,请先调用 get_semesters。'}; } - // Fetch the course table for the requested semester if not cached. - if (scheduleService.courseTable == null) { - await scheduleService.fetchCourseTable(semesterId); + // Cache-first per-semester table: storage is keyed by semesterId, so + // a non-selected (e.g. past) semester hits its own cache entry — the + // old bug came from reading the in-memory table, which only holds the + // selected semester. On miss/refresh this fetches live and writes back + // to storage only, never ScheduleService's shared _courseTable, so + // querying a non-selected semester doesn't pollute the schedule UI. + final CourseTable table; + try { + table = await scheduleService.courseTableFor( + semesterId, + refresh: args['refresh'] == true, + ); + } catch (e) { + return {'error': '获取课程表失败:$e'}; } - final table = scheduleService.courseTable; - if (table == null) { - return {'error': scheduleService.error ?? '获取课程表失败'}; + int week; + String? weekNote; + if (args['week'] is int) { + week = args['week'] as int; + } else if (semesterId == scheduleService.selectedSemesterId) { + week = scheduleService.currentWeek(); + } else { + // Non-selected semester with no explicit week: currentWeek() is + // derived from the SELECTED semester's term begin, so it would be + // wrong here. Derive from this semester's cached term begin when + // today actually falls inside it; otherwise default to week 1. + final termBegin = scheduleService.termBeginFor(semesterId); + final diff = + termBegin == null + ? -1 + : DateTime.now().difference(termBegin).inDays; + if (diff >= 0 && diff < 25 * 7) { + week = (diff ~/ 7) + 1; + } else { + week = 1; + weekNote = '未指定周数,已默认第 1 周;如需其他周请传 week 参数。'; + } } - final week = - args['week'] is int - ? args['week'] as int - : scheduleService.currentWeek(); final display = eamsToDisplayCourses(table.courses, week); // Period index → time range, from the table's period definitions. final periodTimes = {}; @@ -163,6 +205,7 @@ ToolRegistry buildAiTools({ 'semesterId': semesterId, 'semesterLabel': info?.findSemesterLabel(semesterId), 'week': week, + if (weekNote != null) 'weekNote': weekNote, 'days': days, if (days.isEmpty) 'note': '本周没有课程', }; @@ -176,10 +219,18 @@ ToolRegistry buildAiTools({ 'all platforms (Blackboard, exams, Gradescope, Hydro), sorted by due ' 'date. Each item has title, course, due date, platform, kind ' '(作业/考试), and status. Optionally filter by platform or kind. Call ' - 'when the user asks about homework, deadlines, or exams.', + 'when the user asks about homework, deadlines, or exams. Served from ' + 'the local cache (refreshed in the background at app start); pass ' + 'refresh=true only if the user explicitly wants fresh data.', parametersSchema: const { 'type': 'object', 'properties': { + 'refresh': { + 'type': 'boolean', + 'description': + 'Force a live fetch from all platforms, bypassing the local ' + 'cache. Default false.', + }, 'platform': { 'type': 'string', 'description': @@ -195,11 +246,28 @@ ToolRegistry buildAiTools({ }, }, execute: (args) async { - assignmentService.loadCached(); - if (assignmentService.visibleAssignments.isEmpty) { - await assignmentService.fetchAssignments(); + // Cache-first: the in-memory assignments are hydrated from storage at + // boot and refreshed by the background fan-out, so they're fresh + // within a session. Only go live when the cache is empty or the model + // explicitly asks for a refresh. fetchAssignmentsLive is non-mutating: + // it never writes AssignmentService's shared _assignments / + // _platformErrors or notifies, so a live query here doesn't disturb + // the assignments UI or trigger the schedule→assignment refetch chain. + List source; + Map errors; + if (args['refresh'] != true && + assignmentService.assignments.isNotEmpty) { + source = assignmentService.assignments; + errors = assignmentService.platformErrors; + } else { + final result = await assignmentService.fetchAssignmentsLive(); + source = result.assignments; + errors = result.errors; } - var items = assignmentService.visibleAssignments; + // Honor locally-hidden items (matches the UI's visibleAssignments + // behavior) without mutating the overrides store. + var items = + source.where((a) => !assignmentService.isHidden(a)).toList(); final platform = args['platform'] as String?; final kind = args['kind'] as String?; if (platform != null) { @@ -208,7 +276,6 @@ ToolRegistry buildAiTools({ if (kind != null) { items = items.where((a) => a.kind.id == kind).toList(); } - final errors = assignmentService.platformErrors; return { 'assignments': [ for (final a in items) @@ -224,7 +291,6 @@ ToolRegistry buildAiTools({ if (a.url != null) 'url': a.url, }, ], - if (assignmentService.error != null) 'error': assignmentService.error, if (errors.isNotEmpty) 'platformErrors': errors, }; }, diff --git a/lib/services/assignment_service.dart b/lib/services/assignment_service.dart index 5808150..5f21f04 100644 --- a/lib/services/assignment_service.dart +++ b/lib/services/assignment_service.dart @@ -205,18 +205,46 @@ class AssignmentService extends ChangeNotifier { _platformErrors.clear(); notifyListeners(); + final successfulResults = await _fanOutFetches(_platformErrors); + + try { + final merged = _mergeAssignments( + successfulResults: successfulResults, + ); + + _assignments = merged; + await _storage.saveCachedAssignments( + merged.map((a) => a.toJson()).toList(), + ); + } catch (e) { + _error = '同步失败,请检查网络或稍后重试'; + } finally { + _loading = false; + notifyListeners(); + } + } + + /// Fan out all platform deadline fetches concurrently, writing per-platform + /// failures into [errors] and returning the successful platforms' results. + /// Shared by the state-mutating [fetchAssignments] (which uses + /// [_platformErrors]) and the non-mutating [fetchAssignmentsLive] (which + /// uses a local map). Never throws — a single platform failure is recorded + /// in [errors] and simply omitted from the results. + Future>> _fanOutFetches( + Map errors, + ) async { final successfulResults = >{}; final futures = >[]; if (_tpAuth.hasCpdailyBinding) { futures.add( - _fetchBlackboard().then((items) { + _fetchBlackboard(errors).then((items) { if (items != null) successfulResults['blackboard'] = items; }), ); futures.add( - _fetchExamTable().then((items) { + _fetchExamTable(errors).then((items) { if (items != null) successfulResults['exam'] = items; }), ); @@ -226,14 +254,14 @@ class AssignmentService extends ChangeNotifier { switch (acc.platform) { case ThirdPartyPlatform.gradescope: futures.add( - _fetchGradescope(acc).then((items) { + _fetchGradescope(acc, errors).then((items) { if (items != null) successfulResults[acc.platform.id] = items; }), ); break; case ThirdPartyPlatform.hydro: futures.add( - _fetchHydro(acc).then((items) { + _fetchHydro(acc, errors).then((items) { if (items != null) successfulResults[acc.platform.id] = items; }), ); @@ -244,22 +272,26 @@ class AssignmentService extends ChangeNotifier { } } - try { - await Future.wait(futures); - final merged = _mergeAssignments( - successfulResults: successfulResults, - ); + await Future.wait(futures); + return successfulResults; + } - _assignments = merged; - await _storage.saveCachedAssignments( - merged.map((a) => a.toJson()).toList(), - ); - } catch (e) { - _error = '同步失败,请检查网络或稍后重试'; - } finally { - _loading = false; - notifyListeners(); - } + /// Live, non-mutating fetch of all deadlines. Mirrors the fetch fan-out of + /// [fetchAssignments] but returns the merged list + per-platform errors + /// without touching [_assignments], [_platformErrors], the cache, or the + /// listeners. Used by the AI tools so a query for deadlines doesn't disturb + /// the UI's assignment state. Only successfully-fetched platforms are + /// included — there is no blending with the cached [_assignments] (so a + /// transient platform failure simply omits that platform from the result). + Future<({List assignments, Map errors})> + fetchAssignmentsLive() async { + final errors = {}; + final successfulResults = await _fanOutFetches(errors); + final assignments = successfulResults.values + .expand((items) => items) + .toList() + ..sort((a, b) => a.due.compareTo(b.due)); + return (assignments: assignments, errors: errors); } List _mergeAssignments({ @@ -287,22 +319,22 @@ class AssignmentService extends ChangeNotifier { Future? future; if (platformId == 'blackboard' && _tpAuth.hasCpdailyBinding) { - future = _fetchBlackboard().then((items) { + future = _fetchBlackboard(_platformErrors).then((items) { if (items != null) successfulResults['blackboard'] = items; }); } else if (platformId == 'exam' && _tpAuth.hasCpdailyBinding) { - future = _fetchExamTable().then((items) { + future = _fetchExamTable(_platformErrors).then((items) { if (items != null) successfulResults['exam'] = items; }); } else { for (final acc in _tpAuth.accounts) { if (acc.platform.id == platformId) { if (acc.platform == ThirdPartyPlatform.gradescope) { - future = _fetchGradescope(acc).then((items) { + future = _fetchGradescope(acc, _platformErrors).then((items) { if (items != null) successfulResults[platformId] = items; }); } else if (acc.platform == ThirdPartyPlatform.hydro) { - future = _fetchHydro(acc).then((items) { + future = _fetchHydro(acc, _platformErrors).then((items) { if (items != null) successfulResults[platformId] = items; }); } @@ -331,7 +363,9 @@ class AssignmentService extends ChangeNotifier { notifyListeners(); } - Future?> _fetchBlackboard() async { + Future?> _fetchBlackboard( + Map errors, + ) async { final node = _tpAuth.elearningNode; // withCookie handles initial minting if the downstream cookie isn't set. if (!_tpAuth.hasCpdailyBinding) return null; @@ -353,14 +387,16 @@ class AssignmentService extends ChangeNotifier { }, ); if (resp == null) return null; - return _parseDeadlinesResponse(resp, 'blackboard'); + return _parseDeadlinesResponse(resp, 'blackboard', errors); } catch (e) { - _platformErrors['blackboard'] = '同步失败,请检查网络或稍后重试'; + errors['blackboard'] = '同步失败,请检查网络或稍后重试'; return null; } } - Future?> _fetchExamTable() async { + Future?> _fetchExamTable( + Map errors, + ) async { final semesterId = _selectedSemesterId(); final node = _tpAuth.eamsNode; if (!_tpAuth.hasCpdailyBinding || @@ -386,14 +422,17 @@ class AssignmentService extends ChangeNotifier { }, ); if (resp == null) return null; - return _parseExamTableResponse(resp); + return _parseExamTableResponse(resp, errors); } catch (e) { - _platformErrors['exam'] = '同步失败,请检查网络或稍后重试'; + errors['exam'] = '同步失败,请检查网络或稍后重试'; return null; } } - Future?> _fetchGradescope(ThirdPartyAccount acc) async { + Future?> _fetchGradescope( + ThirdPartyAccount acc, + Map errors, + ) async { final node = _tpAuth.gradescopeNode; if (!node.isAvailable) return null; try { @@ -413,21 +452,24 @@ class AssignmentService extends ChangeNotifier { if (resp == null) return null; if (resp.statusCode == 401) { await _tpAuth.unbind(ThirdPartyPlatform.gradescope); - _platformErrors['gradescope'] = 'token 已失效,请重新绑定'; + errors['gradescope'] = 'token 已失效,请重新绑定'; return null; } - return _parseDeadlinesResponse(resp, 'gradescope'); + return _parseDeadlinesResponse(resp, 'gradescope', errors); } catch (e) { - _platformErrors['gradescope'] = '同步失败,请检查网络或稍后重试'; + errors['gradescope'] = '同步失败,请检查网络或稍后重试'; return null; } } - Future?> _fetchHydro(ThirdPartyAccount acc) async { + Future?> _fetchHydro( + ThirdPartyAccount acc, + Map errors, + ) async { final origin = acc.hydroOrigin ?? 'https://acm.shanghaitech.edu.cn'; final domains = acc.hydroDomains ?? const []; if (domains.isEmpty) { - _platformErrors['hydro'] = '未配置 Hydro 课程域 (domain),前往设置补全'; + errors['hydro'] = '未配置 Hydro 课程域 (domain),前往设置补全'; return null; } @@ -458,17 +500,17 @@ class AssignmentService extends ChangeNotifier { if (resp == null) return null; if (resp.statusCode == 401) { await _tpAuth.unbind(ThirdPartyPlatform.hydro); - _platformErrors['hydro'] = 'token 已失效,请重新绑定'; + errors['hydro'] = 'token 已失效,请重新绑定'; return null; } - final items = _parseDeadlinesResponse(resp, 'hydro'); + final items = _parseDeadlinesResponse(resp, 'hydro', errors); if (items != null) { all.addAll(items); } else { hadError = true; } } catch (e) { - _platformErrors['hydro'] = '同步失败,请检查网络或稍后重试'; + errors['hydro'] = '同步失败,请检查网络或稍后重试'; hadError = true; } } @@ -479,17 +521,18 @@ class AssignmentService extends ChangeNotifier { List? _parseDeadlinesResponse( http.Response resp, String platformKey, + Map errors, ) { Map data; try { data = jsonDecode(resp.body) as Map; } catch (_) { - _platformErrors[platformKey] = '同步失败,服务器返回异常数据'; + errors[platformKey] = '同步失败,服务器返回异常数据'; return null; } if (resp.statusCode != 200 || data['success'] != true) { - _platformErrors[platformKey] = + errors[platformKey] = (data['error'] as String?) ?? '同步失败 (HTTP ${resp.statusCode})'; return null; } @@ -500,17 +543,20 @@ class AssignmentService extends ChangeNotifier { .toList(); } - List? _parseExamTableResponse(http.Response resp) { + List? _parseExamTableResponse( + http.Response resp, + Map errors, + ) { Map data; try { data = jsonDecode(resp.body) as Map; } catch (_) { - _platformErrors['exam'] = '同步失败,服务器返回异常数据'; + errors['exam'] = '同步失败,服务器返回异常数据'; return null; } if (resp.statusCode != 200 || data['success'] != true) { - _platformErrors['exam'] = + errors['exam'] = (data['error'] as String?) ?? '同步失败 (HTTP ${resp.statusCode})'; return null; } diff --git a/lib/services/schedule_service.dart b/lib/services/schedule_service.dart index 1fc813a..01bd0fb 100644 --- a/lib/services/schedule_service.dart +++ b/lib/services/schedule_service.dart @@ -187,6 +187,86 @@ class ScheduleService extends ChangeNotifier { notifyListeners(); } + /// Live, non-mutating fetch of the semester list. Mirrors [fetchSemesters] + /// but returns the parsed [SemesterInfo] instead of writing it into + /// [_semesterInfo] and never notifies. Used by the AI tools so a query about + /// available terms doesn't touch the UI's selected-semester state. + Future fetchSemestersLive() async { + final resp = await _postWithRetry( + '$_baseUrl/schedule/semesters', + const {}, + 'fetchSemestersLive', + ); + final data = jsonDecode(resp.body) as Map; + if (data['success'] != true) { + throw Exception(data['error'] as String? ?? 'Failed to fetch semesters'); + } + return SemesterInfo.fromJson(data['data'] as Map); + } + + /// Live, non-mutating fetch of one semester's course table. Mirrors + /// [fetchCourseTable] but returns the parsed [CourseTable] instead of + /// writing it into [_courseTable] and never notifies. The AI tools use this + /// (via [courseTableFor]) to read an arbitrary semester (e.g. a + /// non-selected one) without polluting the UI's selected-semester view. The + /// table_id is best-effort: sent only when [_semesterInfo] is available. + Future fetchCourseTableLive(String semesterId) async { + final tableId = _semesterInfo?.tableId; + final extra = { + 'semester_id': semesterId, + if (tableId != null && tableId.isNotEmpty) 'table_id': tableId, + }; + final resp = await _postWithRetry( + '$_baseUrl/schedule/course_table', + extra, + 'fetchCourseTableLive', + ); + final data = jsonDecode(resp.body) as Map; + if (data['success'] != true) { + throw Exception( + data['error'] as String? ?? 'Failed to fetch course table', + ); + } + return CourseTable.fromApiResponse(data['data'] as Map); + } + + /// Cache-first semester list for read-only consumers (AI tools): in-memory + /// [_semesterInfo] → storage → live fetch (written back to storage only, + /// never into [_semesterInfo], so UI state is untouched). + Future semestersCachedOrLive({bool refresh = false}) async { + if (!refresh) { + final cached = _semesterInfo ?? _storage.loadSemesters(); + if (cached != null) return cached; + } + final info = await fetchSemestersLive(); + await _storage.saveSemesters(info); + return info; + } + + /// Cache-first course table for an arbitrary semester. Storage is keyed per + /// semester, so a non-selected (e.g. past) semester hits its own cache + /// entry — the old AI-tool bug came from reading the in-memory + /// [_courseTable], which only ever holds the selected semester. On miss (or + /// [refresh]) fetches live and writes back to storage only, never into + /// [_courseTable], so querying another semester can't pollute the UI. + Future courseTableFor( + String semesterId, { + bool refresh = false, + }) async { + if (!refresh) { + final cached = _storage.loadCourseTable(semesterId); + if (cached != null) return cached; + } + final table = await fetchCourseTableLive(semesterId); + await _storage.saveCourseTable(semesterId, table); + return table; + } + + /// Cached term-begin date for [semesterId], if any (storage is per-semester + /// keyed). Used to derive a default week for non-selected semesters. + DateTime? termBeginFor(String semesterId) => + _storage.loadTermBegin(semesterId); + Future selectSemester(String semesterId) async { // No-op if the semester is already selected. if (_selectedSemesterId == semesterId) return; From b08d8297aecd3a90c736b69f1ac83cffdf999631 Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Sun, 26 Jul 2026 16:26:13 +0800 Subject: [PATCH 07/15] fix(ai): stop losing tool results across restarts; block sends mid tool-loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AiThread.fromJson filtered messages by text.isNotEmpty, which dropped every AiRole.tool message (tool results have no text part) on each app restart — leaving historical tool_use blocks unanswered, so Anthropic rejected the whole conversation (400 'tool_use ids were found without tool_result'). Keep any message with non-empty or non-text parts. Also gate isStreaming/send on status.isBusy so the executingTools phase counts as busy — previously a message sent while tools were running landed on a transcript with unanswered tool calls. Co-Authored-By: Claude Fable 5 --- lib/models/ai_chat.dart | 11 ++++++++- lib/services/ai_service.dart | 7 ++++-- test/ai_service_test.dart | 46 ++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/lib/models/ai_chat.dart b/lib/models/ai_chat.dart index bd4267f..0b49df8 100644 --- a/lib/models/ai_chat.dart +++ b/lib/models/ai_chat.dart @@ -61,7 +61,16 @@ class AiThread { factory AiThread.fromJson(Map json) { final rawMessages = (json['messages'] as List? ?? const []) .map((e) => AiMessage.fromJson((e as Map).cast())) - .where((m) => m.text.isNotEmpty || m.role == AiRole.system) + // Drop only messages with no content at all (e.g. an aborted + // streaming placeholder with a single empty text part). Filtering on + // `text` alone would drop AiRole.tool messages — tool results have no + // text part — leaving every historical tool_use unanswered, which + // providers reject on the next request. + .where( + (m) => + m.role == AiRole.system || + m.parts.any((p) => p is! TextPart || p.text.isNotEmpty), + ) .toList(); return AiThread( id: json['id'] as String, diff --git a/lib/services/ai_service.dart b/lib/services/ai_service.dart index 1336ab2..b711612 100644 --- a/lib/services/ai_service.dart +++ b/lib/services/ai_service.dart @@ -81,7 +81,10 @@ class AiService extends ChangeNotifier { bool get isStreaming { final c = _controller; - return c != null && c.status == ChatStatus.streaming; + // isBusy covers submitted/streaming/executingTools — the tool-execution + // phase must count as busy too, or the UI re-enables input mid agent-loop + // and a new send() lands on a transcript with unanswered tool calls. + return c != null && c.status.isBusy; } /// The error from the last failed turn, surfaced as a string for the UI @@ -238,7 +241,7 @@ class AiService extends ChangeNotifier { notifyListeners(); return; } - if (c.status == ChatStatus.streaming) return; + if (c.status.isBusy) return; // Starting a real turn — clear any prior service-level error. if (_userError != null) { _userError = null; diff --git a/test/ai_service_test.dart b/test/ai_service_test.dart index ec0b413..e369f9d 100644 --- a/test/ai_service_test.dart +++ b/test/ai_service_test.dart @@ -1,4 +1,6 @@ import 'package:flutter/services.dart'; +import 'package:flutter_ai_core/flutter_ai_core.dart' + show ToolCallPart, ToolCallState, ToolResultPart; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -94,6 +96,50 @@ void main() { expect(restored.messages, hasLength(1)); expect(restored.messages.first.text, 'real reply'); }); + + test('tool-result messages survive a persistence round-trip', () { + // Tool messages have no text part. The load filter must not drop them: + // losing a tool result leaves its tool_use unanswered, and providers + // reject the whole conversation on the next request. + final thread = AiThread( + id: 't3', + title: 'x', + updatedAt: DateTime.utc(2026, 7, 26), + messages: const [ + AiMessage(id: 'u', role: AiRole.user, parts: [TextPart('几点了')]), + AiMessage( + id: 'a', + role: AiRole.assistant, + parts: [ + ToolCallPart( + toolCallId: 'c1', + toolName: 'get_current_time', + state: ToolCallState.inputAvailable, + ), + ], + status: AiMessageStatus.complete, + ), + AiMessage( + id: 't', + role: AiRole.tool, + parts: [ + ToolResultPart( + toolCallId: 'c1', + result: {'now': '2026-07-26T12:00:00'}, + ), + ], + status: AiMessageStatus.complete, + ), + ], + ); + final restored = AiThread.fromJson(thread.toJson()); + expect(restored.messages, hasLength(3)); + expect(restored.messages[2].role, AiRole.tool); + expect( + restored.messages[2].parts.single, + isA(), + ); + }); }); group('AiService lifecycle', () { From 1fc019cb90802e8e77464decd445fe25af9e669a Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Sun, 26 Jul 2026 16:29:30 +0800 Subject: [PATCH 08/15] chore(ai): drop internal tech-atlas reference from config docs/UI Co-Authored-By: Claude Fable 5 --- lib/models/ai_chat.dart | 5 ++--- lib/pages/ai_config_page.dart | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/models/ai_chat.dart b/lib/models/ai_chat.dart index 0b49df8..5feeff9 100644 --- a/lib/models/ai_chat.dart +++ b/lib/models/ai_chat.dart @@ -125,9 +125,8 @@ class AiConfig { this.maxTokens = 2048, }); - /// Defaults taken from `tech-atlas/.env.local` (DeepSeek's Anthropic-compat - /// endpoint). The `/v1` segment is included because `AnthropicProvider` - /// appends `/messages` itself. + /// Default endpoint: DeepSeek's Anthropic-compatible API. The `/v1` segment + /// is included because `AnthropicProvider` appends `/messages` itself. static const defaultBaseUrl = 'https://api.deepseek.com/anthropic/v1'; static const defaultModel = 'deepseek-v4-flash'; static const _defaultSystemPrompt = diff --git a/lib/pages/ai_config_page.dart b/lib/pages/ai_config_page.dart index 909b36f..d196343 100644 --- a/lib/pages/ai_config_page.dart +++ b/lib/pages/ai_config_page.dart @@ -355,8 +355,7 @@ class _AiConfigPageState extends State { Padding( padding: const EdgeInsets.all(16), child: Text( - '默认值取自 tech-atlas/.env.local(DeepSeek 的 Anthropic 兼容端点)。' - '令牌仅保存在设备安全存储中。', + '支持任何 Anthropic 兼容端点。令牌仅保存在设备安全存储中。', style: theme.textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), From 00d7ca1dd28d969cc5f08d04c7225d07c72a71e6 Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Sun, 26 Jul 2026 18:59:16 +0800 Subject: [PATCH 09/15] fix: vendor flutter_ai packages so CI/remote builds are reproducible The pubspec referenced ../flutter_ai path deps that only exist on the dev machine, and the checkout carries two fixes (OHOS haptics compile, dangling tool-call settlement) not yet published to pub.dev. Vendor the five packages under packages/flutter_ai/ and point all path deps and overrides at them. Vendored code is excluded from techpie's analyzer. Also dedupe the flutter_ai entries that the master merge left duplicated in pubspec.yaml. Co-Authored-By: Claude Fable 5 --- analysis_options.yaml | 3 + .../flutter_ai/flutter_ai_client/CHANGELOG.md | 166 ++ packages/flutter_ai/flutter_ai_client/LICENSE | 29 + .../flutter_ai/flutter_ai_client/README.md | 97 + .../flutter_ai_client/analysis_options.yaml | 2 + .../example/flutter_ai_client_example.dart | 107 ++ .../lib/flutter_ai_client.dart | 19 + .../lib/src/chat_observer.dart | 50 + .../lib/src/chat_status.dart | 23 + .../flutter_ai_client/lib/src/chat_store.dart | 270 +++ .../lib/src/context_strategy.dart | 154 ++ .../flutter_ai_client/lib/src/follow_ups.dart | 61 + .../lib/src/use_chat_controller.dart | 944 ++++++++++ .../flutter_ai/flutter_ai_client/pubspec.yaml | 39 + .../test/context_strategy_test.dart | 76 + .../test/follow_ups_and_store_test.dart | 134 ++ .../test/use_chat_controller_test.dart | 1614 +++++++++++++++++ .../flutter_ai/flutter_ai_core/CHANGELOG.md | 128 ++ packages/flutter_ai/flutter_ai_core/LICENSE | 29 + packages/flutter_ai/flutter_ai_core/README.md | 84 + .../flutter_ai_core/analysis_options.yaml | 3 + .../example/flutter_ai_core_example.dart | 91 + .../flutter_ai_core/lib/flutter_ai_core.dart | 38 + .../lib/src/internal/equality.dart | 53 + .../lib/src/models/ai_conversation.dart | 84 + .../lib/src/models/ai_message.dart | 184 ++ .../lib/src/models/ai_part.dart | 497 +++++ .../lib/src/models/ai_role.dart | 35 + .../lib/src/models/finish_reason.dart | 39 + .../lib/src/models/tool_call_state.dart | 38 + .../lib/src/models/tool_definition.dart | 57 + .../flutter_ai_core/lib/src/models/usage.dart | 136 ++ .../lib/src/provider/ai_capabilities.dart | 69 + .../lib/src/provider/ai_request_options.dart | 132 ++ .../lib/src/provider/ai_response_format.dart | 40 + .../lib/src/provider/generate_object.dart | 129 ++ .../lib/src/provider/llm_exception.dart | 57 + .../lib/src/provider/llm_provider.dart | 33 + .../lib/src/rendering/text_renderer.dart | 18 + .../lib/src/streaming/ai_stream_event.dart | 497 +++++ .../lib/src/streaming/json_accumulator.dart | 261 +++ .../lib/src/streaming/message_processor.dart | 357 ++++ .../lib/src/streaming/mutation_result.dart | 28 + .../lib/src/tools/json_schema_validator.dart | 190 ++ .../flutter_ai/flutter_ai_core/pubspec.yaml | 34 + .../test/ai_capabilities_test.dart | 139 ++ .../test/ai_stream_event_test.dart | 51 + .../test/json_accumulator_test.dart | 134 ++ .../test/json_schema_validator_test.dart | 103 ++ .../test/message_processor_perf_test.dart | 45 + .../test/message_processor_test.dart | 396 ++++ .../flutter_ai_core/test/models_test.dart | 187 ++ .../flutter_ai_core/test/usage_test.dart | 130 ++ .../flutter_ai_elements/CHANGELOG.md | 215 +++ .../flutter_ai/flutter_ai_elements/LICENSE | 29 + .../flutter_ai/flutter_ai_elements/README.md | 168 ++ .../flutter_ai_elements/analysis_options.yaml | 2 + .../example/flutter_ai_elements_example.dart | 73 + .../lib/flutter_ai_elements.dart | 56 + .../src/generative_ui/ai_widget_registry.dart | 66 + .../lib/src/l10n/ai_localizations.dart | 201 ++ .../lib/src/rendering/ai_text_renderer.dart | 24 + .../lib/src/theme/ai_theme_extension.dart | 358 ++++ .../lib/src/widgets/ai_animated_response.dart | 327 ++++ .../lib/src/widgets/ai_attachment.dart | 98 + .../lib/src/widgets/ai_avatar.dart | 53 + .../lib/src/widgets/ai_branch.dart | 99 + .../lib/src/widgets/ai_chain_of_thought.dart | 190 ++ .../lib/src/widgets/ai_chat.dart | 364 ++++ .../lib/src/widgets/ai_chat_view.dart | 88 + .../lib/src/widgets/ai_code_block.dart | 98 + .../lib/src/widgets/ai_composer.dart | 518 ++++++ .../lib/src/widgets/ai_confirmation.dart | 198 ++ .../lib/src/widgets/ai_context_meter.dart | 84 + .../lib/src/widgets/ai_conversation_list.dart | 117 ++ .../lib/src/widgets/ai_conversation_view.dart | 165 ++ .../lib/src/widgets/ai_empty_state.dart | 154 ++ .../lib/src/widgets/ai_error_banner.dart | 61 + .../lib/src/widgets/ai_haptics.dart | 23 + .../lib/src/widgets/ai_image.dart | 129 ++ .../lib/src/widgets/ai_inline_citation.dart | 49 + .../lib/src/widgets/ai_live_controller.dart | 203 +++ .../lib/src/widgets/ai_live_session.dart | 409 +++++ .../lib/src/widgets/ai_loader.dart | 93 + .../lib/src/widgets/ai_message_actions.dart | 235 +++ .../lib/src/widgets/ai_message_bubble.dart | 258 +++ .../lib/src/widgets/ai_model_selector.dart | 153 ++ .../lib/src/widgets/ai_orb.dart | 101 ++ .../lib/src/widgets/ai_prompt_input.dart | 88 + .../lib/src/widgets/ai_reasoning.dart | 88 + .../lib/src/widgets/ai_response.dart | 662 +++++++ .../lib/src/widgets/ai_shimmer.dart | 101 ++ .../lib/src/widgets/ai_sources.dart | 229 +++ .../lib/src/widgets/ai_suggestions.dart | 77 + .../lib/src/widgets/ai_task.dart | 184 ++ .../lib/src/widgets/ai_tool_group.dart | 45 + .../lib/src/widgets/ai_tool_invocation.dart | 190 ++ .../flutter_ai_elements/pubspec.yaml | 52 + .../screenshots/element_code_block.png | Bin 0 -> 7158 bytes .../screenshots/element_message_assistant.png | Bin 0 -> 19230 bytes .../screenshots/element_reasoning.png | Bin 0 -> 9727 bytes .../screenshots/element_sources.png | Bin 0 -> 4518 bytes .../screenshots/element_tool_invocation.png | Bin 0 -> 16093 bytes .../test/ai_chat_scroll_test.dart | 112 ++ .../test/dogfood_apis_test.dart | 153 ++ .../test/widgets_test.dart | 1019 +++++++++++ .../CHANGELOG.md | 111 ++ .../flutter_ai_provider_anthropic/LICENSE | 29 + .../flutter_ai_provider_anthropic/README.md | 80 + .../analysis_options.yaml | 2 + ...flutter_ai_provider_anthropic_example.dart | 33 + .../lib/flutter_ai_provider_anthropic.dart | 14 + .../lib/src/anthropic_event_parser.dart | 199 ++ .../lib/src/anthropic_provider.dart | 366 ++++ .../lib/src/default_http_client.dart | 4 + .../lib/src/default_http_client_io.dart | 5 + .../lib/src/default_http_client_web.dart | 11 + .../lib/src/http_retry.dart | 80 + .../pubspec.yaml | 35 + .../test/anthropic_provider_test.dart | 652 +++++++ .../test/default_http_client_test.dart | 11 + .../test/live_test.dart | 34 + .../test/reasoning_effort_test.dart | 98 + .../flutter_ai/flutter_ai_tools/CHANGELOG.md | 34 + packages/flutter_ai/flutter_ai_tools/LICENSE | 29 + .../flutter_ai/flutter_ai_tools/README.md | 80 + .../flutter_ai_tools/analysis_options.yaml | 2 + .../example/flutter_ai_tools_example.dart | 39 + .../lib/flutter_ai_tools.dart | 15 + .../lib/src/tool_registry.dart | 59 + .../flutter_ai_tools/lib/src/tool_spec.dart | 44 + .../flutter_ai_tools/lib/src/web_search.dart | 90 + .../flutter_ai/flutter_ai_tools/pubspec.yaml | 32 + .../flutter_ai_tools/test/tools_test.dart | 151 ++ pubspec.lock | 24 +- pubspec.yaml | 45 +- 136 files changed, 19143 insertions(+), 38 deletions(-) create mode 100644 packages/flutter_ai/flutter_ai_client/CHANGELOG.md create mode 100644 packages/flutter_ai/flutter_ai_client/LICENSE create mode 100644 packages/flutter_ai/flutter_ai_client/README.md create mode 100644 packages/flutter_ai/flutter_ai_client/analysis_options.yaml create mode 100644 packages/flutter_ai/flutter_ai_client/example/flutter_ai_client_example.dart create mode 100644 packages/flutter_ai/flutter_ai_client/lib/flutter_ai_client.dart create mode 100644 packages/flutter_ai/flutter_ai_client/lib/src/chat_observer.dart create mode 100644 packages/flutter_ai/flutter_ai_client/lib/src/chat_status.dart create mode 100644 packages/flutter_ai/flutter_ai_client/lib/src/chat_store.dart create mode 100644 packages/flutter_ai/flutter_ai_client/lib/src/context_strategy.dart create mode 100644 packages/flutter_ai/flutter_ai_client/lib/src/follow_ups.dart create mode 100644 packages/flutter_ai/flutter_ai_client/lib/src/use_chat_controller.dart create mode 100644 packages/flutter_ai/flutter_ai_client/pubspec.yaml create mode 100644 packages/flutter_ai/flutter_ai_client/test/context_strategy_test.dart create mode 100644 packages/flutter_ai/flutter_ai_client/test/follow_ups_and_store_test.dart create mode 100644 packages/flutter_ai/flutter_ai_client/test/use_chat_controller_test.dart create mode 100644 packages/flutter_ai/flutter_ai_core/CHANGELOG.md create mode 100644 packages/flutter_ai/flutter_ai_core/LICENSE create mode 100644 packages/flutter_ai/flutter_ai_core/README.md create mode 100644 packages/flutter_ai/flutter_ai_core/analysis_options.yaml create mode 100644 packages/flutter_ai/flutter_ai_core/example/flutter_ai_core_example.dart create mode 100644 packages/flutter_ai/flutter_ai_core/lib/flutter_ai_core.dart create mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/internal/equality.dart create mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/models/ai_conversation.dart create mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/models/ai_message.dart create mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/models/ai_part.dart create mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/models/ai_role.dart create mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/models/finish_reason.dart create mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/models/tool_call_state.dart create mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/models/tool_definition.dart create mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/models/usage.dart create mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/provider/ai_capabilities.dart create mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/provider/ai_request_options.dart create mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/provider/ai_response_format.dart create mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/provider/generate_object.dart create mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/provider/llm_exception.dart create mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/provider/llm_provider.dart create mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/rendering/text_renderer.dart create mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/streaming/ai_stream_event.dart create mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/streaming/json_accumulator.dart create mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/streaming/message_processor.dart create mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/streaming/mutation_result.dart create mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/tools/json_schema_validator.dart create mode 100644 packages/flutter_ai/flutter_ai_core/pubspec.yaml create mode 100644 packages/flutter_ai/flutter_ai_core/test/ai_capabilities_test.dart create mode 100644 packages/flutter_ai/flutter_ai_core/test/ai_stream_event_test.dart create mode 100644 packages/flutter_ai/flutter_ai_core/test/json_accumulator_test.dart create mode 100644 packages/flutter_ai/flutter_ai_core/test/json_schema_validator_test.dart create mode 100644 packages/flutter_ai/flutter_ai_core/test/message_processor_perf_test.dart create mode 100644 packages/flutter_ai/flutter_ai_core/test/message_processor_test.dart create mode 100644 packages/flutter_ai/flutter_ai_core/test/models_test.dart create mode 100644 packages/flutter_ai/flutter_ai_core/test/usage_test.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/CHANGELOG.md create mode 100644 packages/flutter_ai/flutter_ai_elements/LICENSE create mode 100644 packages/flutter_ai/flutter_ai_elements/README.md create mode 100644 packages/flutter_ai/flutter_ai_elements/analysis_options.yaml create mode 100644 packages/flutter_ai/flutter_ai_elements/example/flutter_ai_elements_example.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/flutter_ai_elements.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/generative_ui/ai_widget_registry.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/l10n/ai_localizations.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/rendering/ai_text_renderer.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/theme/ai_theme_extension.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_animated_response.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_attachment.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_avatar.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_branch.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_chain_of_thought.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_chat.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_chat_view.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_code_block.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_composer.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_confirmation.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_context_meter.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_conversation_list.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_conversation_view.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_empty_state.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_error_banner.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_haptics.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_image.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_inline_citation.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_live_controller.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_live_session.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_loader.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_message_actions.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_message_bubble.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_model_selector.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_orb.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_prompt_input.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_reasoning.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_response.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_shimmer.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_sources.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_suggestions.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_task.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_tool_group.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_tool_invocation.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/pubspec.yaml create mode 100644 packages/flutter_ai/flutter_ai_elements/screenshots/element_code_block.png create mode 100644 packages/flutter_ai/flutter_ai_elements/screenshots/element_message_assistant.png create mode 100644 packages/flutter_ai/flutter_ai_elements/screenshots/element_reasoning.png create mode 100644 packages/flutter_ai/flutter_ai_elements/screenshots/element_sources.png create mode 100644 packages/flutter_ai/flutter_ai_elements/screenshots/element_tool_invocation.png create mode 100644 packages/flutter_ai/flutter_ai_elements/test/ai_chat_scroll_test.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/test/dogfood_apis_test.dart create mode 100644 packages/flutter_ai/flutter_ai_elements/test/widgets_test.dart create mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/CHANGELOG.md create mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/LICENSE create mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/README.md create mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/analysis_options.yaml create mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/example/flutter_ai_provider_anthropic_example.dart create mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/lib/flutter_ai_provider_anthropic.dart create mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/anthropic_event_parser.dart create mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/anthropic_provider.dart create mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/default_http_client.dart create mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/default_http_client_io.dart create mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/default_http_client_web.dart create mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/http_retry.dart create mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/pubspec.yaml create mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/test/anthropic_provider_test.dart create mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/test/default_http_client_test.dart create mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/test/live_test.dart create mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/test/reasoning_effort_test.dart create mode 100644 packages/flutter_ai/flutter_ai_tools/CHANGELOG.md create mode 100644 packages/flutter_ai/flutter_ai_tools/LICENSE create mode 100644 packages/flutter_ai/flutter_ai_tools/README.md create mode 100644 packages/flutter_ai/flutter_ai_tools/analysis_options.yaml create mode 100644 packages/flutter_ai/flutter_ai_tools/example/flutter_ai_tools_example.dart create mode 100644 packages/flutter_ai/flutter_ai_tools/lib/flutter_ai_tools.dart create mode 100644 packages/flutter_ai/flutter_ai_tools/lib/src/tool_registry.dart create mode 100644 packages/flutter_ai/flutter_ai_tools/lib/src/tool_spec.dart create mode 100644 packages/flutter_ai/flutter_ai_tools/lib/src/web_search.dart create mode 100644 packages/flutter_ai/flutter_ai_tools/pubspec.yaml create mode 100644 packages/flutter_ai/flutter_ai_tools/test/tools_test.dart diff --git a/analysis_options.yaml b/analysis_options.yaml index 73e3edc..9aa05d5 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -4,6 +4,9 @@ analyzer: exclude: - build/** - .dart_tool/** + # Vendored upstream code (ananmouaz/flutter_ai + local fixes); keeps its + # own style, not held to techpie's lint set. + - packages/flutter_ai/** language: strict-casts: true strict-inference: true diff --git a/packages/flutter_ai/flutter_ai_client/CHANGELOG.md b/packages/flutter_ai/flutter_ai_client/CHANGELOG.md new file mode 100644 index 0000000..c2cab4b --- /dev/null +++ b/packages/flutter_ai/flutter_ai_client/CHANGELOG.md @@ -0,0 +1,166 @@ +# Changelog + +## 0.3.0 + +- Add `UseChatController.load(AiConversation)` — swaps the transcript in place + (cancelling any in-flight turn) so hosts can switch threads without disposing + and recreating the controller. (#136) +- Add `KeyValueChatThreadStore`, a persistent `ChatThreadStore` backed by any + `KeyValueStore` you supply (`shared_preferences`, a file, secure storage, …), + so chat history survives app restarts without the package depending on a + storage plugin. (#137) +- Add `suggestFollowUps(conversation, provider, {count, options})` — generates + contextual follow-up prompts (for the `AiSuggestions` strip) via a one-off + model call, so follow-ups can be dynamic instead of a static set. (#140) + +## 0.2.5 + +- Fix: the controller no longer reports `idle` while the agent loop runs its tool + executor between model calls. A new `ChatStatus.executingTools` (included in + `isBusy`) keeps the turn marked busy, so UIs don't re-enable input mid-turn and + `attachStore` doesn't persist a transcript with unanswered tool calls. +- Fix: `selectBranch` is now a no-op whenever a turn is in flight (including the + tool-execution phase), preventing a mid-loop branch switch from corrupting the + transcript. +- Fix: the default message-id generator now uses a per-controller random prefix + (`msg--`) instead of restarting at `msg-0`, so seeding a controller + with a rehydrated `ChatStore` transcript no longer produces colliding ids. Pass + a custom `idGenerator` to override. +- Fix: interrupting a stream with `submit`/`addToolResults`, and an in-band + `StreamErrorEvent` with no `messageId`, no longer leave the interrupted message + stuck in the `streaming` state (a permanent typing indicator that also got + persisted). The trailing message is now finalized. +- Fix: a synchronous throw from `provider.send` or a `trimHistory` callback is + now caught and surfaced as `ChatStatus.error` (with the turn future + completing), instead of escaping — which left the status stuck at `submitted`, + or became an unhandled zone error inside the agent loop. + +## 0.2.4 + +- `keepLastWithSummary`: a context strategy that folds a caller-supplied rolling + summary of older turns into the request (as a synthetic `system` message) + instead of silently dropping them — compaction that preserves load-bearing + context. Your app owns producing/persisting the summary (any model or + heuristic, saved via `ChatStore`); the strategy just injects it and windows + the recent messages. No memory service baked in. + +## 0.2.3 + +- Observability: `UseChatController(observer:)` accepts a `ChatObserver` that + receives the agent lifecycle — turn start, each model request, response with + token `AiUsage` + finish reason, tool calls/results, errors, and turn end. + Shaped after the OpenTelemetry GenAI semantic conventions, with no OTel + dependency — map the callbacks onto your own tracer or analytics sink. Opt-in + and no-op by default. + +## 0.2.2 + +- Agent guardrail: `maxIdenticalToolCalls` (opt-in, 0 = off) halts the agent + loop with a typed `AgentLoopException` when the model keeps requesting the + same tool call (identical name + args) after it has already run that many + times in a turn — a runaway-loop guard that stops before burning tokens up to + `maxSteps`. Complements the existing `tokenBudget` token ceiling. + +## 0.2.1 + +- Fix: raise the `flutter_ai_core` lower bound to `^0.1.11` — the controller + uses `AiUsage` (added in core 0.1.3) and later APIs, so the old `^0.1.0` + bound let dependency downgrades resolve a core that couldn't compile. +- Docs: shortened the pubspec `description` into pub.dev's 60–180 character + window. + +## 0.2.0 + +- **BREAKING**: `onToolCalls` now receives a second argument, an + `AiToolCallSignal`. The controller cancels it when the turn is stopped, + replaced, or disposed while the executor is still running, so long-running + tools can abort in-flight work instead of finishing only to have their result + discarded. Observe it via `signal.isCancelled`, `await signal.whenCancelled`, + or `signal.throwIfCancelled()`. + + Migration: change `onToolCalls: (calls) async { ... }` to + `onToolCalls: (calls, signal) async { ... }`. Honoring the signal is optional; + adding the parameter is required. + +## 0.1.8 + +- Docs: refreshed the README listing with a hero image, screenshot gallery, + and badges (consistent across the package family). No code changes. + +## 0.1.7 + +- Tool-argument validation (`validateToolArgs`, default on): the agent loop + validates each model-produced tool call against the tool's + `parametersSchema` before running it. Calls with invalid args are not + executed — an error `ToolResultPart` describing the violations is fed back so + the model can self-correct (bounded by `maxSteps`). Opt out with + `validateToolArgs: false`. +- History trimming (`trimHistory`): a pluggable strategy that maps the full + conversation to the (smaller) conversation actually sent to the provider; the + stored transcript is never trimmed. Ships with `keepLastMessages(n)` and + `trimToApproxTokenBudget(maxTokens)` strategies (both preserve the system + prefix and avoid orphaning tool results). + +## 0.1.6 + +- Declare supported platforms (Android/iOS/web/macOS/Windows/Linux) for the + pub.dev listing. + +## 0.1.5 + +- Turn-sequence guard: a late event from a cancelled stream can no longer mutate + the conversation or leak onto the `events` stream after a new turn starts. +- `maxBranches` (default 20) caps retained regenerations so a long chat can't + grow without bound. +- `tokenBudget`: stop the agent loop once cumulative usage exceeds the budget (a + cost ceiling on top of `maxSteps`). + +## 0.1.4 + +- Thread management: `ChatThread`, a `ChatThreadStore` (list/delete on top of + `ChatStore`), `autoTitle(conversation)`, and an `InMemoryChatThreadStore` for + demos/tests — enough to drive a multi-conversation sidebar. + +## 0.1.3 + +- `totalUsage` getter on `UseChatController`: summed `AiUsage` across the + conversation (feed an `AiContextMeter` or estimate cost). + +## 0.1.2 + +- Agent loop: pass `onToolCalls` (and optional `maxSteps`, default 8) to + `UseChatController` and it becomes an automatic agent — when a model turn ends + with unanswered tool calls it runs the executor, feeds the results back, and + re-prompts until there are no pending calls or `maxSteps` model calls have run. + Without `onToolCalls` behavior is unchanged (the host drives tools manually via + `addToolResults`). Cancellation/stop aborts the loop mid-flight. + +## 0.1.1 + +- `editMessage(id, text)` / `editLastUserMessage(text)`: edit a sent user + message (keeping attachments), discard everything after it, and re-run from + that point — starting a fresh branch set. Closes the previously dead "edit" + affordance in `AiMessageActions`. +- Persistence seam: a `ChatStore` interface (`load`/`save`) plus an + `attachStore(controller, store, id)` helper that debounce-auto-saves the + conversation once each turn settles. History is still in memory by default; + this makes saving/restoring a thread a few lines. `AiConversation` is already + JSON-serializable, so a store is just encode/decode around your storage. + +## 0.1.0 + +Initial release. + +- `UseChatController` — a `ChangeNotifier` wrapping any `LlmProvider`: + - optimistic, synchronous user-message append + - `sendText` / `submit` / `stop` / `regenerate` / `clear` + - live model/provider switching (`setProvider`, `setOptions`, `setTools`) + - coalesced, injectable notification scheduling (frame-batched streaming) + - raw `events` stream escape hatch +- `ChatStatus` (idle / submitted / streaming / error). +- Exposes `stackTrace` alongside `error` so failures can be reported with full + context. +- A fatal (message-scoped) `StreamErrorEvent` tears down the active turn so a + misbehaving provider can't keep mutating the conversation after a fatal error; + tool-scoped errors remain non-fatal. +- Re-exports `flutter_ai_core`. diff --git a/packages/flutter_ai/flutter_ai_client/LICENSE b/packages/flutter_ai/flutter_ai_client/LICENSE new file mode 100644 index 0000000..56023ee --- /dev/null +++ b/packages/flutter_ai/flutter_ai_client/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2026, The flutter_ai authors. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/flutter_ai/flutter_ai_client/README.md b/packages/flutter_ai/flutter_ai_client/README.md new file mode 100644 index 0000000..050088d --- /dev/null +++ b/packages/flutter_ai/flutter_ai_client/README.md @@ -0,0 +1,97 @@ +

flutter_ai_client

+ +

The useChat controller for Flutter — wrap any LlmProvider and get optimistic send, batched streaming, cancel, and regenerate as a plain Listenable. No state-manager lock-in.

+ +

+ flutter_ai: a streaming answer with chain-of-thought and a generative-UI task card +

+ +

+ flutter_ai_client on pub.dev + pub points + License: BSD-3-Clause +

+ +

+ Family: flutter_ai · + core · elements · + openai · anthropic · gemini · + tools · mcp · voice
+ Recipes · Migrating from the Vercel AI SDK +

+ +

The transcript above is driven by this package's UseChatController (rendered with flutter_ai_elements).

+ +--- + +Provider-agnostic chat controller for the [`flutter_ai`](../../README.md) family. + +`UseChatController` wraps any `LlmProvider` (from `flutter_ai_core`) and exposes +conversation state as a plain `Listenable` — so it drops into `ListenableBuilder` +and adapts cleanly to Bloc, Riverpod, or Provider. **It bundles no state-manager +of its own.** + +## Features + +- **Optimistic send** — the user's message paints synchronously, before the + request is dispatched. +- **Streaming, batched** — events are folded by `flutter_ai_core`'s + `MessageProcessor`; notifications are coalesced (injectable scheduler) so high + token rates don't drop frames. +- **Full control** — `sendText`, `submit`, `stop`, `regenerate`, `clear`. +- **Provider/model switching** — `setProvider`, `setOptions`, `setTools` take + effect on the next turn without touching the UI. +- **Escape hatch** — a raw `events` stream for custom state layers. + +## Usage + +```dart +final controller = UseChatController( + provider: myProvider, // any LlmProvider + options: const AiRequestOptions(model: 'gpt-4o'), +); + +// Bind to the UI — rebuilds when the conversation changes. +ListenableBuilder( + listenable: controller, + builder: (context, _) => ListView( + children: [ + for (final m in controller.messages) Text('${m.role.name}: ${m.text}'), + ], + ), +); + +// Send / stop. +controller.sendText('Hello'); +if (controller.status.isBusy) controller.stop(); + +// Switch model live. +controller.setOptions(const AiRequestOptions(model: 'gpt-4o-mini')); +``` + +See [`example/`](example/) for a minimal end-to-end widget. + +## Implementing a provider + +A provider maps your backend's stream onto `flutter_ai_core`'s `AiStreamEvent`s: + +```dart +class MyProvider implements LlmProvider { + @override + Stream send( + AiConversation conversation, { + List? tools, + AiRequestOptions? options, + }) async* { + yield const MessageStarted(messageId: 'a1', role: AiRole.assistant); + yield const TextDelta(messageId: 'a1', delta: 'Hello!'); + yield const MessageFinished(messageId: 'a1', reason: FinishReason.stop); + } +} +``` + +## Status + +Published on pub.dev (see the CHANGELOG for versions); depends on `flutter_ai_core`. + +_If `flutter_ai` saves you time, you can [buy me a coffee ☕](https://ko-fi.com/ananmouaz)._ diff --git a/packages/flutter_ai/flutter_ai_client/analysis_options.yaml b/packages/flutter_ai/flutter_ai_client/analysis_options.yaml new file mode 100644 index 0000000..bddaa31 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_client/analysis_options.yaml @@ -0,0 +1,2 @@ +# Inherits the workspace-wide strict configuration. +include: ../../analysis_options.yaml diff --git a/packages/flutter_ai/flutter_ai_client/example/flutter_ai_client_example.dart b/packages/flutter_ai/flutter_ai_client/example/flutter_ai_client_example.dart new file mode 100644 index 0000000..db266aa --- /dev/null +++ b/packages/flutter_ai/flutter_ai_client/example/flutter_ai_client_example.dart @@ -0,0 +1,107 @@ +// A minimal chat UI bound to UseChatController via ListenableBuilder. +// +// The provider here echoes the user's text back one word at a time to simulate +// streaming. Swap in a real LlmProvider to talk to a model. +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_client/flutter_ai_client.dart'; + +void main() => runApp(const _ExampleApp()); + +/// Echoes the user's last message back, streamed word by word. +class _EchoProvider implements LlmProvider { + @override + Stream send( + AiConversation conversation, { + List? tools, + AiRequestOptions? options, + }) async* { + const id = 'assistant'; + final prompt = conversation.lastMessage?.text ?? ''; + yield const MessageStarted(messageId: id, role: AiRole.assistant); + for (final word in prompt.split(' ')) { + await Future.delayed(const Duration(milliseconds: 60)); + yield TextDelta(messageId: id, delta: '$word '); + } + yield const MessageFinished(messageId: id, reason: FinishReason.stop); + } +} + +class _ExampleApp extends StatefulWidget { + const _ExampleApp(); + + @override + State<_ExampleApp> createState() => _ExampleAppState(); +} + +class _ExampleAppState extends State<_ExampleApp> { + late final UseChatController _controller = + UseChatController(provider: _EchoProvider()); + final TextEditingController _input = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + _input.dispose(); + super.dispose(); + } + + void _send() { + final text = _input.text.trim(); + if (text.isEmpty) return; + _input.clear(); + unawaited(_controller.sendText(text)); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('flutter_ai_client')), + body: Column( + children: [ + Expanded( + child: ListenableBuilder( + listenable: _controller, + builder: (context, _) => ListView( + children: [ + for (final message in _controller.messages) + ListTile( + title: Text(message.role.name), + subtitle: Text(message.text), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _input, + onSubmitted: (_) => _send(), + ), + ), + // Swap Send for Stop while a response streams. + ListenableBuilder( + listenable: _controller, + builder: (context, _) => IconButton( + icon: Icon( + _controller.status.isBusy ? Icons.stop : Icons.send, + ), + onPressed: + _controller.status.isBusy ? _controller.stop : _send, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_client/lib/flutter_ai_client.dart b/packages/flutter_ai/flutter_ai_client/lib/flutter_ai_client.dart new file mode 100644 index 0000000..2f67880 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_client/lib/flutter_ai_client.dart @@ -0,0 +1,19 @@ +/// Provider-agnostic chat controller for the `flutter_ai` family. +/// +/// Exposes `UseChatController`, a `ChangeNotifier` that wraps any +/// `LlmProvider` from `flutter_ai_core` with optimistic send, cancellation, +/// regeneration, model/provider switching, and frame-batched streaming — +/// without imposing a state-management library. +/// +/// Re-exports `flutter_ai_core` so consumers get the model and provider types +/// from a single import. +library; + +export 'package:flutter_ai_core/flutter_ai_core.dart'; + +export 'src/chat_observer.dart'; +export 'src/chat_status.dart'; +export 'src/chat_store.dart'; +export 'src/context_strategy.dart'; +export 'src/follow_ups.dart'; +export 'src/use_chat_controller.dart'; diff --git a/packages/flutter_ai/flutter_ai_client/lib/src/chat_observer.dart b/packages/flutter_ai/flutter_ai_client/lib/src/chat_observer.dart new file mode 100644 index 0000000..4067f34 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_client/lib/src/chat_observer.dart @@ -0,0 +1,50 @@ +import 'package:flutter_ai_core/flutter_ai_core.dart'; + +/// Observes the agent lifecycle of a `UseChatController` for tracing, metrics, +/// and logging. +/// +/// The callbacks are shaped after the OpenTelemetry **GenAI semantic +/// conventions** — a turn wraps one or more model requests, each of which +/// finishes with a reason and token [AiUsage], with tool executions in between +/// — but this carries **no OpenTelemetry dependency**. Map the callbacks onto +/// your own tracer, span exporter, or analytics sink. Stamp your own timing on +/// receipt; the controller does not impose a clock. +/// +/// Every method has a no-op default, so subclasses override only what they +/// need. Callbacks are invoked synchronously from the controller; keep them +/// cheap (enqueue, don't block). +abstract class ChatObserver { + /// Const constructor for subclasses. + const ChatObserver(); + + /// A turn began: the user submitted, regenerated, retried, or edited. + /// [conversation] is the transcript at that moment. + void onTurnStart(AiConversation conversation) {} + + /// A model request is about to be dispatched. [step] is 1-based within the + /// turn (it increments for each tool-loop re-prompt). + void onModelRequest(int step) {} + + /// A model response finished streaming cleanly for [step]. [usage] and + /// [finishReason] are provided when the provider reported them. + void onModelResponse({ + required int step, + AiUsage? usage, + FinishReason? finishReason, + }) {} + + /// A batch of tool [calls] is about to be executed by the agent loop. + void onToolCalls(List calls) {} + + /// Tool [results] were produced (executed results plus any validation-error + /// results) and fed back to the model. + void onToolResults(List results) {} + + /// The turn failed with [error] (and [stackTrace] when available). Followed + /// by [onTurnEnd]. + void onError(Object error, StackTrace? stackTrace) {} + + /// The turn ended — success, stop, or error. [totalUsage] is the summed usage + /// across the whole conversation, or null if none was reported. + void onTurnEnd({AiUsage? totalUsage}) {} +} diff --git a/packages/flutter_ai/flutter_ai_client/lib/src/chat_status.dart b/packages/flutter_ai/flutter_ai_client/lib/src/chat_status.dart new file mode 100644 index 0000000..617b839 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_client/lib/src/chat_status.dart @@ -0,0 +1,23 @@ +/// The lifecycle state of a chat turn driven by a controller. +enum ChatStatus { + /// No request is in flight. + idle, + + /// A request has been sent but no events have arrived yet. + submitted, + + /// Events are actively streaming in. + streaming, + + /// The model's stream finished with tool calls and the agent loop is running + /// the tool executor before re-prompting. The turn is still in flight. + executingTools, + + /// The last request failed. + error; + + /// Whether a turn is currently in flight ([submitted], [streaming], or + /// [executingTools]) — i.e. the model or its tools are still working. + bool get isBusy => + this == submitted || this == streaming || this == executingTools; +} diff --git a/packages/flutter_ai/flutter_ai_client/lib/src/chat_store.dart b/packages/flutter_ai/flutter_ai_client/lib/src/chat_store.dart new file mode 100644 index 0000000..9cc1fec --- /dev/null +++ b/packages/flutter_ai/flutter_ai_client/lib/src/chat_store.dart @@ -0,0 +1,270 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_ai_client/src/use_chat_controller.dart'; +import 'package:flutter_ai_core/flutter_ai_core.dart'; + +/// Persists and restores [AiConversation]s so a chat survives app restarts. +/// +/// [UseChatController] keeps history in memory only; implement this against +/// whatever storage you like (a file, `shared_preferences`, SQLite, an HTTP +/// API, …) and pair it with [attachStore] to auto-save, seeding new +/// controllers from [load]: +/// +/// ```dart +/// final store = MyChatStore(); +/// final controller = UseChatController( +/// provider: provider, +/// initial: await store.load('thread-42'), +/// ); +/// final detach = attachStore(controller, store, 'thread-42'); +/// // ...later, before controller.dispose(): +/// detach(); +/// ``` +/// +/// [AiConversation] (and every [AiMessage]/[AiPart]) is JSON-serializable via +/// `toJson`/`fromJson`, so a minimal store is just an encode/decode around your +/// storage layer. +abstract interface class ChatStore { + /// Returns the stored conversation for [id], or `null` if none exists. + Future load(String id); + + /// Writes [conversation] for [id], replacing any previous value. + Future save(String id, AiConversation conversation); +} + +/// Auto-saves [controller]'s conversation to [store] under [id] whenever it +/// changes and the turn has settled, coalescing rapid changes over [debounce]. +/// +/// Returns a disposer that detaches the listener; call it before disposing the +/// controller. If a save is pending when you detach, it is flushed immediately +/// so the latest state is not lost. +/// +/// Saves are skipped while a turn is in flight (streaming) — the conversation +/// is persisted once it settles, avoiding a write per streamed frame. Loading +/// is the caller's job: pass `await store.load(id)` as the controller's +/// `initial`. +VoidCallback attachStore( + UseChatController controller, + ChatStore store, + String id, { + Duration debounce = const Duration(milliseconds: 400), +}) { + Timer? timer; + void save() => unawaited(store.save(id, controller.conversation)); + void listener() { + timer?.cancel(); + timer = Timer(debounce, () { + // Wait for the turn to settle; the settling notification reschedules us. + if (controller.status.isBusy) return; + save(); + }); + } + + controller.addListener(listener); + return () { + controller.removeListener(listener); + if (timer?.isActive ?? false) { + timer!.cancel(); + save(); + } + }; +} + +/// A lightweight summary of a stored conversation, for a thread list / sidebar. +class ChatThread { + /// Creates a thread summary. + const ChatThread({required this.id, required this.title, this.updatedAt}); + + /// The conversation id (pass to [ChatStore.load]). + final String id; + + /// A human-readable title (see [autoTitle]). + final String title; + + /// When the thread was last saved, if tracked. Newest-first ordering. + final DateTime? updatedAt; +} + +/// A [ChatStore] that can also enumerate and delete threads — enough to drive a +/// conversation list / sidebar. +abstract interface class ChatThreadStore implements ChatStore { + /// All stored threads, newest first. + Future> listThreads(); + + /// Removes the thread [id] (no-op if absent). + Future delete(String id); +} + +/// Derives a short title from a conversation's first user message, falling back +/// to [fallback]. Trims to [maxLength] characters. +String autoTitle( + AiConversation conversation, { + String fallback = 'New chat', + int maxLength = 40, +}) { + final firstUser = conversation.messages + .where((m) => m.role == AiRole.user) + .map((m) => m.text.trim()) + .firstWhere((t) => t.isNotEmpty, orElse: () => ''); + if (firstUser.isEmpty) return fallback; + final oneLine = firstUser.replaceAll(RegExp(r'\s+'), ' '); + return oneLine.length <= maxLength + ? oneLine + : '${oneLine.substring(0, maxLength).trimRight()}…'; +} + +/// An in-memory [ChatThreadStore] — handy for demos, tests, and prototyping +/// before wiring real storage. Titles are derived via [autoTitle] on save. +class InMemoryChatThreadStore implements ChatThreadStore { + final Map _conversations = {}; + final Map _threads = {}; + + @override + Future load(String id) async => _conversations[id]; + + @override + Future save(String id, AiConversation conversation) async { + _conversations[id] = conversation; + _threads[id] = ChatThread( + id: id, + title: autoTitle(conversation), + updatedAt: DateTime.now(), + ); + } + + @override + Future> listThreads() async { + final threads = _threads.values.toList(); + threads.sort((a, b) { + final at = a.updatedAt, bt = b.updatedAt; + if (at == null || bt == null) return 0; + return bt.compareTo(at); // newest first + }); + return threads; + } + + @override + Future delete(String id) async { + _conversations.remove(id); + _threads.remove(id); + } +} + +/// A minimal async key→string storage — the seam a [KeyValueChatThreadStore] +/// persists through. Keeps the package plugin-free: back it with +/// `shared_preferences`, a file, secure storage, or an HTTP API in a few lines: +/// +/// ```dart +/// class PrefsStore implements KeyValueStore { +/// PrefsStore(this._prefs); +/// final SharedPreferences _prefs; +/// @override +/// Future read(String key) async => _prefs.getString(key); +/// @override +/// Future write(String key, String value) async => +/// _prefs.setString(key, value); +/// @override +/// Future remove(String key) async => _prefs.remove(key); +/// } +/// ``` +abstract interface class KeyValueStore { + /// Returns the value for [key], or `null` if unset. + Future read(String key); + + /// Stores [value] under [key], replacing any previous value. + Future write(String key, String value); + + /// Removes [key] (no-op if absent). + Future remove(String key); +} + +/// A persistent [ChatThreadStore] backed by any [KeyValueStore], so a chat +/// drawer survives app restarts without pulling a storage plugin into the +/// package. Each conversation is stored as JSON under `"$prefix$id"`, with a +/// small index under `"${prefix}index"` for [listThreads]. Titles are derived +/// via [autoTitle] on save. +class KeyValueChatThreadStore implements ChatThreadStore { + /// Creates a store over [store]. [prefix] namespaces all keys it owns. + KeyValueChatThreadStore(this.store, {this.prefix = 'flutter_ai_chat/'}); + + /// The backing key→string storage. + final KeyValueStore store; + + /// Key namespace for everything this store writes. + final String prefix; + + String get _indexKey => '${prefix}index'; + String _threadKey(String id) => '$prefix$id'; + + Future> _readIndex() async { + final raw = await store.read(_indexKey); + if (raw == null || raw.isEmpty) return []; + final list = (jsonDecode(raw) as List).cast>(); + return [ + for (final e in list) + ChatThread( + id: e['id']! as String, + title: e['title']! as String, + updatedAt: e['updatedAt'] == null + ? null + : DateTime.tryParse(e['updatedAt']! as String), + ), + ]; + } + + Future _writeIndex(List threads) => store.write( + _indexKey, + jsonEncode([ + for (final t in threads) + { + 'id': t.id, + 'title': t.title, + 'updatedAt': t.updatedAt?.toIso8601String(), + }, + ]), + ); + + @override + Future load(String id) async { + final raw = await store.read(_threadKey(id)); + if (raw == null) return null; + return AiConversation.fromJson( + (jsonDecode(raw) as Map).cast(), + ); + } + + @override + Future save(String id, AiConversation conversation) async { + await store.write(_threadKey(id), jsonEncode(conversation.toJson())); + final thread = ChatThread( + id: id, + title: autoTitle(conversation), + updatedAt: DateTime.now(), + ); + final index = await _readIndex() + ..removeWhere((t) => t.id == id) + ..insert(0, thread); + await _writeIndex(index); + } + + @override + Future> listThreads() async { + final threads = await _readIndex(); + threads.sort((a, b) { + final at = a.updatedAt, bt = b.updatedAt; + if (at == null || bt == null) return 0; + return bt.compareTo(at); // newest first + }); + return threads; + } + + @override + Future delete(String id) async { + await store.remove(_threadKey(id)); + final index = await _readIndex() + ..removeWhere((t) => t.id == id); + await _writeIndex(index); + } +} diff --git a/packages/flutter_ai/flutter_ai_client/lib/src/context_strategy.dart b/packages/flutter_ai/flutter_ai_client/lib/src/context_strategy.dart new file mode 100644 index 0000000..c00a937 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_client/lib/src/context_strategy.dart @@ -0,0 +1,154 @@ +import 'package:flutter_ai_core/flutter_ai_core.dart'; + +/// History-trimming strategies for `UseChatController.trimHistory`. +/// +/// A strategy maps the full stored conversation to the (smaller) conversation +/// sent to the provider. The controller never trims its stored transcript, so +/// these only bound what each request costs — the UI keeps the full history. +/// +/// Both built-ins always preserve leading `system` messages and avoid starting +/// the kept window on an orphaned `tool` result (which strict providers +/// reject). Conversations with deeply interleaved tool calls may still need a +/// bespoke strategy — these are pragmatic defaults, not a general solution. + +/// Keeps the system prefix plus the most recent [count] non-system messages. +/// +/// If the kept window would begin on a `tool` message (a result whose +/// originating assistant tool-call would be trimmed away), the window is +/// advanced forward past it so no orphaned tool result is sent. +AiConversation Function(AiConversation) keepLastMessages(int count) { + assert(count >= 0, 'count must be >= 0'); + return (conversation) { + final messages = conversation.messages; + final system = [ + for (final m in messages) + if (m.role == AiRole.system) m, + ]; + final rest = [ + for (final m in messages) + if (m.role != AiRole.system) m, + ]; + if (rest.length <= count) return conversation; + + var start = rest.length - count; + while (start < rest.length && rest[start].role == AiRole.tool) { + start++; + } + return conversation.copyWith(messages: [...system, ...rest.sublist(start)]); + }; +} + +/// Keeps the system prefix plus a rolling **summary** of older turns, plus the +/// most recent [count] non-system messages. +/// +/// This is the compaction counterpart to [keepLastMessages]: instead of +/// silently dropping older context (losing load-bearing facts — "context rot"), +/// it folds a caller-supplied summary of the trimmed span into the request as a +/// synthetic `system` message, right after any real system messages. +/// +/// [summary] is called on each request and should return the current rolling +/// summary text (empty to inject nothing). The controller does **not** produce +/// the summary — your app owns that, exactly as the roadmap intends: run your +/// own periodic summarization (any model, or a cheap heuristic), persist the +/// result with the conversation via `ChatStore`, and return it here. That keeps +/// durable, cross-session memory without this package owning a memory service. +/// +/// As with [keepLastMessages], leading `tool` results in the kept window are +/// skipped so none is orphaned. The stored transcript is never modified — only +/// what each request sends. +AiConversation Function(AiConversation) keepLastWithSummary({ + required String Function() summary, + required int count, + String summaryLabel = 'Summary of earlier conversation:', +}) { + assert(count >= 0, 'count must be >= 0'); + return (conversation) { + final messages = conversation.messages; + final system = [ + for (final m in messages) + if (m.role == AiRole.system) m, + ]; + final rest = [ + for (final m in messages) + if (m.role != AiRole.system) m, + ]; + + var start = rest.length <= count ? 0 : rest.length - count; + while (start < rest.length && rest[start].role == AiRole.tool) { + start++; + } + final kept = rest.sublist(start); + + // Only inject a summary when something was actually dropped and the app + // supplied non-empty text. + final summaryText = start > 0 ? summary().trim() : ''; + final summaryMessages = summaryText.isEmpty + ? const [] + : [ + AiMessage( + id: 'summary', + role: AiRole.system, + parts: [TextPart('$summaryLabel\n$summaryText')], + ), + ]; + + if (summaryMessages.isEmpty && kept.length == rest.length) { + return conversation; + } + return conversation.copyWith( + messages: [...system, ...summaryMessages, ...kept], + ); + }; +} + +/// Keeps the system prefix plus as many of the most recent non-system messages +/// as fit within [maxTokens], estimated from text length. +/// +/// Token counts are approximated as `ceil(textLength / charsPerToken)` per +/// message (default ~4 characters per token — a reasonable English heuristic; +/// use a provider `countTokens` for exact budgeting). System messages are +/// always kept and counted. As with [keepLastMessages], the window is advanced +/// past a leading `tool` result so none is orphaned. +AiConversation Function(AiConversation) trimToApproxTokenBudget( + int maxTokens, { + int charsPerToken = 4, +}) { + assert(maxTokens >= 0, 'maxTokens must be >= 0'); + assert(charsPerToken >= 1, 'charsPerToken must be >= 1'); + int estimate(AiMessage m) => (m.text.length / charsPerToken).ceil(); + + return (conversation) { + final messages = conversation.messages; + final system = [ + for (final m in messages) + if (m.role == AiRole.system) m, + ]; + final rest = [ + for (final m in messages) + if (m.role != AiRole.system) m, + ]; + + var budget = maxTokens; + for (final m in system) { + budget -= estimate(m); + } + + // Walk newest -> oldest, keeping messages until the budget is exhausted. + final keptReversed = []; + for (var i = rest.length - 1; i >= 0; i--) { + final cost = estimate(rest[i]); + if (keptReversed.isNotEmpty && budget - cost < 0) break; + budget -= cost; + keptReversed.add(rest[i]); + } + var kept = keptReversed.reversed.toList(); + + // Don't begin on an orphaned tool result. + while (kept.isNotEmpty && kept.first.role == AiRole.tool) { + kept = kept.sublist(1); + } + + if (kept.length == rest.length) return conversation; + return conversation.copyWith(messages: [...system, ...kept]); + }; +} diff --git a/packages/flutter_ai/flutter_ai_client/lib/src/follow_ups.dart b/packages/flutter_ai/flutter_ai_client/lib/src/follow_ups.dart new file mode 100644 index 0000000..e959a9c --- /dev/null +++ b/packages/flutter_ai/flutter_ai_client/lib/src/follow_ups.dart @@ -0,0 +1,61 @@ +import 'package:flutter_ai_core/flutter_ai_core.dart'; + +/// Generates up to [count] short follow-up prompts a user might send next, given +/// the current [conversation], via a one-off call to [provider]. +/// +/// This is the model call the presentational `AiSuggestions` strip needs to show +/// *contextual* follow-ups (it renders whatever list you give it). Returns an +/// empty list if the model produces nothing usable; it never throws for an empty +/// or malformed reply. +/// +/// ```dart +/// final followUps = await suggestFollowUps(controller.conversation, provider); +/// // → feed into AiSuggestions(suggestions: followUps, onSelected: ...) +/// ``` +/// +/// Pass [options] to pick a cheaper/faster model for this side call (e.g. a +/// flash/mini model) independent of the main chat model. +Future> suggestFollowUps( + AiConversation conversation, + LlmProvider provider, { + int count = 3, + AiRequestOptions? options, +}) async { + if (conversation.messages.isEmpty) return const []; + + final prompt = AiMessage.text( + id: 'follow-ups-prompt', + role: AiRole.user, + text: 'Based on the conversation so far, suggest $count brief follow-up ' + 'questions I might ask next. Keep each under 8 words. ' + 'Respond with one question per line, no numbering, bullets, or quotes.', + ); + final request = conversation.copyWith( + messages: [...conversation.messages, prompt], + ); + + final buffer = StringBuffer(); + await for (final event in provider.send(request, options: options)) { + if (event is TextDelta) buffer.write(event.delta); + } + + return _parseLines(buffer.toString(), count); +} + +/// Splits the model reply into clean one-line suggestions, stripping any +/// leftover numbering/bullets/quotes and dropping blanks. +List _parseLines(String reply, int count) { + final cleaned = []; + for (final raw in reply.split('\n')) { + var line = raw.trim(); + if (line.isEmpty) continue; + // Strip a leading "1.", "1)", "-", "*", "•" list marker. + line = line.replaceFirst(RegExp(r'^\s*(\d+[.)]|[-*•])\s*'), ''); + // Strip surrounding quotes. + line = line.replaceAll(RegExp(r'''^["']+|["']+$'''), '').trim(); + if (line.isEmpty) continue; + cleaned.add(line); + if (cleaned.length >= count) break; + } + return cleaned; +} diff --git a/packages/flutter_ai/flutter_ai_client/lib/src/use_chat_controller.dart b/packages/flutter_ai/flutter_ai_client/lib/src/use_chat_controller.dart new file mode 100644 index 0000000..588e38e --- /dev/null +++ b/packages/flutter_ai/flutter_ai_client/lib/src/use_chat_controller.dart @@ -0,0 +1,944 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_ai_client/src/chat_observer.dart'; +import 'package:flutter_ai_client/src/chat_status.dart'; +import 'package:flutter_ai_core/flutter_ai_core.dart'; + +/// Drives a chat conversation against any [LlmProvider], exposing state as a +/// [Listenable] (this class is a [ChangeNotifier]). +/// +/// This is the Dart analogue of the web `useChat` hook. It is deliberately +/// **un-opinionated about state management**: bind it with `ListenableBuilder`, +/// or adapt it to Bloc / Riverpod / Provider — the controller imposes nothing. +/// The raw [events] stream is available as an escape hatch for custom state +/// layers. +/// +/// ### Streaming performance +/// +/// Incoming events are folded by an internal [MessageProcessor], and +/// [notifyListeners] is **coalesced**: many events arriving in one turn trigger +/// a single notification. The coalescing strategy is injectable via `scheduler` +/// (defaulting to [scheduleMicrotask]); combined with Flutter's per-frame +/// rebuild pipeline this keeps high token rates from dropping frames. A host +/// that wants strict frame alignment can pass a scheduler backed by +/// `SchedulerBinding.addPostFrameCallback`. +/// +/// ### History +/// +/// The full message history is retained so the user can scroll the whole +/// session; trimming for token budgets is a provider/server concern, not the +/// controller's. +class UseChatController extends ChangeNotifier { + /// Creates a controller bound to [provider]. + /// + /// [initial] seeds the conversation. [tools] and [options] are forwarded to + /// the provider on every request. [scheduler] customizes notification + /// batching (defaults to [scheduleMicrotask]). [idGenerator] supplies ids for + /// locally-created user messages (defaults to a sequential generator). + /// + /// ### Agent loop + /// + /// Provide [onToolCalls] to turn the controller into an automatic agent: when + /// a model turn ends with tool calls that have no results yet, the controller + /// invokes [onToolCalls], appends the returned [ToolResultPart]s, and + /// re-prompts the model — repeating until a turn has no pending tool calls or + /// [maxSteps] model calls have run. Without [onToolCalls] the behavior is + /// unchanged: the turn ends with the tool calls and the host drives execution + /// manually via [addToolResults]. + /// + /// [onToolCalls] receives an [AiToolCallSignal] as its second argument. The + /// controller cancels it if the turn is stopped, replaced, or disposed while + /// the executor is still running, so a long-running tool can abort in-flight + /// work (e.g. cancel an HTTP request via [AiToolCallSignal.whenCancelled]) + /// instead of running to completion only to have its result discarded. + /// + /// ### Tool-argument validation + /// + /// When [validateToolArgs] is true (the default) and a tool's + /// [ToolDefinition.parametersSchema] is non-empty, the controller validates + /// each model-produced call's arguments against that schema *before* running + /// [onToolCalls]. A call whose args violate the schema is not executed; + /// instead an error [ToolResultPart] describing the violations is fed back to + /// the model, which then gets a chance to correct itself (still bounded by + /// [maxSteps]). Tools with no schema, and calls for unknown tool names, skip + /// validation. + /// + /// ### Runaway-loop guard + /// + /// [maxIdenticalToolCalls] (0 = off, the default) halts the agent loop if the + /// model requests the same tool call — identical name **and** arguments — + /// after it has already run that many times in the turn. Instead of looping + /// up to [maxSteps] and spending tokens, the turn ends with [error] set to an + /// [AgentLoopException]. Complements [tokenBudget], which caps total tokens. + /// + /// ### Observability + /// + /// Pass a [ChatObserver] to receive lifecycle callbacks (turn start, each + /// model request, response + token usage, tool calls/results, errors, turn + /// end) shaped after the OpenTelemetry GenAI semantic conventions — with no + /// OpenTelemetry dependency. Map them onto your own tracer or analytics sink. + /// + /// ### History trimming + /// + /// [trimHistory], when provided, maps the full conversation to the (smaller) + /// conversation actually sent to the provider on each request. The stored + /// transcript is never trimmed — [conversation]/[messages] still return + /// everything — so the UI keeps the full history while requests stay within a + /// token budget. See `keepLastMessages` and `trimToApproxTokenBudget` for + /// ready-made strategies. + UseChatController({ + required LlmProvider provider, + AiConversation? initial, + List tools = const [], + AiRequestOptions? options, + Future> Function( + List calls, + AiToolCallSignal signal, + )? onToolCalls, + int maxSteps = 8, + int maxBranches = 20, + int? tokenBudget, + int maxIdenticalToolCalls = 0, + bool validateToolArgs = true, + ChatObserver? observer, + AiConversation Function(AiConversation conversation)? trimHistory, + void Function(VoidCallback callback)? scheduler, + String Function()? idGenerator, + }) : assert(maxSteps >= 1, 'maxSteps must be at least 1'), + assert(maxBranches >= 1, 'maxBranches must be at least 1'), + assert(maxIdenticalToolCalls >= 0, + 'maxIdenticalToolCalls must be >= 0 (0 disables loop detection)'), + _provider = provider, + _tools = List.unmodifiable(tools), + _options = options, + _onToolCalls = onToolCalls, + _maxSteps = maxSteps, + _maxBranches = maxBranches, + _tokenBudget = tokenBudget, + _maxIdenticalToolCalls = maxIdenticalToolCalls, + _observer = observer, + _validateToolArgs = validateToolArgs, + _trimHistory = trimHistory, + _scheduler = scheduler ?? scheduleMicrotask, + _newId = idGenerator ?? _sequentialIdGenerator(), + _processor = MessageProcessor(conversation: initial); + + final MessageProcessor _processor; + final void Function(VoidCallback callback) _scheduler; + final String Function() _newId; + final Future> Function( + List, + AiToolCallSignal, + )? _onToolCalls; + // The signal for the tool batch currently executing, cancelled if the turn is + // torn down (stop/replace/dispose) while the executor runs. + AiToolCallSignal? _activeToolSignal; + final int _maxSteps; + final int _maxBranches; + final bool _validateToolArgs; + final AiConversation Function(AiConversation)? _trimHistory; + final int? _tokenBudget; // stop the agent loop once cumulative tokens exceed + // Halt the agent loop if the model requests the same (toolName, args) call + // this many times in one turn — a runaway-loop guard. 0 disables it. + final int _maxIdenticalToolCalls; + // Per-turn count of executed tool-call signatures, for loop detection. + final Map _toolCallCounts = {}; + // Optional lifecycle observer for tracing/metrics. + final ChatObserver? _observer; + // The finish reason from the most recent MessageFinished, for the observer. + FinishReason? _lastFinishReason; + final StreamController _events = + StreamController.broadcast(); + + LlmProvider _provider; + List _tools; + AiRequestOptions? _options; + + // Regeneration branches for the latest turn: each version is the slice of + // messages after the last user message. `regenerate` appends a version; + // navigating swaps which one is shown. + List> _branches = []; + int _branchIndex = 0; + _Capture _capture = _Capture.reset; + + ChatStatus _status = ChatStatus.idle; + Object? _error; + StackTrace? _stackTrace; + StreamSubscription? _subscription; + Completer? _turn; + int _step = 0; // model calls executed so far in the current agent turn + int _turnSeq = 0; // bumped whenever a turn is torn down/replaced + bool _notifyScheduled = false; + bool _disposed = false; + + /// The full conversation transcript. + AiConversation get conversation => _processor.conversation; + + /// The messages in the conversation. + List get messages => _processor.conversation.messages; + + /// The current turn status. + ChatStatus get status => _status; + + /// The error from the last failed turn, or `null`. + Object? get error => _error; + + /// The stack trace captured alongside [error], or `null`. + StackTrace? get stackTrace => _stackTrace; + + /// How many regenerated versions exist for the latest turn (1 = no + /// alternatives). Drive an `AiBranch` with this and [branchIndex]. + int get branchCount => _branches.length; + + /// The 0-based index of the version currently shown for the latest turn. + int get branchIndex => _branchIndex; + + /// The summed token usage across every message in the conversation that + /// reported it, or `null` if none did. Feed an `AiContextMeter` or estimate + /// cost with [AiUsage.estimateCost]. + AiUsage? get totalUsage { + AiUsage? total; + for (final message in _processor.conversation.messages) { + final usage = message.usage; + if (usage != null) total = total == null ? usage : total + usage; + } + return total; + } + + /// A broadcast stream of every event applied to the conversation. + /// + /// An escape hatch for hosts that want to react to raw events (analytics, + /// custom state). Most callers should rely on [conversation] plus listener + /// notifications instead. + Stream get events => _events.stream; + + /// Sends a user message composed of [text] and optional [attachments]. + /// + /// Returns a future that completes when the resulting turn finishes (or is + /// stopped). A no-op if [text] is empty and there are no [attachments]. + Future sendText( + String text, { + List attachments = const [], + }) { + final parts = [ + ...attachments, + if (text.isNotEmpty) TextPart(text), + ]; + if (parts.isEmpty) return Future.value(); + return submit(AiMessage(id: _newId(), role: AiRole.user, parts: parts)); + } + + /// Appends [userMessage] optimistically and streams the model's response. + /// + /// The append happens **synchronously** before the request is dispatched, so + /// the user's message paints immediately. Any in-flight turn is cancelled + /// first. Returns a future that completes when the new turn finishes, errors, + /// or is stopped. + Future submit(AiMessage userMessage) { + _stopActiveStream(); + _error = null; + _stackTrace = null; + _capture = _Capture.reset; // a new user turn starts a fresh branch set + _step = 0; + _toolCallCounts.clear(); + _processor.reset( + _settleDanglingToolCalls(_processor.conversation).append(userMessage), + ); + _status = ChatStatus.submitted; + _scheduleNotify(); + return _beginTurn(); + } + + /// Re-runs the model from the most recent user message, discarding everything + /// after it. A no-op if there is no user message. + Future regenerate() { + final all = _processor.conversation.messages; + final lastUser = all.lastIndexWhere((m) => m.role == AiRole.user); + if (lastUser == -1) return Future.value(); + _stopActiveStream(); + _error = null; + _stackTrace = null; + _capture = _Capture.append; // keep the prior version, add a new one + _step = 0; + _toolCallCounts.clear(); + _processor.reset( + _settleDanglingToolCalls( + _processor.conversation + .copyWith(messages: all.sublist(0, lastUser + 1)), + ), + ); + _status = ChatStatus.submitted; + _scheduleNotify(); + return _beginTurn(); + } + + /// Edits the user message [messageId] to [text] — keeping any non-text parts + /// such as attachments — discards every message after it, and re-runs the + /// model from that point. A no-op if [messageId] is not a user message in the + /// transcript, or if the edit would leave the message empty. + /// + /// A reworded prompt starts a fresh branch set (the previous answer was to a + /// different question), like editing a sent message in a typical chat UI. + Future editMessage(String messageId, String text) { + final all = _processor.conversation.messages; + final index = all.indexWhere((m) => m.id == messageId); + if (index == -1 || all[index].role != AiRole.user) { + return Future.value(); + } + final original = all[index]; + // Replace the first text part in place (preserving attachment order); drop + // any other text parts. Append the new text if the message had none. + final parts = []; + var replaced = false; + for (final part in original.parts) { + if (part is TextPart) { + if (!replaced && text.isNotEmpty) { + parts.add(TextPart(text)); + replaced = true; + } + } else { + parts.add(part); + } + } + if (!replaced && text.isNotEmpty) parts.add(TextPart(text)); + if (parts.isEmpty) return Future.value(); + + _stopActiveStream(); + _error = null; + _stackTrace = null; + _capture = _Capture.reset; + _step = 0; + _toolCallCounts.clear(); + _processor.reset( + _settleDanglingToolCalls( + _processor.conversation.copyWith( + messages: [ + ...all.sublist(0, index), + original.copyWith(parts: parts, status: AiMessageStatus.complete), + ], + ), + ), + ); + _status = ChatStatus.submitted; + _scheduleNotify(); + return _beginTurn(); + } + + /// Edits the most recent user message to [text] and re-runs from it. A no-op + /// if there is no user message. See [editMessage]. + Future editLastUserMessage(String text) { + final lastUser = _lastUserIndex(); + if (lastUser == -1) return Future.value(); + return editMessage(_processor.conversation.messages[lastUser].id, text); + } + + /// Switches the latest turn to regenerated version [index] (0-based). A no-op + /// out of range, while a turn is in flight, or if already showing it. + void selectBranch(int index) { + if (index < 0 || index >= _branches.length || index == _branchIndex) return; + // Never switch branches while a turn is in flight — including the agent + // loop's tool-execution phase, whose live continuation would otherwise + // append tool results onto the swapped transcript and corrupt it. + if (_turn != null) return; + final lastUser = _lastUserIndex(); + if (lastUser == -1) return; + final head = _processor.conversation.messages.sublist(0, lastUser + 1); + _branchIndex = index; + _processor.reset( + _processor.conversation + .copyWith(messages: [...head, ..._branches[index]]), + ); + _scheduleNotify(); + } + + /// Appends tool [results] as an [AiRole.tool] message and streams the model's + /// continuation — call this after executing the tool calls the model + /// requested. A no-op if [results] is empty. + /// + /// Every [ToolCallPart] in the preceding assistant message should have a + /// matching [ToolResultPart] here before continuing, as providers require a + /// result per call. When an `onToolCalls` executor is configured the + /// controller calls this for you (the agent loop); use it directly only for + /// manual tool handling. + Future addToolResults(List results) { + if (results.isEmpty) return Future.value(); + _stopActiveStream(); + _error = null; + _stackTrace = null; + _capture = _Capture.update; // continuation of the current version's turn + _processor.reset( + _processor.conversation.append( + AiMessage( + id: _newId(), + role: AiRole.tool, + parts: List.of(results), + ), + ), + ); + _status = ChatStatus.submitted; + _scheduleNotify(); + return _beginTurn(); + } + + /// Cancels the in-flight turn, finalizing the streaming message as stopped. + void stop() { + _stopActiveStream(); // finalizes the trailing streaming message + _status = ChatStatus.idle; + _scheduleNotify(); + } + + /// Switches the active provider. Does not affect the current transcript or + /// interrupt an in-flight turn. + void setProvider(LlmProvider provider) { + _provider = provider; + _scheduleNotify(); + } + + /// Replaces the request options applied to subsequent turns (for example, to + /// change the model). + void setOptions(AiRequestOptions? options) { + _options = options; + _scheduleNotify(); + } + + /// Replaces the tools advertised to the provider on subsequent turns. + void setTools(List tools) { + _tools = List.unmodifiable(tools); + _scheduleNotify(); + } + + /// Clears the conversation and cancels any in-flight turn. + void clear() { + _stopActiveStream(); + _processor.reset(AiConversation.empty(_processor.conversation.id)); + _error = null; + _stackTrace = null; + _status = ChatStatus.idle; + _branches = []; + _branchIndex = 0; + _capture = _Capture.reset; + _scheduleNotify(); + } + + /// Swaps the transcript to [conversation] in place, cancelling any in-flight + /// turn — the way to switch threads without disposing and recreating the + /// controller. Rehydrate a thread with `controller.load(await store.load(id))` + /// and, if the target thread differs, re-point `attachStore` to its id. + /// + /// Branch/regeneration history is reset to the loaded turn. + void load(AiConversation conversation) { + _stopActiveStream(); + _processor.reset(conversation); + _error = null; + _stackTrace = null; + _status = ChatStatus.idle; + _branches = []; + _branchIndex = 0; + _capture = _Capture.reset; + _scheduleNotify(); + } + + int _lastUserIndex() => _processor.conversation.messages + .lastIndexWhere((m) => m.role == AiRole.user); + + /// Snapshots the post-user-message tail as the current branch version. Called + /// on each successful turn completion; the [_capture] mode decides whether to + /// start fresh, append a new version, or update the in-progress one. + void _captureBranch() { + final lastUser = _lastUserIndex(); + if (lastUser == -1) return; + final tail = _processor.conversation.messages.sublist(lastUser + 1); + if (tail.isEmpty) return; + switch (_capture) { + case _Capture.reset: + _branches = [tail]; + _branchIndex = 0; + case _Capture.append: + _branches.add(tail); + // Cap retained regenerations so a long-running chat can't grow without + // bound; drop the oldest version(s) and keep the index aligned. + while (_branches.length > _maxBranches) { + _branches.removeAt(0); + } + _branchIndex = _branches.length - 1; + case _Capture.update: + if (_branches.isEmpty) { + _branches = [tail]; + _branchIndex = 0; + } else { + _branches[_branchIndex] = tail; + } + } + // Further completions in the same turn (tool rounds) update this version. + _capture = _Capture.update; + } + + /// Opens a fresh turn future and dispatches the first model call. The future + /// completes when the whole turn ends — including any automatic agent-loop + /// continuations. + Future _beginTurn() { + final completer = Completer(); + _turn = completer; + _observer?.onTurnStart(_processor.conversation); + _dispatch(); + return completer.future; + } + + /// Subscribes to one provider stream, folding events into the conversation. + void _dispatch() { + _step++; // one model call + _observer?.onModelRequest(_step); + // Capture the turn this subscription belongs to. A late event from a + // cancelled stream (a microtask already queued when the turn was torn down) + // must not mutate the conversation or leak onto the events stream after a + // new turn started. + final seq = _turnSeq; + // Building the request can throw synchronously: trimHistory is a + // caller-supplied callback, and the LlmProvider contract permits send() to + // throw for unrecoverable transport faults. Without this guard the thrown + // error escapes (leaving status stuck at `submitted` and the turn future + // never completing, or an unhandled zone error inside the agent loop). + final Stream stream; + try { + // The provider sees the (optionally trimmed) conversation; the stored + // transcript is never trimmed. + final outgoing = _trimHistory?.call(_processor.conversation) ?? + _processor.conversation; + stream = _provider.send(outgoing, tools: _tools, options: _options); + } catch (error, stackTrace) { + _failTurn(error, stackTrace); + return; + } + _subscription = stream.listen( + (event) { + if (_disposed || seq != _turnSeq) return; + _processor.apply(event); + if (!_events.isClosed) _events.add(event); + if (event is MessageFinished) _lastFinishReason = event.reason; + // A message-scoped error event is fatal: record the error and tear the + // turn down so a misbehaving provider cannot keep mutating the + // conversation past the failure. A tool-scoped error is left to the + // tool result instead and streaming continues. + if (event is StreamErrorEvent && event.toolCallId == null) { + _failTurn(event.error, null); + return; + } else if (_status == ChatStatus.submitted) { + _status = ChatStatus.streaming; + } + _scheduleNotify(); + }, + onError: (Object error, StackTrace stackTrace) { + if (_disposed || seq != _turnSeq) return; + _failTurn(error, stackTrace); + }, + onDone: () { + if (_disposed || seq != _turnSeq) return; + _onStreamDone(); + }, + cancelOnError: true, + ); + } + + /// A provider stream completed cleanly. Captures the branch, then either runs + /// the agent loop (execute pending tool calls and re-prompt) or ends the turn. + void _onStreamDone() { + _subscription = null; + if (_status == ChatStatus.error) { + _completeTurn(); + _scheduleNotify(); + return; + } + _captureBranch(); + _observer?.onModelResponse( + step: _step, + usage: _processor.conversation.lastMessage?.usage, + finishReason: _lastFinishReason, + ); + + final pending = _pendingToolCalls(); + // Runaway-loop guard: if the model keeps requesting a tool call it has + // already run `maxIdenticalToolCalls` times with identical args, halt with + // a typed error instead of looping (and burning tokens) to `maxSteps`. + if (_onToolCalls != null && + pending.isNotEmpty && + _maxIdenticalToolCalls > 0) { + for (final call in pending) { + if ((_toolCallCounts[_toolCallSignature(call)] ?? 0) >= + _maxIdenticalToolCalls) { + _error = AgentLoopException(call.toolName, _maxIdenticalToolCalls); + _status = ChatStatus.error; + _completeTurn(); + _scheduleNotify(); + return; + } + } + } + final overBudget = _tokenBudget != null && + (totalUsage?.resolvedTotal ?? 0) >= _tokenBudget; + if (_onToolCalls != null && + pending.isNotEmpty && + _step < _maxSteps && + !overBudget) { + // The turn stays in flight while the tool executor runs; keep the + // controller busy so UIs don't re-enable input and stores don't persist a + // mid-turn transcript with unanswered tool calls (see selectBranch). + _status = ChatStatus.executingTools; + _scheduleNotify(); + unawaited(_continueWithTools(pending, _turn)); + return; + } + _status = ChatStatus.idle; + _completeTurn(); + _scheduleNotify(); + } + + /// Runs [_onToolCalls] for [calls] and feeds the results back into the model, + /// continuing the same [turn]. Aborts silently if the turn was stopped or + /// replaced while the executor ran. + Future _continueWithTools( + List calls, + Completer? turn, + ) async { + // Split off calls whose arguments violate the tool's parametersSchema: + // those are answered with an error result (so the model can retry) instead + // of being handed to the executor. + _observer?.onToolCalls(calls); + final (valid, validationErrors) = _validateToolArgs + ? _splitInvalidCalls(calls) + : (calls, const []); + + // Record what we're about to run so the loop guard in _onStreamDone can spot + // the model re-requesting an identical call. + if (_maxIdenticalToolCalls > 0) { + for (final call in valid) { + final sig = _toolCallSignature(call); + _toolCallCounts[sig] = (_toolCallCounts[sig] ?? 0) + 1; + } + } + + List executed = const []; + if (valid.isNotEmpty) { + final signal = AiToolCallSignal(); + _activeToolSignal = signal; + try { + executed = await _onToolCalls!(valid, signal); + } catch (error, stackTrace) { + if (identical(_activeToolSignal, signal)) _activeToolSignal = null; + if (_disposed || !identical(_turn, turn)) return; + _error = error; + _stackTrace = stackTrace; + _status = ChatStatus.error; + _observer?.onError(error, stackTrace); + _completeTurn(); + _scheduleNotify(); + return; + } + if (identical(_activeToolSignal, signal)) _activeToolSignal = null; + } + if (_disposed || !identical(_turn, turn) || turn == null) return; + final results = [...validationErrors, ...executed]; + _observer?.onToolResults(results); + if (results.isEmpty) { + _status = ChatStatus.idle; + _completeTurn(); + _scheduleNotify(); + return; + } + _capture = _Capture.update; + _processor.reset( + _processor.conversation.append( + AiMessage( + id: _newId(), + role: AiRole.tool, + parts: List.of(results), + ), + ), + ); + _status = ChatStatus.submitted; + _scheduleNotify(); + _dispatch(); + } + + /// Settles tool calls left unanswered anywhere in the transcript — a turn + /// stopped/replaced mid agent-loop, `maxSteps` cutting a loop short, or a + /// dirty transcript rehydrated from storage — by inserting a synthesized + /// error [ToolResultPart] message directly after each affected assistant + /// message. Providers reject a history containing a tool call with no + /// result in the immediately-following turn (so the fix-up must be inserted + /// in place, not appended at the end), and without it every subsequent + /// [submit] on the conversation fails with a request error. + AiConversation _settleDanglingToolCalls(AiConversation conversation) { + final msgs = conversation.messages; + final answered = { + for (final m in msgs) + for (final p in m.parts) + if (p is ToolResultPart) p.toolCallId, + }; + var changed = false; + final out = []; + for (final m in msgs) { + out.add(m); + if (m.role != AiRole.assistant) continue; + final dangling = m.parts + .whereType() + .where((c) => !answered.contains(c.toolCallId)) + .toList(); + if (dangling.isEmpty) continue; + changed = true; + out.add( + AiMessage( + id: _newId(), + role: AiRole.tool, + parts: [ + for (final call in dangling) + ToolResultPart( + toolCallId: call.toolCallId, + isError: true, + result: + 'Cancelled: the turn was interrupted before this tool ' + 'call produced a result.', + ), + ], + ), + ); + } + return changed ? conversation.copyWith(messages: out) : conversation; + } + + /// Tool calls in the latest assistant message that have no matching + /// [ToolResultPart] anywhere in the transcript yet. + /// A stable identity for a tool call — name plus JSON-encoded args — used to + /// detect the model re-requesting the exact same call. + String _toolCallSignature(ToolCallPart call) => + '${call.toolName}(${jsonEncode(call.args)})'; + + List _pendingToolCalls() { + final msgs = _processor.conversation.messages; + final lastAssistant = + msgs.lastIndexWhere((m) => m.role == AiRole.assistant); + if (lastAssistant == -1) return const []; + final calls = msgs[lastAssistant].parts.whereType().toList(); + if (calls.isEmpty) return const []; + final answered = { + for (final m in msgs) + for (final p in m.parts) + if (p is ToolResultPart) p.toolCallId, + }; + return calls.where((c) => !answered.contains(c.toolCallId)).toList(); + } + + /// Partitions [calls] into those whose arguments satisfy the matching tool's + /// [ToolDefinition.parametersSchema] and, for the rest, an error + /// [ToolResultPart] describing the schema violations. Calls for tools with no + /// schema, or for tool names not in [_tools], are treated as valid (nothing + /// to validate against). + (List, List) _splitInvalidCalls( + List calls, + ) { + final valid = []; + final errors = []; + for (final call in calls) { + Map schema = const {}; + for (final t in _tools) { + if (t.name == call.toolName) { + schema = t.parametersSchema; + break; + } + } + final violations = schema.isEmpty + ? const [] + : validateJsonSchema(call.args, schema); + if (violations.isEmpty) { + valid.add(call); + } else { + errors.add( + ToolResultPart( + toolCallId: call.toolCallId, + isError: true, + result: { + 'error': 'invalid_arguments', + 'message': 'Arguments for "${call.toolName}" failed validation. ' + 'Fix them and call the tool again.', + 'violations': violations, + }, + ), + ); + } + } + return (valid, errors); + } + + /// Cancels the active subscription (if any) and completes its turn future. + /// The cancel itself is fire-and-forget — a new stream is started right after, + /// and `StreamSubscription.cancel` stops delivery immediately. + void _stopActiveStream() { + _turnSeq++; // invalidate any in-flight subscription's late events + // Tell a tool executor running between streams to abort its in-flight work. + _activeToolSignal?._cancel(); + _activeToolSignal = null; + final sub = _subscription; + _subscription = null; + if (sub != null) unawaited(sub.cancel()); + // Finalize a still-streaming message so it doesn't linger in the transcript + // as a permanent typing indicator (and get persisted that way). Callers that + // discard the message afterwards (clear/regenerate/editMessage) are + // unaffected; submit/addToolResults keep it, so it must settle here. + final last = _processor.conversation.lastMessage; + if (last != null && last.status == AiMessageStatus.streaming) { + _processor.apply( + MessageFinished(messageId: last.id, reason: FinishReason.stop), + ); + } + _completeTurn(); + } + + /// Routes a turn-fatal [error] to the error state: records it, notifies the + /// observer, finalizes any trailing streaming message, cancels the active + /// stream, and completes the turn. Shared by the async `onError`, a fatal + /// in-band `StreamErrorEvent`, and a synchronous throw from + /// `provider.send`/`trimHistory`. + void _failTurn(Object error, StackTrace? stackTrace) { + _error = error; + _stackTrace = stackTrace; + _status = ChatStatus.error; + _observer?.onError(error, stackTrace); + final last = _processor.conversation.lastMessage; + if (last != null && last.status == AiMessageStatus.streaming) { + _processor.apply(StreamErrorEvent(error: error, messageId: last.id)); + } + final sub = _subscription; + _subscription = null; + if (sub != null) unawaited(sub.cancel()); + _completeTurn(); + _scheduleNotify(); + } + + void _completeTurn() { + final turn = _turn; + _turn = null; + if (turn != null && !turn.isCompleted) { + turn.complete(); + _observer?.onTurnEnd(totalUsage: totalUsage); + } + } + + void _scheduleNotify() { + if (_notifyScheduled || _disposed) return; + _notifyScheduled = true; + _scheduler(() { + _notifyScheduled = false; + if (!_disposed) notifyListeners(); + }); + } + + @override + void dispose() { + _disposed = true; + _activeToolSignal?._cancel(); + _activeToolSignal = null; + unawaited(_subscription?.cancel()); + _completeTurn(); + unawaited(_events.close()); + super.dispose(); + } + + /// The default message-id generator: a per-controller random prefix plus an + /// incrementing counter, e.g. `msg-k3f9a1-0`. + /// + /// The random prefix is what makes ids collision-resistant. A plain `msg-N` + /// counter restarts at 0 for every controller, so seeding a controller with a + /// rehydrated transcript (`ChatStore.load`, which already contains `msg-0…N`) + /// would make the first new message reuse an existing id — silently corrupting + /// `messageById`/`replace`/`editMessage` and producing duplicate widget keys. + /// The prefix also keeps two controllers writing to the same store from + /// colliding. Pass a custom `idGenerator` to override. + static String Function() _sequentialIdGenerator() { + final prefix = (Random().nextInt(1 << 32)).toRadixString(36); + var n = 0; + return () => 'msg-$prefix-${n++}'; + } +} + +/// How [UseChatController] folds the next completed turn into branch history. +enum _Capture { reset, append, update } + +/// A cancellation signal handed to an `onToolCalls` executor as its second +/// argument. +/// +/// [UseChatController] cancels it when the turn that launched the tool batch is +/// stopped, replaced (a new turn started), or the controller is disposed while +/// the executor is still running. A long-running tool should observe it and +/// abort its in-flight work — its returned results are discarded once the turn +/// is gone anyway, so honoring cancellation just frees resources sooner. +/// +/// Three ways to consume it: +/// ```dart +/// onToolCalls: (calls, signal) async { +/// // 1) Race cancellable I/O against cancellation: +/// final res = await Future.any([httpCall(), signal.whenCancelled]); +/// if (signal.isCancelled) return const []; // 2) poll before/after work +/// signal.throwIfCancelled(); // 3) bail between steps +/// ... +/// } +/// ``` +class AiToolCallSignal { + /// Creates an uncancelled signal. The controller constructs one per tool + /// batch; hosts rarely need to create their own. + AiToolCallSignal(); + + final Completer _completer = Completer(); + bool _cancelled = false; + + /// Whether the owning turn has been cancelled. + bool get isCancelled => _cancelled; + + /// Completes when the owning turn is cancelled (never with an error). Race it + /// against cancellable work with [Future.any]. + Future get whenCancelled => _completer.future; + + /// Throws [AiToolCallCancelled] if [isCancelled]. Call between steps of a + /// long tool to bail out promptly. + void throwIfCancelled() { + if (_cancelled) throw const AiToolCallCancelled(); + } + + void _cancel() { + if (_cancelled) return; + _cancelled = true; + if (!_completer.isCompleted) _completer.complete(); + } +} + +/// Thrown by [AiToolCallSignal.throwIfCancelled] when the turn was cancelled. +class AiToolCallCancelled implements Exception { + /// Creates the exception. + const AiToolCallCancelled(); + + @override + String toString() => + 'AiToolCallCancelled: the tool-call batch was cancelled (the turn was ' + 'stopped, replaced, or disposed).'; +} + +/// Surfaced on [UseChatController.error] when the agent loop is halted because +/// the model requested the same tool call (identical name + args) more than the +/// controller's `maxIdenticalToolCalls` limit — a runaway-loop guard that stops +/// the turn instead of looping (and spending tokens) up to `maxSteps`. +class AgentLoopException implements Exception { + /// Creates the exception for [toolName] after hitting [limit] identical calls. + const AgentLoopException(this.toolName, this.limit); + + /// The tool whose repeated identical calls tripped the guard. + final String toolName; + + /// The configured `maxIdenticalToolCalls` limit that was reached. + final int limit; + + @override + String toString() => + 'AgentLoopException: tool "$toolName" was requested with identical ' + 'arguments more than $limit times; halting the agent loop.'; +} diff --git a/packages/flutter_ai/flutter_ai_client/pubspec.yaml b/packages/flutter_ai/flutter_ai_client/pubspec.yaml new file mode 100644 index 0000000..e11716f --- /dev/null +++ b/packages/flutter_ai/flutter_ai_client/pubspec.yaml @@ -0,0 +1,39 @@ +name: flutter_ai_client +description: "Provider-agnostic chat controller for flutter_ai: wraps any LlmProvider with optimistic send, cancellation, regeneration, and frame-batched streaming." +version: 0.3.0 +repository: https://github.com/ananmouaz/flutter_ai/tree/main/packages/flutter_ai_client +issue_tracker: https://github.com/ananmouaz/flutter_ai/issues +homepage: https://github.com/ananmouaz/flutter_ai + +topics: + - ai + - llm + - chat + - streaming + - flutter + +environment: + sdk: ^3.6.0 + flutter: ">=3.27.0" + +# No platform channels — supported everywhere Flutter is. +platforms: + android: + ios: + linux: + macos: + web: + windows: + +# Part of the flutter_ai workspace. +resolution: workspace + +dependencies: + flutter: + sdk: flutter + flutter_ai_core: ^0.1.11 + +dev_dependencies: + flutter_test: + sdk: flutter + lints: ^5.0.0 diff --git a/packages/flutter_ai/flutter_ai_client/test/context_strategy_test.dart b/packages/flutter_ai/flutter_ai_client/test/context_strategy_test.dart new file mode 100644 index 0000000..63ea000 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_client/test/context_strategy_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter_ai_client/flutter_ai_client.dart'; +import 'package:flutter_test/flutter_test.dart'; + +AiConversation _conv(List messages) => + AiConversation(id: 'c', messages: messages); + +AiMessage _m(String id, AiRole role, String text) => + AiMessage(id: id, role: role, parts: [TextPart(text)]); + +void main() { + group('keepLastWithSummary', () { + final base = _conv([ + _m('s', AiRole.system, 'sys'), + _m('u1', AiRole.user, 'one'), + _m('a1', AiRole.assistant, '1'), + _m('u2', AiRole.user, 'two'), + _m('a2', AiRole.assistant, '2'), + _m('u3', AiRole.user, 'three'), + ]); + + test('injects the summary as a system message when older turns are dropped', + () { + final trimmed = keepLastWithSummary( + summary: () => 'user greeted and asked two things', + count: 2, + )(base); + + final roles = trimmed.messages.map((m) => m.role).toList(); + // real system, injected summary system message, then last 2 (a2, u3). + expect(roles, [ + AiRole.system, + AiRole.system, + AiRole.assistant, + AiRole.user, + ]); + expect(trimmed.messages[1].text, contains('user greeted')); + expect(trimmed.messages.last.id, 'u3'); + }); + + test('injects nothing when nothing is dropped', () { + final trimmed = keepLastWithSummary( + summary: () => 'should not appear', + count: 10, + )(base); + expect(trimmed, same(base)); + }); + + test('injects nothing when the summary is empty', () { + final trimmed = keepLastWithSummary( + summary: () => ' ', + count: 1, + )(base); + expect( + trimmed.messages.where((m) => m.role == AiRole.system), + hasLength(1), // only the real system message + ); + expect(trimmed.messages.last.id, 'u3'); + }); + + test('does not begin the kept window on an orphaned tool result', () { + final conv = _conv([ + _m('u1', AiRole.user, 'q'), + _m('a1', AiRole.assistant, 'call'), + _m('t1', AiRole.tool, 'result'), + _m('a2', AiRole.assistant, 'answer'), + ]); + final trimmed = keepLastWithSummary( + summary: () => 'summary', + count: 2, // would start on the tool result; must advance past it + )(conv); + expect(trimmed.messages.any((m) => m.role == AiRole.tool), isFalse); + expect(trimmed.messages.last.id, 'a2'); + expect(trimmed.messages.any((m) => m.id == 't1'), isFalse); + }); + }); +} diff --git a/packages/flutter_ai/flutter_ai_client/test/follow_ups_and_store_test.dart b/packages/flutter_ai/flutter_ai_client/test/follow_ups_and_store_test.dart new file mode 100644 index 0000000..be770f3 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_client/test/follow_ups_and_store_test.dart @@ -0,0 +1,134 @@ +import 'package:flutter_ai_client/flutter_ai_client.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A provider that replays fixed events, recording the conversation it saw. +class ScriptedProvider implements LlmProvider { + ScriptedProvider(this.events); + + final List events; + AiConversation? lastConversation; + + @override + Stream send( + AiConversation conversation, { + List? tools, + AiRequestOptions? options, + }) async* { + lastConversation = conversation; + for (final event in events) { + yield event; + } + } +} + +/// An in-memory [KeyValueStore] standing in for shared_preferences/a file. +class MapKeyValueStore implements KeyValueStore { + final Map data = {}; + + @override + Future read(String key) async => data[key]; + + @override + Future write(String key, String value) async => data[key] = value; + + @override + Future remove(String key) async => data.remove(key); +} + +AiConversation _conv(String id, List userTexts) => AiConversation( + id: id, + messages: [ + for (var i = 0; i < userTexts.length; i++) + AiMessage.text(id: 'm$i', role: AiRole.user, text: userTexts[i]), + ], + ); + +void main() { + group('suggestFollowUps', () { + test('parses one-per-line, strips markers, and caps at count', () async { + final provider = ScriptedProvider([ + const MessageStarted(messageId: 'a1', role: AiRole.assistant), + const TextDelta(messageId: 'a1', delta: '1. What about pricing?\n'), + const TextDelta(messageId: 'a1', delta: '- How do I deploy?\n'), + const TextDelta(messageId: 'a1', delta: '"Any alternatives?"\n'), + const TextDelta(messageId: 'a1', delta: 'Extra one that is dropped\n'), + const MessageFinished(messageId: 'a1', reason: FinishReason.stop), + ]); + + final result = await suggestFollowUps( + _conv('c1', ['Tell me about the product']), + provider, + count: 3, + ); + + expect(result, + ['What about pricing?', 'How do I deploy?', 'Any alternatives?']); + // The follow-up instruction is appended to the sent conversation. + expect(provider.lastConversation!.messages.length, 2); + }); + + test('returns empty for an empty conversation without calling send', + () async { + final provider = ScriptedProvider([]); + final result = + await suggestFollowUps(const AiConversation.empty('c0'), provider); + expect(result, isEmpty); + expect(provider.lastConversation, isNull); + }); + }); + + group('KeyValueChatThreadStore', () { + test('round-trips conversations and maintains a newest-first index', + () async { + final kv = MapKeyValueStore(); + final store = KeyValueChatThreadStore(kv); + + await store.save('t1', _conv('t1', ['First thread hello'])); + await store.save('t2', _conv('t2', ['Second thread hi'])); + + final loaded = await store.load('t1'); + expect(loaded, isNotNull); + expect(loaded!.messages.single.text, 'First thread hello'); + + final threads = await store.listThreads(); + expect(threads.map((t) => t.id), ['t2', 't1']); // newest first + expect(threads.first.title, 'Second thread hi'); + + await store.delete('t1'); + expect(await store.load('t1'), isNull); + expect((await store.listThreads()).map((t) => t.id), ['t2']); + }); + + test('survives a fresh store instance over the same backing storage', + () async { + final kv = MapKeyValueStore(); + await KeyValueChatThreadStore(kv).save('t1', _conv('t1', ['persisted'])); + + // A new app launch: a new store over the same storage. + final reopened = KeyValueChatThreadStore(kv); + expect((await reopened.load('t1'))!.messages.single.text, 'persisted'); + expect((await reopened.listThreads()).single.id, 't1'); + }); + }); + + group('UseChatController.load', () { + void syncScheduler(void Function() callback) => callback(); + + test('swaps the transcript in place and resets branch state', () { + final controller = UseChatController( + provider: ScriptedProvider([]), + initial: _conv('a', ['old thread']), + scheduler: syncScheduler, + ); + addTearDown(controller.dispose); + + controller.load(_conv('b', ['new thread', 'and more'])); + + expect(controller.conversation.id, 'b'); + expect(controller.messages.length, 2); + expect(controller.messages.first.text, 'new thread'); + expect(controller.status, ChatStatus.idle); + expect(controller.branchCount, 0); + }); + }); +} diff --git a/packages/flutter_ai/flutter_ai_client/test/use_chat_controller_test.dart b/packages/flutter_ai/flutter_ai_client/test/use_chat_controller_test.dart new file mode 100644 index 0000000..23839f2 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_client/test/use_chat_controller_test.dart @@ -0,0 +1,1614 @@ +import 'dart:async'; + +import 'package:flutter_ai_client/flutter_ai_client.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A provider whose stream is driven manually by the test. +class ManualProvider implements LlmProvider { + StreamController? _controller; + + /// The controller backing the most recent [send] call. + StreamController get current => _controller!; + + @override + Stream send( + AiConversation conversation, { + List? tools, + AiRequestOptions? options, + }) { + // ignore: close_sinks — test fixture; closed indirectly via controller.stop. + final controller = StreamController(); + _controller = controller; + return controller.stream; + } +} + +/// A provider that replays a fixed list of events, then closes. +class ScriptedProvider implements LlmProvider { + ScriptedProvider(this.events); + + final List events; + int sendCount = 0; + AiConversation? lastConversation; + + @override + Stream send( + AiConversation conversation, { + List? tools, + AiRequestOptions? options, + }) async* { + sendCount++; + lastConversation = conversation; + for (final event in events) { + yield event; + } + } +} + +void main() { + // Run scheduled notifications synchronously for deterministic assertions. + void syncScheduler(void Function() callback) => callback(); + + group('message ids', () { + test('default generator does not collide with a rehydrated transcript', () { + // A transcript persisted by a previous session, using the old msg-N ids. + const rehydrated = AiConversation( + id: 'thread-1', + messages: [ + AiMessage(id: 'msg-0', role: AiRole.user, parts: [TextPart('hi')]), + AiMessage( + id: 'msg-1', + role: AiRole.assistant, + parts: [TextPart('hello')], + status: AiMessageStatus.complete, + ), + ], + ); + final controller = UseChatController( + provider: ManualProvider(), + scheduler: syncScheduler, + initial: rehydrated, + // No idGenerator override: exercise the real default. + ); + addTearDown(controller.dispose); + + unawaited(controller.sendText('second question')); + + final ids = controller.messages.map((m) => m.id).toList(); + expect(ids.toSet(), hasLength(ids.length), reason: 'ids must be unique'); + expect(ids, containsAll(['msg-0', 'msg-1'])); + // The newly appended user message must not reuse a rehydrated id. + expect(ids.where((id) => id == 'msg-0' || id == 'msg-1'), hasLength(2)); + }); + }); + + group('sendText / submit', () { + test('appends the user message optimistically before any response', () { + final provider = ManualProvider(); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: () => 'u1', + ); + addTearDown(controller.dispose); + + unawaited(controller.sendText('Hello')); + + expect(controller.messages, hasLength(1)); + expect(controller.messages.single.role, AiRole.user); + expect(controller.messages.single.text, 'Hello'); + expect(controller.status, ChatStatus.submitted); + }); + + test('folds streamed events into an assistant message', () async { + final provider = ScriptedProvider(const [ + MessageStarted(messageId: 'a1', role: AiRole.assistant), + TextDelta(messageId: 'a1', delta: 'Hi '), + TextDelta(messageId: 'a1', delta: 'there'), + MessageFinished(messageId: 'a1', reason: FinishReason.stop), + ]); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: () => 'u1', + ); + addTearDown(controller.dispose); + + await controller.sendText('Hello'); + + expect(controller.status, ChatStatus.idle); + expect(controller.messages.map((m) => m.role), [ + AiRole.user, + AiRole.assistant, + ]); + expect(controller.messages.last.text, 'Hi there'); + expect(controller.messages.last.status, AiMessageStatus.complete); + }); + + test('sendText with empty text and no attachments is a no-op', () async { + final provider = ScriptedProvider(const []); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + ); + addTearDown(controller.dispose); + + await controller.sendText(''); + expect(controller.messages, isEmpty); + expect(provider.sendCount, 0); + }); + + test('notifies listeners as the turn progresses', () async { + final provider = ScriptedProvider(const [ + TextDelta(messageId: 'a1', delta: 'x'), + MessageFinished(messageId: 'a1', reason: FinishReason.stop), + ]); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + ); + addTearDown(controller.dispose); + + var notifications = 0; + controller.addListener(() => notifications++); + + await controller.sendText('hi'); + expect(notifications, greaterThan(0)); + }); + }); + + group('events stream', () { + test('re-emits applied events', () async { + final provider = ScriptedProvider(const [ + TextDelta(messageId: 'a1', delta: 'one'), + MessageFinished(messageId: 'a1', reason: FinishReason.stop), + ]); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + ); + addTearDown(controller.dispose); + + final seen = []; + final sub = controller.events.listen(seen.add); + addTearDown(sub.cancel); + + await controller.sendText('hi'); + expect(seen, hasLength(2)); + expect(seen.first, isA()); + }); + }); + + group('stop', () { + test('cancels streaming and finalizes the message as stopped', () async { + final provider = ManualProvider(); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: () => 'u1', + ); + addTearDown(controller.dispose); + + final turn = controller.sendText('Hello'); + provider.current.add( + const MessageStarted(messageId: 'a1', role: AiRole.assistant), + ); + provider.current.add(const TextDelta(messageId: 'a1', delta: 'partial')); + await Future.delayed(Duration.zero); + + controller.stop(); + await turn; // stop completes the in-flight turn future + + expect(controller.status, ChatStatus.idle); + final assistant = controller.messages.last; + expect(assistant.status, AiMessageStatus.complete); + expect(assistant.finishReason, FinishReason.stop); + }); + }); + + group('interrupting a stream finalizes the trailing message', () { + test('submit mid-stream settles the interrupted assistant message', + () async { + final provider = ManualProvider(); + var n = 0; + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: () => 'u${n++}', + ); + addTearDown(controller.dispose); + + unawaited(controller.sendText('Hello')); + provider.current + ..add(const MessageStarted(messageId: 'a1', role: AiRole.assistant)) + ..add(const TextDelta(messageId: 'a1', delta: 'partial')); + await Future.delayed(Duration.zero); + expect(controller.messages.firstWhere((m) => m.id == 'a1').status, + AiMessageStatus.streaming); + + // Start a new turn before the first finished. + unawaited(controller.sendText('Again')); + + // The interrupted assistant message must not linger as a typing + // indicator (which would also be persisted by attachStore). + final a1 = controller.messages.firstWhere((m) => m.id == 'a1'); + expect(a1.status, isNot(AiMessageStatus.streaming)); + }); + + test('an in-band messageId-less error settles the streaming message', + () async { + final provider = ManualProvider(); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: () => 'u1', + ); + addTearDown(controller.dispose); + + final turn = controller.sendText('Hello'); + provider.current + ..add(const MessageStarted(messageId: 'a1', role: AiRole.assistant)) + ..add(const TextDelta(messageId: 'a1', delta: 'partial')) + ..add(const StreamErrorEvent(error: 'boom')); // messageId: null + await turn; + + expect(controller.status, ChatStatus.error); + final a1 = controller.messages.firstWhere((m) => m.id == 'a1'); + expect(a1.status, AiMessageStatus.error); + }); + }); + + group('regenerate', () { + test('drops the prior assistant turn and re-runs from the user message', + () async { + final provider = ScriptedProvider(const [ + MessageStarted(messageId: 'a1', role: AiRole.assistant), + TextDelta(messageId: 'a1', delta: 'answer'), + MessageFinished(messageId: 'a1', reason: FinishReason.stop), + ]); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: () => 'u1', + ); + addTearDown(controller.dispose); + + await controller.sendText('question'); + expect(controller.messages, hasLength(2)); + + await controller.regenerate(); + expect(provider.sendCount, 2); + // Still exactly one user + one assistant; the old assistant was dropped. + expect(controller.messages.map((m) => m.role), [ + AiRole.user, + AiRole.assistant, + ]); + }); + + test('is a no-op with no user message', () async { + final provider = ScriptedProvider(const []); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + ); + addTearDown(controller.dispose); + + await controller.regenerate(); + expect(provider.sendCount, 0); + }); + }); + + group('error handling', () { + test('surfaces a thrown provider error as error status', () async { + final provider = _ThrowingProvider(); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + ); + addTearDown(controller.dispose); + + await controller.sendText('hi'); + expect(controller.status, ChatStatus.error); + expect(controller.error, isNotNull); + }); + + test('surfaces an in-band StreamErrorEvent as error status', () async { + final provider = ScriptedProvider(const [ + MessageStarted(messageId: 'a1', role: AiRole.assistant), + StreamErrorEvent(error: 'upstream timeout', messageId: 'a1'), + ]); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + ); + addTearDown(controller.dispose); + + await controller.sendText('hi'); + expect(controller.status, ChatStatus.error); + expect(controller.error, 'upstream timeout'); + }); + + test('a synchronous throw from provider.send fails the turn cleanly', + () async { + final controller = UseChatController( + provider: _SyncThrowingProvider(), + scheduler: syncScheduler, + ); + addTearDown(controller.dispose); + + // Must complete (not hang) and land in error, not stay `submitted`. + await controller.sendText('hi'); + expect(controller.status, ChatStatus.error); + expect(controller.error, isA()); + }); + + test('a synchronous throw from trimHistory fails the turn cleanly', + () async { + final controller = UseChatController( + provider: ManualProvider(), + scheduler: syncScheduler, + trimHistory: (_) => throw StateError('trim boom'), + ); + addTearDown(controller.dispose); + + await controller.sendText('hi'); + expect(controller.status, ChatStatus.error); + expect(controller.error, isA()); + }); + + test('captures the stack trace alongside a thrown provider error', + () async { + final provider = _ThrowingProvider(); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + ); + addTearDown(controller.dispose); + + await controller.sendText('hi'); + expect(controller.error, isNotNull); + expect(controller.stackTrace, isNotNull); + + // A new turn resets both error and stack trace. + controller.setProvider(ManualProvider()); + unawaited(controller.sendText('again')); + expect(controller.error, isNull); + expect(controller.stackTrace, isNull); + }); + + test('a fatal in-band error tears down the turn and ignores later deltas', + () async { + // Message-scoped error, followed by more deltas the provider keeps + // pushing. The fatal error must cancel the subscription so the later + // deltas never reach the conversation, and the turn future must complete. + final provider = ManualProvider(); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: () => 'u1', + ); + addTearDown(controller.dispose); + + final turn = controller.sendText('hi'); + provider.current + ..add(const MessageStarted(messageId: 'a1', role: AiRole.assistant)) + ..add(const TextDelta(messageId: 'a1', delta: 'before')); + await Future.delayed(Duration.zero); + + provider.current + ..add(const StreamErrorEvent(error: 'fatal', messageId: 'a1')) + // These arrive after the fatal error and must be ignored. + ..add(const TextDelta(messageId: 'a1', delta: ' AFTER')) + ..add( + const MessageFinished(messageId: 'a1', reason: FinishReason.stop)); + + // The turn future completes despite the stream never closing. + await turn; + + expect(controller.status, ChatStatus.error); + expect(controller.error, 'fatal'); + expect(controller.messages.last.text, 'before'); + expect(controller.messages.last.text, isNot(contains('AFTER'))); + }); + }); + + group('addToolResults', () { + test('appends a tool message and continues the turn', () async { + final provider = ScriptedProvider(const [ + MessageStarted(messageId: 'a2', role: AiRole.assistant), + TextDelta(messageId: 'a2', delta: 'done'), + MessageFinished(messageId: 'a2', reason: FinishReason.stop), + ]); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: () => 't1', + ); + addTearDown(controller.dispose); + + await controller.addToolResults(const [ + ToolResultPart(toolCallId: 'c1', result: 'ok'), + ]); + + expect(provider.sendCount, 1); + expect(controller.messages.first.role, AiRole.tool); + expect(controller.messages.last.role, AiRole.assistant); + expect(controller.messages.last.text, 'done'); + }); + + test('is a no-op with empty results', () async { + final provider = ScriptedProvider(const []); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + ); + addTearDown(controller.dispose); + + await controller.addToolResults(const []); + expect(provider.sendCount, 0); + expect(controller.messages, isEmpty); + }); + }); + + group('configuration', () { + test('setOptions forwards new options to the provider', () async { + final provider = _OptionsCapturingProvider(); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + ); + addTearDown(controller.dispose); + + controller.setOptions(const AiRequestOptions(model: 'gpt-4o-mini')); + await controller.sendText('hi'); + expect(provider.lastOptions?.model, 'gpt-4o-mini'); + }); + + test('setTools forwards new tools to the provider', () async { + final provider = _ToolsCapturingProvider(); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + ); + addTearDown(controller.dispose); + + controller.setTools(const [ + ToolDefinition(name: 'lookup', description: 'Looks something up'), + ]); + await controller.sendText('hi'); + expect(provider.lastTools, hasLength(1)); + expect(provider.lastTools?.single.name, 'lookup'); + }); + + test('clear empties the transcript', () async { + final provider = ScriptedProvider(const [ + MessageFinished(messageId: 'a1', reason: FinishReason.stop), + ]); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + ); + addTearDown(controller.dispose); + + await controller.sendText('hi'); + expect(controller.messages, isNotEmpty); + controller.clear(); + expect(controller.messages, isEmpty); + expect(controller.status, ChatStatus.idle); + }); + }); + + group('regeneration branches', () { + test('regenerate keeps prior versions; selectBranch navigates them', + () async { + final provider = _CountingProvider(); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: () => 'u1', + ); + addTearDown(controller.dispose); + + await controller.sendText('hi'); + expect(controller.branchCount, 1); + expect(controller.messages.last.text, 'reply 1'); + + await controller.regenerate(); + expect(controller.branchCount, 2); + expect(controller.branchIndex, 1); + expect(controller.messages.last.text, 'reply 2'); + + // Navigate back to the first version. + controller.selectBranch(0); + expect(controller.branchIndex, 0); + expect(controller.messages.last.text, 'reply 1'); + + // A new user message resets the branch set. + await controller.sendText('again'); + expect(controller.branchCount, 1); + expect(controller.branchIndex, 0); + }); + }); + + group('editMessage', () { + String Function() seqIds() { + var n = 0; + return () => 'u${n++}'; + } + + test('rewrites a user message, drops what follows, and re-runs', () async { + final provider = _CountingProvider(); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: seqIds(), + ); + addTearDown(controller.dispose); + + await controller.sendText('first'); + await controller.sendText('second'); + expect(controller.messages, hasLength(4)); // 2 user + 2 assistant + + final firstUserId = + controller.messages.firstWhere((m) => m.role == AiRole.user).id; + await controller.editMessage(firstUserId, 'first edited'); + + final users = + controller.messages.where((m) => m.role == AiRole.user).toList(); + expect(users, hasLength(1)); // 'second' and its answer were discarded + expect(users.single.text, 'first edited'); + expect(controller.messages, hasLength(2)); // edited user + fresh reply + expect(controller.branchCount, 1); // a reworded prompt resets branches + expect(provider.lastConversation?.messages.last.text, 'first edited'); + }); + + test('editLastUserMessage edits the most recent user turn', () async { + final provider = _CountingProvider(); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: seqIds(), + ); + addTearDown(controller.dispose); + + await controller.sendText('first'); + await controller.sendText('second'); + await controller.editLastUserMessage('second edited'); + + final users = + controller.messages.where((m) => m.role == AiRole.user).toList(); + expect(users, hasLength(2)); + expect(users.last.text, 'second edited'); + }); + + test('preserves non-text parts (attachments) when editing text', () async { + final provider = _CountingProvider(); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: seqIds(), + ); + addTearDown(controller.dispose); + + final image = FilePart( + mediaType: 'image/png', + url: Uri.parse('https://example.com/cat.png'), + ); + await controller.sendText('look', attachments: [image]); + final userId = + controller.messages.firstWhere((m) => m.role == AiRole.user).id; + + await controller.editMessage(userId, 'look again'); + + final edited = + controller.messages.firstWhere((m) => m.role == AiRole.user); + expect(edited.text, 'look again'); + expect(edited.parts.whereType(), hasLength(1)); + }); + + test('is a no-op for an unknown id or a non-user message', () async { + final provider = _CountingProvider(); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: seqIds(), + ); + addTearDown(controller.dispose); + + await controller.sendText('hi'); + final assistantId = + controller.messages.firstWhere((m) => m.role == AiRole.assistant).id; + + await controller.editMessage('does-not-exist', 'x'); + await controller.editMessage(assistantId, 'x'); + expect(controller.messages, hasLength(2)); + expect(controller.messages.first.text, 'hi'); // unchanged + }); + }); + + group('branch memory', () { + test('caps retained regenerations at maxBranches', () async { + final provider = _CountingProvider(); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: () => 'u1', + maxBranches: 3, + ); + addTearDown(controller.dispose); + + await controller.sendText('hi'); + for (var i = 0; i < 6; i++) { + await controller.regenerate(); + } + expect(controller.branchCount, 3); // oldest versions evicted + expect(controller.branchIndex, 2); + }); + }); + + group('agent loop (onToolCalls)', () { + String Function() seqIds() { + var n = 0; + return () => 'm${n++}'; + } + + test('auto-executes tools and continues to a final answer', () async { + final provider = _ToolThenTextProvider(); + var executed = 0; + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: seqIds(), + onToolCalls: (calls, signal) async { + executed++; + return [ + for (final c in calls) + ToolResultPart(toolCallId: c.toolCallId, result: {'temp': 25}), + ]; + }, + ); + addTearDown(controller.dispose); + + await controller.sendText('weather in Lisbon?'); + + expect(executed, 1); + expect(provider.sendCount, 2); // tool call, then final answer + expect(controller.status, ChatStatus.idle); + expect(controller.messages.last.role, AiRole.assistant); + expect(controller.messages.last.text, 'It is sunny.'); + // The tool result message is in the transcript between the two assistant + // turns. + expect( + controller.messages.where((m) => m.role == AiRole.tool), + hasLength(1), + ); + }); + + test('stays busy (executingTools) while the tool executor runs', () async { + final provider = _ToolThenTextProvider(); + ChatStatus? statusDuringExecutor; + late UseChatController controller; + controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: seqIds(), + onToolCalls: (calls, signal) async { + statusDuringExecutor = controller.status; + return [ + for (final c in calls) + ToolResultPart(toolCallId: c.toolCallId, result: {'temp': 25}), + ]; + }, + ); + addTearDown(controller.dispose); + + await controller.sendText('weather in Lisbon?'); + + // The turn must not appear idle between the model's tool call and the + // executor's results — that flicker re-enables input and lets stores + // persist a mid-turn transcript. + expect(statusDuringExecutor, ChatStatus.executingTools); + expect(statusDuringExecutor!.isBusy, isTrue); + expect(controller.status, ChatStatus.idle); + }); + + test('selectBranch is a no-op while the tool executor runs', () async { + final provider = _ToolThenTextProvider(); + var branchAttemptRejected = false; + late UseChatController controller; + controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: seqIds(), + onToolCalls: (calls, signal) async { + // Attempting a branch switch mid-loop must be rejected (no _turn is + // ever null here), preventing transcript corruption. + final before = controller.messages.length; + controller.selectBranch(0); + branchAttemptRejected = controller.messages.length == before; + return [ + for (final c in calls) + ToolResultPart(toolCallId: c.toolCallId, result: {'temp': 25}), + ]; + }, + ); + addTearDown(controller.dispose); + + await controller.sendText('weather in Lisbon?'); + + expect(branchAttemptRejected, isTrue); + expect(controller.status, ChatStatus.idle); + }); + + test('stops at maxSteps when tools never resolve', () async { + final provider = _AlwaysToolProvider(); + var executed = 0; + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: seqIds(), + maxSteps: 3, + onToolCalls: (calls, signal) async { + executed++; + return [ + for (final c in calls) + ToolResultPart(toolCallId: c.toolCallId, result: 'ok'), + ]; + }, + ); + addTearDown(controller.dispose); + + await controller.sendText('loop forever'); + + expect(provider.sendCount, 3); // bounded by maxSteps model calls + expect(executed, 2); // tools run between calls (3 calls -> 2 rounds) + expect(controller.status, ChatStatus.idle); + }); + + test('halts with AgentLoopException on a runaway identical-call loop', + () async { + final provider = _AlwaysToolProvider(); // always requests ping({}) + var executed = 0; + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: seqIds(), + maxSteps: 20, + maxIdenticalToolCalls: 2, + onToolCalls: (calls, signal) async { + executed++; + return [ + for (final c in calls) + ToolResultPart(toolCallId: c.toolCallId, result: 'ok'), + ]; + }, + ); + addTearDown(controller.dispose); + + await controller.sendText('go'); + + // ping({}) runs twice (counts 1, 2); the third request trips the guard + // well before maxSteps (20). + expect(executed, 2); + expect(provider.sendCount, 3); + expect(controller.status, ChatStatus.error); + expect(controller.error, isA()); + expect((controller.error as AgentLoopException).toolName, 'ping'); + }); + + test('does not trip the loop guard when it is disabled (default)', + () async { + final provider = _AlwaysToolProvider(); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: seqIds(), + maxSteps: 3, // bounded by maxSteps, not the loop guard + onToolCalls: (calls, signal) async => [ + for (final c in calls) + ToolResultPart(toolCallId: c.toolCallId, result: 'ok'), + ], + ); + addTearDown(controller.dispose); + + await controller.sendText('go'); + + expect(controller.status, ChatStatus.idle); + expect(controller.error, isNull); + expect(provider.sendCount, 3); + }); + + test('ChatObserver receives the full lifecycle across a tool loop', + () async { + final provider = _ToolThenTextProvider(); + final observer = _RecordingObserver(); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: seqIds(), + observer: observer, + onToolCalls: (calls, signal) async => [ + for (final c in calls) + ToolResultPart(toolCallId: c.toolCallId, result: {'temp': 25}), + ], + ); + addTearDown(controller.dispose); + + await controller.sendText('weather?'); + + expect(observer.events, [ + 'turnStart', + 'request:1', + 'response:1:toolCalls', + 'toolCalls:1', + 'toolResults:1', + 'request:2', + 'response:2:stop', + 'turnEnd', + ]); + }); + + test('ChatObserver.onError fires before onTurnEnd on a failed turn', + () async { + final provider = ScriptedProvider([ + const StreamErrorEvent(error: 'boom'), + ]); + final observer = _RecordingObserver(); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: seqIds(), + observer: observer, + ); + addTearDown(controller.dispose); + + await controller.sendText('go'); + + expect(observer.events, ['turnStart', 'request:1', 'error', 'turnEnd']); + }); + + test('stops the loop once the token budget is exceeded', () async { + final provider = _AlwaysToolProvider(); // 100 output tokens per turn + var executed = 0; + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: seqIds(), + maxSteps: 10, + tokenBudget: 150, + onToolCalls: (calls, signal) async { + executed++; + return [ + for (final c in calls) + ToolResultPart(toolCallId: c.toolCallId, result: 'ok'), + ]; + }, + ); + addTearDown(controller.dispose); + + await controller.sendText('go'); + + // turn1 (100 < 150) continues; after turn2 (200 >= 150) the loop stops. + expect(provider.sendCount, 2); + expect(executed, 1); + }); + + test('stop() cancels the in-flight tool-call signal', () async { + final provider = _ToolThenTextProvider(); + final gate = Completer(); // holds the executor open + var observedCancel = false; + AiToolCallSignal? captured; + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: seqIds(), + onToolCalls: (calls, signal) async { + captured = signal; + unawaited(signal.whenCancelled.then((_) => observedCancel = true)); + await gate.future; // long-running tool work + return [ + for (final c in calls) + ToolResultPart(toolCallId: c.toolCallId, result: 'ok'), + ]; + }, + ); + addTearDown(() { + if (!gate.isCompleted) gate.complete(); + controller.dispose(); + }); + + unawaited(controller.sendText('weather?')); + // Let the first stream finish and the executor start awaiting the gate. + for (var i = 0; i < 10 && captured == null; i++) { + await Future.delayed(Duration.zero); + } + expect(captured, isNotNull); + expect(captured!.isCancelled, isFalse); + + controller.stop(); + await Future.delayed(Duration.zero); // let whenCancelled fire + + expect(captured!.isCancelled, isTrue); + expect(observedCancel, isTrue); + // throwIfCancelled now throws for the executor. + expect(captured!.throwIfCancelled, throwsA(isA())); + expect(controller.status, ChatStatus.idle); + }); + + test('without onToolCalls, the turn ends with the tool call (manual mode)', + () async { + final provider = _ToolThenTextProvider(); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: seqIds(), + ); + addTearDown(controller.dispose); + + await controller.sendText('weather?'); + + expect(provider.sendCount, 1); // no auto-continue + expect( + controller.messages.last.parts.whereType(), + hasLength(1), + ); + }); + + test('submit settles dangling tool calls with synthesized error results', + () async { + // Turn 1 ends with an unanswered tool call (manual mode, never + // executed). Submitting a new user message must first append error + // results for it — providers reject a history containing a tool call + // with no following result. + final provider = _ToolThenTextProvider(); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: seqIds(), + ); + addTearDown(controller.dispose); + + await controller.sendText('weather?'); + await controller.sendText('never mind'); + + final messages = controller.messages; + // user, assistant(tool call), tool(synthesized error), user, assistant. + expect(messages.map((m) => m.role).toList(), [ + AiRole.user, + AiRole.assistant, + AiRole.tool, + AiRole.user, + AiRole.assistant, + ]); + final settled = + messages[2].parts.whereType().single; + expect(settled.toolCallId, 'c1'); + expect(settled.isError, isTrue); + }); + + test('submit settles dangling tool calls buried mid-history', () async { + // A rehydrated transcript where an interrupted agent loop left an + // unanswered tool call in the MIDDLE of the history (later turns + // completed normally). The settle must insert the synthesized result + // directly after the affected assistant message — providers require the + // result in the immediately-following turn, so appending at the end + // would not fix the request. + const dirty = AiConversation( + id: 'thread-dirty', + messages: [ + AiMessage(id: 'u1', role: AiRole.user, parts: [TextPart('q1')]), + AiMessage( + id: 'a1', + role: AiRole.assistant, + parts: [ + ToolCallPart( + toolCallId: 'c9', + toolName: 'get_week_schedule', + args: {}, + ), + ], + status: AiMessageStatus.complete, + ), + AiMessage(id: 'u2', role: AiRole.user, parts: [TextPart('q2')]), + AiMessage( + id: 'a2', + role: AiRole.assistant, + parts: [TextPart('answer 2')], + status: AiMessageStatus.complete, + ), + ], + ); + final provider = ScriptedProvider(const [ + MessageStarted(messageId: 'a3', role: AiRole.assistant), + TextDelta(messageId: 'a3', delta: 'answer 3'), + MessageFinished(messageId: 'a3', reason: FinishReason.stop), + ]); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: seqIds(), + initial: dirty, + ); + addTearDown(controller.dispose); + + await controller.sendText('q3'); + + final messages = controller.messages; + // u1, a1(tool call), tool(synthesized), u2, a2, u3, a3. + expect(messages.map((m) => m.role).toList(), [ + AiRole.user, + AiRole.assistant, + AiRole.tool, + AiRole.user, + AiRole.assistant, + AiRole.user, + AiRole.assistant, + ]); + final settled = messages[2].parts.whereType().single; + expect(settled.toolCallId, 'c9'); + expect(settled.isError, isTrue); + // The provider must have been sent the settled history too. + final sent = provider.lastConversation!.messages; + expect(sent[2].role, AiRole.tool); + }); + }); + + group('threads', () { + test('autoTitle uses the first user message, trimmed', () { + const convo = AiConversation( + id: 't', + messages: [ + AiMessage(id: 'a', role: AiRole.assistant, parts: [TextPart('hi')]), + AiMessage( + id: 'u', + role: AiRole.user, + parts: [TextPart(' Plan a weekend in Lisbon please ')], + ), + ], + ); + expect(autoTitle(convo), 'Plan a weekend in Lisbon please'); + expect( + autoTitle(const AiConversation(id: 'e', messages: [])), 'New chat'); + }); + + test('InMemoryChatThreadStore saves, lists, loads, and deletes', () async { + final store = InMemoryChatThreadStore(); + const a = AiConversation( + id: 'a', + messages: [ + AiMessage(id: 'u', role: AiRole.user, parts: [TextPart('First')]), + ], + ); + const b = AiConversation( + id: 'b', + messages: [ + AiMessage(id: 'u', role: AiRole.user, parts: [TextPart('Second')]), + ], + ); + await store.save('a', a); + await store.save('b', b); + + final threads = await store.listThreads(); + expect(threads.map((t) => t.title), containsAll(['First', 'Second'])); + expect((await store.load('a'))?.messages.single.text, 'First'); + + await store.delete('a'); + expect(await store.load('a'), isNull); + expect((await store.listThreads()).map((t) => t.id), ['b']); + }); + }); + + group('usage', () { + test('totalUsage sums reported usage across messages', () async { + final provider = ScriptedProvider(const [ + MessageStarted(messageId: 'a1', role: AiRole.assistant), + TextDelta(messageId: 'a1', delta: 'hi'), + MessageFinished( + messageId: 'a1', + reason: FinishReason.stop, + usage: AiUsage(inputTokens: 10, outputTokens: 5), + ), + ]); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: () => 'u1', + ); + addTearDown(controller.dispose); + + await controller.sendText('hello'); + + expect(controller.totalUsage?.inputTokens, 10); + expect(controller.totalUsage?.outputTokens, 5); + expect(controller.messages.last.usage?.outputTokens, 5); + }); + }); + + group('attachStore / ChatStore', () { + test('auto-saves the settled conversation and can be reloaded', () async { + final store = FakeChatStore(); + final provider = ScriptedProvider(const [ + MessageStarted(messageId: 'a1', role: AiRole.assistant), + TextDelta(messageId: 'a1', delta: 'hi'), + MessageFinished(messageId: 'a1', reason: FinishReason.stop), + ]); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: () => 'u1', + ); + final detach = attachStore( + controller, + store, + 'thread-1', + debounce: const Duration(milliseconds: 5), + ); + addTearDown(controller.dispose); + + await controller.sendText('hello'); + // Let the debounce timer fire after the turn has settled. + await Future.delayed(const Duration(milliseconds: 20)); + + final saved = store.saves['thread-1']; + expect(saved, isNotNull); + expect(saved!.messages, hasLength(2)); + expect(saved.messages.last.text, 'hi'); + + // A fresh controller seeded from the store restores the transcript. + final restored = UseChatController( + provider: provider, + scheduler: syncScheduler, + initial: await store.load('thread-1'), + ); + addTearDown(restored.dispose); + expect(restored.messages, hasLength(2)); + expect(restored.messages.last.text, 'hi'); + + detach(); + }); + + test('detach flushes a pending save without waiting for the debounce', + () async { + final store = FakeChatStore(); + final provider = ScriptedProvider(const [ + MessageStarted(messageId: 'a1', role: AiRole.assistant), + TextDelta(messageId: 'a1', delta: 'hi'), + MessageFinished(messageId: 'a1', reason: FinishReason.stop), + ]); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: () => 'u1', + ); + // Long debounce: the save is scheduled but won't fire on its own here. + final detach = attachStore( + controller, + store, + 'thread-2', + debounce: const Duration(seconds: 5), + ); + addTearDown(controller.dispose); + + await controller.sendText('hello'); + expect(store.saves['thread-2'], isNull); // debounce hasn't elapsed + + detach(); // flushes the pending save synchronously + expect(store.saves['thread-2'], isNotNull); + expect(store.saves['thread-2']!.messages.last.text, 'hi'); + }); + + test('skips saving mid-stream, then saves once the turn settles', () async { + final store = FakeChatStore(); + final provider = ManualProvider(); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: () => 'u1', + ); + final detach = attachStore( + controller, + store, + 'thread-3', + debounce: const Duration(milliseconds: 5), + ); + addTearDown(controller.dispose); + + unawaited(controller.sendText('hello')); + provider.current.add( + const MessageStarted(messageId: 'a1', role: AiRole.assistant), + ); + provider.current.add(const TextDelta(messageId: 'a1', delta: 'partial')); + await Future.delayed(const Duration(milliseconds: 20)); + expect(store.saves['thread-3'], isNull); // still streaming + + controller.stop(); // turn settles + await Future.delayed(const Duration(milliseconds: 20)); + expect(store.saves['thread-3'], isNotNull); + + detach(); + }); + }); + + group('tool-argument validation', () { + String Function() seqIds() { + var n = 0; + return () => 'm${n++}'; + } + + const weatherTool = ToolDefinition( + name: 'get_weather', + description: 'weather', + parametersSchema: { + 'type': 'object', + 'properties': { + 'city': {'type': 'string'}, + }, + 'required': ['city'], + }, + ); + + test('invalid args are not executed and an error result is fed back', + () async { + final provider = _BadThenGoodToolProvider(); + final executedArgs = >[]; + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: seqIds(), + tools: const [weatherTool], + onToolCalls: (calls, signal) async { + for (final c in calls) { + executedArgs.add(c.args); + } + return [ + for (final c in calls) + ToolResultPart(toolCallId: c.toolCallId, result: {'temp': 25}), + ]; + }, + ); + addTearDown(controller.dispose); + + await controller.sendText('weather?'); + + // The executor only ever saw the corrected (valid) call. + expect(executedArgs, [ + {'city': 'Lisbon'} + ]); + // Three model calls: bad call, corrected call, final text. + expect(provider.sendCount, 3); + expect(controller.messages.last.text, 'It is sunny.'); + + // An error tool result for the bad call is in the transcript. + final errorResults = [ + for (final m in controller.messages) + for (final p in m.parts) + if (p is ToolResultPart && p.isError) p, + ]; + expect(errorResults, hasLength(1)); + expect( + (errorResults.single.result! as Map)['error'], + 'invalid_arguments', + ); + }); + + test( + 'validateToolArgs: false hands malformed args straight to the executor', + () async { + final provider = _BadThenGoodToolProvider(); + final executedArgs = >[]; + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: seqIds(), + tools: const [weatherTool], + validateToolArgs: false, + onToolCalls: (calls, signal) async { + for (final c in calls) { + executedArgs.add(c.args); + } + return [ + for (final c in calls) + ToolResultPart(toolCallId: c.toolCallId, result: 'ok'), + ]; + }, + ); + addTearDown(controller.dispose); + + await controller.sendText('weather?'); + + // The bad args (city is an int) reached the executor unchallenged. + expect(executedArgs.first['city'], 123); + }); + }); + + group('history trimming (trimHistory)', () { + test('the provider sees a trimmed conversation; the store keeps all', + () async { + final provider = _ConversationCapturingProvider(); + final controller = UseChatController( + provider: provider, + scheduler: syncScheduler, + idGenerator: () => 'u-new', + initial: const AiConversation( + id: 'c', + messages: [ + AiMessage(id: 's', role: AiRole.system, parts: [TextPart('sys')]), + AiMessage(id: 'u1', role: AiRole.user, parts: [TextPart('one')]), + AiMessage(id: 'a1', role: AiRole.assistant, parts: [TextPart('1')]), + AiMessage(id: 'u2', role: AiRole.user, parts: [TextPart('two')]), + AiMessage(id: 'a2', role: AiRole.assistant, parts: [TextPart('2')]), + ], + ), + trimHistory: keepLastMessages(1), + ); + addTearDown(controller.dispose); + + await controller.sendText('three'); + + // Provider saw: system + only the most recent non-system message before + // this turn's user message... plus the new user message. + final sentRoles = + provider.lastConversation!.messages.map((m) => m.role).toList(); + expect(sentRoles.first, AiRole.system); + // Far fewer than the full transcript. + expect( + provider.lastConversation!.messages.length, + lessThan(controller.messages.length), + ); + // Full transcript is retained on the controller. + expect(controller.messages.first.id, 's'); + expect(controller.messages.any((m) => m.id == 'u1'), isTrue); + }); + }); +} + +/// An in-memory [ChatStore] that records the latest save per id. +class FakeChatStore implements ChatStore { + final Map saves = {}; + + @override + Future load(String id) async => saves[id]; + + @override + Future save(String id, AiConversation conversation) async { + saves[id] = conversation; + } +} + +/// A provider whose reply text increments on every call, so regenerated +/// versions are distinguishable. +class _CountingProvider implements LlmProvider { + int _n = 0; + AiConversation? lastConversation; + + @override + Stream send( + AiConversation conversation, { + List? tools, + AiRequestOptions? options, + }) async* { + lastConversation = conversation; + _n++; + final id = 'a$_n'; + yield MessageStarted(messageId: id, role: AiRole.assistant); + yield TextDelta(messageId: id, delta: 'reply $_n'); + yield MessageFinished(messageId: id, reason: FinishReason.stop); + } +} + +class _ThrowingProvider implements LlmProvider { + @override + Stream send( + AiConversation conversation, { + List? tools, + AiRequestOptions? options, + }) async* { + throw StateError('provider exploded'); + } +} + +/// Throws synchronously from `send` (not via the stream), as the LlmProvider +/// contract permits for unrecoverable transport faults. +class _SyncThrowingProvider implements LlmProvider { + @override + Stream send( + AiConversation conversation, { + List? tools, + AiRequestOptions? options, + }) { + throw StateError('sync boom'); + } +} + +class _OptionsCapturingProvider implements LlmProvider { + AiRequestOptions? lastOptions; + + @override + Stream send( + AiConversation conversation, { + List? tools, + AiRequestOptions? options, + }) async* { + lastOptions = options; + yield const MessageFinished(messageId: 'a1', reason: FinishReason.stop); + } +} + +class _ToolsCapturingProvider implements LlmProvider { + List? lastTools; + + @override + Stream send( + AiConversation conversation, { + List? tools, + AiRequestOptions? options, + }) async* { + lastTools = tools; + yield const MessageFinished(messageId: 'a1', reason: FinishReason.stop); + } +} + +/// First send: a tool call. Second send: a final text answer. +class _ToolThenTextProvider implements LlmProvider { + int sendCount = 0; + + @override + Stream send( + AiConversation conversation, { + List? tools, + AiRequestOptions? options, + }) async* { + sendCount++; + if (sendCount == 1) { + yield const MessageStarted(messageId: 'a1', role: AiRole.assistant); + yield const ToolCallStarted( + messageId: 'a1', + toolCallId: 'c1', + toolName: 'get_weather', + ); + yield const ToolCallDelta( + toolCallId: 'c1', + argumentsDelta: '{"city":"Lisbon"}', + ); + yield const ToolCallReady(toolCallId: 'c1'); + yield const MessageFinished( + messageId: 'a1', + reason: FinishReason.toolCalls, + ); + } else { + yield const MessageStarted(messageId: 'a2', role: AiRole.assistant); + yield const TextDelta(messageId: 'a2', delta: 'It is sunny.'); + yield const MessageFinished(messageId: 'a2', reason: FinishReason.stop); + } + } +} + +/// Records the observer callbacks it receives as compact strings, for +/// order-sensitive assertions. +class _RecordingObserver extends ChatObserver { + final List events = []; + + @override + void onTurnStart(AiConversation conversation) => events.add('turnStart'); + + @override + void onModelRequest(int step) => events.add('request:$step'); + + @override + void onModelResponse({ + required int step, + AiUsage? usage, + FinishReason? finishReason, + }) => + events.add('response:$step:${finishReason?.name}'); + + @override + void onToolCalls(List calls) => + events.add('toolCalls:${calls.length}'); + + @override + void onToolResults(List results) => + events.add('toolResults:${results.length}'); + + @override + void onError(Object error, StackTrace? stackTrace) => events.add('error'); + + @override + void onTurnEnd({AiUsage? totalUsage}) => events.add('turnEnd'); +} + +/// Every send returns a fresh tool call, so the agent loop only stops at +/// maxSteps. Each assistant message gets a unique id/tool-call id. +class _AlwaysToolProvider implements LlmProvider { + int sendCount = 0; + + @override + Stream send( + AiConversation conversation, { + List? tools, + AiRequestOptions? options, + }) async* { + sendCount++; + final id = 'a$sendCount'; + final callId = 'c$sendCount'; + yield MessageStarted(messageId: id, role: AiRole.assistant); + yield ToolCallStarted(messageId: id, toolCallId: callId, toolName: 'ping'); + yield ToolCallDelta(toolCallId: callId, argumentsDelta: '{}'); + yield ToolCallReady(toolCallId: callId); + yield MessageFinished( + messageId: id, + reason: FinishReason.toolCalls, + usage: const AiUsage(outputTokens: 100), + ); + } +} + +/// First emits a `get_weather` call with a type-invalid `city` (an int), then a +/// corrected call, then a final text answer — exercising arg validation + +/// model self-correction. +class _BadThenGoodToolProvider implements LlmProvider { + int sendCount = 0; + + @override + Stream send( + AiConversation conversation, { + List? tools, + AiRequestOptions? options, + }) async* { + sendCount++; + if (sendCount == 1) { + yield const MessageStarted(messageId: 'a1', role: AiRole.assistant); + yield const ToolCallStarted( + messageId: 'a1', + toolCallId: 'c1', + toolName: 'get_weather', + ); + yield const ToolCallDelta( + toolCallId: 'c1', + argumentsDelta: '{"city":123}', + ); + yield const ToolCallReady(toolCallId: 'c1'); + yield const MessageFinished( + messageId: 'a1', + reason: FinishReason.toolCalls, + ); + } else if (sendCount == 2) { + yield const MessageStarted(messageId: 'a2', role: AiRole.assistant); + yield const ToolCallStarted( + messageId: 'a2', + toolCallId: 'c2', + toolName: 'get_weather', + ); + yield const ToolCallDelta( + toolCallId: 'c2', + argumentsDelta: '{"city":"Lisbon"}', + ); + yield const ToolCallReady(toolCallId: 'c2'); + yield const MessageFinished( + messageId: 'a2', + reason: FinishReason.toolCalls, + ); + } else { + yield const MessageStarted(messageId: 'a3', role: AiRole.assistant); + yield const TextDelta(messageId: 'a3', delta: 'It is sunny.'); + yield const MessageFinished(messageId: 'a3', reason: FinishReason.stop); + } + } +} + +/// Records the conversation passed to it, so trimming can be asserted. +class _ConversationCapturingProvider implements LlmProvider { + AiConversation? lastConversation; + + @override + Stream send( + AiConversation conversation, { + List? tools, + AiRequestOptions? options, + }) async* { + lastConversation = conversation; + yield const MessageStarted(messageId: 'r1', role: AiRole.assistant); + yield const TextDelta(messageId: 'r1', delta: 'ok'); + yield const MessageFinished(messageId: 'r1', reason: FinishReason.stop); + } +} diff --git a/packages/flutter_ai/flutter_ai_core/CHANGELOG.md b/packages/flutter_ai/flutter_ai_core/CHANGELOG.md new file mode 100644 index 0000000..009a3d8 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/CHANGELOG.md @@ -0,0 +1,128 @@ +# Changelog + +## 0.1.14 + +- Fix: streamed `TextPart`/`ReasoningPart` now freeze at the buffer prefix + captured when each snapshot was produced, so a previously returned + conversation no longer mutates retroactively and value equality holds + mid-stream. Equality-based state management (Bloc `Equatable`, Riverpod + `select`, `distinct()`) now observes streaming updates. Accumulation stays + O(delta); a finished message materializes its buffer into a plain part. + +## 0.1.13 + +- `ReasoningEffort` (minimal/low/medium/high) + `AiRequestOptions.reasoningEffort`: + a provider-neutral knob for how hard a reasoning model should think. Exposes + `budgetTokens` (a canonical effort→budget heuristic) and `openAiValue` for + providers to map onto their native control. Additive and opt-in. + +## 0.1.12 + +- Docs: shortened the pubspec `description` into pub.dev's 60–180 character + window so it renders in full in search results. No code changes. + +## 0.1.11 + +- Docs: refreshed the README listing with a hero image, screenshot gallery, + and badges (consistent across the package family). No code changes. + +## 0.1.10 + +- New AI primitives (opt-in, additive): + - `EmbeddingProvider` / `AiEmbedding` and `TokenCounter` capability + interfaces a provider may implement (check with `provider is ...`). + - `GenerateObject` extension on `LlmProvider`: `generateObject` returns a + decoded `Map` constrained to an `AiResponseFormat`; `streamObject` yields + the evolving partial object as it streams (via `JsonAccumulator`). + +## 0.1.9 + +- `validateJsonSchema`: a tiny, dependency-free validator for the JSON-Schema + subset LLM tool declarations use (`type`, `properties`, `required`, `items`, + `enum`, `additionalProperties: false`, numeric/string/array bounds, union + types). Returns human-readable violation messages. `UseChatController` uses it + to validate tool-call args before execution. + +## 0.1.8 + +- Perf: streaming text/reasoning deltas accumulate into a per-part + `StringBuffer` and materialize the `String` lazily, instead of + `last.text + delta` reallocating the whole answer on every token (was + quadratic on long responses — the hottest path in the stack). Observably + identical: `TextPart.text`/`ReasoningPart.text` still return a plain `String`. +- `AiUsage.cacheCreationTokens`: carries prompt-cache **write** tokens (a subset + of `inputTokens`) distinctly; `estimateCost` bills them at `cacheWritePer1M` + (defaulting to `1.25 * inputPer1M`) so cache writes aren't billed at the base + input rate. +- Declares supported `platforms:` (all 6). + +## 0.1.7 + +- Typed errors: `LlmException` hierarchy (`LlmAuthException`, + `LlmRateLimitException`, `LlmServerException`, `LlmRequestException`) + a + `llmExceptionFor` mapper, surfaced on `StreamErrorEvent.error` so hosts can + branch on the failure type instead of string-matching. + +## 0.1.6 + +- `ReasoningPart` / `ReasoningDelta` gain an optional `signature` (preserved and + replayed so providers like Anthropic accept thinking blocks on tool rounds). +- `MessageProcessor` keeps the last good partial tool-call args instead of + clobbering them to `{}` mid-stream. + +## 0.1.5 + +- `AiRequestOptions.cachePrompt`: hint that the stable prompt prefix (system + + tools) should be cached. Anthropic applies `cache_control`; OpenAI/Gemini cache + automatically (no-op). + +## 0.1.4 + +- `AiResponseFormat` (+ `AiRequestOptions.responseFormat`): request structured + output constrained to a JSON schema. Providers route it to their native + mechanism; the assistant's text is the JSON object. + +## 0.1.3 + +- `AiUsage` model (input/output/cached/reasoning/total tokens) with `+` to + accumulate and `estimateCost(...)` for cost from per-million prices. Carried on + `MessageFinished` and stored on the completed `AiMessage`; the processor + applies it on finish. + +## 0.1.2 + +- Docs: added a "Buy me a coffee" (Ko-fi) support section to the README. No code + changes. + +## 0.1.1 + +Bug fixes in `MessageProcessor`: +- Zero-argument tool calls (a `ToolCallReady` with no streamed arguments) now + resolve to empty args + `inputAvailable` instead of being marked errored. +- A `ToolResultReceived` whose `messageId` differs from the call's message (the + normal case — results arrive in a separate tool-role message) now correctly + advances the original call to `outputAvailable`. +- A tool-scoped `StreamErrorEvent` (with `toolCallId`) now marks only that call + errored and lets generation continue, instead of failing the whole message — + matching `UseChatController`. +- Doc fix: corrected a stale reference to `flutter_markdown_plus`. +- `JsonAccumulator` no longer surfaces an unterminated trailing number/keyword + (e.g. `1234` from `{"n": 1234`) as a complete value — a literal must be + delimiter-terminated, preserving the "a partial is always a prefix" contract. +- `MessageProcessor` resolves a tool result to its owning call by scanning the + conversation when the in-memory map misses (after `reset()`/rehydration). +- `deepHash` uses order-independent hashing for maps (better distribution). + +## 0.1.0 + +Initial release. + +- Models: `AiConversation`, `AiMessage`, `AiMessageStatus`, `AiRole`, + `FinishReason`, and the sealed `AiPart` hierarchy (`TextPart`, + `ReasoningPart`, `ToolCallPart`, `ToolResultPart`, `FilePart`, `SourcePart`, + `DataPart`) with manual JSON serialization and value equality. +- Streaming: sealed `AiStreamEvent` set, `MessageProcessor` reducer with granular + `MutationResult`s, and the tolerant `JsonAccumulator` for partial tool-call + arguments. +- Contracts: `LlmProvider`, `TextRenderer`, `AiRequestOptions`, `ToolDefinition`. +- Zero runtime dependencies (`dart:core` + `dart:convert` only). diff --git a/packages/flutter_ai/flutter_ai_core/LICENSE b/packages/flutter_ai/flutter_ai_core/LICENSE new file mode 100644 index 0000000..56023ee --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2026, The flutter_ai authors. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/flutter_ai/flutter_ai_core/README.md b/packages/flutter_ai/flutter_ai_core/README.md new file mode 100644 index 0000000..d8972f7 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/README.md @@ -0,0 +1,84 @@ +

flutter_ai_core

+ +

The dependency-free Dart engine under flutter_ai — immutable conversation models, a streaming-event reducer, and the LlmProvider contract every provider speaks.

+ +

+ flutter_ai: a streaming answer with chain-of-thought and a generative-UI task card +

+ +

+ flutter_ai_core on pub.dev + pub points + License: BSD-3-Clause +

+ +

+ Family: flutter_ai · + client · elements · + openai · anthropic · gemini · + tools · mcp · voice
+ Recipes · Migrating from the Vercel AI SDK +

+ +

The transcript above is produced by this package's MessageProcessor folding provider events into messages (rendered with flutter_ai_elements).

+ +--- + +Dependency-free Dart foundation for building AI chat experiences — the shared +contract layer of the [`flutter_ai`](../../README.md) package family. + +`flutter_ai_core` has **no runtime dependencies** beyond `dart:core` and +`dart:convert`: no Flutter, no code generation, no `build_runner`. That keeps it +safe to depend on from anywhere and free of version conflicts. + +## What's inside + +- **Models** — `AiConversation`, `AiMessage`, and the sealed `AiPart` hierarchy + (`TextPart`, `ReasoningPart`, `ToolCallPart`, `ToolResultPart`, `FilePart`, + `SourcePart`, `DataPart`). All immutable value types with manual, hand-written + JSON. +- **Streaming** — the sealed `AiStreamEvent` set and a `MessageProcessor` that + folds events into conversation state, reporting exactly which messages changed + so a UI can rebuild only those nodes. +- **Tolerant JSON** — `JsonAccumulator` parses partial tool-call arguments as + they stream, repairing incomplete JSON without ever throwing. +- **Contracts** — `LlmProvider` (provider abstraction) and `TextRenderer` + (pluggable text rendering), with `AiRequestOptions` and `ToolDefinition`. + +## Design principles + +- **Un-opinionated.** No bundled state manager. The processor is a pure, + synchronous reducer; batching updates to the frame boundary is the consumer's + job, which keeps this package UI-agnostic and trivially testable. +- **Granular by construction.** `MutationResult.changedMessageIds` lets the UI + avoid rebuilding the whole transcript on every token. +- **Fails soft.** Malformed streamed tool arguments mark a single call errored + rather than crashing the stream. + +## Example + +```dart +import 'package:flutter_ai_core/flutter_ai_core.dart'; + +void main() { + final processor = MessageProcessor(); + + // Events would normally come from an LlmProvider's stream. + processor.apply(const MessageStarted(messageId: 'a1', role: AiRole.assistant)); + processor.apply(const TextDelta(messageId: 'a1', delta: 'Hello, ')); + final result = processor.apply(const TextDelta(messageId: 'a1', delta: 'world!')); + + print(result.conversation.messageById('a1')!.text); // Hello, world! + print(result.changedMessageIds); // {a1} +} +``` + +See [`example/`](example/) for a fuller walkthrough including tool calls. + +## Status + +Part of the `flutter_ai` ecosystem; the UI layer (`flutter_ai_elements`) and +provider/controller layer (`flutter_ai_client`) build on these types. See the +CHANGELOG for version history. + +_If `flutter_ai` saves you time, you can [buy me a coffee ☕](https://ko-fi.com/ananmouaz)._ diff --git a/packages/flutter_ai/flutter_ai_core/analysis_options.yaml b/packages/flutter_ai/flutter_ai_core/analysis_options.yaml new file mode 100644 index 0000000..8b97ccc --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/analysis_options.yaml @@ -0,0 +1,3 @@ +# Inherits the workspace-wide strict configuration. Package-specific overrides, +# if ever needed, go below. +include: ../../analysis_options.yaml diff --git a/packages/flutter_ai/flutter_ai_core/example/flutter_ai_core_example.dart b/packages/flutter_ai/flutter_ai_core/example/flutter_ai_core_example.dart new file mode 100644 index 0000000..aed38b4 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/example/flutter_ai_core_example.dart @@ -0,0 +1,91 @@ +// Demonstrates folding a provider's event stream into conversation state with +// MessageProcessor, including streamed tool-call arguments. +// +// Run with: dart run example/flutter_ai_core_example.dart +import 'package:flutter_ai_core/flutter_ai_core.dart'; + +/// A trivial in-memory provider that replays a scripted stream of events. +/// +/// A real provider would translate an SSE / gRPC / local-callback protocol into +/// these same [AiStreamEvent]s. +class ScriptedProvider implements LlmProvider { + @override + Stream send( + AiConversation conversation, { + List? tools, + AiRequestOptions? options, + }) async* { + const id = 'assistant-1'; + yield const MessageStarted(messageId: id, role: AiRole.assistant); + yield const TextDelta(messageId: id, delta: 'Let me check the weather. '); + + // A tool call whose arguments stream in as partial JSON. + yield const ToolCallStarted( + messageId: id, + toolCallId: 'call-1', + toolName: 'get_weather', + ); + yield const ToolCallDelta(toolCallId: 'call-1', argumentsDelta: '{"city":'); + yield const ToolCallDelta( + toolCallId: 'call-1', + argumentsDelta: '"London"}', + ); + yield const ToolCallReady(toolCallId: 'call-1'); + + // The tool result, then the model's final answer. + yield const ToolResultReceived( + messageId: id, + toolCallId: 'call-1', + result: {'tempC': 21, 'condition': 'Cloudy'}, + ); + yield const TextDelta(messageId: id, delta: "It's 21°C and cloudy."); + yield const MessageFinished(messageId: id, reason: FinishReason.stop); + } +} + +Future main() async { + final processor = MessageProcessor( + conversation: const AiConversation( + id: 'demo', + messages: [ + AiMessage( + id: 'user-1', + role: AiRole.user, + parts: [TextPart('What is the weather in London?')], + ), + ], + ), + ); + + final provider = ScriptedProvider(); + await for (final event in provider.send(processor.conversation)) { + final result = processor.apply(event); + // A UI would batch these changed ids to the frame boundary; here we just + // log them to show the granularity. + if (result.hasChanges) { + print('changed: ${result.changedMessageIds}'); + } + } + + print('\n--- final transcript ---'); + for (final message in processor.conversation.messages) { + print('${message.role.name}: ${_describe(message)}'); + } +} + +String _describe(AiMessage message) { + final buffer = StringBuffer(); + for (final part in message.parts) { + switch (part) { + case TextPart(:final text): + buffer.write(text); + case ToolCallPart(:final toolName, :final args, :final state): + buffer.write('[tool $toolName($args) ${state.name}] '); + case ToolResultPart(:final result): + buffer.write('[result $result] '); + case ReasoningPart() || FilePart() || SourcePart() || DataPart(): + buffer.write('[${part.runtimeType}] '); + } + } + return buffer.toString(); +} diff --git a/packages/flutter_ai/flutter_ai_core/lib/flutter_ai_core.dart b/packages/flutter_ai/flutter_ai_core/lib/flutter_ai_core.dart new file mode 100644 index 0000000..beb5e3c --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/lib/flutter_ai_core.dart @@ -0,0 +1,38 @@ +/// Dependency-free Dart foundation for AI chat experiences. +/// +/// `flutter_ai_core` defines the shared vocabulary the rest of the `flutter_ai` +/// family builds on: +/// +/// * **Models** — `AiConversation`, `AiMessage`, and the sealed `AiPart` +/// hierarchy (`TextPart`, `ReasoningPart`, `ToolCallPart`, `ToolResultPart`, +/// `FilePart`, `SourcePart`, `DataPart`). +/// * **Streaming** — the sealed `AiStreamEvent` set and a `MessageProcessor` +/// that folds events into state with granular `MutationResult`s, plus a +/// tolerant `JsonAccumulator` for partial tool-call arguments. +/// * **Contracts** — `LlmProvider` for provider abstraction and `TextRenderer` +/// for pluggable text rendering, with `AiRequestOptions` and `ToolDefinition`. +/// +/// It depends only on `dart:core` and `dart:convert` — no Flutter, no code +/// generation — so downstream apps never face build-tool or version conflicts. +library; + +export 'src/models/ai_conversation.dart'; +export 'src/models/ai_message.dart'; +export 'src/models/ai_part.dart'; +export 'src/models/ai_role.dart'; +export 'src/models/finish_reason.dart'; +export 'src/models/tool_call_state.dart'; +export 'src/models/tool_definition.dart'; +export 'src/models/usage.dart'; +export 'src/provider/ai_capabilities.dart'; +export 'src/provider/ai_request_options.dart'; +export 'src/provider/ai_response_format.dart'; +export 'src/provider/generate_object.dart'; +export 'src/provider/llm_exception.dart'; +export 'src/provider/llm_provider.dart'; +export 'src/rendering/text_renderer.dart'; +export 'src/streaming/ai_stream_event.dart'; +export 'src/streaming/json_accumulator.dart'; +export 'src/streaming/message_processor.dart'; +export 'src/streaming/mutation_result.dart'; +export 'src/tools/json_schema_validator.dart'; diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/internal/equality.dart b/packages/flutter_ai/flutter_ai_core/lib/src/internal/equality.dart new file mode 100644 index 0000000..091cf0e --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/lib/src/internal/equality.dart @@ -0,0 +1,53 @@ +/// Structural equality and hashing for JSON-like values. +/// +/// The core models carry decoded JSON (`Map`, `List`, +/// and scalars) in fields such as tool-call arguments and data payloads. Value +/// equality on those models therefore needs deep, structural comparison rather +/// than identity. These helpers provide it without depending on +/// `package:collection`, honoring the package's dependency-free contract. +library; + +/// Returns whether [a] and [b] are structurally equal. +/// +/// Scalars are compared with `==`. [List]s are compared element-wise and in +/// order. [Map]s are compared by key set and per-key values, independent of +/// insertion order. Comparison recurses through nested lists and maps. +bool deepEquals(Object? a, Object? b) { + if (identical(a, b)) return true; + if (a is List && b is List) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (!deepEquals(a[i], b[i])) return false; + } + return true; + } + if (a is Map && b is Map) { + if (a.length != b.length) return false; + for (final entry in a.entries) { + if (!b.containsKey(entry.key) || !deepEquals(entry.value, b[entry.key])) { + return false; + } + } + return true; + } + return a == b; +} + +/// Returns a hash code for [value] consistent with [deepEquals]. +/// +/// Lists hash in order; maps hash independent of insertion order so that two +/// equal maps with different orderings produce the same hash. +int deepHash(Object? value) { + if (value is List) { + return Object.hashAll(value.map(deepHash)); + } + if (value is Map) { + // Hash the per-entry pairs unordered so insertion order does not matter, + // while giving better distribution than XOR-folding the entry hashes. + return Object.hashAllUnordered([ + for (final entry in value.entries) + Object.hash(deepHash(entry.key), deepHash(entry.value)), + ]); + } + return value.hashCode; +} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/models/ai_conversation.dart b/packages/flutter_ai/flutter_ai_core/lib/src/models/ai_conversation.dart new file mode 100644 index 0000000..dcaf03b --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/lib/src/models/ai_conversation.dart @@ -0,0 +1,84 @@ +import 'package:flutter_ai_core/src/internal/equality.dart'; +import 'package:flutter_ai_core/src/models/ai_message.dart'; + +/// An ordered, immutable transcript of [AiMessage]s. +/// +/// The conversation retains the **full** message history; trimming for token +/// budgets is deliberately out of scope and belongs to the server or provider +/// integration, so the user can always scroll back through the entire session. +/// +/// Every mutating helper returns a new instance, preserving value semantics. +final class AiConversation { + /// Creates a conversation. + const AiConversation({required this.id, this.messages = const []}); + + /// An empty conversation with the given [id]. + const AiConversation.empty(String id) : this(id: id); + + /// Reconstructs a conversation from [json]. + factory AiConversation.fromJson(Map json) { + final rawMessages = (json['messages'] as List?) ?? const []; + return AiConversation( + id: json['id']! as String, + messages: [ + for (final message in rawMessages) + AiMessage.fromJson((message! as Map).cast()), + ], + ); + } + + /// A stable, unique identifier for this conversation. + final String id; + + /// The full ordered transcript. + final List messages; + + /// The most recent message, or `null` if the conversation is empty. + AiMessage? get lastMessage => messages.isEmpty ? null : messages.last; + + /// Returns the message with the given [messageId], or `null` if absent. + AiMessage? messageById(String messageId) { + for (final message in messages) { + if (message.id == messageId) return message; + } + return null; + } + + /// Returns a copy with [message] appended. + AiConversation append(AiMessage message) => + copyWith(messages: [...messages, message]); + + /// Returns a copy in which the message sharing [message]'s id is replaced. + /// + /// If no message has that id, [message] is appended instead, making this safe + /// to call as an upsert during streaming. + AiConversation replace(AiMessage message) { + final index = messages.indexWhere((m) => m.id == message.id); + if (index == -1) return append(message); + final next = [...messages]..[index] = message; + return copyWith(messages: next); + } + + /// Returns a copy with the given fields replaced. + AiConversation copyWith({String? id, List? messages}) => + AiConversation(id: id ?? this.id, messages: messages ?? this.messages); + + /// Serializes this conversation. + Map toJson() => { + 'id': id, + 'messages': [for (final message in messages) message.toJson()], + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AiConversation && + other.id == id && + deepEquals(other.messages, messages)); + + @override + int get hashCode => Object.hash(id, Object.hashAll(messages)); + + @override + String toString() => 'AiConversation(id: $id, messages: ${messages.length})'; +} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/models/ai_message.dart b/packages/flutter_ai/flutter_ai_core/lib/src/models/ai_message.dart new file mode 100644 index 0000000..7dca336 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/lib/src/models/ai_message.dart @@ -0,0 +1,184 @@ +import 'package:flutter_ai_core/src/internal/equality.dart'; +import 'package:flutter_ai_core/src/models/ai_part.dart'; +import 'package:flutter_ai_core/src/models/ai_role.dart'; +import 'package:flutter_ai_core/src/models/finish_reason.dart'; +import 'package:flutter_ai_core/src/models/usage.dart'; + +/// The delivery state of an [AiMessage]. +enum AiMessageStatus { + /// Created locally and awaiting a response; no content yet. + pending('pending'), + + /// Content is actively streaming in. + streaming('streaming'), + + /// Fully received. + complete('complete'), + + /// Terminated by an error. + error('error'); + + const AiMessageStatus(this.wireName); + + /// The stable string used on the wire and in JSON. + final String wireName; + + /// Parses a [wireName] into its [AiMessageStatus]. + /// + /// Throws a [FormatException] if [value] is not a known status. + static AiMessageStatus fromJson(String value) { + for (final status in values) { + if (status.wireName == value) return status; + } + throw FormatException('Unknown AiMessageStatus: "$value"'); + } + + /// The wire representation of this status. + String toJson() => wireName; +} + +/// A single turn in a conversation, authored by one [AiRole]. +/// +/// A message is an ordered, immutable list of [parts]; mutations during +/// streaming produce new [AiMessage] instances via [copyWith] rather than +/// editing in place, preserving value semantics. +final class AiMessage { + /// Creates a message. + const AiMessage({ + required this.id, + required this.role, + this.parts = const [], + this.status = AiMessageStatus.complete, + this.finishReason, + this.createdAt, + this.usage, + }); + + /// Convenience constructor for a plain-text message. + AiMessage.text({ + required String id, + required AiRole role, + required String text, + AiMessageStatus status = AiMessageStatus.complete, + DateTime? createdAt, + }) : this( + id: id, + role: role, + parts: [TextPart(text)], + status: status, + createdAt: createdAt, + ); + + /// Reconstructs a message from [json]. + factory AiMessage.fromJson(Map json) { + final rawParts = (json['parts'] as List?) ?? const []; + final createdAt = json['createdAt'] as String?; + final finishReason = json['finishReason'] as String?; + final usage = json['usage']; + return AiMessage( + id: json['id']! as String, + role: AiRole.fromJson(json['role']! as String), + parts: [ + for (final part in rawParts) + AiPart.fromJson((part! as Map).cast()), + ], + status: AiMessageStatus.fromJson(json['status']! as String), + finishReason: + finishReason == null ? null : FinishReason.fromJson(finishReason), + createdAt: createdAt == null ? null : DateTime.parse(createdAt), + usage: usage == null + ? null + : AiUsage.fromJson((usage as Map).cast()), + ); + } + + /// A stable, unique identifier for this message. + final String id; + + /// Who authored the message. + final AiRole role; + + /// The ordered content of the message. + final List parts; + + /// The current delivery state. + final AiMessageStatus status; + + /// Why generation stopped, once [status] is terminal. `null` while pending or + /// streaming. + final FinishReason? finishReason; + + /// When the message was created, if tracked. + final DateTime? createdAt; + + /// Token usage for this message's turn, if the provider reported it. Set on + /// the assistant message when its turn finishes. + final AiUsage? usage; + + /// The concatenated text of every [TextPart], ignoring other part types. + /// + /// A convenience for the common case of reading a message's prose. + String get text => parts.whereType().map((p) => p.text).join(); + + /// Returns a copy with the given fields replaced. + /// + /// Passing [finishReason] or [createdAt] cannot clear them to `null`; that is + /// an intentional trade-off favoring the common "set or keep" case. + AiMessage copyWith({ + String? id, + AiRole? role, + List? parts, + AiMessageStatus? status, + FinishReason? finishReason, + DateTime? createdAt, + AiUsage? usage, + }) => + AiMessage( + id: id ?? this.id, + role: role ?? this.role, + parts: parts ?? this.parts, + status: status ?? this.status, + finishReason: finishReason ?? this.finishReason, + createdAt: createdAt ?? this.createdAt, + usage: usage ?? this.usage, + ); + + /// Serializes this message. + Map toJson() => { + 'id': id, + 'role': role.toJson(), + 'parts': [for (final part in parts) part.toJson()], + 'status': status.toJson(), + if (finishReason != null) 'finishReason': finishReason!.toJson(), + if (createdAt != null) 'createdAt': createdAt!.toIso8601String(), + if (usage != null) 'usage': usage!.toJson(), + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AiMessage && + other.id == id && + other.role == role && + other.status == status && + other.finishReason == finishReason && + other.createdAt == createdAt && + other.usage == usage && + deepEquals(other.parts, parts)); + + @override + int get hashCode => Object.hash( + id, + role, + status, + finishReason, + createdAt, + usage, + Object.hashAll(parts), + ); + + @override + String toString() => + 'AiMessage(id: $id, role: ${role.name}, status: ${status.name}, ' + 'parts: ${parts.length})'; +} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/models/ai_part.dart b/packages/flutter_ai/flutter_ai_core/lib/src/models/ai_part.dart new file mode 100644 index 0000000..4e15b7d --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/lib/src/models/ai_part.dart @@ -0,0 +1,497 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_ai_core/src/internal/equality.dart'; +import 'package:flutter_ai_core/src/models/tool_call_state.dart'; + +/// A single typed segment of an `AiMessage`. +/// +/// A message is an ordered list of parts, mirroring the "parts" model used by +/// modern AI SDKs: a turn may interleave prose, reasoning, tool calls and their +/// results, files, and citations. [AiPart] is `sealed`, so a `switch` over a +/// part is exhaustively checked at compile time — adding a new part type forces +/// every consumer to handle it. +/// +/// Every part serializes with a `type` discriminator. [AiPart.fromJson] +/// dispatches on that field; subclasses round-trip their own payload. +/// +/// See also `AiMessage`, which owns an ordered list of parts. +sealed class AiPart { + /// Const base constructor for subclasses. + const AiPart(); + + /// Reconstructs a part from its [json] map by dispatching on `type`. + /// + /// Throws a [FormatException] if `type` is missing or unrecognized. + factory AiPart.fromJson(Map json) { + final type = json['type']; + return switch (type) { + 'text' => TextPart.fromJson(json), + 'reasoning' => ReasoningPart.fromJson(json), + 'tool-call' => ToolCallPart.fromJson(json), + 'tool-result' => ToolResultPart.fromJson(json), + 'file' => FilePart.fromJson(json), + 'source' => SourcePart.fromJson(json), + 'data' => DataPart.fromJson(json), + _ => throw FormatException('Unknown AiPart type: "$type"'), + }; + } + + /// Serializes this part, including its `type` discriminator. + Map toJson(); +} + +/// Human- or model-authored prose, typically rendered as Markdown. +final class TextPart extends AiPart { + /// Creates a text part holding [text]. + const TextPart(String text) + : _text = text, + _buffer = null, + _bufferLength = 0; + + /// Reconstructs a [TextPart] from [json]. + factory TextPart.fromJson(Map json) => + TextPart(json['text']! as String); + + /// Creates a text part whose content is accumulated in [buffer], materialized + /// to a [String] lazily on first read of [text]. + /// + /// Internal to the streaming reducer: appending deltas to one shared + /// [StringBuffer] keeps accumulation linear (O(total length)) instead of + /// reallocating the whole string on every delta. The expensive `toString()` + /// happens only when a consumer actually reads the text (e.g. at a frame + /// boundary), not once per token. + /// + /// This wrapper freezes at the buffer's length *at construction time* (see + /// [text]): the reducer appends the next delta to the same buffer and wraps it + /// in a *new* `TextPart`, so a previously returned conversation snapshot never + /// observes the later appends. Value equality therefore holds mid-stream — + /// two snapshots taken at different points compare unequal. Do not construct + /// or read the [buffer] outside the reducer. + TextPart.buffered(StringBuffer buffer) + : _text = null, + _buffer = buffer, + _bufferLength = buffer.length; + + final String? _text; + final StringBuffer? _buffer; + + /// The buffer's length (in UTF-16 code units) captured when this wrapper was + /// created, freezing the prefix this part represents. See [TextPart.buffered]. + final int _bufferLength; + + /// The textual content. + /// + /// For a [TextPart.buffered] this materializes the backing buffer on demand, + /// truncated to the prefix captured at construction so later appends to the + /// shared buffer (which belong to newer snapshots) are never observed. + String get text { + final text = _text; + if (text != null) return text; + final buffer = _buffer!; + final materialized = buffer.toString(); + return materialized.length == _bufferLength + ? materialized + : materialized.substring(0, _bufferLength); + } + + /// The live accumulation buffer backing this part, or `null` for an ordinary + /// part. Internal to the streaming reducer, which appends the next delta in + /// place rather than rebuilding the string. + StringBuffer? get buffer => _buffer; + + /// Returns a copy with [text] replaced. + TextPart copyWith({String? text}) => TextPart(text ?? this.text); + + @override + Map toJson() => {'type': 'text', 'text': text}; + + @override + bool operator ==(Object other) => + identical(this, other) || (other is TextPart && other.text == text); + + @override + int get hashCode => text.hashCode; + + @override + String toString() => 'TextPart(${text.length} chars)'; +} + +/// The model's intermediate reasoning ("chain of thought"). +/// +/// Surfaced separately from prose so the UI can disclose it in a collapsible +/// region rather than mixing it into the answer. +final class ReasoningPart extends AiPart { + /// Creates a reasoning part holding [text]. + const ReasoningPart(String text, {this.signature}) + : _text = text, + _buffer = null, + _bufferLength = 0; + + /// Reconstructs a [ReasoningPart] from [json]. + factory ReasoningPart.fromJson(Map json) => ReasoningPart( + json['text']! as String, + signature: json['signature'] as String?, + ); + + /// Creates a reasoning part whose content is accumulated in [buffer], + /// materialized lazily on first read of [text]. + /// + /// See [TextPart.buffered]: this keeps reasoning-delta accumulation linear + /// rather than reallocating the whole string per delta, and freezes at the + /// buffer length captured here so previously returned snapshots never observe + /// later appends. + ReasoningPart.buffered(StringBuffer buffer, {this.signature}) + : _text = null, + _buffer = buffer, + _bufferLength = buffer.length; + + final String? _text; + final StringBuffer? _buffer; + + /// The buffer's length (in UTF-16 code units) captured when this wrapper was + /// created, freezing the prefix this part represents. See [TextPart.buffered]. + final int _bufferLength; + + /// The reasoning content. + /// + /// For a [ReasoningPart.buffered] this materializes the backing buffer on + /// demand, truncated to the prefix captured at construction. + String get text { + final text = _text; + if (text != null) return text; + final buffer = _buffer!; + final materialized = buffer.toString(); + return materialized.length == _bufferLength + ? materialized + : materialized.substring(0, _bufferLength); + } + + /// The live accumulation buffer backing this part, or `null` for an ordinary + /// part. Internal to the streaming reducer. + StringBuffer? get buffer => _buffer; + + /// An opaque provider signature for this reasoning block, when the provider + /// supplies one (e.g. Anthropic extended thinking). It must be preserved and + /// replayed verbatim on subsequent turns or the API rejects the request. + final String? signature; + + /// Returns a copy with the given fields replaced. + ReasoningPart copyWith({String? text, String? signature}) => + ReasoningPart(text ?? this.text, signature: signature ?? this.signature); + + @override + Map toJson() => { + 'type': 'reasoning', + 'text': text, + if (signature != null) 'signature': signature, + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ReasoningPart && + other.text == text && + other.signature == signature); + + @override + int get hashCode => Object.hash(text, signature); + + @override + String toString() => 'ReasoningPart(${text.length} chars)'; +} + +/// A request from the model to invoke a tool. +/// +/// During streaming, [args] fills in incrementally and [state] advances from +/// [ToolCallState.inputStreaming] to [ToolCallState.inputAvailable]. The +/// matching output arrives later as a [ToolResultPart] carrying the same +/// [toolCallId]. +final class ToolCallPart extends AiPart { + /// Creates a tool-call part. + const ToolCallPart({ + required this.toolCallId, + required this.toolName, + this.args = const {}, + this.state = ToolCallState.inputStreaming, + }); + + /// Reconstructs a [ToolCallPart] from [json]. + factory ToolCallPart.fromJson(Map json) => ToolCallPart( + toolCallId: json['toolCallId']! as String, + toolName: json['toolName']! as String, + args: (json['args'] as Map?)?.cast() ?? const {}, + state: ToolCallState.fromJson(json['state']! as String), + ); + + /// Correlates this call with its [ToolResultPart]. + final String toolCallId; + + /// The name of the tool being invoked. + final String toolName; + + /// The (possibly partial) arguments decoded from the model's JSON. + final Map args; + + /// The lifecycle stage of this call. + final ToolCallState state; + + /// Returns a copy with the given fields replaced. + ToolCallPart copyWith({ + String? toolCallId, + String? toolName, + Map? args, + ToolCallState? state, + }) => + ToolCallPart( + toolCallId: toolCallId ?? this.toolCallId, + toolName: toolName ?? this.toolName, + args: args ?? this.args, + state: state ?? this.state, + ); + + @override + Map toJson() => { + 'type': 'tool-call', + 'toolCallId': toolCallId, + 'toolName': toolName, + 'args': args, + 'state': state.toJson(), + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ToolCallPart && + other.toolCallId == toolCallId && + other.toolName == toolName && + other.state == state && + deepEquals(other.args, args)); + + @override + int get hashCode => Object.hash(toolCallId, toolName, state, deepHash(args)); + + @override + String toString() => + 'ToolCallPart($toolName, id: $toolCallId, state: ${state.name})'; +} + +/// The output of a tool, fed back to the model and shown to the user. +final class ToolResultPart extends AiPart { + /// Creates a tool-result part. + const ToolResultPart({ + required this.toolCallId, + required this.result, + this.isError = false, + }); + + /// Reconstructs a [ToolResultPart] from [json]. + factory ToolResultPart.fromJson(Map json) => ToolResultPart( + toolCallId: json['toolCallId']! as String, + result: json['result'], + isError: json['isError'] as bool? ?? false, + ); + + /// The id of the [ToolCallPart] this result answers. + final String toolCallId; + + /// The tool's output. Any JSON-encodable value, or `null`. + final Object? result; + + /// Whether [result] represents an error rather than a success payload. + final bool isError; + + /// Returns a copy with the given fields replaced. + ToolResultPart copyWith({ + String? toolCallId, + Object? result, + bool? isError, + }) => + ToolResultPart( + toolCallId: toolCallId ?? this.toolCallId, + result: result ?? this.result, + isError: isError ?? this.isError, + ); + + @override + Map toJson() => { + 'type': 'tool-result', + 'toolCallId': toolCallId, + 'result': result, + 'isError': isError, + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ToolResultPart && + other.toolCallId == toolCallId && + other.isError == isError && + deepEquals(other.result, result)); + + @override + int get hashCode => Object.hash(toolCallId, isError, deepHash(result)); + + @override + String toString() => 'ToolResultPart(id: $toolCallId, isError: $isError)'; +} + +/// A file attachment: an image, document, or audio clip. +/// +/// Carries either a [url] (hosted/remote) or inline [bytes]. Document text +/// extraction is deliberately out of scope here — that is a backend concern, to +/// keep it off the UI thread. +final class FilePart extends AiPart { + /// Creates a file part. Provide a [url], [bytes], or both. + const FilePart({ + required this.mediaType, + this.url, + this.bytes, + this.name, + }); + + /// Reconstructs a [FilePart] from [json]. + /// + /// Inline [bytes] are expected as a base64 string under `bytes`. + factory FilePart.fromJson(Map json) { + final encoded = json['bytes'] as String?; + final url = json['url'] as String?; + return FilePart( + mediaType: json['mediaType']! as String, + url: url == null ? null : Uri.parse(url), + bytes: encoded == null ? null : base64Decode(encoded), + name: json['name'] as String?, + ); + } + + /// The IANA media type, e.g. `image/png` or `application/pdf`. + final String mediaType; + + /// The remote location of the file, if hosted. + final Uri? url; + + /// The inline contents of the file, if embedded. + final Uint8List? bytes; + + /// A human-readable file name, if known. + final String? name; + + /// Returns a copy with the given fields replaced. + FilePart copyWith({ + String? mediaType, + Uri? url, + Uint8List? bytes, + String? name, + }) => + FilePart( + mediaType: mediaType ?? this.mediaType, + url: url ?? this.url, + bytes: bytes ?? this.bytes, + name: name ?? this.name, + ); + + @override + Map toJson() => { + 'type': 'file', + 'mediaType': mediaType, + if (url != null) 'url': url.toString(), + if (bytes != null) 'bytes': base64Encode(bytes!), + if (name != null) 'name': name, + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is FilePart && + other.mediaType == mediaType && + other.url == url && + other.name == name && + deepEquals(other.bytes, bytes)); + + @override + int get hashCode => + Object.hash(mediaType, url, name, bytes == null ? null : deepHash(bytes)); + + @override + String toString() => 'FilePart($mediaType${name != null ? ', $name' : ''})'; +} + +/// A citation or source referenced by the model, rendered as a link or chip. +final class SourcePart extends AiPart { + /// Creates a source part pointing at [url]. + const SourcePart({required this.url, this.title}); + + /// Reconstructs a [SourcePart] from [json]. + factory SourcePart.fromJson(Map json) => SourcePart( + url: Uri.parse(json['url']! as String), + title: json['title'] as String?, + ); + + /// The source location. + final Uri url; + + /// A human-readable title for the source, if known. + final String? title; + + @override + Map toJson() => { + 'type': 'source', + 'url': url.toString(), + if (title != null) 'title': title, + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SourcePart && other.url == url && other.title == title); + + @override + int get hashCode => Object.hash(url, title); + + @override + String toString() => 'SourcePart($url)'; +} + +/// A structured data payload that drives generative UI. +/// +/// The model emits a [dataType] naming a developer-registered widget plus a +/// [data] map of its inputs. Rendering is resolved against a strict catalog in +/// the UI layer — never via reflection — so only explicitly registered widgets +/// can be instantiated. +final class DataPart extends AiPart { + /// Creates a data part of the given [dataType] carrying [data]. + const DataPart({required this.dataType, this.data = const {}}); + + /// Reconstructs a [DataPart] from [json]. + factory DataPart.fromJson(Map json) => DataPart( + dataType: json['dataType']! as String, + data: (json['data'] as Map?)?.cast() ?? const {}, + ); + + /// Names the registered widget this payload targets. + final String dataType; + + /// The widget's inputs. + final Map data; + + /// Returns a copy with the given fields replaced. + DataPart copyWith({String? dataType, Map? data}) => + DataPart(dataType: dataType ?? this.dataType, data: data ?? this.data); + + @override + Map toJson() => + {'type': 'data', 'dataType': dataType, 'data': data}; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is DataPart && + other.dataType == dataType && + deepEquals(other.data, data)); + + @override + int get hashCode => Object.hash(dataType, deepHash(data)); + + @override + String toString() => 'DataPart($dataType)'; +} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/models/ai_role.dart b/packages/flutter_ai/flutter_ai_core/lib/src/models/ai_role.dart new file mode 100644 index 0000000..c6a470a --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/lib/src/models/ai_role.dart @@ -0,0 +1,35 @@ +/// The author of a message in a conversation. +enum AiRole { + /// System or developer instructions that condition the model's behavior. + system('system'), + + /// A human end user. + user('user'), + + /// The model. + assistant('assistant'), + + /// Output produced by a tool and fed back to the model. + tool('tool'); + + const AiRole(this.wireName); + + /// The stable string used on the wire and in JSON. + /// + /// Decoupled from `Enum.name` so renaming a Dart identifier never silently + /// changes the serialized form. + final String wireName; + + /// Parses a [wireName] into its [AiRole]. + /// + /// Throws a [FormatException] if [value] does not match a known role. + static AiRole fromJson(String value) { + for (final role in values) { + if (role.wireName == value) return role; + } + throw FormatException('Unknown AiRole: "$value"'); + } + + /// The wire representation of this role. + String toJson() => wireName; +} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/models/finish_reason.dart b/packages/flutter_ai/flutter_ai_core/lib/src/models/finish_reason.dart new file mode 100644 index 0000000..207f900 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/lib/src/models/finish_reason.dart @@ -0,0 +1,39 @@ +/// Why the model stopped generating a message. +/// +/// Surfaced on the terminal stream event so the UI can react (for example, +/// announcing the final text to assistive technologies once generation is +/// complete). +enum FinishReason { + /// The model emitted a natural stopping point or a stop sequence. + stop('stop'), + + /// Generation was truncated by the maximum output token limit. + length('length'), + + /// The model paused to call one or more tools. + toolCalls('tool-calls'), + + /// Output was withheld or truncated by a content filter. + contentFilter('content-filter'), + + /// Generation ended because of an error. + error('error'); + + const FinishReason(this.wireName); + + /// The stable string used on the wire and in JSON. + final String wireName; + + /// Parses a [wireName] into its [FinishReason]. + /// + /// Throws a [FormatException] if [value] is not a known reason. + static FinishReason fromJson(String value) { + for (final reason in values) { + if (reason.wireName == value) return reason; + } + throw FormatException('Unknown FinishReason: "$value"'); + } + + /// The wire representation of this reason. + String toJson() => wireName; +} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/models/tool_call_state.dart b/packages/flutter_ai/flutter_ai_core/lib/src/models/tool_call_state.dart new file mode 100644 index 0000000..4e97043 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/lib/src/models/tool_call_state.dart @@ -0,0 +1,38 @@ +/// The lifecycle stage of a single tool call. +/// +/// A call advances monotonically: its arguments stream in, become complete and +/// valid, the tool executes, and finally a result (or an error) is available. +enum ToolCallState { + /// The model is still streaming the call's arguments; the JSON is partial. + inputStreaming('input-streaming'), + + /// Arguments have fully arrived and parsed into valid JSON. + inputAvailable('input-available'), + + /// The tool is executing. + executing('executing'), + + /// The tool finished and produced a result. + outputAvailable('output-available'), + + /// The call failed — argument validation or execution raised an error. + error('error'); + + const ToolCallState(this.wireName); + + /// The stable string used on the wire and in JSON. + final String wireName; + + /// Parses a [wireName] into its [ToolCallState]. + /// + /// Throws a [FormatException] if [value] is not a known state. + static ToolCallState fromJson(String value) { + for (final state in values) { + if (state.wireName == value) return state; + } + throw FormatException('Unknown ToolCallState: "$value"'); + } + + /// The wire representation of this state. + String toJson() => wireName; +} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/models/tool_definition.dart b/packages/flutter_ai/flutter_ai_core/lib/src/models/tool_definition.dart new file mode 100644 index 0000000..30265b1 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/lib/src/models/tool_definition.dart @@ -0,0 +1,57 @@ +import 'package:flutter_ai_core/src/internal/equality.dart'; + +/// A declaration of a tool the model may call: its name, purpose, and the +/// JSON Schema describing its arguments. +/// +/// This is pure data — it carries no executor. The `flutter_ai_tools` package +/// builds on it to add client-side execution. Keeping the declaration in the +/// core lets [provider contracts](LlmProvider) accept tools without depending on +/// the tools package. +final class ToolDefinition { + /// Creates a tool definition. + const ToolDefinition({ + required this.name, + required this.description, + this.parametersSchema = const {}, + }); + + /// Reconstructs a [ToolDefinition] from [json]. + factory ToolDefinition.fromJson(Map json) => ToolDefinition( + name: json['name']! as String, + description: json['description']! as String, + parametersSchema: + (json['parametersSchema'] as Map?)?.cast() ?? + const {}, + ); + + /// The tool's unique name, as referenced in tool calls. + final String name; + + /// A natural-language description the model uses to decide when to call it. + final String description; + + /// A JSON Schema object describing the tool's arguments. + final Map parametersSchema; + + /// Serializes this definition. + Map toJson() => { + 'name': name, + 'description': description, + 'parametersSchema': parametersSchema, + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ToolDefinition && + other.name == name && + other.description == description && + deepEquals(other.parametersSchema, parametersSchema)); + + @override + int get hashCode => + Object.hash(name, description, deepHash(parametersSchema)); + + @override + String toString() => 'ToolDefinition($name)'; +} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/models/usage.dart b/packages/flutter_ai/flutter_ai_core/lib/src/models/usage.dart new file mode 100644 index 0000000..95f9c78 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/lib/src/models/usage.dart @@ -0,0 +1,136 @@ +/// Token usage for a model turn, with an optional cost estimate. +/// +/// Every field is nullable because providers report different subsets (and some +/// only at the end of a stream). [cachedInputTokens] is the portion of +/// [inputTokens] served from a prompt cache; [cacheCreationTokens] is the +/// portion of [inputTokens] written to a prompt cache (billed at a premium); +/// [reasoningTokens] is the portion of [outputTokens] spent on extended +/// thinking. +final class AiUsage { + /// Creates a usage record. + const AiUsage({ + this.inputTokens, + this.outputTokens, + this.cachedInputTokens, + this.cacheCreationTokens, + this.reasoningTokens, + this.totalTokens, + }); + + /// Reconstructs usage from [json]. + factory AiUsage.fromJson(Map json) => AiUsage( + inputTokens: json['inputTokens'] as int?, + outputTokens: json['outputTokens'] as int?, + cachedInputTokens: json['cachedInputTokens'] as int?, + cacheCreationTokens: json['cacheCreationTokens'] as int?, + reasoningTokens: json['reasoningTokens'] as int?, + totalTokens: json['totalTokens'] as int?, + ); + + /// Prompt tokens billed at the input rate (includes [cachedInputTokens]). + final int? inputTokens; + + /// Generated tokens billed at the output rate (includes [reasoningTokens]). + final int? outputTokens; + + /// Portion of [inputTokens] served from a prompt cache (cheaper). + final int? cachedInputTokens; + + /// Portion of [inputTokens] written to a prompt cache. Providers (e.g. + /// Anthropic) bill these at a premium over the base input rate (~1.25x). + final int? cacheCreationTokens; + + /// Portion of [outputTokens] spent on extended thinking. + final int? reasoningTokens; + + /// Total tokens, if the provider reports it directly. Otherwise derive it via + /// [resolvedTotal]. + final int? totalTokens; + + /// [totalTokens] if present, else `inputTokens + outputTokens` when both are + /// known, else `null`. + int? get resolvedTotal { + if (totalTokens != null) return totalTokens; + if (inputTokens == null && outputTokens == null) return null; + return (inputTokens ?? 0) + (outputTokens ?? 0); + } + + /// Merges two partial usages, summing each field. Useful for accumulating + /// across streamed events or summing a whole session. + AiUsage operator +(AiUsage other) => AiUsage( + inputTokens: _add(inputTokens, other.inputTokens), + outputTokens: _add(outputTokens, other.outputTokens), + cachedInputTokens: _add(cachedInputTokens, other.cachedInputTokens), + cacheCreationTokens: + _add(cacheCreationTokens, other.cacheCreationTokens), + reasoningTokens: _add(reasoningTokens, other.reasoningTokens), + totalTokens: _add(totalTokens, other.totalTokens), + ); + + /// Estimates cost given per-million-token prices (typically USD). Returns + /// `null` when neither token count is known. + /// + /// [cachedInputTokens] and [cacheCreationTokens] are subsets of + /// [inputTokens]; they are subtracted out and billed separately so they are + /// never double-counted at the base rate. The remaining uncached, non-cache- + /// write input is billed at [inputPer1M]; cache reads at [cachedInputPer1M] + /// when given (else [inputPer1M]); cache writes at [cacheWritePer1M] when + /// given (else `1.25 * inputPer1M`, the Anthropic convention); all output + /// (including reasoning) at [outputPer1M]. + double? estimateCost({ + required double inputPer1M, + required double outputPer1M, + double? cachedInputPer1M, + double? cacheWritePer1M, + }) { + if (inputTokens == null && outputTokens == null) return null; + final cached = cachedInputTokens ?? 0; + final cacheWrite = cacheCreationTokens ?? 0; + final uncachedInput = (inputTokens ?? 0) - cached - cacheWrite; + final inputCost = uncachedInput * inputPer1M / 1e6 + + cached * (cachedInputPer1M ?? inputPer1M) / 1e6 + + cacheWrite * (cacheWritePer1M ?? inputPer1M * 1.25) / 1e6; + final outputCost = (outputTokens ?? 0) * outputPer1M / 1e6; + return inputCost + outputCost; + } + + /// Serializes this usage, omitting null fields. + Map toJson() => { + if (inputTokens != null) 'inputTokens': inputTokens, + if (outputTokens != null) 'outputTokens': outputTokens, + if (cachedInputTokens != null) 'cachedInputTokens': cachedInputTokens, + if (cacheCreationTokens != null) + 'cacheCreationTokens': cacheCreationTokens, + if (reasoningTokens != null) 'reasoningTokens': reasoningTokens, + if (totalTokens != null) 'totalTokens': totalTokens, + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AiUsage && + other.inputTokens == inputTokens && + other.outputTokens == outputTokens && + other.cachedInputTokens == cachedInputTokens && + other.cacheCreationTokens == cacheCreationTokens && + other.reasoningTokens == reasoningTokens && + other.totalTokens == totalTokens); + + @override + int get hashCode => Object.hash( + inputTokens, + outputTokens, + cachedInputTokens, + cacheCreationTokens, + reasoningTokens, + totalTokens, + ); + + @override + String toString() => 'AiUsage(in: $inputTokens, out: $outputTokens, cached: ' + '$cachedInputTokens, cacheWrite: $cacheCreationTokens, reasoning: ' + '$reasoningTokens, total: $resolvedTotal)'; + + static int? _add(int? a, int? b) => + (a == null && b == null) ? null : (a ?? 0) + (b ?? 0); +} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/provider/ai_capabilities.dart b/packages/flutter_ai/flutter_ai_core/lib/src/provider/ai_capabilities.dart new file mode 100644 index 0000000..7a14433 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/lib/src/provider/ai_capabilities.dart @@ -0,0 +1,69 @@ +import 'package:flutter_ai_core/src/internal/equality.dart'; +import 'package:flutter_ai_core/src/models/ai_conversation.dart'; +import 'package:flutter_ai_core/src/models/tool_definition.dart'; +import 'package:flutter_ai_core/src/provider/ai_request_options.dart'; +import 'package:flutter_ai_core/src/provider/llm_provider.dart'; + +/// A single embedding vector produced by an [EmbeddingProvider]. +/// +/// [values] is the dense vector for one input string; [index] is that input's +/// position in the batch passed to [EmbeddingProvider.embed], so callers can +/// re-associate vectors with their source text when a provider returns them out +/// of order (or simply confirm alignment). +final class AiEmbedding { + /// Creates an embedding holding [values] at batch position [index]. + const AiEmbedding(this.values, {this.index}); + + /// The dense embedding vector. + final List values; + + /// The position of the source input in the request batch, if reported. + final int? index; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AiEmbedding && + other.index == index && + deepEquals(other.values, values)); + + @override + int get hashCode => Object.hash(index, deepHash(values)); + + @override + String toString() => 'AiEmbedding(${values.length} dims, index: $index)'; +} + +/// An **optional** capability a provider MAY implement to turn text into +/// embedding vectors (for semantic search, clustering, and RAG retrieval). +/// +/// This is an opt-in mixin interface, separate from [LlmProvider]: a backend +/// that supports embeddings implements it in addition to (or instead of) +/// generation. Check support at runtime with `provider is EmbeddingProvider` +/// before calling [embed]; providers without an embeddings endpoint simply do +/// not implement it. +abstract interface class EmbeddingProvider { + /// Embeds each string in [inputs], returning one [AiEmbedding] per input. + /// + /// [model] selects the embedding model; when `null` the implementation uses + /// its own default. The returned list aligns with [inputs] by + /// [AiEmbedding.index] (and typically by position). + Future> embed(List inputs, {String? model}); +} + +/// An **optional** capability a provider MAY implement to count the tokens a +/// request would consume *before* sending it. +/// +/// Useful for pre-flight budget checks, context-window guards, and cost +/// estimates. Like [EmbeddingProvider] this is an opt-in mixin interface: +/// check support at runtime with `provider is TokenCounter`. Providers without +/// a token-count endpoint simply do not implement it. +abstract interface class TokenCounter { + /// Returns the number of tokens [conversation] (plus any [tools] and + /// [options]) would occupy in a generation request. + Future countTokens( + AiConversation conversation, { + List tools, + AiRequestOptions? options, + }); +} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/provider/ai_request_options.dart b/packages/flutter_ai/flutter_ai_core/lib/src/provider/ai_request_options.dart new file mode 100644 index 0000000..aae8657 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/lib/src/provider/ai_request_options.dart @@ -0,0 +1,132 @@ +import 'package:flutter_ai_core/src/internal/equality.dart'; +import 'package:flutter_ai_core/src/provider/ai_response_format.dart'; + +/// How much effort a reasoning-capable model should spend on internal thinking +/// before answering. +/// +/// A provider-neutral knob. Providers that expose an effort setting map it +/// directly (OpenAI `reasoning_effort`); providers that use a token budget map +/// it through [budgetTokens] (Anthropic `thinking.budget_tokens`, Gemini +/// `thinkingConfig.thinkingBudget`). Providers that don't support it ignore it. +enum ReasoningEffort { + /// The least thinking the model/provider allows. + minimal, + + /// Light reasoning. + low, + + /// Moderate reasoning. + medium, + + /// Deep reasoning. + high; + + /// A canonical thinking-token budget for providers that take one instead of + /// an effort level. A documented heuristic — pass an exact budget via + /// [AiRequestOptions.extra] when you need provider-specific precision. + int get budgetTokens => switch (this) { + ReasoningEffort.minimal => 1024, + ReasoningEffort.low => 2048, + ReasoningEffort.medium => 8192, + ReasoningEffort.high => 24576, + }; + + /// The wire value OpenAI's `reasoning_effort` expects. + String get openAiValue => name; +} + +/// Provider-neutral knobs for a generation request. +/// +/// Common parameters are first-class; anything provider-specific rides in +/// [extra], which a concrete provider passes through to its backend. Switching +/// models is as simple as constructing options with a different [model]. +final class AiRequestOptions { + /// Creates request options. + const AiRequestOptions({ + this.model, + this.temperature, + this.maxOutputTokens, + this.responseFormat, + this.reasoningEffort, + this.cachePrompt = false, + this.extra = const {}, + }); + + /// The model identifier, e.g. `gpt-4o` or `gemini-2.0-flash`. + final String? model; + + /// Sampling temperature, typically in the range `0.0`–`2.0`. + final double? temperature; + + /// An upper bound on the number of tokens to generate. + final int? maxOutputTokens; + + /// When set, requests structured output constrained to a JSON schema. See + /// [AiResponseFormat]. + final AiResponseFormat? responseFormat; + + /// How hard a reasoning-capable model should think before answering. Maps to + /// each provider's native control (OpenAI `reasoning_effort`, Anthropic + /// `thinking.budget_tokens`, Gemini `thinkingConfig.thinkingBudget`) and is + /// ignored by providers that don't support it. An explicit value in [extra] + /// takes precedence. See [ReasoningEffort]. + final ReasoningEffort? reasoningEffort; + + /// Hints that the stable prompt prefix (system instructions + tools) should be + /// cached to cut cost and latency on repeated context. + /// + /// Anthropic applies explicit `cache_control` markers; OpenAI and Gemini cache + /// automatically, so this is a no-op there. Off by default. + final bool cachePrompt; + + /// Provider-specific parameters passed through verbatim. + final Map extra; + + /// Returns a copy with the given fields replaced. + AiRequestOptions copyWith({ + String? model, + double? temperature, + int? maxOutputTokens, + AiResponseFormat? responseFormat, + ReasoningEffort? reasoningEffort, + bool? cachePrompt, + Map? extra, + }) => + AiRequestOptions( + model: model ?? this.model, + temperature: temperature ?? this.temperature, + maxOutputTokens: maxOutputTokens ?? this.maxOutputTokens, + responseFormat: responseFormat ?? this.responseFormat, + reasoningEffort: reasoningEffort ?? this.reasoningEffort, + cachePrompt: cachePrompt ?? this.cachePrompt, + extra: extra ?? this.extra, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AiRequestOptions && + other.model == model && + other.temperature == temperature && + other.maxOutputTokens == maxOutputTokens && + other.responseFormat == responseFormat && + other.reasoningEffort == reasoningEffort && + other.cachePrompt == cachePrompt && + deepEquals(other.extra, extra)); + + @override + int get hashCode => Object.hash( + model, + temperature, + maxOutputTokens, + responseFormat, + reasoningEffort, + cachePrompt, + deepHash(extra), + ); + + @override + String toString() => + 'AiRequestOptions(model: $model, temperature: $temperature, ' + 'maxOutputTokens: $maxOutputTokens)'; +} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/provider/ai_response_format.dart b/packages/flutter_ai/flutter_ai_core/lib/src/provider/ai_response_format.dart new file mode 100644 index 0000000..4c7719d --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/lib/src/provider/ai_response_format.dart @@ -0,0 +1,40 @@ +import 'package:flutter_ai_core/src/internal/equality.dart'; + +/// Requests structured output constrained to a JSON [schema]. +/// +/// Providers route this to their native mechanism: OpenAI `response_format` +/// (`json_schema`, [strict]), Gemini `responseSchema`, and Anthropic a forced +/// tool whose input is [schema] (its result is surfaced as the JSON answer). In +/// every case the assistant's text is the JSON object, which you can decode and +/// validate against [schema]. +final class AiResponseFormat { + /// Creates a structured-output request for [schema] (a JSON Schema object). + const AiResponseFormat({ + required this.schema, + this.name = 'response', + this.strict = true, + }); + + /// The JSON Schema the output must conform to. + final Map schema; + + /// A short name for the schema (used by providers that require one). + final String name; + + /// Whether to enforce the schema strictly where the provider supports it. + final bool strict; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AiResponseFormat && + other.name == name && + other.strict == strict && + deepEquals(other.schema, schema)); + + @override + int get hashCode => Object.hash(name, strict, deepHash(schema)); + + @override + String toString() => 'AiResponseFormat(name: $name, strict: $strict)'; +} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/provider/generate_object.dart b/packages/flutter_ai/flutter_ai_core/lib/src/provider/generate_object.dart new file mode 100644 index 0000000..6df5d0f --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/lib/src/provider/generate_object.dart @@ -0,0 +1,129 @@ +import 'dart:convert'; + +import 'package:flutter_ai_core/src/internal/equality.dart'; +import 'package:flutter_ai_core/src/models/ai_conversation.dart'; +import 'package:flutter_ai_core/src/models/tool_definition.dart'; +import 'package:flutter_ai_core/src/provider/ai_request_options.dart'; +import 'package:flutter_ai_core/src/provider/ai_response_format.dart'; +import 'package:flutter_ai_core/src/provider/llm_provider.dart'; +import 'package:flutter_ai_core/src/streaming/ai_stream_event.dart'; +import 'package:flutter_ai_core/src/streaming/json_accumulator.dart'; + +/// Structured-output helpers layered on top of any [LlmProvider]. +/// +/// These build on the existing streaming contract: they send the conversation +/// with [AiRequestOptions.responseFormat] set to the requested +/// [AiResponseFormat], collect the assistant's streamed text (which is the JSON +/// object), and surface it as a decoded `Map`. No provider changes are needed — +/// every backend that honors `responseFormat` gets typed objects for free. +extension GenerateObject on LlmProvider { + /// Generates a single structured object constrained to [format]. + /// + /// Sends [conversation] with [options] merged so its + /// [AiRequestOptions.responseFormat] is [format], collects the streamed + /// assistant text, and JSON-decodes it to a `Map`. + /// + /// Throws a [FormatException] (carrying the raw text) if the response is not a + /// JSON object. Soft schema issues do not throw — the decoded object is + /// returned as-is. + Future> generateObject( + AiConversation conversation, { + required AiResponseFormat format, + List tools = const [], + AiRequestOptions? options, + }) async { + final buffer = StringBuffer(); + await for (final event in send( + conversation, + tools: tools, + options: _withFormat(options, format), + )) { + switch (event) { + case TextDelta(:final delta): + buffer.write(delta); + case StreamErrorEvent(:final error): + throw FormatException('generateObject failed: $error'); + case _: + break; + } + } + + final raw = buffer.toString(); + final Object? decoded; + try { + decoded = jsonDecode(raw); + } on FormatException catch (e) { + throw FormatException( + 'generateObject: response was not valid JSON (${e.message})', + raw, + ); + } + if (decoded is! Map) { + throw FormatException( + 'generateObject: expected a JSON object but got ' + '${decoded.runtimeType}', + raw, + ); + } + return decoded.cast(); + } + + /// Generates a structured object, yielding the evolving partial object as it + /// streams. + /// + /// Sends the same request as [generateObject] but feeds each [TextDelta] into + /// a [JsonAccumulator] and yields the best complete-prefix `Map` whenever it + /// advances, ending with the final complete object. Because [JsonAccumulator] + /// only ever surfaces a valid prefix of the document, intermediate yields are + /// growing prefixes of the final object. + Stream> streamObject( + AiConversation conversation, { + required AiResponseFormat format, + List tools = const [], + AiRequestOptions? options, + }) async* { + final accumulator = JsonAccumulator(); + Map? last; + + await for (final event in send( + conversation, + tools: tools, + options: _withFormat(options, format), + )) { + switch (event) { + case TextDelta(:final delta): + accumulator.add(delta); + final partial = accumulator.parsePartial(); + if (partial is Map) { + final next = partial.cast(); + // Only yield when the value actually advanced, so identical + // re-parses between deltas don't emit duplicate frames. + if (last == null || !deepEquals(last, next)) { + last = next; + yield next; + } + } + case StreamErrorEvent(:final error): + throw FormatException('streamObject failed: $error'); + case _: + break; + } + } + + // Surface the final, strictly-parsed object if it differs from the last + // partial (e.g. a trailing token only completed at the end). + final complete = accumulator.tryParseComplete(); + if (complete is Map) { + final next = complete.cast(); + if (last == null || !deepEquals(last, next)) { + yield next; + } + } + } + + AiRequestOptions _withFormat( + AiRequestOptions? options, + AiResponseFormat format, + ) => + (options ?? const AiRequestOptions()).copyWith(responseFormat: format); +} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/provider/llm_exception.dart b/packages/flutter_ai/flutter_ai_core/lib/src/provider/llm_exception.dart new file mode 100644 index 0000000..718e6eb --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/lib/src/provider/llm_exception.dart @@ -0,0 +1,57 @@ +/// A failed provider HTTP request, surfaced on +/// [StreamErrorEvent.error](../streaming/ai_stream_event.dart) so hosts can +/// branch on the *type* (auth vs. rate-limit vs. server) instead of +/// string-matching a message. +sealed class LlmException implements Exception { + /// Creates a provider exception. + const LlmException(this.statusCode, this.body, {this.retryAfter}); + + /// The HTTP status code. + final int statusCode; + + /// The (truncated) response body, for diagnostics. + final String body; + + /// The server-advised retry delay (from `Retry-After`), if any. + final Duration? retryAfter; + + @override + String toString() => '$runtimeType($statusCode): $body'; +} + +/// Authentication/authorization failure (HTTP 401/403) — usually a bad or +/// missing API key. +final class LlmAuthException extends LlmException { + /// Creates an auth exception. + const LlmAuthException(super.statusCode, super.body); +} + +/// Rate limited (HTTP 429). Honor [retryAfter] before retrying. +final class LlmRateLimitException extends LlmException { + /// Creates a rate-limit exception. + const LlmRateLimitException(super.statusCode, super.body, {super.retryAfter}); +} + +/// Server-side failure (HTTP 5xx, incl. Anthropic 529 overloaded). +final class LlmServerException extends LlmException { + /// Creates a server exception. + const LlmServerException(super.statusCode, super.body, {super.retryAfter}); +} + +/// A non-retryable client error (other 4xx) — e.g. a malformed request. +final class LlmRequestException extends LlmException { + /// Creates a request exception. + const LlmRequestException(super.statusCode, super.body); +} + +/// Maps an HTTP [status] to the matching [LlmException] subtype. +LlmException llmExceptionFor(int status, String body, {Duration? retryAfter}) { + if (status == 401 || status == 403) return LlmAuthException(status, body); + if (status == 429) { + return LlmRateLimitException(status, body, retryAfter: retryAfter); + } + if (status >= 500) { + return LlmServerException(status, body, retryAfter: retryAfter); + } + return LlmRequestException(status, body); +} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/provider/llm_provider.dart b/packages/flutter_ai/flutter_ai_core/lib/src/provider/llm_provider.dart new file mode 100644 index 0000000..e7bf94f --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/lib/src/provider/llm_provider.dart @@ -0,0 +1,33 @@ +import 'package:flutter_ai_core/src/models/ai_conversation.dart'; +import 'package:flutter_ai_core/src/models/tool_definition.dart'; +import 'package:flutter_ai_core/src/provider/ai_request_options.dart'; +import 'package:flutter_ai_core/src/streaming/ai_stream_event.dart'; + +/// The contract every model backend implements: turn a conversation into a +/// stream of incremental [AiStreamEvent]s. +/// +/// This is the single seam that makes the ecosystem provider-agnostic. A +/// concrete provider (OpenAI, Anthropic, Gemini, an on-device model, or a +/// custom backend) maps its native protocol onto these events; everything above +/// it — controllers, UI — is written once against this interface. +/// +/// Implementations should: +/// +/// * emit a terminal [MessageFinished] (or [StreamErrorEvent]) for each assistant +/// message they produce, so consumers can finalize state and accessibility; +/// * surface failures as a [StreamErrorEvent] event where possible, reserving thrown +/// exceptions for programming errors and unrecoverable transport faults; +/// * stop work promptly when the returned stream's subscription is cancelled. +abstract interface class LlmProvider { + /// Generates a response to [conversation]. + /// + /// [tools] advertises the tools the model may call; `null` or empty means + /// none. [options] carries model selection and sampling parameters; `null` + /// means provider defaults. The returned stream is single-subscription; + /// cancelling its subscription must cancel the request. + Stream send( + AiConversation conversation, { + List? tools, + AiRequestOptions? options, + }); +} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/rendering/text_renderer.dart b/packages/flutter_ai/flutter_ai_core/lib/src/rendering/text_renderer.dart new file mode 100644 index 0000000..6650bca --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/lib/src/rendering/text_renderer.dart @@ -0,0 +1,18 @@ +/// A strategy for turning message text into a rendered representation. +/// +/// Declared in the core — without a Flutter dependency — so models and contracts +/// can reference the seam, while UI packages provide the concrete widget- +/// producing implementation (the default in `flutter_ai_elements` is a +/// dependency-free Markdown renderer). Hosts inject a custom [TextRenderer] to +/// swap in their own parser or to support dialects such as LaTeX or custom tags. +/// +/// The type parameter [T] is the rendered output — a `Widget` in the UI layer, +/// or any representation in non-UI contexts (tests, server-side rendering). +abstract interface class TextRenderer { + /// Renders [text] into a [T]. + /// + /// [isStreaming] is `true` while the text is still arriving, letting an + /// implementation defer expensive parsing or suppress live-region semantics + /// until generation completes. + T render(String text, {required bool isStreaming}); +} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/streaming/ai_stream_event.dart b/packages/flutter_ai/flutter_ai_core/lib/src/streaming/ai_stream_event.dart new file mode 100644 index 0000000..1830974 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/lib/src/streaming/ai_stream_event.dart @@ -0,0 +1,497 @@ +import 'package:flutter_ai_core/src/internal/equality.dart'; +import 'package:flutter_ai_core/src/models/ai_part.dart'; +import 'package:flutter_ai_core/src/models/ai_role.dart'; +import 'package:flutter_ai_core/src/models/finish_reason.dart'; +import 'package:flutter_ai_core/src/models/usage.dart'; + +/// A single incremental update emitted by an `LlmProvider` during generation. +/// +/// Providers translate their native protocol (SSE, gRPC, a local callback) into +/// this sealed set of events; a `MessageProcessor` folds them into conversation +/// state. Because the type is `sealed`, a `switch` over an event is exhaustively +/// checked, and adding an event forces every consumer to handle it. +/// +/// Events round-trip through JSON so a generic transport can serialize them and +/// tests can replay recorded streams. [AiStreamEvent.fromJson] dispatches on the +/// `type` discriminator. +sealed class AiStreamEvent { + /// Const base constructor for subclasses. + const AiStreamEvent(); + + /// Reconstructs an event from [json] by dispatching on `type`. + /// + /// Throws a [FormatException] if `type` is missing or unrecognized. + factory AiStreamEvent.fromJson(Map json) { + final type = json['type']; + return switch (type) { + 'message-started' => MessageStarted.fromJson(json), + 'text-delta' => TextDelta.fromJson(json), + 'reasoning-delta' => ReasoningDelta.fromJson(json), + 'tool-call-started' => ToolCallStarted.fromJson(json), + 'tool-call-delta' => ToolCallDelta.fromJson(json), + 'tool-call-ready' => ToolCallReady.fromJson(json), + 'tool-result' => ToolResultReceived.fromJson(json), + 'part-received' => PartReceived.fromJson(json), + 'message-finished' => MessageFinished.fromJson(json), + 'error' => StreamErrorEvent.fromJson(json), + _ => throw FormatException('Unknown AiStreamEvent type: "$type"'), + }; + } + + /// Serializes this event, including its `type` discriminator. + Map toJson(); +} + +/// Announces a new message and its author, before any content arrives. +/// +/// Optional: a processor will lazily create an assistant message on the first +/// content event if no start was sent. +final class MessageStarted extends AiStreamEvent { + /// Creates a message-started event. + const MessageStarted({required this.messageId, required this.role}); + + /// Reconstructs a [MessageStarted] from [json]. + factory MessageStarted.fromJson(Map json) => MessageStarted( + messageId: json['messageId']! as String, + role: AiRole.fromJson(json['role']! as String), + ); + + /// The id of the message being started. + final String messageId; + + /// Who authors the message. + final AiRole role; + + @override + Map toJson() => { + 'type': 'message-started', + 'messageId': messageId, + 'role': role.toJson(), + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MessageStarted && + other.messageId == messageId && + other.role == role); + + @override + int get hashCode => Object.hash(messageId, role); + + @override + String toString() => 'MessageStarted($messageId, ${role.name})'; +} + +/// Appends [delta] to the prose of message [messageId]. +final class TextDelta extends AiStreamEvent { + /// Creates a text-delta event. + const TextDelta({required this.messageId, required this.delta}); + + /// Reconstructs a [TextDelta] from [json]. + factory TextDelta.fromJson(Map json) => TextDelta( + messageId: json['messageId']! as String, + delta: json['delta']! as String, + ); + + /// The message receiving the text. + final String messageId; + + /// The text fragment to append. + final String delta; + + @override + Map toJson() => + {'type': 'text-delta', 'messageId': messageId, 'delta': delta}; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TextDelta && + other.messageId == messageId && + other.delta == delta); + + @override + int get hashCode => Object.hash(messageId, delta); + + @override + String toString() => 'TextDelta($messageId, ${delta.length} chars)'; +} + +/// Appends [delta] to the reasoning of message [messageId]. +final class ReasoningDelta extends AiStreamEvent { + /// Creates a reasoning-delta event. + const ReasoningDelta({ + required this.messageId, + required this.delta, + this.signature, + }); + + /// Reconstructs a [ReasoningDelta] from [json]. + factory ReasoningDelta.fromJson(Map json) => ReasoningDelta( + messageId: json['messageId']! as String, + delta: json['delta']! as String, + signature: json['signature'] as String?, + ); + + /// The message receiving the reasoning. + final String messageId; + + /// The reasoning fragment to append. + final String delta; + + /// An opaque provider signature for the reasoning block (set on the + /// [ReasoningPart] when present); see [ReasoningPart.signature]. + final String? signature; + + @override + Map toJson() => { + 'type': 'reasoning-delta', + 'messageId': messageId, + 'delta': delta, + if (signature != null) 'signature': signature, + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ReasoningDelta && + other.messageId == messageId && + other.delta == delta && + other.signature == signature); + + @override + int get hashCode => Object.hash(messageId, delta, signature); + + @override + String toString() => 'ReasoningDelta($messageId, ${delta.length} chars)'; +} + +/// Opens a tool call within message [messageId]. +/// +/// Followed by zero or more [ToolCallDelta]s carrying the argument JSON, then a +/// [ToolCallReady] once the arguments are complete. +final class ToolCallStarted extends AiStreamEvent { + /// Creates a tool-call-started event. + const ToolCallStarted({ + required this.messageId, + required this.toolCallId, + required this.toolName, + }); + + /// Reconstructs a [ToolCallStarted] from [json]. + factory ToolCallStarted.fromJson(Map json) => + ToolCallStarted( + messageId: json['messageId']! as String, + toolCallId: json['toolCallId']! as String, + toolName: json['toolName']! as String, + ); + + /// The message the call belongs to. + final String messageId; + + /// The id correlating this call with its result. + final String toolCallId; + + /// The name of the tool being invoked. + final String toolName; + + @override + Map toJson() => { + 'type': 'tool-call-started', + 'messageId': messageId, + 'toolCallId': toolCallId, + 'toolName': toolName, + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ToolCallStarted && + other.messageId == messageId && + other.toolCallId == toolCallId && + other.toolName == toolName); + + @override + int get hashCode => Object.hash(messageId, toolCallId, toolName); + + @override + String toString() => 'ToolCallStarted($toolName, id: $toolCallId)'; +} + +/// Appends a fragment of argument JSON to tool call [toolCallId]. +/// +/// The fragments accumulate; the JSON is partial until [ToolCallReady]. +final class ToolCallDelta extends AiStreamEvent { + /// Creates a tool-call-delta event. + const ToolCallDelta({ + required this.toolCallId, + required this.argumentsDelta, + }); + + /// Reconstructs a [ToolCallDelta] from [json]. + factory ToolCallDelta.fromJson(Map json) => ToolCallDelta( + toolCallId: json['toolCallId']! as String, + argumentsDelta: json['argumentsDelta']! as String, + ); + + /// The call whose arguments are growing. + final String toolCallId; + + /// A fragment of the arguments JSON. + final String argumentsDelta; + + @override + Map toJson() => { + 'type': 'tool-call-delta', + 'toolCallId': toolCallId, + 'argumentsDelta': argumentsDelta, + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ToolCallDelta && + other.toolCallId == toolCallId && + other.argumentsDelta == argumentsDelta); + + @override + int get hashCode => Object.hash(toolCallId, argumentsDelta); + + @override + String toString() => + 'ToolCallDelta($toolCallId, ${argumentsDelta.length} chars)'; +} + +/// Signals that tool call [toolCallId] has received all its arguments. +/// +/// The processor strictly parses the accumulated JSON: on success the call +/// advances to `ToolCallState.inputAvailable`; on failure it is marked errored. +final class ToolCallReady extends AiStreamEvent { + /// Creates a tool-call-ready event. + const ToolCallReady({required this.toolCallId}); + + /// Reconstructs a [ToolCallReady] from [json]. + factory ToolCallReady.fromJson(Map json) => + ToolCallReady(toolCallId: json['toolCallId']! as String); + + /// The call whose arguments are now complete. + final String toolCallId; + + @override + Map toJson() => + {'type': 'tool-call-ready', 'toolCallId': toolCallId}; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ToolCallReady && other.toolCallId == toolCallId); + + @override + int get hashCode => toolCallId.hashCode; + + @override + String toString() => 'ToolCallReady($toolCallId)'; +} + +/// Delivers the output of tool call [toolCallId] into message [messageId]. +final class ToolResultReceived extends AiStreamEvent { + /// Creates a tool-result event. + const ToolResultReceived({ + required this.messageId, + required this.toolCallId, + required this.result, + this.isError = false, + }); + + /// Reconstructs a [ToolResultReceived] from [json]. + factory ToolResultReceived.fromJson(Map json) => + ToolResultReceived( + messageId: json['messageId']! as String, + toolCallId: json['toolCallId']! as String, + result: json['result'], + isError: json['isError'] as bool? ?? false, + ); + + /// The message the result attaches to. + final String messageId; + + /// The call this result answers. + final String toolCallId; + + /// The tool's output (any JSON-encodable value, or `null`). + final Object? result; + + /// Whether [result] is an error payload. + final bool isError; + + @override + Map toJson() => { + 'type': 'tool-result', + 'messageId': messageId, + 'toolCallId': toolCallId, + 'result': result, + 'isError': isError, + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ToolResultReceived && + other.messageId == messageId && + other.toolCallId == toolCallId && + other.isError == isError && + deepEquals(other.result, result)); + + @override + int get hashCode => + Object.hash(messageId, toolCallId, isError, deepHash(result)); + + @override + String toString() => 'ToolResultReceived($toolCallId, isError: $isError)'; +} + +/// Appends a fully-formed [part] (a file, source, or data payload) to message +/// [messageId]. +final class PartReceived extends AiStreamEvent { + /// Creates a part-received event. + const PartReceived({required this.messageId, required this.part}); + + /// Reconstructs a [PartReceived] from [json]. + factory PartReceived.fromJson(Map json) => PartReceived( + messageId: json['messageId']! as String, + part: AiPart.fromJson((json['part']! as Map).cast()), + ); + + /// The message receiving the part. + final String messageId; + + /// The complete part to append. + final AiPart part; + + @override + Map toJson() => + {'type': 'part-received', 'messageId': messageId, 'part': part.toJson()}; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PartReceived && + other.messageId == messageId && + other.part == part); + + @override + int get hashCode => Object.hash(messageId, part); + + @override + String toString() => 'PartReceived($messageId, $part)'; +} + +/// Marks message [messageId] complete, carrying the [reason] generation ended. +final class MessageFinished extends AiStreamEvent { + /// Creates a message-finished event. + const MessageFinished({ + required this.messageId, + required this.reason, + this.usage, + }); + + /// Reconstructs a [MessageFinished] from [json]. + factory MessageFinished.fromJson(Map json) { + final usage = json['usage']; + return MessageFinished( + messageId: json['messageId']! as String, + reason: FinishReason.fromJson(json['reason']! as String), + usage: usage == null + ? null + : AiUsage.fromJson((usage as Map).cast()), + ); + } + + /// The message that finished. + final String messageId; + + /// Why generation stopped. + final FinishReason reason; + + /// Token usage for the turn, if the provider reported it. + final AiUsage? usage; + + @override + Map toJson() => { + 'type': 'message-finished', + 'messageId': messageId, + 'reason': reason.toJson(), + if (usage != null) 'usage': usage!.toJson(), + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is MessageFinished && + other.messageId == messageId && + other.reason == reason && + other.usage == usage); + + @override + int get hashCode => Object.hash(messageId, reason, usage); + + @override + String toString() => 'MessageFinished($messageId, ${reason.name})'; +} + +/// Reports an error during generation. +/// +/// When [messageId] is set, the processor marks that message errored; a scoped +/// [toolCallId] additionally flags the offending tool call. A `null` +/// [messageId] denotes a stream-level failure not tied to one message. +final class StreamErrorEvent extends AiStreamEvent { + /// Creates an error event. + const StreamErrorEvent({ + required this.error, + this.messageId, + this.toolCallId, + }); + + /// Reconstructs a [StreamErrorEvent] from [json]. + /// + /// The original error object is not recoverable from JSON; its string form is + /// restored as the [error]. + factory StreamErrorEvent.fromJson(Map json) => + StreamErrorEvent( + error: json['error']! as String, + messageId: json['messageId'] as String?, + toolCallId: json['toolCallId'] as String?, + ); + + /// The error that occurred. + final Object error; + + /// The affected message, if the failure is scoped to one. + final String? messageId; + + /// The affected tool call, if the failure is scoped to one. + final String? toolCallId; + + @override + Map toJson() => { + 'type': 'error', + 'error': error.toString(), + if (messageId != null) 'messageId': messageId, + if (toolCallId != null) 'toolCallId': toolCallId, + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is StreamErrorEvent && + other.error.toString() == error.toString() && + other.messageId == messageId && + other.toolCallId == toolCallId); + + @override + int get hashCode => Object.hash(error.toString(), messageId, toolCallId); + + @override + String toString() => 'StreamErrorEvent($error, messageId: $messageId)'; +} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/streaming/json_accumulator.dart b/packages/flutter_ai/flutter_ai_core/lib/src/streaming/json_accumulator.dart new file mode 100644 index 0000000..e480da9 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/lib/src/streaming/json_accumulator.dart @@ -0,0 +1,261 @@ +import 'dart:convert'; + +/// Accumulates a JSON document that arrives in fragments and parses it +/// tolerantly while still incomplete. +/// +/// Tool-call arguments stream from the model as a sequence of partial JSON +/// strings. A naive `jsonDecode` of the buffer throws until the very last +/// fragment lands, which makes live rendering impossible. [JsonAccumulator] +/// instead repairs the partial buffer — closing open strings and containers and +/// dropping any trailing incomplete token — so callers can show a best-effort +/// view at every step, then validate strictly once the document is complete. +/// +/// The repair is conservative: it never throws and never invents data. When a +/// trailing value cannot be completed safely it is dropped rather than guessed, +/// so [parsePartial] only ever returns a prefix of the eventual document. +class JsonAccumulator { + final StringBuffer _buffer = StringBuffer(); + Object? _lastPartial; + + /// Appends a fragment to the buffer. + void add(String fragment) => _buffer.write(fragment); + + /// Clears the buffer and cached partial result. + void reset() { + _buffer.clear(); + _lastPartial = null; + } + + /// The raw accumulated text. + String get raw => _buffer.toString(); + + /// Whether nothing has been accumulated yet. + bool get isEmpty => _buffer.isEmpty; + + /// Strictly parses the buffer, returning `null` if it is not yet valid JSON. + /// + /// Never throws — a parse failure simply yields `null`. + Object? tryParseComplete() { + final source = raw; + if (source.trim().isEmpty) return null; + try { + return jsonDecode(source); + } on FormatException { + return null; + } + } + + /// Returns a best-effort decode of the (possibly partial) buffer. + /// + /// If the buffer is already valid JSON it is returned as-is. Otherwise the + /// buffer is repaired and decoded; if even the repair cannot be parsed, the + /// most recent successful partial is returned (or `null` if there is none). + Object? parsePartial() { + final source = raw; + if (source.trim().isEmpty) return _lastPartial; + + final strict = tryParseComplete(); + if (strict != null) { + _lastPartial = strict; + return strict; + } + + final repaired = _repair(source); + if (repaired != null) { + try { + final value = jsonDecode(repaired); + _lastPartial = value; + return value; + } on FormatException { + // Fall through to the cached partial. + } + } + return _lastPartial; + } +} + +const int _quote = 0x22; // " +const int _backslash = 0x5c; // \ +const int _colon = 0x3a; // : +const int _comma = 0x2c; // , +const int _openBrace = 0x7b; // { +const int _closeBrace = 0x7d; // } +const int _openBracket = 0x5b; // [ +const int _closeBracket = 0x5d; // ] +const int _space = 0x20; +const int _tab = 0x09; +const int _newline = 0x0a; +const int _return = 0x0d; + +bool _isWhitespace(int c) => + c == _space || c == _tab || c == _newline || c == _return; + +// Parser states for the repair scanner. +const int _expectValue = 0; // start of value (array elem, after ':', after '[') +const int _afterValue = 1; // a complete value just ended (a safe cut point) +const int _expectKey = 2; // start of object member (a key string, or '}') +const int _expectColon = 3; // a key just ended, ':' must follow + +/// Completes a truncated JSON [source] into a valid JSON string, or returns +/// `null` if no safe completion exists. +/// +/// Walks the document tracking the open-container stack and a small state +/// machine. It remembers the latest position at which the document could be +/// legally closed (a "safe cut") together with the container stack there, then +/// truncates to that point and appends the matching closers. Anything after the +/// last safe cut — an unterminated string, a dangling `"key":`, a half-written +/// number — is discarded. +String? _repair(String source) { + final stack = []; // _openBrace / _openBracket, outermost first + var state = _expectValue; + var safeLen = -1; + var safeStack = const []; + + void markSafe(int length) { + safeLen = length; + safeStack = List.of(stack); + } + + var i = 0; + final length = source.length; + scan: + while (i < length) { + final c = source.codeUnitAt(i); + if (_isWhitespace(c)) { + i++; + continue; + } + + switch (state) { + case _expectKey: + if (c == _closeBrace) { + stack.removeLast(); + state = _afterValue; + i++; + markSafe(i); + } else if (c == _quote) { + final end = _scanString(source, i); + if (end == -1) break scan; // incomplete key + i = end; + state = _expectColon; + } else { + break scan; + } + + case _expectColon: + if (c == _colon) { + state = _expectValue; + i++; + } else { + break scan; + } + + case _expectValue: + if (c == _openBrace) { + stack.add(_openBrace); + state = _expectKey; + i++; + markSafe(i); // an empty object can be closed + } else if (c == _openBracket) { + stack.add(_openBracket); + state = _expectValue; + i++; + markSafe(i); // an empty array can be closed + } else if (c == _closeBracket && + stack.isNotEmpty && + stack.last == _openBracket) { + stack.removeLast(); // empty array: "[]" + state = _afterValue; + i++; + markSafe(i); + } else if (c == _quote) { + final end = _scanString(source, i); + if (end == -1) break scan; // incomplete value string + i = end; + state = _afterValue; + markSafe(i); + } else { + // A number or keyword. It only counts as complete once a structural + // delimiter or whitespace terminates it — otherwise a buffer like + // `1234` might be a truncated prefix of `123456`, a *different* + // scalar, which would violate the prefix contract. An unterminated + // trailing literal is therefore excluded from the safe cut, exactly + // as an unterminated string is. + final end = _scanLiteral(source, i); + if (end == length) break scan; // literal runs to the buffer end + i = end; + state = _afterValue; + markSafe(i); + } + + case _afterValue: + if (c == _comma) { + state = (stack.isNotEmpty && stack.last == _openBrace) + ? _expectKey + : _expectValue; + i++; + } else if (c == _closeBrace && + stack.isNotEmpty && + stack.last == _openBrace) { + stack.removeLast(); + i++; + markSafe(i); + } else if (c == _closeBracket && + stack.isNotEmpty && + stack.last == _openBracket) { + stack.removeLast(); + i++; + markSafe(i); + } else { + break scan; + } + } + } + + if (safeLen < 0) return null; + final buffer = StringBuffer(source.substring(0, safeLen)); + for (var k = safeStack.length - 1; k >= 0; k--) { + buffer.writeCharCode( + safeStack[k] == _openBrace ? _closeBrace : _closeBracket, + ); + } + return buffer.toString(); +} + +/// Returns the index just past the closing quote of the string starting at +/// [start], or `-1` if the string is unterminated. +int _scanString(String source, int start) { + var i = start + 1; // skip the opening quote + final length = source.length; + while (i < length) { + final c = source.codeUnitAt(i); + if (c == _backslash) { + i += 2; // skip the escaped character + continue; + } + if (c == _quote) return i + 1; + i++; + } + return -1; +} + +/// Returns the index just past a literal (number, `true`, `false`, `null`) +/// starting at [start]. +/// +/// Scanning ends at the first structural delimiter or whitespace, or at the end +/// of [source] if the literal is the final token. +int _scanLiteral(String source, int start) { + var i = start; + final length = source.length; + while (i < length) { + final c = source.codeUnitAt(i); + if (_isWhitespace(c) || + c == _comma || + c == _closeBrace || + c == _closeBracket) { + return i; + } + i++; + } + return length; +} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/streaming/message_processor.dart b/packages/flutter_ai/flutter_ai_core/lib/src/streaming/message_processor.dart new file mode 100644 index 0000000..ff982b3 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/lib/src/streaming/message_processor.dart @@ -0,0 +1,357 @@ +import 'package:flutter_ai_core/src/models/ai_conversation.dart'; +import 'package:flutter_ai_core/src/models/ai_message.dart'; +import 'package:flutter_ai_core/src/models/ai_part.dart'; +import 'package:flutter_ai_core/src/models/ai_role.dart'; +import 'package:flutter_ai_core/src/models/finish_reason.dart'; +import 'package:flutter_ai_core/src/models/tool_call_state.dart'; +import 'package:flutter_ai_core/src/streaming/ai_stream_event.dart'; +import 'package:flutter_ai_core/src/streaming/json_accumulator.dart'; +import 'package:flutter_ai_core/src/streaming/mutation_result.dart'; + +/// Folds a stream of [AiStreamEvent]s into evolving [AiConversation] state. +/// +/// The processor is a pure, synchronous, Flutter-free reducer: each [apply] +/// call returns the new conversation plus the ids of the messages that changed, +/// so a host can rebuild only those nodes. It does **no** scheduling itself — +/// batching updates to the frame boundary is the consumer's job, which keeps the +/// reducer testable and the package UI-agnostic. +/// +/// Tool-call arguments are accumulated per call and parsed tolerantly while +/// streaming (see [JsonAccumulator]); a [ToolCallReady] event triggers a strict +/// re-parse. Malformed arguments do not throw — the offending call is marked +/// [ToolCallState.error] and an error [ToolResultPart] is appended, so the rest +/// of the stream proceeds unaffected. +class MessageProcessor { + /// Creates a processor seeded with an optional starting [conversation]. + MessageProcessor({AiConversation? conversation}) + : _conversation = conversation ?? const AiConversation.empty('default'); + + AiConversation _conversation; + final Map _argAccumulators = {}; + final Map _toolCallToMessage = {}; + + /// The current conversation state. + AiConversation get conversation => _conversation; + + /// Resets the processor to [conversation], discarding streaming scratch state. + void reset(AiConversation conversation) { + _conversation = conversation; + _argAccumulators.clear(); + _toolCallToMessage.clear(); + } + + /// Applies [event] and returns the resulting [MutationResult]. + MutationResult apply(AiStreamEvent event) { + switch (event) { + case MessageStarted(:final messageId, :final role): + if (_conversation.messageById(messageId) == null) { + _conversation = _conversation.append( + AiMessage( + id: messageId, + role: role, + status: AiMessageStatus.streaming, + ), + ); + } + return _changed(messageId); + + case TextDelta(:final messageId, :final delta): + _mutate(messageId, (m) => _appendText(m, delta)); + return _changed(messageId); + + case ReasoningDelta(:final messageId, :final delta, :final signature): + _mutate(messageId, (m) => _appendReasoning(m, delta, signature)); + return _changed(messageId); + + case ToolCallStarted( + :final messageId, + :final toolCallId, + :final toolName + ): + _toolCallToMessage[toolCallId] = messageId; + _argAccumulators[toolCallId] = JsonAccumulator(); + _mutate( + messageId, + (m) => m.copyWith( + parts: [ + ...m.parts, + ToolCallPart(toolCallId: toolCallId, toolName: toolName), + ], + status: AiMessageStatus.streaming, + ), + ); + return _changed(messageId); + + case ToolCallDelta(:final toolCallId, :final argumentsDelta): + final messageId = _toolCallToMessage[toolCallId]; + final accumulator = _argAccumulators[toolCallId]; + if (messageId == null || accumulator == null) return _none(); + accumulator.add(argumentsDelta); + final partial = accumulator.parsePartial(); + _updateToolCall( + messageId, + toolCallId, + (p) => p.copyWith( + // Keep the last good partial args when this fragment isn't yet + // parseable, rather than clobbering to {} and flickering the UI. + args: partial is Map ? partial.cast() : p.args, + state: ToolCallState.inputStreaming, + ), + ); + return _changed(messageId); + + case ToolCallReady(:final toolCallId): + final messageId = _toolCallToMessage[toolCallId]; + final accumulator = _argAccumulators[toolCallId]; + if (messageId == null || accumulator == null) return _none(); + final parsed = accumulator.tryParseComplete(); + if (parsed is Map) { + _updateToolCall( + messageId, + toolCallId, + (p) => p.copyWith( + args: parsed.cast(), + state: ToolCallState.inputAvailable, + ), + ); + } else if (accumulator.raw.trim().isEmpty) { + // No arguments were streamed — a legitimate zero-argument tool call + // (e.g. `get_current_time`). Treat as empty args, not an error. + _updateToolCall( + messageId, + toolCallId, + (p) => p.copyWith( + args: const {}, + state: ToolCallState.inputAvailable, + ), + ); + } else { + // Malformed arguments: halt this call without crashing the stream. + _updateToolCall( + messageId, + toolCallId, + (p) => p.copyWith(state: ToolCallState.error), + ); + _mutate( + messageId, + (m) => m.copyWith( + parts: [ + ...m.parts, + ToolResultPart( + toolCallId: toolCallId, + result: 'Invalid tool arguments: ${accumulator.raw}', + isError: true, + ), + ], + ), + ); + } + return _changed(messageId); + + case ToolResultReceived( + :final messageId, + :final toolCallId, + :final result, + :final isError, + ): + // The call lives in the assistant message it was started on, which is + // usually *not* the message carrying the result (e.g. a separate + // tool-role message). Advance the call's state in its owning message. + // After a reset()/rehydration the in-memory map is empty, so fall back + // to scanning the conversation for the message that actually holds the + // matching ToolCallPart before using the result's own message id. + _updateToolCall( + _toolCallToMessage[toolCallId] ?? + _messageIdForToolCall(toolCallId) ?? + messageId, + toolCallId, + (p) => p.copyWith( + state: + isError ? ToolCallState.error : ToolCallState.outputAvailable, + ), + ); + _mutate( + messageId, + (m) => m.copyWith( + parts: [ + ...m.parts, + ToolResultPart( + toolCallId: toolCallId, + result: result, + isError: isError, + ), + ], + ), + ); + return _changed(messageId); + + case PartReceived(:final messageId, :final part): + _mutate( + messageId, + (m) => m.copyWith( + parts: [...m.parts, part], + status: AiMessageStatus.streaming, + ), + ); + return _changed(messageId); + + case MessageFinished(:final messageId, :final reason, :final usage): + _mutate( + messageId, + (m) => m.copyWith( + // Freeze any streaming buffer into a plain, detached part so the + // settled transcript message never pins a live StringBuffer. + parts: _freezeBuffers(m.parts), + status: reason == FinishReason.error + ? AiMessageStatus.error + : AiMessageStatus.complete, + finishReason: reason, + usage: usage, + ), + ); + return _changed(messageId); + + case StreamErrorEvent(:final messageId, :final toolCallId): + // A tool-scoped error fails only that call; generation continues, so + // don't mark the whole message errored (matches UseChatController). + if (toolCallId != null) { + final callMessageId = _toolCallToMessage[toolCallId]; + if (callMessageId == null) return _none(); + _updateToolCall( + callMessageId, + toolCallId, + (p) => p.copyWith(state: ToolCallState.error), + ); + return _changed(callMessageId); + } + if (messageId == null) return _none(); + _mutate( + messageId, + (m) => m.copyWith( + status: AiMessageStatus.error, + finishReason: FinishReason.error, + ), + ); + return _changed(messageId); + } + } + + /// Ensures a message with [messageId] exists, applies [transform], and stores + /// the result. A missing message is created as a streaming [roleIfAbsent] + /// message, so a content event that arrives without a [MessageStarted] still + /// works. + void _mutate( + String messageId, + AiMessage Function(AiMessage message) transform, { + AiRole roleIfAbsent = AiRole.assistant, + }) { + final existing = _conversation.messageById(messageId) ?? + AiMessage( + id: messageId, + role: roleIfAbsent, + status: AiMessageStatus.streaming, + ); + _conversation = _conversation.replace(transform(existing)); + } + + /// Finds the id of the message whose parts contain a [ToolCallPart] with + /// [toolCallId], or `null` if no such message exists. Used to recover the + /// call→message mapping that lives only in memory when results arrive after a + /// [reset] or against a seeded conversation. + String? _messageIdForToolCall(String toolCallId) { + for (final message in _conversation.messages) { + for (final part in message.parts) { + if (part is ToolCallPart && part.toolCallId == toolCallId) { + return message.id; + } + } + } + return null; + } + + void _updateToolCall( + String messageId, + String toolCallId, + ToolCallPart Function(ToolCallPart part) transform, + ) { + _mutate(messageId, (m) { + final parts = [...m.parts]; + final index = parts.indexWhere( + (p) => p is ToolCallPart && p.toolCallId == toolCallId, + ); + if (index == -1) return m; + parts[index] = transform(parts[index] as ToolCallPart); + return m.copyWith(parts: parts); + }); + } + + // Text and reasoning deltas accumulate into a per-part [StringBuffer] rather + // than `last.text + delta`, which would reallocate the whole accumulated + // string on every token (quadratic on long answers). The buffer is appended + // to in place — O(delta) — and the resulting String is materialized lazily, + // only when a consumer reads `TextPart.text`/`ReasoningPart.text`. A buffered + // part already at the tail carries its buffer, so we keep writing to it; a + // plain part (e.g. rehydrated from a stored String) seeds a fresh buffer with + // its current text on the first delta. A non-text part at the tail forces a + // new buffer, so buffers never merge across a part boundary. + + AiMessage _appendText(AiMessage message, String delta) { + final parts = [...message.parts]; + final last = parts.isEmpty ? null : parts.last; + if (last is TextPart) { + final buffer = last.buffer ?? (StringBuffer()..write(last.text)); + buffer.write(delta); + parts[parts.length - 1] = TextPart.buffered(buffer); + } else { + parts.add(TextPart.buffered(StringBuffer()..write(delta))); + } + return message.copyWith(parts: parts, status: AiMessageStatus.streaming); + } + + AiMessage _appendReasoning(AiMessage message, String delta, [String? sig]) { + final parts = [...message.parts]; + final last = parts.isEmpty ? null : parts.last; + if (last is ReasoningPart) { + final buffer = last.buffer ?? (StringBuffer()..write(last.text)); + buffer.write(delta); + parts[parts.length - 1] = ReasoningPart.buffered( + buffer, + signature: sig ?? last.signature, + ); + } else { + parts.add( + ReasoningPart.buffered(StringBuffer()..write(delta), signature: sig)); + } + return message.copyWith(parts: parts, status: AiMessageStatus.streaming); + } + + /// Materializes any still-buffered [TextPart]/[ReasoningPart] into plain, + /// detached parts. Called when a message settles so the stored transcript + /// holds ordinary value objects rather than references to a live buffer. + List _freezeBuffers(List parts) { + var changed = false; + final frozen = []; + for (final part in parts) { + if (part is TextPart && part.buffer != null) { + frozen.add(TextPart(part.text)); + changed = true; + } else if (part is ReasoningPart && part.buffer != null) { + frozen.add(ReasoningPart(part.text, signature: part.signature)); + changed = true; + } else { + frozen.add(part); + } + } + return changed ? frozen : parts; + } + + MutationResult _changed(String messageId) => MutationResult( + conversation: _conversation, + changedMessageIds: {messageId}, + ); + + MutationResult _none() => MutationResult( + conversation: _conversation, + changedMessageIds: const {}, + ); +} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/streaming/mutation_result.dart b/packages/flutter_ai/flutter_ai_core/lib/src/streaming/mutation_result.dart new file mode 100644 index 0000000..cf85719 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/lib/src/streaming/mutation_result.dart @@ -0,0 +1,28 @@ +import 'package:flutter_ai_core/src/models/ai_conversation.dart'; + +/// The outcome of applying one stream event to a `MessageProcessor`. +/// +/// Carries the updated [conversation] and the set of message ids that changed. +/// A UI binds the latter to rebuild only the affected messages — never the whole +/// transcript — which is what keeps streaming at frame rate. +final class MutationResult { + /// Creates a mutation result. + const MutationResult({ + required this.conversation, + required this.changedMessageIds, + }); + + /// The conversation after the event was applied. + final AiConversation conversation; + + /// The ids of messages whose content changed. Empty when the event was a + /// no-op (for example, an event referencing an unknown id). + final Set changedMessageIds; + + /// Whether the event changed any message. + bool get hasChanges => changedMessageIds.isNotEmpty; + + @override + String toString() => 'MutationResult(changed: $changedMessageIds, ' + 'messages: ${conversation.messages.length})'; +} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/tools/json_schema_validator.dart b/packages/flutter_ai/flutter_ai_core/lib/src/tools/json_schema_validator.dart new file mode 100644 index 0000000..4fd48e7 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/lib/src/tools/json_schema_validator.dart @@ -0,0 +1,190 @@ +/// A tiny, dependency-free validator for the subset of JSON Schema that LLM +/// tool/function declarations actually use. +/// +/// This is intentionally *not* a full JSON Schema implementation. It covers the +/// keywords providers emit for tool parameters — `type`, `properties`, +/// `required`, `items`, `enum`, `additionalProperties: false`, and the common +/// numeric/string/array bounds — which is enough to catch the malformed +/// arguments a model occasionally produces and to feed an actionable error back +/// so it can correct itself. +/// +/// [validateJsonSchema] returns a list of human-readable violation messages; +/// an empty list means the value satisfies the schema. Unknown keywords are +/// ignored (treated as "no constraint") rather than rejected, so a richer +/// server-side schema never produces false negatives here. +library; + +/// Validates [value] against [schema], returning a list of violation messages +/// (empty when valid). [path] names the root in messages (defaults to `args`). +List validateJsonSchema( + Object? value, + Map schema, { + String path = 'args', +}) { + final errors = []; + _validate(value, schema, path, errors); + return errors; +} + +void _validate( + Object? value, + Map schema, + String path, + List errors, +) { + // An empty schema imposes no constraints. + if (schema.isEmpty) return; + + final type = schema['type']; + if (type != null && !_typeMatches(value, type)) { + errors.add('$path: expected type $type but got ${_typeName(value)}'); + // A type mismatch makes deeper checks meaningless. + return; + } + + final enumValues = schema['enum']; + if (enumValues is List && !enumValues.any((e) => _deepEq(e, value))) { + errors.add('$path: must be one of $enumValues'); + } + + switch (value) { + case final num n: + _validateNumber(n, schema, path, errors); + case final String s: + _validateString(s, schema, path, errors); + case final List list: + _validateArray(list, schema, path, errors); + case final Map map: + _validateObject(map.cast(), schema, path, errors); + } +} + +void _validateNumber( + num n, + Map schema, + String path, + List errors, +) { + final min = schema['minimum']; + if (min is num && n < min) errors.add('$path: must be >= $min'); + final max = schema['maximum']; + if (max is num && n > max) errors.add('$path: must be <= $max'); +} + +void _validateString( + String s, + Map schema, + String path, + List errors, +) { + final minLen = schema['minLength']; + if (minLen is int && s.length < minLen) { + errors.add('$path: must be at least $minLen characters'); + } + final maxLen = schema['maxLength']; + if (maxLen is int && s.length > maxLen) { + errors.add('$path: must be at most $maxLen characters'); + } +} + +void _validateArray( + List list, + Map schema, + String path, + List errors, +) { + final minItems = schema['minItems']; + if (minItems is int && list.length < minItems) { + errors.add('$path: must have at least $minItems items'); + } + final maxItems = schema['maxItems']; + if (maxItems is int && list.length > maxItems) { + errors.add('$path: must have at most $maxItems items'); + } + final items = schema['items']; + if (items is Map) { + for (var i = 0; i < list.length; i++) { + _validate(list[i], items, '$path[$i]', errors); + } + } +} + +void _validateObject( + Map map, + Map schema, + String path, + List errors, +) { + final required = schema['required']; + if (required is List) { + for (final key in required) { + if (key is String && !map.containsKey(key)) { + errors.add('$path: missing required property "$key"'); + } + } + } + + final properties = schema['properties']; + if (properties is Map) { + properties.forEach((key, propSchema) { + if (propSchema is Map && map.containsKey(key)) { + _validate(map[key], propSchema, '$path.$key', errors); + } + }); + } + + // additionalProperties: false rejects keys not named in `properties`. + if (schema['additionalProperties'] == false && properties is Map) { + final allowed = properties.keys.toSet(); + for (final key in map.keys) { + if (!allowed.contains(key)) { + errors.add('$path: unexpected property "$key"'); + } + } + } +} + +bool _typeMatches(Object? value, Object? type) { + // JSON Schema allows a union of types as a list. + if (type is List) return type.any((t) => _typeMatches(value, t)); + return switch (type) { + 'object' => value is Map, + 'array' => value is List, + 'string' => value is String, + 'integer' => value is int, + 'number' => value is num, + 'boolean' => value is bool, + 'null' => value == null, + _ => true, // unknown type keyword: don't constrain + }; +} + +String _typeName(Object? value) => switch (value) { + null => 'null', + Map() => 'object', + List() => 'array', + String() => 'string', + int() => 'integer', + num() => 'number', + bool() => 'boolean', + _ => value.runtimeType.toString(), + }; + +bool _deepEq(Object? a, Object? b) { + if (identical(a, b)) return true; + if (a is List && b is List) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (!_deepEq(a[i], b[i])) return false; + } + return true; + } + if (a is Map && b is Map) { + if (a.length != b.length) return false; + for (final key in a.keys) { + if (!b.containsKey(key) || !_deepEq(a[key], b[key])) return false; + } + return true; + } + return a == b; +} diff --git a/packages/flutter_ai/flutter_ai_core/pubspec.yaml b/packages/flutter_ai/flutter_ai_core/pubspec.yaml new file mode 100644 index 0000000..2a635d5 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/pubspec.yaml @@ -0,0 +1,34 @@ +name: flutter_ai_core +description: "Dependency-free Dart foundation for AI chat: message models, a streaming MessageProcessor, and the provider and renderer contracts the flutter_ai family builds on." +version: 0.1.14 +homepage: https://github.com/ananmouaz/flutter_ai +repository: https://github.com/ananmouaz/flutter_ai/tree/main/packages/flutter_ai_core +issue_tracker: https://github.com/ananmouaz/flutter_ai/issues +topics: + - ai + - llm + - chat + - streaming + - flutter + +environment: + sdk: ^3.6.0 + +platforms: + android: + ios: + linux: + macos: + web: + windows: + +# Part of the flutter_ai workspace; dependencies resolve from the workspace root. +resolution: workspace + +# Intentionally no runtime dependencies. flutter_ai_core relies solely on +# dart:core and dart:convert so downstream apps never face version conflicts +# with build_runner, codegen, or a UI framework. + +dev_dependencies: + lints: ^5.0.0 + test: ^1.25.0 diff --git a/packages/flutter_ai/flutter_ai_core/test/ai_capabilities_test.dart b/packages/flutter_ai/flutter_ai_core/test/ai_capabilities_test.dart new file mode 100644 index 0000000..9d86c2f --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/test/ai_capabilities_test.dart @@ -0,0 +1,139 @@ +import 'package:flutter_ai_core/flutter_ai_core.dart'; +import 'package:test/test.dart'; + +/// A fake provider that replays a fixed list of [AiStreamEvent]s, so the +/// structured-output helpers can be exercised without any network. +class _FakeProvider implements LlmProvider { + _FakeProvider(this.events); + + final List events; + AiRequestOptions? lastOptions; + + @override + Stream send( + AiConversation conversation, { + List? tools, + AiRequestOptions? options, + }) async* { + lastOptions = options; + for (final event in events) { + yield event; + } + } +} + +/// Splits [text] into individual character TextDeltas, simulating streaming. +List _streamText(String text, {String id = 'm1'}) => [ + MessageStarted(messageId: id, role: AiRole.assistant), + for (final char in text.split('')) TextDelta(messageId: id, delta: char), + const MessageFinished(messageId: 'm1', reason: FinishReason.stop), + ]; + +void main() { + group('AiEmbedding', () { + test('value equality over values and index', () { + expect( + const AiEmbedding([1, 2, 3], index: 0), + const AiEmbedding([1, 2, 3], index: 0), + ); + expect( + const AiEmbedding([1, 2, 3], index: 0).hashCode, + const AiEmbedding([1, 2, 3], index: 0).hashCode, + ); + expect( + const AiEmbedding([1, 2, 3], index: 0), + isNot(const AiEmbedding([1, 2, 4], index: 0)), + ); + expect( + const AiEmbedding([1, 2, 3], index: 0), + isNot(const AiEmbedding([1, 2, 3], index: 1)), + ); + }); + + test('toString reports dimensions and index', () { + expect( + const AiEmbedding([1, 2, 3], index: 2).toString(), + 'AiEmbedding(3 dims, index: 2)', + ); + }); + }); + + group('generateObject', () { + const format = AiResponseFormat( + schema: { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + 'age': {'type': 'integer'}, + }, + }, + ); + + test('decodes the streamed JSON text into a Map', () async { + final provider = _FakeProvider(_streamText('{"name":"Ada","age":36}')); + + final object = await provider.generateObject( + const AiConversation.empty('c'), + format: format, + ); + + expect(object, {'name': 'Ada', 'age': 36}); + }); + + test('sets responseFormat on the merged options', () async { + final provider = _FakeProvider(_streamText('{}')); + + await provider.generateObject( + const AiConversation.empty('c'), + format: format, + options: const AiRequestOptions(model: 'gpt-test', temperature: 0.2), + ); + + expect(provider.lastOptions?.responseFormat, format); + // Pre-existing fields are preserved when merging. + expect(provider.lastOptions?.model, 'gpt-test'); + expect(provider.lastOptions?.temperature, 0.2); + }); + + test('throws FormatException with the raw text on a parse failure', + () async { + final provider = _FakeProvider(_streamText('not json')); + + await expectLater( + provider.generateObject( + const AiConversation.empty('c'), + format: format, + ), + throwsA( + isA().having( + (e) => e.source, + 'source', + 'not json', + ), + ), + ); + }); + }); + + group('streamObject', () { + const format = AiResponseFormat(schema: {'type': 'object'}); + + test('yields growing prefixes ending in the complete object', () async { + final provider = _FakeProvider(_streamText('{"a":1,"b":2}')); + + final frames = await provider + .streamObject(const AiConversation.empty('c'), format: format) + .toList(); + + // The final frame is the complete object. + expect(frames.last, {'a': 1, 'b': 2}); + // Every frame is a (growing) prefix: each is a submap of the next. + for (var i = 0; i < frames.length - 1; i++) { + for (final entry in frames[i].entries) { + expect(frames[i + 1][entry.key], entry.value); + } + expect(frames[i].length, lessThanOrEqualTo(frames[i + 1].length)); + } + }); + }); +} diff --git a/packages/flutter_ai/flutter_ai_core/test/ai_stream_event_test.dart b/packages/flutter_ai/flutter_ai_core/test/ai_stream_event_test.dart new file mode 100644 index 0000000..c5f251b --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/test/ai_stream_event_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_ai_core/flutter_ai_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('AiStreamEvent JSON round-trips', () { + final events = [ + const MessageStarted(messageId: 'm1', role: AiRole.assistant), + const TextDelta(messageId: 'm1', delta: 'hello'), + const ReasoningDelta(messageId: 'm1', delta: 'because'), + const ToolCallStarted( + messageId: 'm1', + toolCallId: 'c1', + toolName: 'search', + ), + const ToolCallDelta(toolCallId: 'c1', argumentsDelta: '{"q":'), + const ToolCallReady(toolCallId: 'c1'), + const ToolResultReceived( + messageId: 'm1', + toolCallId: 'c1', + result: {'hits': 3}, + ), + const PartReceived( + messageId: 'm1', + part: DataPart(dataType: 'card', data: {'k': 'v'}), + ), + const MessageFinished(messageId: 'm1', reason: FinishReason.stop), + const StreamErrorEvent(error: 'boom', messageId: 'm1', toolCallId: 'c1'), + ]; + + for (final event in events) { + test('${event.runtimeType}', () { + final decoded = AiStreamEvent.fromJson(event.toJson()); + expect(decoded, event); + expect(decoded.runtimeType, event.runtimeType); + }); + } + + test('rejects an unknown event type', () { + expect( + () => AiStreamEvent.fromJson({'type': 'unknown'}), + throwsFormatException, + ); + }); + + test('error event restores the message form of the error', () { + const event = StreamErrorEvent(error: 'boom'); + final decoded = AiStreamEvent.fromJson(event.toJson()); + expect(decoded, event); + }); + }); +} diff --git a/packages/flutter_ai/flutter_ai_core/test/json_accumulator_test.dart b/packages/flutter_ai/flutter_ai_core/test/json_accumulator_test.dart new file mode 100644 index 0000000..fd54e4f --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/test/json_accumulator_test.dart @@ -0,0 +1,134 @@ +import 'package:flutter_ai_core/flutter_ai_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('JsonAccumulator.tryParseComplete', () { + test('returns null for an empty buffer', () { + expect(JsonAccumulator().tryParseComplete(), isNull); + }); + + test('returns null while the JSON is incomplete', () { + final acc = JsonAccumulator()..add('{"city":"Lon'); + expect(acc.tryParseComplete(), isNull); + }); + + test('decodes a complete document', () { + final acc = JsonAccumulator()..add('{"city":"London","days":3}'); + expect(acc.tryParseComplete(), {'city': 'London', 'days': 3}); + }); + + test('reassembles fragments added across calls', () { + final acc = JsonAccumulator() + ..add('{"ci') + ..add('ty":"Lon') + ..add('don"}'); + expect(acc.tryParseComplete(), {'city': 'London'}); + }); + }); + + group('JsonAccumulator.parsePartial', () { + test('returns null before anything is added', () { + expect(JsonAccumulator().parsePartial(), isNull); + }); + + test('closes an object missing its final brace', () { + final acc = JsonAccumulator()..add('{"city":"London"'); + expect(acc.parsePartial(), {'city': 'London'}); + }); + + test('keeps complete members and drops a dangling key', () { + final acc = JsonAccumulator()..add('{"a":1,"b":'); + expect(acc.parsePartial(), {'a': 1}); + }); + + test('drops an unterminated trailing numeric literal', () { + // `2` is not yet delimited, so it might be a prefix of `25` — surfacing + // it would violate the prefix contract. It is dropped until terminated. + final acc = JsonAccumulator()..add('{"a":1,"b":2'); + expect(acc.parsePartial(), {'a': 1}); + }); + + test('keeps a numeric value once a delimiter terminates it', () { + final acc = JsonAccumulator()..add('{"n": 1234'); + // No delimiter yet: the literal is treated as incomplete. + expect(acc.parsePartial(), {}); + // A comma terminates it: now it is safe to surface. + acc.add(','); + expect(acc.parsePartial(), {'n': 1234}); + }); + + test('keeps a numeric value terminated by a closing brace', () { + final acc = JsonAccumulator()..add('{"n": 1234}'); + expect(acc.parsePartial(), {'n': 1234}); + }); + + test('drops an unterminated trailing keyword literal', () { + // `tru` could complete to `true`; an undelimited keyword is incomplete. + final acc = JsonAccumulator()..add('{"ok":tru'); + expect(acc.parsePartial(), {}); + final acc2 = JsonAccumulator()..add('{"ok":true'); + // Still no delimiter after `true`, so it stays incomplete until one lands. + expect(acc2.parsePartial(), {}); + acc2.add('}'); + expect(acc2.parsePartial(), {'ok': true}); + }); + + test('drops a partially streamed string value', () { + final acc = JsonAccumulator()..add('{"city":"Lon'); + expect(acc.parsePartial(), {}); + }); + + test('closes a partial array dropping its undelimited last element', () { + // `3` is not yet delimited, so it is excluded until a delimiter lands. + final acc = JsonAccumulator()..add('[1,2,3'); + expect(acc.parsePartial(), [1, 2]); + acc.add(']'); + expect(acc.parsePartial(), [1, 2, 3]); + }); + + test('handles nested objects', () { + // `1` is undelimited, so the inner member is dropped until terminated. + final acc = JsonAccumulator()..add('{"a":{"b":1'); + expect(acc.parsePartial(), {'a': {}}); + acc.add('}'); + expect(acc.parsePartial(), { + 'a': {'b': 1}, + }); + }); + + test('handles an array of objects with a trailing partial element', () { + final acc = JsonAccumulator()..add('[{"x":1},{"y":'); + expect(acc.parsePartial(), [ + {'x': 1}, + {}, + ]); + }); + + test('respects escaped quotes inside strings', () { + final acc = JsonAccumulator()..add(r'{"msg":"he said \"hi\""'); + expect(acc.parsePartial(), {'msg': 'he said "hi"'}); + }); + + test('returns the already-valid document unchanged', () { + final acc = JsonAccumulator()..add('{"done":true}'); + expect(acc.parsePartial(), {'done': true}); + }); + + test('falls back to the last good partial when a fragment regresses', () { + final acc = JsonAccumulator()..add('{"a":1,"b":2,'); + expect(acc.parsePartial(), {'a': 1, 'b': 2}); + // A lone open quote cannot be repaired to anything new; the previous + // partial is retained rather than regressing to {}. + acc.add('"c":"'); + expect(acc.parsePartial(), {'a': 1, 'b': 2}); + }); + + test('reset clears buffer and cached partial', () { + final acc = JsonAccumulator()..add('{"a":1}'); + expect(acc.parsePartial(), {'a': 1}); + acc.reset(); + expect(acc.isEmpty, isTrue); + expect(acc.parsePartial(), isNull); + }); + }); +} diff --git a/packages/flutter_ai/flutter_ai_core/test/json_schema_validator_test.dart b/packages/flutter_ai/flutter_ai_core/test/json_schema_validator_test.dart new file mode 100644 index 0000000..40a4198 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/test/json_schema_validator_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter_ai_core/flutter_ai_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('validateJsonSchema', () { + const objectSchema = { + 'type': 'object', + 'properties': { + 'city': {'type': 'string'}, + 'days': {'type': 'integer', 'minimum': 1, 'maximum': 14}, + 'unit': { + 'type': 'string', + 'enum': ['c', 'f'], + }, + }, + 'required': ['city'], + 'additionalProperties': false, + }; + + test('accepts a valid object', () { + expect( + validateJsonSchema( + {'city': 'Lisbon', 'days': 3, 'unit': 'c'}, objectSchema), + isEmpty, + ); + }); + + test('an empty schema imposes no constraints', () { + expect(validateJsonSchema({'anything': true}, const {}), isEmpty); + }); + + test('reports a missing required property', () { + final errors = validateJsonSchema({'days': 2}, objectSchema); + expect(errors, hasLength(1)); + expect(errors.single, contains('missing required property "city"')); + }); + + test('reports a type mismatch with a path', () { + final errors = validateJsonSchema({'city': 123}, objectSchema); + expect(errors, contains(contains('args.city: expected type string'))); + }); + + test('reports an out-of-range number', () { + final errors = + validateJsonSchema({'city': 'x', 'days': 99}, objectSchema); + expect(errors, contains(contains('args.days: must be <= 14'))); + }); + + test('reports an enum violation', () { + final errors = + validateJsonSchema({'city': 'x', 'unit': 'k'}, objectSchema); + expect(errors, contains(contains('args.unit: must be one of'))); + }); + + test('rejects unexpected properties when additionalProperties is false', + () { + final errors = + validateJsonSchema({'city': 'x', 'extra': 1}, objectSchema); + expect(errors, contains(contains('unexpected property "extra"'))); + }); + + test('validates array items and bounds', () { + const arraySchema = { + 'type': 'array', + 'minItems': 1, + 'items': {'type': 'string'}, + }; + expect(validateJsonSchema(['a', 'b'], arraySchema), isEmpty); + expect(validateJsonSchema(const [], arraySchema), + contains(contains('at least 1 items'))); + expect( + validateJsonSchema(['a', 2], arraySchema), + contains(contains('args[1]: expected type string')), + ); + }); + + test('integer vs number: a double is not an integer', () { + expect( + validateJsonSchema(1.5, const {'type': 'integer'}), + isNotEmpty, + ); + expect(validateJsonSchema(1.5, const {'type': 'number'}), isEmpty); + }); + + test('accepts a union type list', () { + const schema = { + 'type': ['string', 'null'] + }; + expect(validateJsonSchema(null, schema), isEmpty); + expect(validateJsonSchema('x', schema), isEmpty); + expect(validateJsonSchema(5, schema), isNotEmpty); + }); + + test('string length bounds', () { + const schema = {'type': 'string', 'minLength': 2, 'maxLength': 4}; + expect(validateJsonSchema('ab', schema), isEmpty); + expect(validateJsonSchema('a', schema), + contains(contains('at least 2 characters'))); + expect(validateJsonSchema('abcde', schema), + contains(contains('at most 4 characters'))); + }); + }); +} diff --git a/packages/flutter_ai/flutter_ai_core/test/message_processor_perf_test.dart b/packages/flutter_ai/flutter_ai_core/test/message_processor_perf_test.dart new file mode 100644 index 0000000..07c7d65 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/test/message_processor_perf_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_ai_core/flutter_ai_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('MessageProcessor perf', () { + test('accumulates 20000 single-char deltas in linear time', () { + // Regression guard against O(n^2) text accumulation. The processor keeps + // a StringBuffer per text part and appends in place (O(delta) per token), + // so 20000 single-char deltas finish in milliseconds. A regression to + // `last.text + delta` would re-copy the whole accumulated string on every + // token — ~20000^2 / 2 ≈ 200M char-copies — taking many seconds. + // + // The 2-second bound is deliberately GENEROUS: it sits far above the + // real (linear, sub-10ms) runtime yet far below a quadratic blow-up, so + // it distinguishes the two without flaking on slow shared CI runners. + const deltaCount = 20000; + + final processor = MessageProcessor(); + processor.apply( + const MessageStarted(messageId: 'm1', role: AiRole.assistant), + ); + + final stopwatch = Stopwatch()..start(); + for (var i = 0; i < deltaCount; i++) { + processor.apply(const TextDelta(messageId: 'm1', delta: 'x')); + } + processor.apply( + const MessageFinished(messageId: 'm1', reason: FinishReason.stop), + ); + stopwatch.stop(); + + expect( + stopwatch.elapsed, + lessThan(const Duration(seconds: 2)), + reason: 'linear accumulation finishes in ms; a quadratic regression ' + 'would take many seconds (took ${stopwatch.elapsedMilliseconds}ms)', + ); + + // The accumulated text must be exactly the concatenation of every delta. + final message = processor.conversation.messageById('m1')!; + expect(message.text.length, deltaCount); + expect(message.status, AiMessageStatus.complete); + }); + }); +} diff --git a/packages/flutter_ai/flutter_ai_core/test/message_processor_test.dart b/packages/flutter_ai/flutter_ai_core/test/message_processor_test.dart new file mode 100644 index 0000000..8e5f9d2 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/test/message_processor_test.dart @@ -0,0 +1,396 @@ +import 'package:flutter_ai_core/flutter_ai_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('MessageProcessor text streaming', () { + test('concatenates text deltas into one part', () { + final processor = MessageProcessor(); + processor.apply( + const MessageStarted(messageId: 'm1', role: AiRole.assistant), + ); + processor.apply(const TextDelta(messageId: 'm1', delta: 'Hel')); + final result = processor.apply( + const TextDelta(messageId: 'm1', delta: 'lo'), + ); + + final message = result.conversation.messageById('m1')!; + expect(message.parts, const [TextPart('Hello')]); + expect(message.status, AiMessageStatus.streaming); + expect(result.changedMessageIds, {'m1'}); + }); + + test('auto-creates an assistant message without a start event', () { + final processor = MessageProcessor(); + processor.apply(const TextDelta(messageId: 'm1', delta: 'hi')); + final message = processor.conversation.messageById('m1')!; + expect(message.role, AiRole.assistant); + expect(message.text, 'hi'); + }); + + test('finishing sets status and finishReason', () { + final processor = MessageProcessor(); + processor.apply(const TextDelta(messageId: 'm1', delta: 'done')); + processor.apply( + const MessageFinished(messageId: 'm1', reason: FinishReason.stop), + ); + final message = processor.conversation.messageById('m1')!; + expect(message.status, AiMessageStatus.complete); + expect(message.finishReason, FinishReason.stop); + }); + + test('a returned snapshot is frozen — later deltas do not mutate it', () { + final processor = MessageProcessor(); + processor.apply( + const MessageStarted(messageId: 'm1', role: AiRole.assistant), + ); + final before = + processor.apply(const TextDelta(messageId: 'm1', delta: 'Hello')); + final beforeMessage = before.conversation.messageById('m1')!; + // Capture the text of the earlier snapshot. + expect(beforeMessage.text, 'Hello'); + + final after = + processor.apply(const TextDelta(messageId: 'm1', delta: ' world')); + + // The earlier snapshot must NOT observe the later append (no retroactive + // mutation through the shared buffer). + expect(beforeMessage.text, 'Hello'); + expect(after.conversation.messageById('m1')!.text, 'Hello world'); + }); + + test('consecutive streaming snapshots compare unequal (value semantics)', + () { + final processor = MessageProcessor(); + processor.apply( + const MessageStarted(messageId: 'm1', role: AiRole.assistant), + ); + final first = processor + .apply(const TextDelta(messageId: 'm1', delta: 'Hel')) + .conversation; + final second = processor + .apply(const TextDelta(messageId: 'm1', delta: 'lo')) + .conversation; + + // A consumer that dedupes by equality (Bloc/Riverpod/distinct) must see a + // change between the two streamed snapshots. + expect(first == second, isFalse); + expect(first.messageById('m1') == second.messageById('m1'), isFalse); + }); + + test('finishing freezes the buffer into a plain, detached TextPart', () { + final processor = MessageProcessor(); + processor.apply(const TextDelta(messageId: 'm1', delta: 'done')); + processor.apply( + const MessageFinished(messageId: 'm1', reason: FinishReason.stop), + ); + final part = processor.conversation.messageById('m1')!.parts.single; + expect(part, isA().having((p) => p.buffer, 'buffer', isNull)); + expect((part as TextPart).text, 'done'); + }); + + test('reasoning deltas accumulate in a ReasoningPart', () { + final processor = MessageProcessor(); + processor.apply(const ReasoningDelta(messageId: 'm1', delta: 'be')); + processor.apply(const ReasoningDelta(messageId: 'm1', delta: 'cause')); + final part = processor.conversation.messageById('m1')!.parts.single; + expect(part, const ReasoningPart('because')); + }); + + test('thousands of small deltas materialize to the exact concatenation', + () { + final processor = MessageProcessor(); + processor.apply( + const MessageStarted(messageId: 'm1', role: AiRole.assistant), + ); + final expected = StringBuffer(); + for (var i = 0; i < 5000; i++) { + final token = 'tok$i '; + expected.write(token); + processor.apply(TextDelta(messageId: 'm1', delta: token)); + } + processor.apply( + const MessageFinished(messageId: 'm1', reason: FinishReason.stop), + ); + + final message = processor.conversation.messageById('m1')!; + expect(message.parts, [TextPart(expected.toString())]); + expect(message.text, expected.toString()); + expect(message.status, AiMessageStatus.complete); + }); + + test('a text part rehydrated from a String keeps accumulating correctly', + () { + const seed = AiConversation( + id: 'c1', + messages: [ + AiMessage( + id: 'm1', + role: AiRole.assistant, + parts: [TextPart('Hello')], + status: AiMessageStatus.streaming, + ), + ], + ); + final processor = MessageProcessor(conversation: seed); + processor.apply(const TextDelta(messageId: 'm1', delta: ', world')); + expect(processor.conversation.messageById('m1')!.text, 'Hello, world'); + }); + + test('text after a tool call lands in a separate part, never merged', () { + final processor = MessageProcessor(); + processor.apply(const TextDelta(messageId: 'm1', delta: 'before ')); + processor.apply( + const ToolCallStarted( + messageId: 'm1', + toolCallId: 'c1', + toolName: 'noop', + ), + ); + processor.apply(const TextDelta(messageId: 'm1', delta: 'after')); + + final parts = processor.conversation.messageById('m1')!.parts; + expect( + parts.whereType().map((p) => p.text), ['before ', 'after']); + }); + }); + + group('MessageProcessor tool calls', () { + test('streams arguments then validates on ready', () { + final processor = MessageProcessor(); + processor.apply( + const ToolCallStarted( + messageId: 'm1', + toolCallId: 'c1', + toolName: 'get_weather', + ), + ); + processor.apply( + const ToolCallDelta(toolCallId: 'c1', argumentsDelta: '{"city":"Lon'), + ); + + var call = _firstToolCall(processor); + expect(call.state, ToolCallState.inputStreaming); + + processor.apply( + const ToolCallDelta(toolCallId: 'c1', argumentsDelta: 'don"}'), + ); + processor.apply(const ToolCallReady(toolCallId: 'c1')); + + call = _firstToolCall(processor); + expect(call.state, ToolCallState.inputAvailable); + expect(call.args, {'city': 'London'}); + }); + + test('appends a result and advances the call state', () { + final processor = MessageProcessor(); + processor.apply( + const ToolCallStarted( + messageId: 'm1', + toolCallId: 'c1', + toolName: 'get_weather', + ), + ); + processor.apply( + const ToolResultReceived( + messageId: 'm1', + toolCallId: 'c1', + result: {'tempC': 21}, + ), + ); + + final message = processor.conversation.messageById('m1')!; + expect(_firstToolCall(processor).state, ToolCallState.outputAvailable); + final resultPart = message.parts.whereType().single; + expect(resultPart.result, {'tempC': 21}); + expect(resultPart.isError, isFalse); + }); + + test('malformed arguments mark the call errored without throwing', () { + final processor = MessageProcessor(); + processor.apply( + const ToolCallStarted( + messageId: 'm1', + toolCallId: 'c1', + toolName: 'broken', + ), + ); + processor.apply( + const ToolCallDelta(toolCallId: 'c1', argumentsDelta: '{not json'), + ); + + expect( + () => processor.apply(const ToolCallReady(toolCallId: 'c1')), + returnsNormally, + ); + + final message = processor.conversation.messageById('m1')!; + expect(_firstToolCall(processor).state, ToolCallState.error); + final errorResult = message.parts.whereType().single; + expect(errorResult.isError, isTrue); + }); + + test('a delta for an unknown call is a no-op', () { + final processor = MessageProcessor(); + final result = processor.apply( + const ToolCallDelta(toolCallId: 'ghost', argumentsDelta: '{}'), + ); + expect(result.hasChanges, isFalse); + expect(result.conversation.messages, isEmpty); + }); + }); + + group('MessageProcessor errors and lifecycle', () { + test('scoped stream error marks the message errored', () { + final processor = MessageProcessor(); + processor.apply(const TextDelta(messageId: 'm1', delta: 'partial')); + processor.apply( + const StreamErrorEvent(error: 'boom', messageId: 'm1'), + ); + expect( + processor.conversation.messageById('m1')!.status, + AiMessageStatus.error, + ); + }); + + test('an unscoped stream error changes nothing', () { + final processor = MessageProcessor(); + final result = + processor.apply(const StreamErrorEvent(error: 'transport down')); + expect(result.hasChanges, isFalse); + }); + + test('reset restores a seed conversation and clears scratch state', () { + final processor = MessageProcessor(); + processor.apply(const TextDelta(messageId: 'm1', delta: 'hi')); + processor.reset(const AiConversation.empty('fresh')); + expect(processor.conversation.id, 'fresh'); + expect(processor.conversation.messages, isEmpty); + }); + + test('seeds from an existing conversation', () { + const seed = AiConversation( + id: 'c1', + messages: [ + AiMessage(id: 'm1', role: AiRole.user, parts: [TextPart('q')]), + ], + ); + final processor = MessageProcessor(conversation: seed); + processor.apply(const TextDelta(messageId: 'm2', delta: 'a')); + expect(processor.conversation.messages.map((m) => m.id), ['m1', 'm2']); + }); + }); + + group('MessageProcessor tool fixes', () { + ToolCallPart callOf(MutationResult r, String mid, String cid) => + r.conversation + .messageById(mid)! + .parts + .whereType() + .firstWhere((p) => p.toolCallId == cid); + + test('a zero-argument tool call becomes inputAvailable, not error', () { + final processor = MessageProcessor(); + processor.apply( + const MessageStarted(messageId: 'm1', role: AiRole.assistant), + ); + processor.apply( + const ToolCallStarted( + messageId: 'm1', + toolCallId: 'c1', + toolName: 'refresh', + ), + ); + final r = processor.apply(const ToolCallReady(toolCallId: 'c1')); + final call = callOf(r, 'm1', 'c1'); + expect(call.state, ToolCallState.inputAvailable); + expect(call.args, isEmpty); + expect( + r.conversation.messageById('m1')!.parts.whereType(), + isEmpty, + ); + }); + + test('a result in a separate message advances the call to outputAvailable', + () { + final processor = MessageProcessor(); + processor.apply( + const MessageStarted(messageId: 'a1', role: AiRole.assistant), + ); + processor.apply( + const ToolCallStarted( + messageId: 'a1', + toolCallId: 'c1', + toolName: 'get_weather', + ), + ); + processor.apply(const ToolCallReady(toolCallId: 'c1')); + // Result arrives in a separate tool-role message (as addToolResults does). + final r = processor.apply( + const ToolResultReceived( + messageId: 't1', + toolCallId: 'c1', + result: {'tempC': 18}, + ), + ); + expect(callOf(r, 'a1', 'c1').state, ToolCallState.outputAvailable); + }); + + test('result advances a call seeded from a rehydrated conversation', () { + // Seed a conversation that already holds an assistant message with a tool + // call (e.g. loaded from persistence), so the in-memory call→message map + // is empty. A result must still find the owning message by scanning. + const seed = AiConversation( + id: 'c1', + messages: [ + AiMessage( + id: 'a1', + role: AiRole.assistant, + parts: [ + ToolCallPart( + toolCallId: 'c1', + toolName: 'get_weather', + state: ToolCallState.inputAvailable, + ), + ], + ), + ], + ); + final processor = MessageProcessor(conversation: seed); + final r = processor.apply( + const ToolResultReceived( + messageId: 't1', + toolCallId: 'c1', + result: {'tempC': 18}, + ), + ); + expect(callOf(r, 'a1', 'c1').state, ToolCallState.outputAvailable); + }); + + test('a tool-scoped error marks only the call, not the whole message', () { + final processor = MessageProcessor(); + processor.apply( + const MessageStarted(messageId: 'a1', role: AiRole.assistant), + ); + processor.apply( + const ToolCallStarted( + messageId: 'a1', + toolCallId: 'c1', + toolName: 'get_weather', + ), + ); + final r = processor.apply( + const StreamErrorEvent(error: 'tool failed', toolCallId: 'c1'), + ); + final message = r.conversation.messageById('a1')!; + expect(callOf(r, 'a1', 'c1').state, ToolCallState.error); + expect(message.status, AiMessageStatus.streaming); // message not killed + }); + }); +} + +/// The first tool call across the processor's conversation. +ToolCallPart _firstToolCall(MessageProcessor processor) => + processor.conversation.messages + .expand((m) => m.parts) + .whereType() + .first; diff --git a/packages/flutter_ai/flutter_ai_core/test/models_test.dart b/packages/flutter_ai/flutter_ai_core/test/models_test.dart new file mode 100644 index 0000000..40a8240 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/test/models_test.dart @@ -0,0 +1,187 @@ +import 'dart:typed_data'; + +import 'package:flutter_ai_core/flutter_ai_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('enum JSON', () { + test('AiRole round-trips and rejects unknown values', () { + for (final role in AiRole.values) { + expect(AiRole.fromJson(role.toJson()), role); + } + expect(() => AiRole.fromJson('nope'), throwsFormatException); + }); + + test('FinishReason round-trips with hyphenated wire names', () { + expect(FinishReason.toolCalls.toJson(), 'tool-calls'); + expect(FinishReason.fromJson('tool-calls'), FinishReason.toolCalls); + expect(() => FinishReason.fromJson('x'), throwsFormatException); + }); + + test('ToolCallState round-trips', () { + for (final state in ToolCallState.values) { + expect(ToolCallState.fromJson(state.toJson()), state); + } + }); + }); + + group('AiPart', () { + test('TextPart round-trips and compares by value', () { + const part = TextPart('hello'); + expect(AiPart.fromJson(part.toJson()), part); + expect(part, const TextPart('hello')); + expect(part.copyWith(text: 'hi'), const TextPart('hi')); + }); + + test('ToolCallPart preserves args with deep equality', () { + const part = ToolCallPart( + toolCallId: 'c1', + toolName: 'search', + args: { + 'query': 'flutter', + 'filters': ['recent', 'open'], + }, + state: ToolCallState.inputAvailable, + ); + final decoded = AiPart.fromJson(part.toJson()); + expect(decoded, part); + expect(decoded.hashCode, part.hashCode); + }); + + test('FilePart round-trips inline bytes via base64', () { + final part = FilePart( + mediaType: 'image/png', + bytes: Uint8List.fromList([1, 2, 3, 250]), + name: 'pixel.png', + ); + final decoded = AiPart.fromJson(part.toJson()) as FilePart; + expect(decoded.bytes, part.bytes); + expect(decoded, part); + }); + + test('FilePart round-trips a url', () { + final part = FilePart( + mediaType: 'application/pdf', + url: Uri.parse('https://example.com/a.pdf'), + ); + expect(AiPart.fromJson(part.toJson()), part); + }); + + test('SourcePart and DataPart round-trip', () { + final source = SourcePart(url: Uri.parse('https://x.test'), title: 'X'); + expect(AiPart.fromJson(source.toJson()), source); + + const data = DataPart(dataType: 'weather_card', data: {'tempC': 21}); + expect(AiPart.fromJson(data.toJson()), data); + }); + + test('fromJson rejects an unknown type', () { + expect( + () => AiPart.fromJson({'type': 'mystery'}), + throwsFormatException, + ); + }); + }); + + group('AiMessage', () { + test('text getter concatenates only TextParts', () { + const message = AiMessage( + id: 'm1', + role: AiRole.assistant, + parts: [ + TextPart('Hello '), + ReasoningPart('thinking'), + TextPart('world'), + ], + ); + expect(message.text, 'Hello world'); + }); + + test('round-trips including finishReason and createdAt', () { + final message = AiMessage( + id: 'm1', + role: AiRole.assistant, + parts: const [TextPart('hi')], + status: AiMessageStatus.complete, + finishReason: FinishReason.stop, + createdAt: DateTime.utc(2026, 6, 26, 12), + ); + expect(AiMessage.fromJson(message.toJson()), message); + }); + + test('text convenience constructor builds a single TextPart', () { + final message = AiMessage.text( + id: 'm1', + role: AiRole.user, + text: 'hey', + ); + expect(message.parts, const [TextPart('hey')]); + }); + + test('copyWith replaces only provided fields', () { + const message = AiMessage(id: 'm1', role: AiRole.user); + final updated = message.copyWith(status: AiMessageStatus.streaming); + expect(updated.id, 'm1'); + expect(updated.role, AiRole.user); + expect(updated.status, AiMessageStatus.streaming); + }); + }); + + group('AiConversation', () { + const m1 = AiMessage(id: 'm1', role: AiRole.user, parts: [TextPart('hi')]); + const m2 = AiMessage(id: 'm2', role: AiRole.assistant); + + test('append and messageById', () { + const convo = AiConversation.empty('c1'); + final next = convo.append(m1); + expect(next.messages, [m1]); + expect(next.messageById('m1'), m1); + expect(next.messageById('absent'), isNull); + expect(next.lastMessage, m1); + }); + + test('replace upserts by id', () { + const convo = AiConversation(id: 'c1', messages: [m1, m2]); + final edited = m1.copyWith(parts: const [TextPart('edited')]); + final next = convo.replace(edited); + expect(next.messages.length, 2); + expect(next.messageById('m1')!.text, 'edited'); + + const m3 = AiMessage(id: 'm3', role: AiRole.user); + expect(convo.replace(m3).messages.last, m3); + }); + + test('round-trips through JSON', () { + const convo = AiConversation(id: 'c1', messages: [m1, m2]); + expect(AiConversation.fromJson(convo.toJson()), convo); + }); + }); + + group('ToolDefinition', () { + test('round-trips with a JSON schema', () { + const tool = ToolDefinition( + name: 'get_weather', + description: 'Get weather for a city', + parametersSchema: { + 'type': 'object', + 'properties': { + 'city': {'type': 'string'}, + }, + }, + ); + expect(ToolDefinition.fromJson(tool.toJson()), tool); + }); + }); + + group('AiRequestOptions', () { + test('copyWith and value equality', () { + const options = AiRequestOptions(model: 'gpt-4o', temperature: 0.7); + expect(options.copyWith(model: 'gpt-4o-mini').model, 'gpt-4o-mini'); + expect(options.copyWith(model: 'gpt-4o-mini').temperature, 0.7); + expect( + const AiRequestOptions(model: 'gpt-4o', temperature: 0.7), + options, + ); + }); + }); +} diff --git a/packages/flutter_ai/flutter_ai_core/test/usage_test.dart b/packages/flutter_ai/flutter_ai_core/test/usage_test.dart new file mode 100644 index 0000000..698eb67 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_core/test/usage_test.dart @@ -0,0 +1,130 @@ +import 'package:flutter_ai_core/flutter_ai_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('AiUsage', () { + test('round-trips through JSON, omitting null fields', () { + const usage = AiUsage( + inputTokens: 100, + outputTokens: 50, + cachedInputTokens: 20, + cacheCreationTokens: 15, + totalTokens: 150, + ); + final json = usage.toJson(); + expect(json.containsKey('reasoningTokens'), isFalse); + expect(json['cacheCreationTokens'], 15); + expect(AiUsage.fromJson(json), usage); + }); + + test('omits cacheCreationTokens from JSON when null', () { + const usage = AiUsage(inputTokens: 10, outputTokens: 5); + expect(usage.toJson().containsKey('cacheCreationTokens'), isFalse); + }); + + test('resolvedTotal derives from input + output when total is absent', () { + const usage = AiUsage(inputTokens: 30, outputTokens: 12); + expect(usage.resolvedTotal, 42); + expect(const AiUsage().resolvedTotal, isNull); + }); + + test('operator + sums each field', () { + const a = AiUsage( + inputTokens: 10, + outputTokens: 5, + cacheCreationTokens: 4, + ); + const b = AiUsage( + inputTokens: 3, + outputTokens: 7, + cacheCreationTokens: 6, + totalTokens: 10, + ); + final sum = a + b; + expect(sum.inputTokens, 13); + expect(sum.outputTokens, 12); + expect(sum.cacheCreationTokens, 10); + expect(sum.totalTokens, 10); // null + 10 + }); + + test('equality distinguishes cacheCreationTokens', () { + const a = AiUsage(inputTokens: 10, cacheCreationTokens: 4); + const b = AiUsage(inputTokens: 10, cacheCreationTokens: 5); + const c = AiUsage(inputTokens: 10, cacheCreationTokens: 4); + expect(a, isNot(b)); + expect(a, c); + expect(a.hashCode, c.hashCode); + }); + + test('estimateCost bills cached input at the discounted rate', () { + const usage = AiUsage( + inputTokens: 1000, + cachedInputTokens: 400, + outputTokens: 500, + ); + // 600 uncached @ $3/M + 400 cached @ $0.3/M + 500 out @ $15/M + final cost = usage.estimateCost( + inputPer1M: 3, + outputPer1M: 15, + cachedInputPer1M: 0.3, + ); + expect(cost, closeTo(0.0018 + 0.00012 + 0.0075, 1e-9)); + }); + + test('estimateCost bills cache writes at an explicit write rate', () { + const usage = AiUsage( + inputTokens: 1000, + cachedInputTokens: 200, + cacheCreationTokens: 300, + outputTokens: 500, + ); + // 500 uncached @ $3/M + 200 read @ $0.3/M + 300 write @ $3.75/M + // + 500 out @ $15/M + final cost = usage.estimateCost( + inputPer1M: 3, + outputPer1M: 15, + cachedInputPer1M: 0.3, + cacheWritePer1M: 3.75, + ); + expect( + cost, + closeTo(0.0015 + 0.00006 + 0.001125 + 0.0075, 1e-9), + ); + }); + + test('estimateCost defaults cache-write rate to 1.25x input', () { + const usage = AiUsage( + inputTokens: 1000, + cacheCreationTokens: 400, + ); + // 600 uncached @ $3/M + 400 write @ (1.25 * $3)/M = $3.75/M + final cost = usage.estimateCost(inputPer1M: 3, outputPer1M: 15); + expect(cost, closeTo(0.0018 + 0.0015, 1e-9)); + }); + + test('estimateCost does not double-count cache writes at input rate', () { + const withWrite = AiUsage(inputTokens: 1000, cacheCreationTokens: 1000); + // All 1000 are cache writes -> billed only at the 1.25x write rate. + final cost = withWrite.estimateCost(inputPer1M: 3, outputPer1M: 15); + expect(cost, closeTo(1000 * 3.75 / 1e6, 1e-9)); + }); + + test('estimateCost returns null with no token counts', () { + expect( + const AiUsage().estimateCost(inputPer1M: 1, outputPer1M: 1), + isNull, + ); + }); + }); + + test('MessageFinished carries usage through JSON', () { + const event = MessageFinished( + messageId: 'a1', + reason: FinishReason.stop, + usage: AiUsage(inputTokens: 5, outputTokens: 9), + ); + final restored = AiStreamEvent.fromJson(event.toJson()) as MessageFinished; + expect(restored.usage?.inputTokens, 5); + expect(restored.usage?.outputTokens, 9); + }); +} diff --git a/packages/flutter_ai/flutter_ai_elements/CHANGELOG.md b/packages/flutter_ai/flutter_ai_elements/CHANGELOG.md new file mode 100644 index 0000000..e2d970b --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/CHANGELOG.md @@ -0,0 +1,215 @@ +# Changelog + +## 0.2.0 + +Extensibility release — driven by dogfooding a full Gemini-clone app on the +packages. All changes are additive; requires `flutter_ai_client ^0.3.0`. + +- Add `AiThemeExtension.chipColor` (resolved via `effectiveChipColor`) so + **bubble-less** themes (a transparent `assistantBubbleColor`) keep visible + suggestion chips, the selected `AiConversationList` row, and the + scroll-to-latest button instead of having them vanish. (#135) +- `AiModelSelector`: add `labelStyle`, `labelBuilder`, `showBorder`, and + `padding` so the trigger chip can be brand-styled (e.g. a larger two-tone + title) while keeping the package's picker sheet. (#143) +- `AiConversationList`: add `header`, `footer`, and a per-thread + `trailingBuilder` so a real sidebar can have section headers, an account + footer, and custom per-thread affordances (pin/overflow). (#144) +- `AiMessageActions`: add `order` and `trailing` (with the new + `AiMessageActionKind` enum) to reorder actions and push some — e.g. read-aloud + — to the far side. (#145) +- `AiEmptyState`: add `titleStyle`, `subtitleStyle`, and a `background` slot for + a gradient/hero greeting. (#141) +- `AiPromptInput`: add `textController` so voice dictation / quick-replies can + populate the composer for review instead of dictate-and-send. (#138) +- Add `AiLiveController` + `AiVoiceEngine`: a drop-in + listen → send → speak → re-listen state machine that maps a `UseChatController` + and a pluggable audio engine onto `AiLiveSession`, so live-voice plumbing no + longer lives entirely in the app. (#139) +- `AiLiveSession`: add a `backgroundColor` knob for apps that want a non-black + live surface. (#146) +- Docs: document the `share_plus` recipe on `AiMessageActions.onShare` (the + package ships no share implementation to stay plugin-free). (#142) + +## 0.1.16 + +- Fix: `AiChat` auto-scroll no longer fights mouse-wheel, trackpad, or keyboard + scrolling during streaming. The top-pin now releases on any upward + user-initiated scroll (`UserScrollNotification`), not only touch drags, so + scrolling up to re-read while the response streams works on desktop/web. +- Fix: `AiComposer`'s main button is now Send (not Live) whenever there are + staged attachments, so tapping it with an attachment-only draft sends the + attachment instead of launching full-screen voice mode. + +## 0.1.15 + +- Fix: raise the `flutter_ai_core` (`^0.1.11`) and `flutter_ai_client` + (`^0.2.0`) lower bounds so dependency downgrades can't resolve sibling + versions the widgets can't compile against. +- Docs: shortened the pubspec `description` into pub.dev's 60–180 character + window. + +## 0.1.14 + +- Widen the `flutter_ai_client` constraint to `>=0.1.0 <0.3.0` so it resolves + with client 0.2.0 (which adds the tool-call cancellation signal). No API + changes here. + +## 0.1.13 + +- Docs: refreshed the README listing with a hero image, screenshot gallery, + and badges (consistent across the package family). No code changes. + +## 0.1.12 + +UX polish bundle: + +- Skeleton shimmer that crossfades into the first streamed token, and a + streaming→Markdown crossfade when a turn finishes (both reduced-motion aware). +- Reading-width column: new `AiThemeExtension.maxContentWidth` (default 720) + centers long answers on wide screens; set to `double.infinity` to disable. +- `AiEmptyState` gains a brand `glyph` and tappable `suggestions`. +- Light haptics on turn completion, confirmation, and chip taps (opt-out via + `enableHaptics`; no-op on web/desktop). +- Markdown: strikethrough, horizontal rules, and GFM task-list checkboxes; + link color is now themeable (`AiThemeExtension.linkColor`). +- Source chips: numeric index badge, hover state, and an opt-in favicon + (`AiSources.showFavicons`, default off — fetching discloses cited hosts to a + third-party service). +- `AiConfirmation.tone` (`neutral`/`caution`/`danger`) restyles the confirm + button; `danger` uses the theme error color. +- New `AiOrb` widget and a themeable live-session orb (`AiThemeExtension.orbColor`). + +## 0.1.11 + +- Reduce-motion: `AiLoader` and `AiShimmer` now hold a static state (and stop + their controllers) when the platform "reduce motion" setting is on, completing + the accessibility pass across the animated widgets. + +## 0.1.10 + +- `AiAnimatedResponse` shows a blinking caret at the streaming edge (the + "being written" cue); it holds steady under reduce-motion. + +## 0.1.9 + +- Focus / hover / keyboard on the primary controls (desktop & web): the + composer's attach/mic and send buttons and the confirmation Allow/Deny buttons + use Material ink + focus traversal + Enter/Space instead of bare gesture + detectors. The send/stop/live button now morphs (AnimatedSwitcher) and Stop + reads as a distinct error-toned affordance. + +## 0.1.8 + +- Semantic theme tokens: `AiThemeExtension` gains `errorColor`, `successColor`, + `warningColor`, `codeBackgroundColor`, and `codeForegroundColor` (light + dark + defaults). Previously-hardcoded error/success/warning colors and the code + block's dark palette now read from the theme, so the family is fully + rebrandable. + +## 0.1.7 + +- `AiChatView`: a batteries-included drop-in (transcript + composer + layout + + safe area) so a working chat is a single widget in your `Scaffold` body. + +## 0.1.6 + +- Declare supported platforms (Android/iOS/web/macOS/Windows/Linux) for the + pub.dev listing; fix a stale library-doc reference (`AiChat`, not the removed + `AiConversation` widget name). + +## 0.1.5 + +- Performance: `AiConversationView` memoizes bubbles by message identity, so + only the changing message rebuilds while streaming. +- `AiAnimatedResponse` honors reduce-motion (renders plain text) and isolates + its reveal in a `RepaintBoundary`. +- `AiLocalizationsScope`: override UI strings with one widget, no delegate + wiring. Remaining hardcoded strings (reasoning, Allow/Deny, loader/shimmer/ + avatar a11y labels) are now localized. + +## 0.1.4 + +- Internationalization: `AiLocalizations` (+ `AiLocalizationsDelegate`) holds the + widgets' user-facing strings (defaults English). Every previously-hardcoded + tooltip/label/action now reads from it, so apps can translate the UI by + providing a delegate. `AiConversationList.newChatLabel` now defaults to the + localized value. + +## 0.1.3 + +- `AiConversationList`: a ChatGPT-style conversation sidebar (New chat + a list + of `ChatThread`s with select/delete) to pair with a `ChatThreadStore`. + +## 0.1.2 + +- Generative UI: `AiWidgetRegistry` (a `dataType`→widget allowlist) and + `AiDataView` render `DataPart`s the model emits as your own widgets — no + reflection, unknown types fall back. The demo wires its chain-of-thought / task + / confirmation cards through it. + +## 0.1.1 + +- `AiChat` now anchors the message you just sent to the **top** of the viewport + (ChatGPT-style) and **holds it there** while the answer streams in below, + reserving just enough trailing space and releasing it as the answer grows. + A drag releases the pin; a floating "scroll to latest" button appears whenever + the conversation is scrolled above the bottom. +- New `AiAnimatedResponse`: a **blur fade-in** reveal (the Apple-Intelligence / + Siri look) so streamed answers appear smoothly — each newly revealed word + arrives blurred and faded, then sharpens into place over `fadeDuration` + (`blurSigma` controls the starting blur). Text is paced at a readable + `charsPerSecond` (default 120) and accelerates to drain a backlog within + `catchUpWindow` so it never trails far behind a fast stream. Only the few + words at the leading edge animate at once, so the cost stays bounded. The + in-flight text renders as plain prose and settles into full Markdown once + complete. `MarkdownTextRenderer` uses it automatically while streaming. +- `AiMessageActions` is restyled with compact, evenly spaced icon buttons + (ChatGPT-style) and gains optional `onSpeak`/`onGood`/`onBad`/`onShare` + actions. +- `AiSources` collapses past `maxVisible` chips (default 6) behind a "+N more" + toggle, so grounded answers that return dozens of sources no longer flood the + bubble. +- Pluggable syntax highlighting: `AiCodeBlock`, `AiResponse`, and + `MarkdownTextRenderer` accept an optional `CodeHighlighter` that turns code + + language into styled spans. The package ships no grammar engine (stays + dependency-free); supply one from the app. Defaults to plain monospace. +- Fixed the Markdown block parser hanging (and exhausting memory) when handed a + partial stream that ended mid-construct, e.g. a lone `#` before its heading + text arrived — the parser now always makes forward progress. + +## 0.1.0 + +Initial release. + +- `AiThemeExtension` — a `ThemeExtension` of design tokens (bubble colors, + shapes, ambient shadow, spacing, typography, motion, haptics) with `copyWith`, + `lerp`, `of(context)`, and a mobile-first `fallback()`. +- Presentational widgets: `AiMessageBubble` (renders every `AiPart` type; + streaming-safe semantics), `AiConversationView`, `AiComposer` (Send↔Stop swap, + haptics), `AiLoader`. +- Controller-bound widgets: `AiChat` (live transcript with auto-scroll and a + thinking loader) and `AiPromptInput`. +- `AiResponse` — a dependency-free Markdown renderer (headings, bold/italic, + inline + fenced code, lists, blockquotes, links); `MarkdownTextRenderer` wraps + it and is now the **default** `AiTextRenderer`. `PlainTextRenderer` remains. +- `AiChainOfThought` (stepwise timeline), `AiTask` (agent checklist), + `AiInlineCitation` (numbered badge), `AiBranch` (version navigation), + `AiImage` (loading/error/tap-to-zoom). +- Input upgrades: `AiComposer` gains an attach button, a model-selector slot, a + voice button, and removable attachment previews; `AiPromptInput` stages + attachments and switches models via the controller. `AiModelSelector`, + `AiConfirmation` (approve/deny), `AiContextMeter` (token usage), and + `AiShimmer` (loading skeleton). +- `AiLiveSession` — a full-screen, engine-agnostic Live voice surface (animated + orb reacting to amplitude + status, live transcript, mute/keyboard/end). UI + only; drive it from a realtime audio engine. +- Performance & a11y hardening: `AiResponse` parses Markdown and builds gesture + recognizers once per text change (not every frame) — important on the + streaming hot path; `AiLiveSession` animates only the orb (60fps no longer + rebuilds the conversation); message bubbles no longer subscribe to window + size in the common bounded case; list items carry stable keys; the composer + measures with the ambient text scale + direction; disclosure widgets expose + button/expanded semantics; modal sheets scroll; high-traffic layout uses + directional insets/alignment for RTL. +- Re-exports `flutter_ai_client` (and `flutter_ai_core`). diff --git a/packages/flutter_ai/flutter_ai_elements/LICENSE b/packages/flutter_ai/flutter_ai_elements/LICENSE new file mode 100644 index 0000000..56023ee --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2026, The flutter_ai authors. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/flutter_ai/flutter_ai_elements/README.md b/packages/flutter_ai/flutter_ai_elements/README.md new file mode 100644 index 0000000..ed03441 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/README.md @@ -0,0 +1,168 @@ +

flutter_ai_elements

+ +

The batteries-included AI chat UI kit for Flutter — drop in a polished, streaming chat in one widget, or compose 30+ themeable pieces yourself.

+ +

+ flutter_ai_elements: a streaming answer with chain-of-thought and a generative-UI task card +

+ +

+ flutter_ai_elements on pub.dev + pub points + License: BSD-3-Clause +

+ +

+ Family: flutter_ai · + core · client · + openai · anthropic · gemini · + tools · mcp · voice
+ Recipes · Migrating from the Vercel AI SDK +

+ +--- + +## Gallery + + + + + + + + + + + + +
+ Streaming response
+ Streaming response
+ AiChat · AiResponse · AiLoader +
+ Generative UI task card
+ Generative UI
+ AiMessageBubble (custom DataPart renderers) +
+ Tool calls
+ Tool calls
+ AiToolGroup · AiReasoning +
+ Source citations
+ Citations
+ AiSources · AiInlineCitation +
+ Theming
+ Theming
+ AiThemeExtension tokens +
+ Dark mode
+ Dark mode
+ One theme extension, light & dark +
+ +Composable, themeable Flutter UI for AI chat — the UI layer of the +[`flutter_ai`](../../README.md) family. + +It adopts the Vercel AI Elements component vocabulary but renders through a +**mobile-first `AiThemeExtension`**, built from base Flutter widgets. No +`shadcn_flutter` / `forui` dependency, no hardcoded Material or Cupertino look — +restyle everything via theme tokens. + +## Widgets + +**Presentational** (plain data + callbacks; reusable, testable): +- `AiMessageBubble` — renders one message's parts (text, reasoning, tool calls, + results, files, sources, data), role-aware, with streaming-safe semantics. +- `AiConversationView` — a scrolling list of bubbles, optional thinking loader. +- `AiComposer` — the **presentational** input: a leading attach (`+`) button and + a main button that is Live while empty, Send once you type, and Stop while + streaming; emits haptics. Use this only if you're wiring callbacks yourself. +- `AiLoader` — a pulsing three-dot thinking indicator. + +**Controller-bound** (wire to a `UseChatController` — what you usually want): +- `AiChatView` — the batteries-included one-widget chat: transcript + composer + + layout. The fastest way to drop in a full chat. +- `AiChat` — live transcript with auto-scroll and a thinking loader. +- `AiPromptInput` — the drop-in composer: wraps `AiComposer` and wires it to + `sendText` / `stop`. Prefer this over `AiComposer`. + +## Quick start + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/flutter_ai_elements.dart'; + +class ChatScreen extends StatelessWidget { + const ChatScreen({super.key, required this.controller}); + final UseChatController controller; // from flutter_ai_client + + @override + Widget build(BuildContext context) => Scaffold( + // Batteries-included: transcript + composer + layout in one widget. + body: AiChatView(controller: controller), + ); +} +``` + +Need a custom layout between the transcript and composer? Compose the pieces +yourself instead: + +```dart +Scaffold( + body: Column( + children: [ + Expanded(child: AiChat(controller: controller)), + AiPromptInput(controller: controller), + ], + ), +); +``` + +## Theming + +Register an `AiThemeExtension` (or override the default) on your `ThemeData`: + +```dart +MaterialApp( + theme: ThemeData( + extensions: [ + AiThemeExtension.fallback().copyWith( + userBubbleColor: const Color(0xFF7C3AED), + bubbleRadius: const BorderRadius.all(Radius.circular(28)), + enableHaptics: true, + ), + ], + ), + home: const ChatScreen(...), +); +``` + +Widgets read tokens via `AiThemeExtension.of(context)`, falling back to the +mobile-first default when none is registered. All visual constants live behind +this one extension, so a future `flutter_ai_design_system` can extract them +without breaking the API. + +## Rich text + +Markdown renders **by default** — headings, lists, bold/italic, links, and fenced +code blocks — via `MarkdownTextRenderer`, so streamed answers format themselves +out of the box. Text flows through an injectable `AiTextRenderer` +(`TextRenderer`), so you can swap in `PlainTextRenderer` for raw text, or +your own renderer for LaTeX or custom syntax highlighting: + +```dart +// Markdown is the default — this line is optional. +AiChat(controller: controller, textRenderer: const MarkdownTextRenderer()); + +// Opt out to plain text, or bring your own. +AiChat(controller: controller, textRenderer: const PlainTextRenderer()); +``` + +## Status + +Published on pub.dev (see the CHANGELOG); depends on the sibling `flutter_ai` +packages. +See [`example/`](example/) for a full app. + +_If `flutter_ai` saves you time, you can [buy me a coffee ☕](https://ko-fi.com/ananmouaz)._ diff --git a/packages/flutter_ai/flutter_ai_elements/analysis_options.yaml b/packages/flutter_ai/flutter_ai_elements/analysis_options.yaml new file mode 100644 index 0000000..bddaa31 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/analysis_options.yaml @@ -0,0 +1,2 @@ +# Inherits the workspace-wide strict configuration. +include: ../../analysis_options.yaml diff --git a/packages/flutter_ai/flutter_ai_elements/example/flutter_ai_elements_example.dart b/packages/flutter_ai/flutter_ai_elements/example/flutter_ai_elements_example.dart new file mode 100644 index 0000000..5e648d9 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/example/flutter_ai_elements_example.dart @@ -0,0 +1,73 @@ +// A complete chat screen built from flutter_ai_elements, driven by a fake +// provider that echoes the prompt back word by word. +// +// Run inside a Flutter app target; this file shows the wiring. +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/flutter_ai_elements.dart'; + +void main() => runApp(const _ExampleApp()); + +/// Echoes the user's last message, streamed word by word. +class _EchoProvider implements LlmProvider { + @override + Stream send( + AiConversation conversation, { + List? tools, + AiRequestOptions? options, + }) async* { + const id = 'assistant'; + final prompt = conversation.lastMessage?.text ?? ''; + yield const MessageStarted(messageId: id, role: AiRole.assistant); + for (final word in prompt.split(' ')) { + await Future.delayed(const Duration(milliseconds: 80)); + yield TextDelta(messageId: id, delta: '$word '); + } + yield const MessageFinished(messageId: id, reason: FinishReason.stop); + } +} + +class _ExampleApp extends StatefulWidget { + const _ExampleApp(); + + @override + State<_ExampleApp> createState() => _ExampleAppState(); +} + +class _ExampleAppState extends State<_ExampleApp> { + late final UseChatController _controller = + UseChatController(provider: _EchoProvider()); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData( + useMaterial3: true, + extensions: [ + // A bespoke mobile skin layered on the mobile-first default. + AiThemeExtension.fallback().copyWith( + userBubbleColor: const Color(0xFF7C3AED), + bubbleRadius: const BorderRadius.all(Radius.circular(24)), + ), + ], + ), + home: Scaffold( + appBar: AppBar(title: const Text('flutter_ai_elements')), + body: SafeArea( + child: Column( + children: [ + Expanded(child: AiChat(controller: _controller)), + const Divider(height: 1), + AiPromptInput(controller: _controller), + ], + ), + ), + ), + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/flutter_ai_elements.dart b/packages/flutter_ai/flutter_ai_elements/lib/flutter_ai_elements.dart new file mode 100644 index 0000000..fdc9169 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/flutter_ai_elements.dart @@ -0,0 +1,56 @@ +/// Composable, themeable Flutter UI for AI chat. +/// +/// Adopts the Vercel AI Elements component vocabulary while rendering through a +/// mobile-first `AiThemeExtension` — no shadcn or forui dependency. Built from +/// base Flutter widgets so any design system can restyle it via theme tokens. +/// +/// ### Presentational vs. bound widgets +/// +/// - **Presentational** (`AiMessageBubble`, `AiConversationView`, `AiComposer`, +/// `AiLoader`) take plain data and callbacks; reusable and easy to test. +/// - **Bound** (`AiChat`, `AiPromptInput`) wire those to a +/// `UseChatController` from `flutter_ai_client` for a drop-in chat surface. +/// +/// Re-exports `flutter_ai_client` (and transitively `flutter_ai_core`) so a +/// single import provides the controller, models, and UI. +library; + +export 'package:flutter_ai_client/flutter_ai_client.dart'; + +export 'src/generative_ui/ai_widget_registry.dart'; +export 'src/l10n/ai_localizations.dart'; +export 'src/rendering/ai_text_renderer.dart'; +export 'src/theme/ai_theme_extension.dart'; +export 'src/widgets/ai_animated_response.dart'; +export 'src/widgets/ai_attachment.dart'; +export 'src/widgets/ai_avatar.dart'; +export 'src/widgets/ai_branch.dart'; +export 'src/widgets/ai_chain_of_thought.dart'; +export 'src/widgets/ai_chat.dart'; +export 'src/widgets/ai_chat_view.dart'; +export 'src/widgets/ai_code_block.dart'; +export 'src/widgets/ai_composer.dart'; +export 'src/widgets/ai_confirmation.dart'; +export 'src/widgets/ai_context_meter.dart'; +export 'src/widgets/ai_conversation_list.dart'; +export 'src/widgets/ai_conversation_view.dart'; +export 'src/widgets/ai_empty_state.dart'; +export 'src/widgets/ai_error_banner.dart'; +export 'src/widgets/ai_image.dart'; +export 'src/widgets/ai_inline_citation.dart'; +export 'src/widgets/ai_live_controller.dart'; +export 'src/widgets/ai_live_session.dart'; +export 'src/widgets/ai_loader.dart'; +export 'src/widgets/ai_message_actions.dart'; +export 'src/widgets/ai_message_bubble.dart'; +export 'src/widgets/ai_model_selector.dart'; +export 'src/widgets/ai_orb.dart'; +export 'src/widgets/ai_prompt_input.dart'; +export 'src/widgets/ai_reasoning.dart'; +export 'src/widgets/ai_response.dart'; +export 'src/widgets/ai_shimmer.dart'; +export 'src/widgets/ai_sources.dart'; +export 'src/widgets/ai_suggestions.dart'; +export 'src/widgets/ai_task.dart'; +export 'src/widgets/ai_tool_group.dart'; +export 'src/widgets/ai_tool_invocation.dart'; diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/generative_ui/ai_widget_registry.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/generative_ui/ai_widget_registry.dart new file mode 100644 index 0000000..056ea7f --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/generative_ui/ai_widget_registry.dart @@ -0,0 +1,66 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_ai_core/flutter_ai_core.dart'; + +/// Builds a widget for a [DataPart]'s payload. +typedef AiDataWidgetBuilder = Widget Function( + BuildContext context, + Map data, +); + +/// A name→widget allowlist for **generative UI**: the model emits a [DataPart] +/// with a `dataType` discriminator and a JSON payload, and the registry maps it +/// to a Flutter widget. +/// +/// This is a deliberate allowlist — only registered `dataType`s render (no +/// reflection, no arbitrary instantiation), so a model can't conjure UI you +/// didn't sanction. Unknown types fall back (see [AiDataView]). +class AiWidgetRegistry { + /// Creates a registry, optionally seeded with [builders]. + AiWidgetRegistry([Map? builders]) + : _builders = {...?builders}; + + final Map _builders; + + /// Registers [builder] for [dataType], replacing any previous entry. Returns + /// the registry for chaining. + AiWidgetRegistry register(String dataType, AiDataWidgetBuilder builder) { + _builders[dataType] = builder; + return this; + } + + /// Whether a builder is registered for [dataType]. + bool contains(String dataType) => _builders.containsKey(dataType); + + /// The `dataType`s with a registered builder. + Iterable get types => _builders.keys; + + /// Builds the widget for [part], or `null` if its `dataType` is not + /// registered. + Widget? build(BuildContext context, DataPart part) => + _builders[part.dataType]?.call(context, part.data); +} + +/// Renders a [DataPart] via a [registry], showing [fallback] (or nothing) when +/// the part's `dataType` is not registered. +class AiDataView extends StatelessWidget { + /// Creates a view for [part]. + const AiDataView({ + super.key, + required this.part, + required this.registry, + this.fallback, + }); + + /// The structured part to render. + final DataPart part; + + /// The allowlist of `dataType`→widget builders. + final AiWidgetRegistry registry; + + /// Shown when [part]'s `dataType` is not registered. Defaults to an empty box. + final Widget? fallback; + + @override + Widget build(BuildContext context) => + registry.build(context, part) ?? fallback ?? const SizedBox.shrink(); +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/l10n/ai_localizations.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/l10n/ai_localizations.dart new file mode 100644 index 0000000..a7da073 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/l10n/ai_localizations.dart @@ -0,0 +1,201 @@ +import 'package:flutter/widgets.dart'; + +/// The user-facing strings used by `flutter_ai_elements` widgets. +/// +/// Defaults are English. To translate, provide an [AiLocalizations] (or a +/// per-locale [AiLocalizationsDelegate]) through `MaterialApp.localizationsDelegates`: +/// +/// ```dart +/// MaterialApp( +/// localizationsDelegates: const [ +/// AiLocalizationsDelegate(AiLocalizations(copy: 'Copier', send: 'Envoyer')), +/// ...GlobalMaterialLocalizations.delegates, +/// ], +/// ); +/// ``` +/// +/// Widgets read these via [AiLocalizations.of], which falls back to the English +/// defaults when none is provided. +@immutable +class AiLocalizations { + /// Creates a set of strings (English by default). + const AiLocalizations({ + this.copy = 'Copy', + this.regenerate = 'Regenerate', + this.edit = 'Edit', + this.readAloud = 'Read aloud', + this.share = 'Share', + this.goodResponse = 'Good response', + this.badResponse = 'Bad response', + this.stop = 'Stop', + this.send = 'Send', + this.live = 'Live', + this.attach = 'Attach', + this.dictate = 'Dictate', + this.delete = 'Delete', + this.dismiss = 'Dismiss', + this.retry = 'Retry', + this.close = 'Close', + this.newChat = 'New chat', + this.previousVersion = 'Previous version', + this.nextVersion = 'Next version', + this.scrollToLatest = 'Scroll to latest', + this.messageHint = 'Message', + this.reasoning = 'Reasoning', + this.chainOfThought = 'Chain of thought', + this.allow = 'Allow', + this.deny = 'Deny', + this.thinking = 'Assistant is thinking', + this.loading = 'Loading', + this.you = 'You', + this.assistant = 'Assistant', + }); + + /// Copy-to-clipboard action. + final String copy; + + /// Regenerate-response action. + final String regenerate; + + /// Edit-message action. + final String edit; + + /// Read-aloud (TTS) action. + final String readAloud; + + /// Share action. + final String share; + + /// Thumbs-up action. + final String goodResponse; + + /// Thumbs-down action. + final String badResponse; + + /// Stop-generation action. + final String stop; + + /// Send-message action. + final String send; + + /// Start-live-voice action. + final String live; + + /// Attach-file action. + final String attach; + + /// Start-dictation (mic) action. + final String dictate; + + /// Delete action. + final String delete; + + /// Dismiss action (e.g. error banner). + final String dismiss; + + /// Retry action. + final String retry; + + /// Close action (e.g. full-screen image). + final String close; + + /// New-conversation action. + final String newChat; + + /// Previous-branch navigation label. + final String previousVersion; + + /// Next-branch navigation label. + final String nextVersion; + + /// Scroll-to-latest button label. + final String scrollToLatest; + + /// Composer placeholder text. + final String messageHint; + + /// Read-aloud / collapsible reasoning section title. + final String reasoning; + + /// Chain-of-thought section title. + final String chainOfThought; + + /// Approve action on a confirmation card. + final String allow; + + /// Deny action on a confirmation card. + final String deny; + + /// Accessibility label while the assistant is generating. + final String thinking; + + /// Accessibility label for a loading placeholder. + final String loading; + + /// Avatar accessibility label for the user. + final String you; + + /// Avatar accessibility label for the assistant. + final String assistant; + + /// The nearest [AiLocalizations]. Resolution order: an [AiLocalizationsScope] + /// in the tree (the simplest way to override — no delegate wiring), then a + /// `Localizations` delegate, then the English defaults. + static AiLocalizations of(BuildContext context) => + context + .dependOnInheritedWidgetOfExactType() + ?.strings ?? + Localizations.of(context, AiLocalizations) ?? + const AiLocalizations(); + + /// A delegate serving the English defaults. Wrap your own + /// [AiLocalizations] with [AiLocalizationsDelegate] to translate. Prefer + /// [AiLocalizationsScope] unless you switch strings by locale. + static const LocalizationsDelegate delegate = + AiLocalizationsDelegate(); +} + +/// Overrides the [AiLocalizations] for the widgets below it — the simplest way +/// to translate or customize labels, with no `localizationsDelegates` wiring: +/// +/// ```dart +/// AiLocalizationsScope( +/// strings: const AiLocalizations(send: 'Envoyer', copy: 'Copier'), +/// child: myChat, +/// ); +/// ``` +class AiLocalizationsScope extends InheritedWidget { + /// Provides [strings] to descendants. + const AiLocalizationsScope({ + super.key, + required this.strings, + required super.child, + }); + + /// The strings descendants read via [AiLocalizations.of]. + final AiLocalizations strings; + + @override + bool updateShouldNotify(AiLocalizationsScope oldWidget) => + oldWidget.strings != strings; +} + +/// Serves a fixed [AiLocalizations] instance. Provide a translated instance to +/// localize, or implement your own delegate to switch by locale. +class AiLocalizationsDelegate extends LocalizationsDelegate { + /// Creates a delegate serving [strings] (English defaults if omitted). + const AiLocalizationsDelegate([this.strings = const AiLocalizations()]); + + /// The strings this delegate serves. + final AiLocalizations strings; + + @override + bool isSupported(Locale locale) => true; + + @override + Future load(Locale locale) async => strings; + + @override + bool shouldReload(AiLocalizationsDelegate old) => + !identical(old.strings, strings); +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/rendering/ai_text_renderer.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/rendering/ai_text_renderer.dart new file mode 100644 index 0000000..ee24fe1 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/rendering/ai_text_renderer.dart @@ -0,0 +1,24 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_ai_core/flutter_ai_core.dart'; + +/// A `TextRenderer` that produces a Flutter [Widget] — the rendering seam used +/// throughout the UI. +/// +/// The widgets default to `MarkdownTextRenderer` (Markdown, incl. fenced code +/// blocks). Inject [PlainTextRenderer] for raw text, or a custom implementation +/// (for example a LaTeX renderer) wherever a renderer is accepted. +typedef AiTextRenderer = TextRenderer; + +/// A renderer that emits a plain [Text] widget (opt in; the widgets default to +/// `MarkdownTextRenderer`). +/// +/// It intentionally sets no color or size so the surrounding `DefaultTextStyle` +/// (driven by the active theme and message role) governs appearance. Use it when +/// you want raw, unformatted text instead of the default Markdown rendering. +class PlainTextRenderer implements AiTextRenderer { + /// Creates a plain-text renderer. + const PlainTextRenderer(); + + @override + Widget render(String text, {required bool isStreaming}) => Text(text); +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/theme/ai_theme_extension.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/theme/ai_theme_extension.dart new file mode 100644 index 0000000..846a99b --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/theme/ai_theme_extension.dart @@ -0,0 +1,358 @@ +import 'dart:ui' show lerpDouble; + +import 'package:flutter/material.dart'; + +/// How an assistant message is laid out. +enum AiMessageStyle { + /// Full-width text on the page, with no container — the modern AI-assistant + /// look (ChatGPT / Claude / Gemini). The default. + plain, + + /// Wrapped in a filled bubble, like a messaging app. + bubble, +} + +/// The design tokens that style every `flutter_ai_elements` widget. +/// +/// Registered as a Flutter [ThemeExtension], so the components adopt any host +/// design system without being hardcoded to Material or Cupertino. Read it with +/// [AiThemeExtension.of]; override individual tokens via [copyWith]; or replace +/// it wholesale in `ThemeData.extensions`. +/// +/// [AiThemeExtension.fallback] supplies a clean, modern default modeled on +/// current AI assistants: a near-monochrome palette, a **bubble-less** assistant +/// ([AiMessageStyle.plain]), a quiet user bubble, no shadows, and a solid +/// [accentColor] for actions. Re-theme it to anything, or swap message rendering +/// entirely via `AiChat`'s `messageBuilder`. +/// +/// All visual constants for the package live behind this one extension, so a +/// future `flutter_ai_design_system` can lift them out without an API break. +@immutable +class AiThemeExtension extends ThemeExtension { + /// Creates a theme extension. Prefer [AiThemeExtension.fallback] and + /// [copyWith] for most cases. + const AiThemeExtension({ + required this.assistantMessageStyle, + required this.userBubbleColor, + required this.assistantBubbleColor, + this.chipColor, + required this.userTextColor, + required this.assistantTextColor, + required this.accentColor, + required this.onAccentColor, + required this.borderColor, + required this.errorColor, + required this.successColor, + required this.warningColor, + required this.codeBackgroundColor, + required this.codeForegroundColor, + required this.linkColor, + required this.bubbleRadius, + required this.bubbleShadow, + required this.bubblePadding, + required this.messageSpacing, + required this.maxBubbleWidthFraction, + required this.maxContentWidth, + required this.composerPadding, + required this.textStyle, + required this.codeStyle, + required this.loaderColor, + required this.orbColor, + required this.motionDuration, + required this.motionCurve, + required this.enableHaptics, + }); + + /// The modern, near-monochrome default (light). + factory AiThemeExtension.fallback() => const AiThemeExtension( + assistantMessageStyle: AiMessageStyle.plain, + userBubbleColor: Color(0xFFF4F4F4), + assistantBubbleColor: Color(0xFFF7F7F8), + userTextColor: Color(0xFF0D0D0D), + assistantTextColor: Color(0xFF0D0D0D), + accentColor: Color(0xFF0D0D0D), + onAccentColor: Color(0xFFFFFFFF), + borderColor: Color(0xFFE5E5E5), + errorColor: Color(0xFFDC2626), + successColor: Color(0xFF16A34A), + warningColor: Color(0xFFF59E0B), + codeBackgroundColor: Color(0xFF1E1E1E), + codeForegroundColor: Color(0xFFE6E6E6), + linkColor: Color(0xFF2563EB), + bubbleRadius: BorderRadius.all(Radius.circular(22)), + bubbleShadow: [], + bubblePadding: EdgeInsets.symmetric(horizontal: 16, vertical: 11), + messageSpacing: 18, + maxBubbleWidthFraction: 0.80, + maxContentWidth: 720, + composerPadding: EdgeInsets.fromLTRB(14, 8, 14, 12), + textStyle: TextStyle(fontSize: 16.5, height: 1.5), + codeStyle: + TextStyle(fontFamily: 'monospace', fontSize: 14, height: 1.45), + loaderColor: Color(0xFF8E8EA0), + orbColor: Color(0xFF2F7BE6), + motionDuration: Duration(milliseconds: 240), + motionCurve: Curves.easeOutCubic, + enableHaptics: true, + ); + + /// A dark counterpart to [AiThemeExtension.fallback]. Pair it with a dark + /// `ThemeData` so ambient text/icon colors are light. + factory AiThemeExtension.dark() => const AiThemeExtension( + assistantMessageStyle: AiMessageStyle.plain, + userBubbleColor: Color(0xFF2F2F33), + assistantBubbleColor: Color(0xFF202024), + userTextColor: Color(0xFFECECEC), + assistantTextColor: Color(0xFFECECEC), + accentColor: Color(0xFFFFFFFF), + onAccentColor: Color(0xFF0D0D0D), + borderColor: Color(0xFF3A3A40), + errorColor: Color(0xFFF87171), + successColor: Color(0xFF4ADE80), + warningColor: Color(0xFFFBBF24), + codeBackgroundColor: Color(0xFF1E1E1E), + codeForegroundColor: Color(0xFFE6E6E6), + linkColor: Color(0xFF60A5FA), + bubbleRadius: BorderRadius.all(Radius.circular(22)), + bubbleShadow: [], + bubblePadding: EdgeInsets.symmetric(horizontal: 16, vertical: 11), + messageSpacing: 18, + maxBubbleWidthFraction: 0.80, + maxContentWidth: 720, + composerPadding: EdgeInsets.fromLTRB(14, 8, 14, 12), + textStyle: TextStyle(fontSize: 16.5, height: 1.5), + codeStyle: + TextStyle(fontFamily: 'monospace', fontSize: 14, height: 1.45), + loaderColor: Color(0xFF8E8EA0), + orbColor: Color(0xFF2F7BE6), + motionDuration: Duration(milliseconds: 240), + motionCurve: Curves.easeOutCubic, + enableHaptics: true, + ); + + /// How assistant messages are laid out (plain full-width vs. bubble). + final AiMessageStyle assistantMessageStyle; + + /// Background of a user's message bubble. + final Color userBubbleColor; + + /// Background of an assistant bubble (used when [assistantMessageStyle] is + /// [AiMessageStyle.bubble]) and of the composer field. + final Color assistantBubbleColor; + + /// Fill for small standalone surfaces — suggestion/starter chips, the selected + /// conversation-list row, and the scroll-to-latest button. + /// + /// These reuse [assistantBubbleColor] when this is null. Set it explicitly for + /// **bubble-less** themes (a transparent [assistantBubbleColor], e.g. a + /// Gemini-style plain assistant) so those chips/rows don't visually vanish. + final Color? chipColor; + + /// The resolved chip/selection surface: [chipColor] if set, otherwise + /// [assistantBubbleColor]. Widgets should read this rather than + /// [assistantBubbleColor] directly. + Color get effectiveChipColor => chipColor ?? assistantBubbleColor; + + /// Text color inside a user bubble. + final Color userTextColor; + + /// Text color for assistant content. + final Color assistantTextColor; + + /// Solid accent for primary actions (the send button, etc.). + final Color accentColor; + + /// Foreground drawn on top of [accentColor]. + final Color onAccentColor; + + /// Hairline/border color for fields, cards, and dividers. + final Color borderColor; + + /// Error/destructive accent (error banner, failed tool, danger states). + final Color errorColor; + + /// Success/positive accent (completed tasks, succeeded tools). + final Color successColor; + + /// Warning/caution accent (e.g. context meter approaching the limit). + final Color warningColor; + + /// Background of a fenced code block. + final Color codeBackgroundColor; + + /// Default foreground (text) color inside a fenced code block. + final Color codeForegroundColor; + + /// Color of inline links in rendered Markdown. + final Color linkColor; + + /// Corner radius of bubbles and the composer field. + final BorderRadius bubbleRadius; + + /// Shadow cast by bubbles. Empty by default (flat). + final List bubbleShadow; + + /// Inner padding of a message bubble. + final EdgeInsets bubblePadding; + + /// Vertical gap between consecutive messages. + final double messageSpacing; + + /// Maximum width of a *bubble* as a fraction of available width (`0`–`1`). + /// Plain assistant messages always span the full width. + final double maxBubbleWidthFraction; + + /// Default reading-width the conversation column is centered at on wide + /// screens, so prose doesn't run edge-to-edge (like ChatGPT/Claude on + /// desktop). Set to [double.infinity] for full-width. A `maxContentWidth` + /// passed directly to a widget overrides this. + final double maxContentWidth; + + /// Padding around the composer. + final EdgeInsets composerPadding; + + /// Base text style for message prose. + final TextStyle textStyle; + + /// Text style for code spans and blocks. + final TextStyle codeStyle; + + /// Color of the thinking/typing loader. + final Color loaderColor; + + /// Base color of the live-voice `AiOrb` / `AiLiveSession` orb. + final Color orbColor; + + /// Duration for entrance and state-change animations. + final Duration motionDuration; + + /// Curve for entrance and state-change animations. + final Curve motionCurve; + + /// Whether widgets emit haptic feedback on key interactions. + final bool enableHaptics; + + /// Returns the extension from [context], or [AiThemeExtension.fallback] if no + /// theme provides one. + static AiThemeExtension of(BuildContext context) => + Theme.of(context).extension() ?? + AiThemeExtension.fallback(); + + @override + AiThemeExtension copyWith({ + AiMessageStyle? assistantMessageStyle, + Color? userBubbleColor, + Color? assistantBubbleColor, + Color? chipColor, + Color? userTextColor, + Color? assistantTextColor, + Color? accentColor, + Color? onAccentColor, + Color? borderColor, + Color? errorColor, + Color? successColor, + Color? warningColor, + Color? codeBackgroundColor, + Color? codeForegroundColor, + Color? linkColor, + BorderRadius? bubbleRadius, + List? bubbleShadow, + EdgeInsets? bubblePadding, + double? messageSpacing, + double? maxBubbleWidthFraction, + double? maxContentWidth, + EdgeInsets? composerPadding, + TextStyle? textStyle, + TextStyle? codeStyle, + Color? loaderColor, + Color? orbColor, + Duration? motionDuration, + Curve? motionCurve, + bool? enableHaptics, + }) => + AiThemeExtension( + assistantMessageStyle: + assistantMessageStyle ?? this.assistantMessageStyle, + userBubbleColor: userBubbleColor ?? this.userBubbleColor, + assistantBubbleColor: assistantBubbleColor ?? this.assistantBubbleColor, + chipColor: chipColor ?? this.chipColor, + userTextColor: userTextColor ?? this.userTextColor, + assistantTextColor: assistantTextColor ?? this.assistantTextColor, + accentColor: accentColor ?? this.accentColor, + onAccentColor: onAccentColor ?? this.onAccentColor, + borderColor: borderColor ?? this.borderColor, + errorColor: errorColor ?? this.errorColor, + successColor: successColor ?? this.successColor, + warningColor: warningColor ?? this.warningColor, + codeBackgroundColor: codeBackgroundColor ?? this.codeBackgroundColor, + codeForegroundColor: codeForegroundColor ?? this.codeForegroundColor, + linkColor: linkColor ?? this.linkColor, + bubbleRadius: bubbleRadius ?? this.bubbleRadius, + bubbleShadow: bubbleShadow ?? this.bubbleShadow, + bubblePadding: bubblePadding ?? this.bubblePadding, + messageSpacing: messageSpacing ?? this.messageSpacing, + maxBubbleWidthFraction: + maxBubbleWidthFraction ?? this.maxBubbleWidthFraction, + maxContentWidth: maxContentWidth ?? this.maxContentWidth, + composerPadding: composerPadding ?? this.composerPadding, + textStyle: textStyle ?? this.textStyle, + codeStyle: codeStyle ?? this.codeStyle, + loaderColor: loaderColor ?? this.loaderColor, + orbColor: orbColor ?? this.orbColor, + motionDuration: motionDuration ?? this.motionDuration, + motionCurve: motionCurve ?? this.motionCurve, + enableHaptics: enableHaptics ?? this.enableHaptics, + ); + + @override + AiThemeExtension lerp(covariant AiThemeExtension? other, double t) { + if (other == null) return this; + return AiThemeExtension( + assistantMessageStyle: + t < 0.5 ? assistantMessageStyle : other.assistantMessageStyle, + userBubbleColor: Color.lerp(userBubbleColor, other.userBubbleColor, t)!, + assistantBubbleColor: + Color.lerp(assistantBubbleColor, other.assistantBubbleColor, t)!, + chipColor: Color.lerp(chipColor, other.chipColor, t), + userTextColor: Color.lerp(userTextColor, other.userTextColor, t)!, + assistantTextColor: + Color.lerp(assistantTextColor, other.assistantTextColor, t)!, + accentColor: Color.lerp(accentColor, other.accentColor, t)!, + onAccentColor: Color.lerp(onAccentColor, other.onAccentColor, t)!, + borderColor: Color.lerp(borderColor, other.borderColor, t)!, + errorColor: Color.lerp(errorColor, other.errorColor, t)!, + successColor: Color.lerp(successColor, other.successColor, t)!, + warningColor: Color.lerp(warningColor, other.warningColor, t)!, + codeBackgroundColor: + Color.lerp(codeBackgroundColor, other.codeBackgroundColor, t)!, + codeForegroundColor: + Color.lerp(codeForegroundColor, other.codeForegroundColor, t)!, + linkColor: Color.lerp(linkColor, other.linkColor, t)!, + bubbleRadius: BorderRadius.lerp(bubbleRadius, other.bubbleRadius, t)!, + bubbleShadow: BoxShadow.lerpList(bubbleShadow, other.bubbleShadow, t) ?? + bubbleShadow, + bubblePadding: EdgeInsets.lerp(bubblePadding, other.bubblePadding, t)!, + messageSpacing: lerpDouble(messageSpacing, other.messageSpacing, t)!, + maxBubbleWidthFraction: lerpDouble( + maxBubbleWidthFraction, + other.maxBubbleWidthFraction, + t, + )!, + // Guard against a non-finite reading width (e.g. double.infinity) which + // would lerp to NaN; snap discretely instead. + maxContentWidth: + (maxContentWidth.isFinite && other.maxContentWidth.isFinite) + ? lerpDouble(maxContentWidth, other.maxContentWidth, t)! + : (t < 0.5 ? maxContentWidth : other.maxContentWidth), + composerPadding: + EdgeInsets.lerp(composerPadding, other.composerPadding, t)!, + textStyle: TextStyle.lerp(textStyle, other.textStyle, t)!, + codeStyle: TextStyle.lerp(codeStyle, other.codeStyle, t)!, + loaderColor: Color.lerp(loaderColor, other.loaderColor, t)!, + orbColor: Color.lerp(orbColor, other.orbColor, t)!, + motionDuration: t < 0.5 ? motionDuration : other.motionDuration, + motionCurve: t < 0.5 ? motionCurve : other.motionCurve, + enableHaptics: t < 0.5 ? enableHaptics : other.enableHaptics, + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_animated_response.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_animated_response.dart new file mode 100644 index 0000000..39e25cc --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_animated_response.dart @@ -0,0 +1,327 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; + +/// Reveals a streamed answer with a trailing **blur fade-in** — the +/// Apple-Intelligence / Siri look — instead of a hard typewriter edge. +/// +/// Text appears progressively (paced like [charsPerSecond], accelerating to +/// drain a backlog within [catchUpWindow] so it never trails far behind a fast +/// stream), and each newly revealed word arrives blurred and semi-transparent, +/// then sharpens and fades into place over [fadeDuration]. Only the few words +/// at the leading edge animate at once, so the cost stays bounded no matter how +/// long the answer is. +/// +/// This renders the in-flight text as **plain prose** (no Markdown formatting) +/// — inline blur can only be applied to whole inline boxes, not to spans inside +/// a laid-out paragraph. Use it for the *streaming* message only; completed +/// messages should render with the full Markdown widget so headings, lists, +/// code, and links come back. +class AiAnimatedResponse extends StatefulWidget { + /// Creates an animated Markdown response. + const AiAnimatedResponse({ + super.key, + required this.text, + this.onLinkTap, + this.charsPerSecond = 120, + this.catchUpWindow = const Duration(seconds: 1), + this.fadeDuration = const Duration(milliseconds: 340), + this.blurSigma = 5, + }); + + /// The (growing) source text to reveal. + final String text; + + /// Reserved for API compatibility with the completed renderer. Links are not + /// tappable during the animated phase (the in-flight text is plain prose); + /// they become active once the message settles into the Markdown renderer. + final void Function(Uri url)? onLinkTap; + + /// The baseline (readable) reveal speed used while the typewriter is keeping + /// up with the stream feeding it. Tuned to a comfortable reading pace. + final double charsPerSecond; + + /// When the reveal falls behind the stream, it accelerates so the remaining + /// backlog drains within this window — keeping the pace readable on slow + /// streams while never trailing far behind a fast one. + final Duration catchUpWindow; + + /// How long each freshly revealed word takes to sharpen from blurred and + /// faded to crisp and opaque. + final Duration fadeDuration; + + /// The blur applied to a word the moment it appears, in logical pixels. It + /// eases to zero over [fadeDuration]. + final double blurSigma; + + @override + State createState() => _AiAnimatedResponseState(); +} + +class _AiAnimatedResponseState extends State + with SingleTickerProviderStateMixin { + /// At most this many trailing words animate at once, bounding the number of + /// blur/opacity layers regardless of how fast the stream bursts. + static const _maxAnimating = 6; + + late final Ticker _ticker; + + /// `[start, end)` char ranges of every non-whitespace run in the text. + List> _words = const []; + + /// Word start offset -> the ticker time at which it became fully revealed. + final Map _births = {}; + + int _shown = 0; // characters revealed so far + int _settledCursor = 0; // index into [_words] whose births are recorded + Duration _last = Duration.zero; + Duration _elapsed = Duration.zero; + + @visibleForTesting + int get shownChars => _shown; + + @override + void initState() { + super.initState(); + _retokenize(); + _ticker = createTicker(_tick); + if (widget.text.isNotEmpty) unawaited(_ticker.start()); + } + + void _retokenize() { + final s = widget.text; + final words = >[]; + var i = 0; + while (i < s.length) { + if (_isSpace(s.codeUnitAt(i))) { + i++; + continue; + } + final start = i; + while (i < s.length && !_isSpace(s.codeUnitAt(i))) { + i++; + } + words.add([start, i]); + } + _words = words; + } + + static bool _isSpace(int c) => + c == 0x20 || c == 0x0A || c == 0x09 || c == 0x0D; + + @override + void didUpdateWidget(AiAnimatedResponse oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.text == widget.text) return; + // If the text was replaced (e.g. a regenerate) rather than appended to, + // restart the reveal from the beginning. + final appended = widget.text.startsWith(oldWidget.text.substring( + 0, + math.min(oldWidget.text.length, widget.text.length), + )); + _retokenize(); + if (!appended) { + _shown = 0; + _settledCursor = 0; + _births.clear(); + } + if (!_settled() && !_ticker.isActive) { + _last = Duration.zero; + unawaited(_ticker.start()); + } + } + + /// True once everything is revealed and the last word has finished sharpening. + bool _settled() { + if (_shown < widget.text.length) return false; + if (_words.isEmpty) return true; + final birth = _births[_words.last[0]]; + if (birth == null) return false; + return (_elapsed - birth) >= widget.fadeDuration; + } + + void _tick(Duration elapsed) { + final dt = _last == Duration.zero + ? 0.0 + : (elapsed - _last).inMicroseconds / Duration.microsecondsPerSecond; + _last = elapsed; + _elapsed = elapsed; + + final target = widget.text.length; + // Reveal at the readable baseline while caught up, but accelerate to drain + // a large backlog within [catchUpWindow] so the typewriter never trails far + // behind the stream once the answer has fully arrived. + final window = + widget.catchUpWindow.inMicroseconds / Duration.microsecondsPerSecond; + final backlogRate = + window > 0 ? (target - _shown) / window : double.infinity; + final rate = math.max(widget.charsPerSecond, backlogRate); + final step = math.max(1, (rate * dt).round()); + _shown = _shown + step < target ? _shown + step : target; + + // Stamp the birth time of every word that just became fully revealed. + while ( + _settledCursor < _words.length && _words[_settledCursor][1] <= _shown) { + _births.putIfAbsent(_words[_settledCursor][0], () => elapsed); + _settledCursor++; + } + + if (_settled()) { + _ticker.stop(); + _last = Duration.zero; + } + setState(() {}); + } + + @override + void dispose() { + _ticker.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + final base = DefaultTextStyle.of(context).style.merge(theme.textStyle); + final text = widget.text; + + // Respect the platform "reduce motion" setting: skip the blur/typewriter + // and show the text as-is (an accessibility requirement, WCAG 2.3.3). + if (MediaQuery.maybeDisableAnimationsOf(context) ?? false) { + return Text(text, style: base); + } + + final visible = math.min(_shown, text.length); + + // Index of the last word that is fully revealed; only the trailing + // [_maxAnimating] of these (plus any partially revealed word) animate. + var lastFull = -1; + for (var i = 0; i < _words.length; i++) { + if (_words[i][1] <= visible) { + lastFull = i; + } else { + break; + } + } + final animateFrom = lastFull - _maxAnimating + 1; + + final spans = []; + final settled = StringBuffer(); + var cursor = 0; + for (var i = 0; i < _words.length; i++) { + final start = _words[i][0]; + final end = _words[i][1]; + if (start >= visible) break; + if (start > cursor) settled.write(text.substring(cursor, start)); + final shownEnd = math.min(end, visible); + final partial = end > visible; + + double t; // 0 = just born (blurred), 1 = settled (crisp) + if (partial) { + t = 0; + } else { + final birth = _births[start]; + t = birth == null + ? 1 + : ((_elapsed - birth).inMicroseconds / + widget.fadeDuration.inMicroseconds) + .clamp(0.0, 1.0); + } + + final recent = i >= animateFrom; + if (t >= 1.0 || (!partial && !recent)) { + settled.write(text.substring(start, shownEnd)); + } else { + if (settled.isNotEmpty) { + spans.add(TextSpan(text: settled.toString(), style: base)); + settled.clear(); + } + final eased = Curves.easeOut.transform(t); + spans.add(WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: Opacity( + opacity: 0.25 + 0.75 * eased, + child: ImageFiltered( + imageFilter: ui.ImageFilter.blur( + sigmaX: widget.blurSigma * (1 - eased), + sigmaY: widget.blurSigma * (1 - eased), + tileMode: TileMode.decal, + ), + child: Text(text.substring(start, shownEnd), style: base), + ), + ), + )); + } + cursor = shownEnd; + } + if (cursor < visible) settled.write(text.substring(cursor, visible)); + if (settled.isNotEmpty) { + spans.add(TextSpan(text: settled.toString(), style: base)); + } + + // A blinking caret at the leading edge — the "being written right now" cue. + spans.add(WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: _Caret( + key: const ValueKey('ai-caret'), + color: base.color ?? const Color(0xFF000000), + base: base, + ), + )); + + // Isolate the per-frame repaint of the animating reveal from the rest of + // the message/list so the blur layers don't dirty their neighbors. + return RepaintBoundary( + child: Text.rich(TextSpan(children: spans, style: base)), + ); + } +} + +/// A thin blinking text caret. Holds steady (no blink) under reduce-motion. +class _Caret extends StatefulWidget { + const _Caret({super.key, required this.color, required this.base}); + + final Color color; + final TextStyle base; + + @override + State<_Caret> createState() => _CaretState(); +} + +class _CaretState extends State<_Caret> with SingleTickerProviderStateMixin { + late final AnimationController _blink = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1100), + )..repeat(); + + @override + void dispose() { + _blink.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final height = (widget.base.fontSize ?? 16) * (widget.base.height ?? 1.2); + final bar = Padding( + padding: const EdgeInsetsDirectional.only(start: 1), + child: Container(width: 2, height: height * 0.78, color: widget.color), + ); + if (MediaQuery.maybeDisableAnimationsOf(context) ?? false) return bar; + return FadeTransition( + // Square wave-ish blink: mostly on, brief off. + opacity: _blink.drive( + TweenSequence([ + TweenSequenceItem(tween: ConstantTween(1), weight: 55), + TweenSequenceItem(tween: ConstantTween(0), weight: 45), + ]), + ), + child: bar, + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_attachment.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_attachment.dart new file mode 100644 index 0000000..2a556f4 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_attachment.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ai_core/flutter_ai_core.dart'; + +/// A compact preview of a [FilePart] attachment. +/// +/// Images (with inline bytes or a URL) render as a rounded thumbnail; everything +/// else renders as a labeled file chip. Document text extraction is out of scope +/// — that belongs to a backend, off the UI thread. +class AiAttachment extends StatelessWidget { + /// Creates an attachment preview for [file]. + const AiAttachment({ + super.key, + required this.file, + this.maxImageHeight = 200, + }); + + /// The file to preview. + final FilePart file; + + /// Maximum height for image previews. + final double maxImageHeight; + + bool get _isImage => file.mediaType.startsWith('image/'); + + @override + Widget build(BuildContext context) { + if (_isImage) { + final image = _buildImage(); + if (image != null) { + return Semantics( + image: true, + label: file.name ?? 'Image attachment', + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxImageHeight), + child: image, + ), + ), + ); + } + } + return _FileChip(label: file.name ?? file.mediaType); + } + + Widget? _buildImage() { + final bytes = file.bytes; + if (bytes != null) { + return Image.memory(bytes, fit: BoxFit.cover, errorBuilder: _onError); + } + final url = file.url; + if (url != null) { + return Image.network( + url.toString(), + fit: BoxFit.cover, + errorBuilder: _onError, + ); + } + return null; + } + + Widget _onError(BuildContext context, Object error, StackTrace? stack) => + _FileChip(label: file.name ?? file.mediaType); +} + +class _FileChip extends StatelessWidget { + const _FileChip({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final color = DefaultTextStyle.of(context).style.color; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: (color ?? const Color(0xFF000000)).withValues(alpha: 0.18), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.insert_drive_file_outlined, size: 16, color: color), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: color), + ), + ), + ], + ), + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_avatar.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_avatar.dart new file mode 100644 index 0000000..dbbea6d --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_avatar.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ai_core/flutter_ai_core.dart'; +import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; + +/// A small circular avatar identifying a message's author. +/// +/// Colors derive from the active [AiThemeExtension]; override the icon per role. +class AiAvatar extends StatelessWidget { + /// Creates an avatar for [role]. + const AiAvatar({ + super.key, + required this.role, + this.size = 32, + this.userIcon = Icons.person_outline, + this.assistantIcon = Icons.auto_awesome, + }); + + /// The author whose avatar to show. + final AiRole role; + + /// Diameter of the avatar. + final double size; + + /// Icon for user/system messages. + final IconData userIcon; + + /// Icon for assistant/tool messages. + final IconData assistantIcon; + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + final isUser = role == AiRole.user || role == AiRole.system; + final background = + isUser ? theme.userBubbleColor : theme.assistantBubbleColor; + final foreground = isUser ? theme.userTextColor : theme.assistantTextColor; + return Container( + width: size, + height: size, + decoration: BoxDecoration(color: background, shape: BoxShape.circle), + alignment: Alignment.center, + child: Icon( + isUser ? userIcon : assistantIcon, + size: size * 0.56, + color: foreground, + semanticLabel: isUser + ? AiLocalizations.of(context).you + : AiLocalizations.of(context).assistant, + ), + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_branch.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_branch.dart new file mode 100644 index 0000000..50b948d --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_branch.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; + +/// A compact "‹ 2/3 ›" control for navigating between alternate versions of a +/// message (e.g. successive regenerations). +/// +/// Purely presentational: it reports navigation via [onPrevious]/[onNext] and +/// shows [index] of [total] (both 1-based for display; pass a 0-based [index]). +class AiBranch extends StatelessWidget { + /// Creates a branch navigator. + const AiBranch({ + super.key, + required this.index, + required this.total, + this.onPrevious, + this.onNext, + }); + + /// The 0-based index of the current version. + final int index; + + /// The total number of versions. + final int total; + + /// Called to go to the previous version. Disabled at the first. + final VoidCallback? onPrevious; + + /// Called to go to the next version. Disabled at the last. + final VoidCallback? onNext; + + @override + Widget build(BuildContext context) { + if (total <= 1) return const SizedBox.shrink(); + final theme = AiThemeExtension.of(context); + final l = AiLocalizations.of(context); + final color = DefaultTextStyle.of(context).style.color?.withValues( + alpha: 0.7, + ); + final canPrev = index > 0 && onPrevious != null; + final canNext = index < total - 1 && onNext != null; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + _Arrow( + icon: Icons.chevron_left, + label: l.previousVersion, + color: color, + onTap: canPrev ? onPrevious : null, + ), + Text( + '${index + 1}/$total', + style: theme.codeStyle.copyWith(fontSize: 12, color: color), + ), + _Arrow( + icon: Icons.chevron_right, + label: l.nextVersion, + color: color, + onTap: canNext ? onNext : null, + ), + ], + ); + } +} + +class _Arrow extends StatelessWidget { + const _Arrow({ + required this.icon, + required this.label, + required this.color, + required this.onTap, + }); + + final IconData icon; + final String label; + final Color? color; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + enabled: onTap != null, + label: label, + child: GestureDetector( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon( + icon, + size: 18, + color: onTap == null ? color?.withValues(alpha: 0.3) : color, + ), + ), + ), + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_chain_of_thought.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_chain_of_thought.dart new file mode 100644 index 0000000..4dae830 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_chain_of_thought.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; + +/// One step in an [AiChainOfThought]. +@immutable +class AiThoughtStep { + /// Creates a step with a [label] and optional [detail]. + const AiThoughtStep({ + required this.label, + this.detail, + this.isActive = false, + }); + + /// The step's headline. + final String label; + + /// Optional supporting detail shown beneath the label. + final String? detail; + + /// Whether this step is the one currently in progress. + final bool isActive; +} + +/// A collapsible, vertical timeline of reasoning steps. +/// +/// Richer than `AiReasoning` (which shows free-form text): use this when the +/// model exposes discrete steps (search → read → synthesize). +class AiChainOfThought extends StatefulWidget { + /// Creates a chain-of-thought timeline from [steps]. + const AiChainOfThought({ + super.key, + required this.steps, + this.title = 'Chain of thought', + this.initiallyExpanded = false, + }); + + /// The ordered steps. + final List steps; + + /// The disclosure label. + final String title; + + /// Whether the timeline starts expanded. + final bool initiallyExpanded; + + @override + State createState() => _AiChainOfThoughtState(); +} + +class _AiChainOfThoughtState extends State { + late bool _expanded = widget.initiallyExpanded; + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + final color = DefaultTextStyle.of(context).style.color; + final subdued = color?.withValues(alpha: 0.6); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + button: true, + expanded: _expanded, + child: InkWell( + onTap: () => setState(() => _expanded = !_expanded), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.account_tree_outlined, size: 16, color: subdued), + const SizedBox(width: 6), + Text( + widget.title, + style: TextStyle( + color: subdued, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + Icon( + _expanded ? Icons.expand_less : Icons.expand_more, + size: 18, + color: subdued, + ), + ], + ), + ), + ), + AnimatedSize( + duration: theme.motionDuration, + curve: theme.motionCurve, + alignment: Alignment.topCenter, + child: _expanded + ? Padding( + padding: const EdgeInsets.only(top: 8, left: 2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < widget.steps.length; i++) + _StepRow( + step: widget.steps[i], + isLast: i == widget.steps.length - 1, + theme: theme, + textColor: color, + subdued: subdued, + ), + ], + ), + ) + : const SizedBox(width: double.infinity), + ), + ], + ); + } +} + +class _StepRow extends StatelessWidget { + const _StepRow({ + required this.step, + required this.isLast, + required this.theme, + required this.textColor, + required this.subdued, + }); + + final AiThoughtStep step; + final bool isLast; + final AiThemeExtension theme; + final Color? textColor; + final Color? subdued; + + @override + Widget build(BuildContext context) { + final dotColor = step.isActive ? theme.accentColor : subdued; + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + Container( + width: 9, + height: 9, + margin: const EdgeInsets.only(top: 4), + decoration: + BoxDecoration(color: dotColor, shape: BoxShape.circle), + ), + if (!isLast) + Expanded( + child: Container(width: 1.5, color: theme.borderColor), + ), + ], + ), + const SizedBox(width: 10), + Expanded( + child: Padding( + padding: EdgeInsets.only(bottom: isLast ? 0 : 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + step.label, + style: theme.textStyle.copyWith( + color: textColor, + fontSize: 14.5, + fontWeight: + step.isActive ? FontWeight.w600 : FontWeight.w400, + ), + ), + if (step.detail != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + step.detail!, + style: theme.textStyle.copyWith( + color: subdued, + fontSize: 13, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_chat.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_chat.dart new file mode 100644 index 0000000..e65b0ae --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_chat.dart @@ -0,0 +1,364 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_ai_client/flutter_ai_client.dart'; +import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; +import 'package:flutter_ai_elements/src/rendering/ai_text_renderer.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; +import 'package:flutter_ai_elements/src/widgets/ai_conversation_view.dart'; +import 'package:flutter_ai_elements/src/widgets/ai_haptics.dart'; +import 'package:flutter_ai_elements/src/widgets/ai_response.dart'; + +/// A live, drop-in chat transcript bound to a [UseChatController]. +/// +/// Rebuilds as the controller's transcript changes and shows a thinking loader +/// while awaiting the first token. +/// +/// When you send a message, the chat anchors that message to the **top** of the +/// viewport (ChatGPT-style) and lets the answer stream into the space below it, +/// reserving just enough trailing space and releasing it as the answer grows. +/// +/// Named `AiChat` rather than `AiConversation` to avoid colliding with the +/// `AiConversation` data model from `flutter_ai_core`. +class AiChat extends StatefulWidget { + /// Creates a chat transcript bound to [controller]. + const AiChat({ + super.key, + required this.controller, + this.textRenderer = const MarkdownTextRenderer(), + this.messageBuilder, + this.padding = const EdgeInsets.all(16), + this.autoScroll = true, + this.emptyState, + this.loadingBuilder, + this.maxContentWidth, + }); + + /// The chat controller to observe. + final UseChatController controller; + + /// Shown in place of the list while the conversation is empty and idle. + final Widget? emptyState; + + /// Renderer for message text. + final AiTextRenderer textRenderer; + + /// Optional override for how each message is built. + final Widget Function(BuildContext context, AiMessage message)? + messageBuilder; + + /// Padding around the list. + final EdgeInsets padding; + + /// Whether to auto-scroll to the newest message when already near the bottom. + final bool autoScroll; + + /// Builds the thinking indicator (defaults to `AiLoader`). + final WidgetBuilder? loadingBuilder; + + /// On wide screens, centers the conversation at this width. When `null`, + /// falls back to [AiThemeExtension.maxContentWidth]; pass [double.infinity] + /// for full-width. + final double? maxContentWidth; + + @override + State createState() => _AiChatState(); +} + +class _AiChatState extends State { + final ScrollController _scrollController = ScrollController(); + final GlobalKey _anchorKey = GlobalKey(); + + /// Id of the user message currently pinned to the top of the viewport. + String? _anchorId; + + /// Empty space reserved after the last item so the anchor can reach the top. + double _trailingSpace = 0; + + /// Whether we're actively holding the anchor at the top. Released when the + /// user scrolls manually, re-armed on the next sent message. + bool _pinned = false; + + /// Whether to show the floating "scroll to latest" button. + bool _showJump = false; + int _lastCount = 0; + + /// The controller status at the previous change, to detect turn completion. + ChatStatus? _lastStatus; + + /// Bounds the per-change settle retries (waiting for the anchor to lay out). + int _settleAttempts = 0; + + /// Coalesces overlapping settle callbacks into one per frame. + bool _settleScheduled = false; + + @override + void initState() { + super.initState(); + _lastCount = widget.controller.messages.length; + _lastStatus = widget.controller.status; + widget.controller.addListener(_onChange); + } + + @override + void didUpdateWidget(AiChat oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_onChange); + widget.controller.addListener(_onChange); + } + } + + @override + void dispose() { + widget.controller.removeListener(_onChange); + _scrollController.dispose(); + super.dispose(); + } + + void _onChange() { + // A light tap when a turn finishes (busy → idle) — independent of scrolling. + final status = widget.controller.status; + if (_lastStatus != null && + _lastStatus!.isBusy && + !status.isBusy && + mounted) { + aiLightHaptic(AiThemeExtension.of(context)); + } + _lastStatus = status; + + if (!widget.autoScroll) return; + final messages = widget.controller.messages; + final count = messages.length; + final newMessage = count > _lastCount; + _lastCount = count; + + if (messages.isEmpty) { + if (_anchorId != null || _trailingSpace != 0) { + setState(() { + _anchorId = null; + _trailingSpace = 0; + _pinned = false; + _showJump = false; + }); + } + return; + } + + if (newMessage) { + // Pin the latest user turn to the top for the whole turn. Start with no + // reserved space so the freshly-appended anchor (the last item) is within + // the lazy list's build area; _settle() then reserves what's needed and + // holds the anchor at the top as the answer streams in. + final lastUser = _lastUserId(messages); + if (lastUser != null) { + setState(() { + _anchorId = lastUser; + _trailingSpace = 0; + _pinned = true; + }); + } + } + // Re-assert the anchor every change while pinned so it *persists* at the top + // as the answer streams (not just on the first frame). + _settle(); + } + + static String? _lastUserId(List messages) { + for (var i = messages.length - 1; i >= 0; i--) { + if (messages[i].role == AiRole.user) return messages[i].id; + } + return null; + } + + /// Re-asserts the top-pin: reserves just enough trailing space for the + /// anchored message to reach the top, then holds it there. Retries across a + /// few frames while the anchor (or viewport) finishes laying out. + void _settle() { + _settleAttempts = 0; + _scheduleSettle(); + } + + void _scheduleSettle() { + if (_settleScheduled) return; + _settleScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _settleScheduled = false; + _doSettle(); + }); + } + + void _doSettle() { + if (!mounted || !_scrollController.hasClients) return; + final pos = _scrollController.position; + if (!pos.haveDimensions) { + if (_settleAttempts++ < 12) _scheduleSettle(); + return; + } + if (!_pinned || _anchorId == null) { + _updateJump(); + return; + } + + final box = _anchorKey.currentContext?.findRenderObject(); + if (box is! RenderBox || !box.attached) { + // The just-appended anchor isn't built yet. Nudge toward the end (it's the + // last real item, so this builds it) and retry — NEVER leave it bottom- + // pinned, which is what produced "shows previous messages". + if (_settleAttempts++ < 12) { + _scrollController.jumpTo(pos.maxScrollExtent); + _scheduleSettle(); + } + return; + } + + final viewport = pos.viewportDimension; + // Offset that puts the anchor at the very top — independent of the trailing + // spacer (which is below the anchor), so it's stable across frames. + final reveal = + RenderAbstractViewport.of(box).getOffsetToReveal(box, 0).offset; + // Body height excluding the current spacer, computed from this frame's + // consistent (max, trailing) pair — avoids the off-by-one feedback that made + // the reservation oscillate and the anchor land short of the top. + final body = pos.maxScrollExtent + viewport - _trailingSpace; + final contentBelow = body - reveal; + final desired = (viewport - contentBelow).clamp(0.0, viewport); + + if ((desired - _trailingSpace).abs() > 0.5) { + // Set the reservation and pin on the next frame, once it has laid out — + // don't jump using the stale (pre-relayout) extents. + setState(() => _trailingSpace = desired); + if (_settleAttempts++ < 12) _scheduleSettle(); + return; + } + // Reservation is correct for this layout: pin the anchor to the top. + _scrollController.jumpTo(reveal.clamp(0.0, pos.maxScrollExtent)); + _updateJump(); + } + + /// Shows the jump button whenever there's content below the fold. + void _updateJump() { + if (!_scrollController.hasClients) return; + final pos = _scrollController.position; + final show = pos.maxScrollExtent - pos.pixels > 240; + if (show != _showJump) setState(() => _showJump = show); + } + + // A user-initiated scroll away from the bottom releases the top-pin so we stop + // fighting the user, and frees the reserved space so they can't scroll into + // empty space below the last message. + // + // Touch drags surface as a ScrollStartNotification with dragDetails; mouse + // wheel, trackpad, and keyboard scrolling surface only as a + // UserScrollNotification (programmatic jumpTo never emits one, so this won't + // self-trigger). Releasing on an upward user scroll covers all input types. + bool _onScrollNotification(ScrollNotification n) { + final userDrag = n is ScrollStartNotification && n.dragDetails != null; + final scrolledUp = + n is UserScrollNotification && n.direction == ScrollDirection.forward; + if (_pinned && (userDrag || scrolledUp)) { + _pinned = false; + if (_trailingSpace != 0) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && !_pinned && _trailingSpace != 0) { + setState(() => _trailingSpace = 0); + } + }); + } + } + _updateJump(); + return false; + } + + void _jumpToLatest() { + _pinned = false; + if (_scrollController.hasClients) { + unawaited( + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: widget.controller, + builder: (context, _) { + if (widget.emptyState != null && + widget.controller.messages.isEmpty && + !widget.controller.status.isBusy) { + return widget.emptyState!; + } + final view = AiConversationView( + messages: widget.controller.messages, + scrollController: _scrollController, + textRenderer: widget.textRenderer, + messageBuilder: widget.messageBuilder, + loadingBuilder: widget.loadingBuilder, + maxContentWidth: widget.maxContentWidth, + padding: widget.padding, + // Show the loader only while awaiting the first streamed token. + showLoader: widget.controller.status == ChatStatus.submitted, + trailingSpace: widget.autoScroll ? _trailingSpace : 0, + anchorKey: _anchorKey, + anchorId: _anchorId, + ); + return NotificationListener( + onNotification: _onScrollNotification, + child: Stack( + children: [ + view, + if (_showJump) + PositionedDirectional( + bottom: 8, + start: 0, + end: 0, + child: Center(child: _JumpButton(onTap: _jumpToLatest)), + ), + ], + ), + ); + }, + ); + } +} + +/// A small circular "scroll to latest" affordance, shown when the conversation +/// has scrolled above the bottom. +class _JumpButton extends StatelessWidget { + const _JumpButton({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + return Semantics( + button: true, + label: AiLocalizations.of(context).scrollToLatest, + child: Material( + color: theme.effectiveChipColor, + shape: const CircleBorder(), + elevation: 2, + shadowColor: Colors.black.withValues(alpha: 0.2), + child: InkWell( + customBorder: const CircleBorder(), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + Icons.arrow_downward_rounded, + size: 20, + color: theme.assistantTextColor, + ), + ), + ), + ), + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_chat_view.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_chat_view.dart new file mode 100644 index 0000000..a7045db --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_chat_view.dart @@ -0,0 +1,88 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_ai_client/flutter_ai_client.dart'; +import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; +import 'package:flutter_ai_elements/src/rendering/ai_text_renderer.dart'; +import 'package:flutter_ai_elements/src/widgets/ai_chat.dart'; +import 'package:flutter_ai_elements/src/widgets/ai_prompt_input.dart'; +import 'package:flutter_ai_elements/src/widgets/ai_response.dart' + show MarkdownTextRenderer; + +/// A batteries-included chat surface: the [AiChat] transcript above an +/// [AiPromptInput], laid out and safe-area-aware. Drop it straight into a +/// `Scaffold` body — the fastest path from `pub add` to a working chat: +/// +/// ```dart +/// Scaffold(body: AiChatView(controller: controller)); +/// ``` +/// +/// Everything is overridable; reach for [AiChat] + [AiPromptInput] directly +/// only when you need a custom layout between them. +class AiChatView extends StatelessWidget { + /// Creates a chat surface bound to [controller]. + const AiChatView({ + super.key, + required this.controller, + this.textRenderer = const MarkdownTextRenderer(), + this.emptyState, + this.hintText, + this.maxContentWidth, + this.onPickAttachment, + this.onVoice, + this.onLive, + this.messageBuilder, + }); + + /// The chat controller to drive the transcript and input. + final UseChatController controller; + + /// Renderer for message text. Defaults to [MarkdownTextRenderer]. + final AiTextRenderer textRenderer; + + /// Shown when the conversation is empty. + final Widget? emptyState; + + /// Composer placeholder. Defaults to the localized "Message". + final String? hintText; + + /// On wide screens, centers the transcript at this width (like ChatGPT). + final double? maxContentWidth; + + /// Stages attachments to send with the next message. Hidden when null. + final Future> Function()? onPickAttachment; + + /// Voice-dictation entry point. Hidden when null. + final VoidCallback? onVoice; + + /// Live-voice entry point. Hidden when null. + final VoidCallback? onLive; + + /// Optional override for how each message is built. + final Widget Function(BuildContext context, AiMessage message)? + messageBuilder; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Column( + children: [ + Expanded( + child: AiChat( + controller: controller, + textRenderer: textRenderer, + emptyState: emptyState, + maxContentWidth: maxContentWidth, + messageBuilder: messageBuilder, + ), + ), + AiPromptInput( + controller: controller, + hintText: hintText ?? AiLocalizations.of(context).messageHint, + onPickAttachment: onPickAttachment, + onVoice: onVoice, + onLive: onLive, + ), + ], + ), + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_code_block.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_code_block.dart new file mode 100644 index 0000000..9b59c03 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_code_block.dart @@ -0,0 +1,98 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; + +/// Turns [code] in [language] into styled spans for syntax highlighting, using +/// [base] as the baseline text style (family/size/default color). +/// +/// Return `null` to fall back to unhighlighted monospace. This package ships no +/// grammar engine to stay dependency-free; supply one from the app (e.g. wrap +/// the `highlight` package) and pass it to [AiCodeBlock] / `AiResponse`. +typedef CodeHighlighter = List? Function( + String code, + String? language, + TextStyle base, +); + +/// A monospace code block with a header showing the language and a copy button. +/// +/// A useful building block for a custom `AiTextRenderer` that wants to present +/// fenced code distinctly from prose. Pass a [highlighter] to colorize the +/// source; without one it renders plain monospace. +class AiCodeBlock extends StatelessWidget { + /// Creates a code block for [code]. + const AiCodeBlock({ + super.key, + required this.code, + this.language, + this.highlighter, + }); + + /// The source code to display. + final String code; + + /// An optional language label (for example `dart`). + final String? language; + + /// Optional syntax highlighter. When `null`, code renders as plain monospace. + final CodeHighlighter? highlighter; + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + final l = AiLocalizations.of(context); + final background = theme.codeBackgroundColor; + final foreground = theme.codeForegroundColor; + return Container( + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(12), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const SizedBox(width: 12), + Expanded( + child: Text( + language ?? 'code', + style: const TextStyle( + color: Color(0xFF9CA3AF), + fontSize: 12, + ), + ), + ), + IconButton( + icon: + const Icon(Icons.copy, size: 16, color: Color(0xFF9CA3AF)), + tooltip: l.copy, + onPressed: () => unawaited( + Clipboard.setData(ClipboardData(text: code)), + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: SizedBox( + width: double.infinity, + child: _buildCode(theme.codeStyle.copyWith(color: foreground)), + ), + ), + ], + ), + ); + } + + Widget _buildCode(TextStyle base) { + final spans = highlighter?.call(code, language, base); + if (spans == null) return SelectableText(code, style: base); + return SelectableText.rich(TextSpan(style: base, children: spans)); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_composer.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_composer.dart new file mode 100644 index 0000000..68305dc --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_composer.dart @@ -0,0 +1,518 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_ai_core/flutter_ai_core.dart'; +import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; + +/// A modern message composer: a rounded input with a leading attach (`+`) +/// button beside the field, and a trailing pair — a secondary mic and a main +/// button that is **Live** (voice) while the field is empty and swaps to +/// **Send** once you type (hiding the mic), or **Stop** while streaming. +/// +/// The model selector is intentionally *not* here — modern apps put it in the +/// app bar. Everything is opt-in via the callbacks. +class AiComposer extends StatefulWidget { + /// Creates a composer. + const AiComposer({ + super.key, + required this.onSend, + this.onStop, + this.isBusy = false, + this.hintText = 'Message', + this.controller, + this.enabled = true, + this.onAttach, + this.onVoice, + this.onLive, + this.attachments = const [], + this.onRemoveAttachment, + }); + + /// Called with the trimmed text when the user submits. + final ValueChanged onSend; + + /// Called when the user taps Stop while [isBusy]. + final VoidCallback? onStop; + + /// Whether a response is in flight; the main button shows Stop. + final bool isBusy; + + /// Placeholder text. + final String hintText; + + /// Optional external text controller. + final TextEditingController? controller; + + /// Whether the input accepts text. + final bool enabled; + + /// Shows a leading attach (`+`) button when non-null. + final VoidCallback? onAttach; + + /// Shows a secondary mic button (voice dictation) while the field is empty. + final VoidCallback? onVoice; + + /// When non-null, the main button is a **Live** voice button while the field + /// is empty (it becomes Send once the user types). + final VoidCallback? onLive; + + /// Staged attachments shown as removable previews above the field. + final List attachments; + + /// Removes a staged attachment. If `null`, previews aren't removable. + final void Function(FilePart attachment)? onRemoveAttachment; + + @override + State createState() => _AiComposerState(); +} + +class _AiComposerState extends State { + TextEditingController? _internalController; + + // Keeps the field (and its focus) alive when the layout reparents from the + // single-row form to the stacked, full-width form. + final GlobalKey _fieldKey = GlobalKey(); + + TextEditingController get _controller => + widget.controller ?? (_internalController ??= TextEditingController()); + + @override + void didUpdateWidget(AiComposer oldWidget) { + super.didUpdateWidget(oldWidget); + // If a parent starts supplying its own controller, drop the internal one we + // lazily created so it doesn't leak (and we stop driving a stale field). + if (widget.controller != null && _internalController != null) { + _internalController!.dispose(); + _internalController = null; + } + } + + @override + void dispose() { + _internalController?.dispose(); + super.dispose(); + } + + void _handleSend() { + final text = _controller.text.trim(); + if (text.isEmpty && widget.attachments.isEmpty) return; + if (AiThemeExtension.of(context).enableHaptics) { + unawaited(HapticFeedback.lightImpact()); + } + widget.onSend(text); + _controller.clear(); + } + + void _handleStop() { + if (AiThemeExtension.of(context).enableHaptics) { + unawaited(HapticFeedback.mediumImpact()); + } + widget.onStop?.call(); + } + + // Whether [text] needs more than one line at the *single-row* field width. + // Measured against that fixed width (not the current layout's) so the decision + // doesn't flip-flop once the buttons drop below. + bool _isMultiline( + String text, + double innerWidth, + bool hasText, + AiThemeExtension theme, + ) { + if (text.isEmpty) return false; + if (text.contains('\n')) return true; + const iconBox = 40.0; // _ToolIcon tap target + const mainBtn = 38.0; // main circular button + final attachW = widget.onAttach != null ? iconBox : 0.0; + final micW = !hasText && widget.onVoice != null ? iconBox : 0.0; + final trailingW = micW + 2 + mainBtn; + final fieldLeftPad = widget.onAttach == null ? 10.0 : 2.0; + final textWidth = innerWidth - attachW - trailingW - fieldLeftPad - 6; + if (textWidth <= 0) return false; + final painter = TextPainter( + text: TextSpan( + text: text, + style: theme.textStyle.copyWith(color: theme.assistantTextColor), + ), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + maxLines: 1, + )..layout(maxWidth: textWidth); + return painter.didExceedMaxLines; + } + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + final l = AiLocalizations.of(context); + final subdued = theme.assistantTextColor.withValues(alpha: 0.6); + + return Padding( + padding: theme.composerPadding, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (widget.attachments.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final file in widget.attachments) + Padding( + padding: const EdgeInsetsDirectional.only(end: 8), + child: _AttachmentPreview( + file: file, + theme: theme, + onRemove: widget.onRemoveAttachment == null + ? null + : () => widget.onRemoveAttachment!(file), + ), + ), + ], + ), + ), + ), + Container( + decoration: BoxDecoration( + color: theme.assistantBubbleColor, + borderRadius: BorderRadius.circular(26), + border: Border.all(color: theme.borderColor), + ), + padding: const EdgeInsets.fromLTRB(6, 4, 6, 4), + child: ValueListenableBuilder( + valueListenable: _controller, + builder: (context, value, _) { + final hasText = value.text.trim().isNotEmpty; + final field = TextField( + key: _fieldKey, + controller: _controller, + enabled: widget.enabled, + minLines: 1, + maxLines: 6, + cursorColor: theme.accentColor, + style: theme.textStyle.copyWith( + color: theme.assistantTextColor, + ), + textInputAction: TextInputAction.send, + onSubmitted: widget.enabled ? (_) => _handleSend() : null, + decoration: InputDecoration( + hintText: widget.hintText, + hintStyle: theme.textStyle.copyWith( + color: theme.assistantTextColor.withValues(alpha: 0.45), + ), + border: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 10), + ), + ); + final attach = widget.onAttach == null + ? null + : _ToolIcon( + icon: Icons.add, + color: subdued, + tooltip: l.attach, + onTap: widget.enabled ? widget.onAttach : null, + ); + final trailing = _trailing(theme, hasText, subdued, l); + + return LayoutBuilder( + builder: (context, constraints) { + // When the text needs more than one line, give it the full + // width and drop the buttons to a row beneath it. + if (_isMultiline( + value.text, + constraints.maxWidth, + hasText, + theme, + )) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(10, 0, 8, 2), + child: field, + ), + Row( + children: [ + if (attach != null) attach, + const Spacer(), + trailing, + ], + ), + ], + ); + } + // Single-line inline layout: vertically center the icons + // with the field (multi-line goes to the stacked layout). + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (attach != null) attach, + Expanded( + child: Padding( + padding: EdgeInsetsDirectional.only( + start: widget.onAttach == null ? 10 : 2, + ), + child: field, + ), + ), + trailing, + ], + ); + }, + ); + }, + ), + ), + ], + ), + ); + } + + Widget _trailing( + AiThemeExtension theme, + bool hasText, + Color subdued, + AiLocalizations l, + ) { + final showStop = widget.isBusy && widget.onStop != null; + // Staged attachments are sendable even with no text (_handleSend allows an + // attachment-only send), so the main button must be Send — not Live — then. + final hasSendable = hasText || widget.attachments.isNotEmpty; + final liveWhenEmpty = !hasSendable && !showStop && widget.onLive != null; + + final IconData mainIcon; + final VoidCallback? mainTap; + if (showStop) { + mainIcon = Icons.stop_rounded; + mainTap = _handleStop; + } else if (hasSendable) { + mainIcon = Icons.arrow_upward_rounded; + mainTap = _handleSend; + } else if (liveWhenEmpty) { + mainIcon = Icons.graphic_eq; + mainTap = widget.onLive; + } else { + mainIcon = Icons.arrow_upward_rounded; + mainTap = _handleSend; + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Secondary mic, only while empty (and not streaming). + if (!hasText && !showStop && widget.onVoice != null) + _ToolIcon( + icon: Icons.mic_none_rounded, + color: subdued, + tooltip: l.dictate, + onTap: widget.enabled ? widget.onVoice : null, + ), + const SizedBox(width: 2), + _MainButton( + // Stop reads as a distinct (error-toned) affordance, not the same + // accent as Send/Live. + color: showStop ? theme.errorColor : theme.accentColor, + iconColor: theme.onAccentColor, + icon: mainIcon, + tooltip: showStop + ? l.stop + : hasSendable + ? l.send + : liveWhenEmpty + ? l.live + : l.send, + onPressed: widget.enabled ? mainTap : null, + ), + ], + ); + } +} + +class _ToolIcon extends StatelessWidget { + const _ToolIcon({ + required this.icon, + required this.color, + required this.tooltip, + required this.onTap, + }); + + final IconData icon; + final Color color; + final String tooltip; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: tooltip, + child: Tooltip( + message: tooltip, + // InkResponse gives focus traversal, keyboard (Enter/Space), hover, and + // a ripple — none of which a bare GestureDetector provides. + child: InkResponse( + onTap: onTap, + radius: 22, + customBorder: const CircleBorder(), + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon(icon, size: 24, color: color), + ), + ), + ), + ); + } +} + +class _AttachmentPreview extends StatelessWidget { + const _AttachmentPreview({ + required this.file, + required this.theme, + this.onRemove, + }); + + final FilePart file; + final AiThemeExtension theme; + final VoidCallback? onRemove; + + @override + Widget build(BuildContext context) { + final isImage = file.mediaType.startsWith('image/'); + Widget content; + if (isImage && (file.bytes != null || file.url != null)) { + content = ClipRRect( + borderRadius: BorderRadius.circular(10), + child: SizedBox( + width: 52, + height: 52, + child: file.bytes != null + ? Image.memory(file.bytes!, fit: BoxFit.cover) + : Image.network(file.url!.toString(), fit: BoxFit.cover), + ), + ); + } else { + content = Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all(color: theme.borderColor), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.insert_drive_file_outlined, size: 16), + const SizedBox(width: 6), + Text( + file.name ?? file.mediaType, + style: theme.textStyle.copyWith(fontSize: 13), + ), + ], + ), + ); + } + + if (onRemove == null) return content; + return Stack( + clipBehavior: Clip.none, + children: [ + content, + PositionedDirectional( + top: -6, + end: -6, + child: GestureDetector( + onTap: onRemove, + child: Container( + decoration: BoxDecoration( + color: theme.accentColor, + shape: BoxShape.circle, + border: Border.all(color: theme.onAccentColor, width: 1.5), + ), + padding: const EdgeInsets.all(2), + child: Icon(Icons.close, size: 12, color: theme.onAccentColor), + ), + ), + ), + ], + ); + } +} + +/// The circular main action button with a press scale instead of a ripple. +class _MainButton extends StatefulWidget { + const _MainButton({ + required this.color, + required this.iconColor, + required this.icon, + required this.tooltip, + required this.onPressed, + }); + + final Color color; + final Color iconColor; + final IconData icon; + final String tooltip; + final VoidCallback? onPressed; + + @override + State<_MainButton> createState() => _MainButtonState(); +} + +class _MainButtonState extends State<_MainButton> { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + final enabled = widget.onPressed != null; + return Semantics( + button: true, + label: widget.tooltip, + child: Tooltip( + message: widget.tooltip, + child: AnimatedScale( + scale: _pressed ? 0.9 : 1, + duration: const Duration(milliseconds: 100), + child: Material( + color: enabled ? widget.color : widget.color.withValues(alpha: 0.4), + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + customBorder: const CircleBorder(), + onTap: widget.onPressed, + onTapDown: + enabled ? (_) => setState(() => _pressed = true) : null, + onTapCancel: + enabled ? () => setState(() => _pressed = false) : null, + onHighlightChanged: + enabled ? (h) => setState(() => _pressed = h) : null, + child: SizedBox( + width: 38, + height: 38, + // Morph between Send / Stop / Live rather than hard-swapping. + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + transitionBuilder: (child, anim) => ScaleTransition( + scale: anim, + child: FadeTransition(opacity: anim, child: child), + ), + child: Icon( + widget.icon, + key: ValueKey(widget.icon), + color: widget.iconColor, + size: 20, + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_confirmation.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_confirmation.dart new file mode 100644 index 0000000..aca8a65 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_confirmation.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; +import 'package:flutter_ai_elements/src/widgets/ai_haptics.dart'; + +/// The visual weight of an [AiConfirmation], which restyles its confirm button. +enum AiConfirmationTone { + /// The default look — a neutral accent-colored confirm button. + neutral, + + /// A cautionary action — the confirm button uses the theme's warning color. + caution, + + /// A destructive action — the confirm button uses the theme's error color. + danger, +} + +/// An approve/deny card for actions an agent wants to take (running a tool, +/// sending an email, making a purchase) — the human-in-the-loop gate. +class AiConfirmation extends StatelessWidget { + /// Creates a confirmation card. + const AiConfirmation({ + super.key, + required this.title, + this.description, + this.confirmLabel, + this.denyLabel, + this.onConfirm, + this.onDeny, + this.icon = Icons.shield_outlined, + this.tone = AiConfirmationTone.neutral, + }); + + /// The action being confirmed. + final String title; + + /// Optional supporting detail. + final String? description; + + /// Label for the confirm button. Defaults to the localized "Allow". + final String? confirmLabel; + + /// Label for the deny button. Defaults to the localized "Deny". + final String? denyLabel; + + /// Called when the user approves. + final VoidCallback? onConfirm; + + /// Called when the user denies. + final VoidCallback? onDeny; + + /// Leading icon. + final IconData icon; + + /// The action's weight, which restyles the confirm button. Defaults to + /// [AiConfirmationTone.neutral] (the original accent look). + final AiConfirmationTone tone; + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + final l = AiLocalizations.of(context); + final color = DefaultTextStyle.of(context).style.color; + // The confirm button's fill follows the tone; neutral keeps the accent. + final confirmColor = switch (tone) { + AiConfirmationTone.neutral => theme.accentColor, + AiConfirmationTone.caution => theme.warningColor, + AiConfirmationTone.danger => theme.errorColor, + }; + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: theme.borderColor), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon(icon, size: 18, color: color), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: theme.textStyle.copyWith( + color: color, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + if (description != null) ...[ + const SizedBox(height: 6), + Text( + description!, + style: theme.textStyle.copyWith( + color: color?.withValues(alpha: 0.65), + fontSize: 14, + ), + ), + ], + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: _Button( + label: denyLabel ?? l.deny, + onTap: onDeny == null + ? null + : () { + aiLightHaptic(theme); + onDeny!(); + }, + filled: false, + fillColor: confirmColor, + theme: theme, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _Button( + label: confirmLabel ?? l.allow, + onTap: onConfirm == null + ? null + : () { + aiLightHaptic(theme); + onConfirm!(); + }, + filled: true, + fillColor: confirmColor, + theme: theme, + ), + ), + ], + ), + ], + ), + ); + } +} + +class _Button extends StatelessWidget { + const _Button({ + required this.label, + required this.onTap, + required this.filled, + required this.fillColor, + required this.theme, + }); + + final String label; + final VoidCallback? onTap; + final bool filled; + final Color fillColor; + final AiThemeExtension theme; + + @override + Widget build(BuildContext context) { + final radius = BorderRadius.circular(12); + return Semantics( + button: true, + enabled: onTap != null, + label: label, + // A confirmation gate must be keyboard- and focus-reachable (desktop/web) + // — Material + InkWell give focus traversal, Enter/Space, hover, ripple. + child: Material( + color: filled ? fillColor : Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: radius, + side: filled ? BorderSide.none : BorderSide(color: theme.borderColor), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + borderRadius: radius, + child: Container( + height: 40, + alignment: Alignment.center, + child: Text( + label, + style: theme.textStyle.copyWith( + fontSize: 14, + fontWeight: FontWeight.w600, + color: filled + ? theme.onAccentColor + : DefaultTextStyle.of(context).style.color, + ), + ), + ), + ), + ), + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_context_meter.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_context_meter.dart new file mode 100644 index 0000000..af21eef --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_context_meter.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; + +/// A compact context-window usage meter: a label, a `used / total` token +/// readout, and a thin progress bar that turns amber/red as it fills. +class AiContextMeter extends StatelessWidget { + /// Creates a usage meter. + const AiContextMeter({ + super.key, + required this.usedTokens, + required this.totalTokens, + this.label = 'Context', + }); + + /// Tokens used so far. + final int usedTokens; + + /// The context-window size. + final int totalTokens; + + /// Leading label. + final String label; + + double get _fraction => + totalTokens <= 0 ? 0 : (usedTokens / totalTokens).clamp(0, 1); + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + final color = DefaultTextStyle.of(context).style.color; + final fraction = _fraction; + final barColor = fraction > 0.9 + ? theme.errorColor + : fraction > 0.7 + ? theme.warningColor + : theme.accentColor; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Text( + label, + style: theme.textStyle.copyWith( + fontSize: 12, + fontWeight: FontWeight.w600, + color: color?.withValues(alpha: 0.6), + ), + ), + const Spacer(), + Text( + '${_fmt(usedTokens)} / ${_fmt(totalTokens)}', + style: theme.codeStyle.copyWith( + fontSize: 12, + color: color?.withValues(alpha: 0.6), + ), + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Stack( + children: [ + Container(height: 6, color: theme.borderColor), + FractionallySizedBox( + widthFactor: fraction, + child: Container(height: 6, color: barColor), + ), + ], + ), + ), + ], + ); + } + + static String _fmt(int n) { + if (n >= 1000000) return '${(n / 1000000).toStringAsFixed(1)}M'; + if (n >= 1000) return '${(n / 1000).toStringAsFixed(1)}k'; + return '$n'; + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_conversation_list.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_conversation_list.dart new file mode 100644 index 0000000..9fa383c --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_conversation_list.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ai_client/flutter_ai_client.dart'; +import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; + +/// A ChatGPT-style conversation list / sidebar: a "New chat" action above a +/// scrollable list of [ChatThread]s, with select and (optional) delete. +/// +/// Presentational — drive it from a [ChatThreadStore]: pass [threads], +/// [selectedId], and wire [onSelect] / [onNew] / [onDelete] to your store and +/// controller. +class AiConversationList extends StatelessWidget { + /// Creates a conversation list. + const AiConversationList({ + super.key, + required this.threads, + this.selectedId, + this.onSelect, + this.onNew, + this.onDelete, + this.newChatLabel, + this.header, + this.footer, + this.trailingBuilder, + }); + + /// The threads to show, in display order (typically newest first). + final List threads; + + /// The id of the currently open thread, highlighted in the list. + final String? selectedId; + + /// Called when a thread is tapped. + final void Function(ChatThread thread)? onSelect; + + /// Called when the "New chat" action is tapped. Hidden when null. + final VoidCallback? onNew; + + /// Called when a thread's delete affordance is tapped. Hidden when null. + final void Function(ChatThread thread)? onDelete; + + /// Label for the new-chat action. Defaults to the localized "New chat". + final String? newChatLabel; + + /// Optional content pinned above the new-chat action and thread list — e.g. a + /// brand wordmark, a close button, or fixed nav entries (Images/Library/…). + final Widget? header; + + /// Optional content pinned below the thread list — e.g. an account footer + /// (avatar · name · settings). + final Widget? footer; + + /// Per-thread trailing widget (e.g. a pin glyph + overflow menu). When + /// provided it replaces the default delete affordance, so wire delete/pin + /// yourself. Return null for no trailing on a given thread. + final Widget? Function(BuildContext context, ChatThread thread)? + trailingBuilder; + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + final l = AiLocalizations.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (header != null) header!, + if (onNew != null) + Padding( + padding: const EdgeInsets.all(8), + child: OutlinedButton.icon( + onPressed: onNew, + icon: const Icon(Icons.add, size: 18), + label: Align( + alignment: Alignment.centerLeft, + child: Text(newChatLabel ?? l.newChat), + ), + ), + ), + Expanded( + child: ListView.builder( + itemCount: threads.length, + itemBuilder: (context, i) { + final thread = threads[i]; + final selected = thread.id == selectedId; + return Material( + color: selected ? theme.effectiveChipColor : Colors.transparent, + borderRadius: BorderRadius.circular(10), + clipBehavior: Clip.antiAlias, + child: ListTile( + dense: true, + selected: selected, + title: Text( + thread.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: onSelect == null ? null : () => onSelect!(thread), + trailing: trailingBuilder != null + ? trailingBuilder!(context, thread) + : onDelete == null + ? null + : IconButton( + icon: const Icon(Icons.delete_outline, size: 18), + tooltip: l.delete, + visualDensity: VisualDensity.compact, + onPressed: () => onDelete!(thread), + ), + ), + ); + }, + ), + ), + if (footer != null) footer!, + ], + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_conversation_view.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_conversation_view.dart new file mode 100644 index 0000000..3514a0e --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_conversation_view.dart @@ -0,0 +1,165 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_ai_core/flutter_ai_core.dart'; +import 'package:flutter_ai_elements/src/rendering/ai_text_renderer.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; +import 'package:flutter_ai_elements/src/widgets/ai_loader.dart'; +import 'package:flutter_ai_elements/src/widgets/ai_message_bubble.dart'; +import 'package:flutter_ai_elements/src/widgets/ai_response.dart'; + +/// A scrolling list of message bubbles. +/// +/// Presentational: it renders the [messages] it is given and reports nothing +/// back. The controller-bound `AiConversation` wraps it with live updates and +/// auto-scroll. +class AiConversationView extends StatefulWidget { + /// Creates a conversation view. + const AiConversationView({ + super.key, + required this.messages, + this.scrollController, + this.textRenderer = const MarkdownTextRenderer(), + this.messageBuilder, + this.showLoader = false, + this.loadingBuilder, + this.padding = const EdgeInsets.all(16), + this.maxContentWidth, + this.trailingSpace = 0, + this.anchorKey, + this.anchorId, + }); + + /// The messages to display, oldest first. + final List messages; + + /// Optional scroll controller, supplied by a parent that manages scrolling. + final ScrollController? scrollController; + + /// Renderer for message text. Defaults to [MarkdownTextRenderer]. + final AiTextRenderer textRenderer; + + /// Optional override for how each message is built. + final Widget Function(BuildContext context, AiMessage message)? + messageBuilder; + + /// Whether to append a thinking indicator after the last message. + final bool showLoader; + + /// Builds the thinking indicator shown when [showLoader] is true. Defaults to + /// an `AiLoader`; pass one returning `AiShimmer` for a skeleton instead. + final WidgetBuilder? loadingBuilder; + + /// Padding around the list. + final EdgeInsets padding; + + /// On wide screens, centers the conversation at this width (like ChatGPT on + /// tablet/desktop). When `null`, falls back to + /// [AiThemeExtension.maxContentWidth]. Pass [double.infinity] for full-width. + final double? maxContentWidth; + + /// Extra empty space reserved after the last item. Used by `AiChat` to let the + /// newest turn scroll to the top of the viewport (ChatGPT-style anchoring). + final double trailingSpace; + + /// When set, the message whose [AiMessage.id] equals [anchorId] is wrapped in + /// a [KeyedSubtree] keyed by this, so a parent can scroll it into view. + final GlobalKey? anchorKey; + + /// The id of the message to attach [anchorKey] to. + final Object? anchorId; + + @override + State createState() => _AiConversationViewState(); +} + +class _AiConversationViewState extends State { + // Memoize the built bubble per message identity. While streaming, only the + // changed message gets a new AiMessage instance, so unchanged bubbles return + // the *same* widget instance and Flutter skips their rebuild entirely. + final Map _cachedMessage = {}; + final Map _cachedBubble = {}; + + void _clearCache() { + _cachedMessage.clear(); + _cachedBubble.clear(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _clearCache(); // theme/inherited changed — bubbles may need restyling + } + + @override + void didUpdateWidget(AiConversationView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.textRenderer != widget.textRenderer || + oldWidget.messageBuilder != widget.messageBuilder) { + _clearCache(); + } + } + + Widget _bubbleFor(BuildContext context, AiMessage message) { + // Custom builders aren't memoized (they may capture changing state). + if (widget.messageBuilder != null) { + return widget.messageBuilder!(context, message); + } + if (identical(_cachedMessage[message.id], message)) { + return _cachedBubble[message.id]!; + } + final bubble = AiMessageBubble( + key: ValueKey(message.id), + message: message, + textRenderer: widget.textRenderer, + ); + _cachedMessage[message.id] = message; + _cachedBubble[message.id] = bubble; + return bubble; + } + + @override + Widget build(BuildContext context) { + final messages = widget.messages; + final showLoader = widget.showLoader; + final hasSpacer = widget.trailingSpace > 0; + final loaderIndex = showLoader ? messages.length : -1; + final spacerIndex = hasSpacer ? messages.length + (showLoader ? 1 : 0) : -1; + final itemCount = + messages.length + (showLoader ? 1 : 0) + (hasSpacer ? 1 : 0); + final list = ListView.builder( + controller: widget.scrollController, + padding: widget.padding, + itemCount: itemCount, + itemBuilder: (context, index) { + if (index == spacerIndex) { + return SizedBox(height: widget.trailingSpace); + } + if (index == loaderIndex) { + return Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: widget.loadingBuilder?.call(context) ?? const AiLoader(), + ), + ); + } + final message = messages[index]; + final bubble = _bubbleFor(context, message); + if (widget.anchorKey != null && widget.anchorId == message.id) { + return KeyedSubtree(key: widget.anchorKey, child: bubble); + } + return bubble; + }, + ); + // A width passed to the widget wins; otherwise fall back to the theme's + // reading-width default. `double.infinity` means full-width (no column). + final width = + widget.maxContentWidth ?? AiThemeExtension.of(context).maxContentWidth; + if (!width.isFinite) return list; + return Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: width), + child: list, + ), + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_empty_state.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_empty_state.dart new file mode 100644 index 0000000..0f749cc --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_empty_state.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; +import 'package:flutter_ai_elements/src/widgets/ai_haptics.dart'; + +/// A centered placeholder shown when a conversation has no messages yet. +/// +/// Beyond a title/subtitle it can show a brand [glyph] (or a default [icon]) +/// and a set of tappable [suggestions] that seed the first turn via +/// [onSuggestionTap] — the conversation-starter pattern from modern assistants. +/// Fully themed via [AiThemeExtension]. +class AiEmptyState extends StatelessWidget { + /// Creates an empty state. + const AiEmptyState({ + super.key, + this.title = 'Start the conversation', + this.subtitle, + this.icon = Icons.chat_bubble_outline, + this.glyph, + this.suggestions = const [], + this.onSuggestionTap, + this.titleStyle, + this.subtitleStyle, + this.background, + }); + + /// The primary headline. + final String title; + + /// Optional supporting line beneath the title. + final String? subtitle; + + /// The icon shown above the title when [glyph] is null. + final IconData icon; + + /// An optional brand widget shown in place of [icon] (e.g. a logo). + final Widget? glyph; + + /// Conversation-starter prompts shown as tappable chips. Empty hides them. + final List suggestions; + + /// Called with the chosen suggestion's text. Required for the chips to be + /// interactive; without it the chips render but don't respond. + final ValueChanged? onSuggestionTap; + + /// Overrides the title style, merged over the themed default. Use a shader + /// `foreground` here for a gradient "hero" greeting. + final TextStyle? titleStyle; + + /// Overrides the subtitle style, merged over the themed default. + final TextStyle? subtitleStyle; + + /// Optional widget painted behind the content (e.g. an ambient gradient), for + /// a branded hero empty state. Sized to fill the available space. + final Widget? background; + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + final color = DefaultTextStyle.of(context).style.color; + final muted = color?.withValues(alpha: 0.6); + final content = Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + glyph ?? Icon(icon, size: 48, color: muted), + const SizedBox(height: 12), + Text( + title, + textAlign: TextAlign.center, + style: theme.textStyle + .copyWith( + color: color, + fontSize: 18, + fontWeight: FontWeight.w600, + ) + .merge(titleStyle), + ), + if (subtitle != null) ...[ + const SizedBox(height: 4), + Text( + subtitle!, + textAlign: TextAlign.center, + style: + theme.textStyle.copyWith(color: muted).merge(subtitleStyle), + ), + ], + if (suggestions.isNotEmpty) ...[ + const SizedBox(height: 20), + Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: [ + for (final s in suggestions) + _SuggestionChip( + label: s, + theme: theme, + onTap: onSuggestionTap == null + ? null + : () { + aiLightHaptic(theme); + onSuggestionTap!(s); + }, + ), + ], + ), + ], + ], + ), + ), + ); + if (background == null) return content; + return Stack( + fit: StackFit.expand, + children: [background!, content], + ); + } +} + +class _SuggestionChip extends StatelessWidget { + const _SuggestionChip({ + required this.label, + required this.theme, + required this.onTap, + }); + + final String label; + final AiThemeExtension theme; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: theme.effectiveChipColor, + borderRadius: BorderRadius.circular(20), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), + child: Text( + label, + style: theme.textStyle.copyWith( + color: theme.assistantTextColor, + fontSize: 14, + ), + ), + ), + ), + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_error_banner.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_error_banner.dart new file mode 100644 index 0000000..c8daa98 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_error_banner.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; + +/// An inline banner surfacing an error, with optional retry and dismiss. +/// +/// Pair it with a controller's `error`/`status` to show failures without a +/// modal interruption. +class AiErrorBanner extends StatelessWidget { + /// Creates an error banner displaying [message]. + const AiErrorBanner({ + super.key, + required this.message, + this.onRetry, + this.onDismiss, + }); + + /// The error text to display. + final String message; + + /// Called when the user taps Retry. Hidden if `null`. + final VoidCallback? onRetry; + + /// Called when the user dismisses the banner. Hidden if `null`. + final VoidCallback? onDismiss; + + @override + Widget build(BuildContext context) { + final errorColor = AiThemeExtension.of(context).errorColor; + final l = AiLocalizations.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: errorColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: errorColor.withValues(alpha: 0.4)), + ), + child: Row( + children: [ + Icon(Icons.error_outline, size: 18, color: errorColor), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: TextStyle(color: errorColor), + ), + ), + if (onRetry != null) + TextButton(onPressed: onRetry, child: Text(l.retry)), + if (onDismiss != null) + IconButton( + icon: const Icon(Icons.close, size: 18), + color: errorColor, + onPressed: onDismiss, + tooltip: l.dismiss, + ), + ], + ), + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_haptics.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_haptics.dart new file mode 100644 index 0000000..474b4d6 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_haptics.dart @@ -0,0 +1,23 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; + +/// Fires a light haptic tap for a key interaction (turn completion, a +/// confirmation choice, a chip tap), gated on [AiThemeExtension.enableHaptics]. +/// +/// No-op on the web and on desktop platforms, where the `HapticFeedback` +/// channel isn't backed by a tactile actuator — guarded by +/// [defaultTargetPlatform] so a host doesn't get spurious platform-channel +/// chatter. +void aiLightHaptic(AiThemeExtension theme) { + if (!theme.enableHaptics || kIsWeb) return; + // An allowlist `if` rather than an exhaustive switch: the OHOS Flutter fork + // adds TargetPlatform.ohos, so an exhaustive switch can't compile on both + // it and upstream Flutter at once. + final platform = defaultTargetPlatform; + if (platform == TargetPlatform.iOS || platform == TargetPlatform.android) { + unawaited(HapticFeedback.lightImpact()); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_image.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_image.dart new file mode 100644 index 0000000..23827e0 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_image.dart @@ -0,0 +1,129 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; + +/// Displays an AI-generated (or attached) image with rounded corners, a loading +/// placeholder, an error fallback, and tap-to-zoom into a full-screen, +/// pinch-zoomable viewer. +/// +/// Provide inline [bytes] or a remote [url]. +class AiImage extends StatelessWidget { + /// Creates an image from inline [bytes] or a remote [url]. + const AiImage({ + super.key, + this.bytes, + this.url, + this.aspectRatio = 1, + this.enableZoom = true, + }) : assert(bytes != null || url != null, 'Provide bytes or url'); + + /// Inline image bytes. + final Uint8List? bytes; + + /// Remote image location. + final Uri? url; + + /// Aspect ratio of the inline preview. + final double aspectRatio; + + /// Whether tapping opens a full-screen zoomable viewer. + final bool enableZoom; + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + final image = _image(fit: BoxFit.cover); + + return Semantics( + image: true, + button: enableZoom, + label: enableZoom ? 'Image, double tap to zoom' : 'Image', + child: GestureDetector( + onTap: enableZoom ? () => _openViewer(context) : null, + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: AspectRatio( + aspectRatio: aspectRatio, + child: DecoratedBox( + decoration: BoxDecoration(color: theme.assistantBubbleColor), + child: image, + ), + ), + ), + ), + ); + } + + Image _image({required BoxFit fit}) { + if (bytes != null) { + return Image.memory(bytes!, fit: fit, errorBuilder: _error); + } + return Image.network( + url!.toString(), + fit: fit, + errorBuilder: _error, + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return const Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + }, + ); + } + + Widget _error(BuildContext context, Object error, StackTrace? stack) => + const Center(child: Icon(Icons.broken_image_outlined, size: 32)); + + void _openViewer(BuildContext context) { + unawaited( + Navigator.of(context).push( + PageRouteBuilder( + opaque: false, + barrierColor: Colors.black, + pageBuilder: (context, _, __) => + _FullScreenImage(child: _image(fit: BoxFit.contain)), + ), + ), + ); + } +} + +class _FullScreenImage extends StatelessWidget { + const _FullScreenImage({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.transparent, + body: Stack( + children: [ + Positioned.fill( + child: InteractiveViewer( + minScale: 1, + maxScale: 5, + child: Center(child: child), + ), + ), + Positioned( + top: MediaQuery.paddingOf(context).top + 8, + right: 8, + child: IconButton( + icon: const Icon(Icons.close, color: Colors.white), + tooltip: AiLocalizations.of(context).close, + onPressed: () => Navigator.of(context).pop(), + ), + ), + ], + ), + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_inline_citation.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_inline_citation.dart new file mode 100644 index 0000000..d5f7384 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_inline_citation.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; + +/// A small numbered citation badge (e.g. `1`) shown inline with text or after a +/// claim, tappable to open or reveal the source. +/// +/// Compose it into rich text with a `WidgetSpan`, or place it in a row of +/// citations. +class AiInlineCitation extends StatelessWidget { + /// Creates a citation badge for [number]. + const AiInlineCitation({super.key, required this.number, this.onTap}); + + /// The 1-based citation index. + final int number; + + /// Called when the badge is tapped. + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + final color = DefaultTextStyle.of(context).style.color; + // Sizes to its content. (Note: a Container with `alignment` set expands to + // fill bounded parents — so this badge intentionally has none.) + return Semantics( + button: onTap != null, + label: 'Citation $number', + child: GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: theme.assistantBubbleColor, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: theme.borderColor), + ), + child: Text( + '$number', + style: theme.codeStyle.copyWith( + fontSize: 11, + height: 1.3, + color: color?.withValues(alpha: 0.75), + ), + ), + ), + ), + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_live_controller.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_live_controller.dart new file mode 100644 index 0000000..d7cb0a6 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_live_controller.dart @@ -0,0 +1,203 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_ai_client/flutter_ai_client.dart'; +import 'package:flutter_ai_elements/src/widgets/ai_live_session.dart'; + +/// The audio side of a live voice session: speech-to-text in, text-to-speech +/// out. Implement it over your engine of choice (`speech_to_text` + +/// `flutter_tts`, a realtime API, …); [AiLiveController] drives the rest. +/// +/// The package ships no implementation (it has no platform plugins) — a typical +/// `speech_to_text` + `flutter_tts` adapter is ~30 lines. +abstract interface class AiVoiceEngine { + /// Starts a single listen turn. Report interim transcripts through [onPartial] + /// (for live display) and optional normalized mic level (`0`–`1`) through + /// [onLevel]. Call [onDone] with the settled text when the turn ends (silence + /// timeout or error included); pass an empty string if nothing was recognized. + Future startListening({ + required void Function(String text) onPartial, + required void Function(String finalText) onDone, + void Function(double level)? onLevel, + }); + + /// Stops an in-progress listen turn. + Future stopListening(); + + /// Speaks [text], calling [onDone] when playback finishes or is interrupted. + Future speak(String text, {required void Function() onDone}); + + /// Stops any in-progress speech. + Future stopSpeaking(); + + /// Releases engine resources. + Future dispose(); +} + +/// Drives a live-voice loop — **listen → send → speak → re-listen** — by mapping +/// an [AiVoiceEngine] onto a [UseChatController], and exposes the +/// [AiLiveSession] props ([status], [amplitude], [transcript], [muted]) as a +/// [ChangeNotifier]. +/// +/// This removes the hand-rolled voice state machine from the app: build the UI +/// with `AnimatedBuilder(animation: liveController, ...)` feeding an +/// `AiLiveSession`, and call [start] / [toggleMute] / [stop]. +/// +/// ```dart +/// final live = AiLiveController(controller: chat, engine: MyVoiceEngine()); +/// live.start(); +/// // AiLiveSession(status: live.status, amplitude: live.amplitude, +/// // transcript: live.transcript, muted: live.muted, +/// // onMute: live.toggleMute, onEnd: live.stop) +/// ``` +class AiLiveController extends ChangeNotifier { + /// Creates a live controller over [controller] and [engine]. + AiLiveController({required this.controller, required this.engine}); + + /// The chat controller the voice loop sends to and reads replies from. + final UseChatController controller; + + /// The audio engine (STT + TTS). + final AiVoiceEngine engine; + + AiLiveStatus _status = AiLiveStatus.connecting; + + /// The current phase, for [AiLiveSession.status]. + AiLiveStatus get status => _status; + + double _amplitude = 0; + + /// The latest normalized mic level (`0`–`1`), for [AiLiveSession.amplitude]. + double get amplitude => _amplitude; + + String? _transcript; + + /// The in-progress transcript, for [AiLiveSession.transcript]. + String? get transcript => _transcript; + + bool _muted = false; + + /// Whether the mic is muted, for [AiLiveSession.muted]. + bool get muted => _muted; + + bool _running = false; + // Bumped on every stop()/dispose() so late engine callbacks from a torn-down + // turn are ignored instead of resurrecting the loop. + int _generation = 0; + + void _set({AiLiveStatus? status, double? amplitude, String? transcript}) { + if (_disposed) return; + if (status != null) _status = status; + if (amplitude != null) _amplitude = amplitude; + if (transcript != null) _transcript = transcript; + notifyListeners(); + } + + /// Starts the session and begins listening. + void start() { + if (_running || _disposed) return; + _running = true; + _listen(); + } + + void _listen() { + if (!_running || _muted || _disposed) return; + final gen = _generation; + _set(status: AiLiveStatus.listening, transcript: ''); + unawaited(engine.startListening( + onPartial: (text) { + if (gen != _generation) return; + _set(transcript: text); + }, + onLevel: (level) { + if (gen != _generation) return; + _set(amplitude: level.clamp(0, 1).toDouble()); + }, + onDone: (finalText) { + if (gen != _generation) return; + unawaited(_onHeard(finalText.trim())); + }, + )); + } + + Future _onHeard(String text) async { + if (!_running || _disposed) return; + // Nothing recognized — just listen again. + if (text.isEmpty) { + _listen(); + return; + } + _set(status: AiLiveStatus.thinking, amplitude: 0); + final gen = _generation; + try { + await controller.sendText(text); + } catch (_) { + // Surface nothing audibly; drop back to listening. + if (gen == _generation && _running) _listen(); + return; + } + if (gen != _generation || !_running || _disposed) return; + final reply = controller.conversation.lastMessage; + final replyText = reply?.role == AiRole.assistant ? reply?.text ?? '' : ''; + if (replyText.trim().isEmpty) { + _listen(); + return; + } + _speak(replyText); + } + + void _speak(String text) { + if (!_running || _disposed) return; + final gen = _generation; + _set(status: AiLiveStatus.speaking, transcript: null); + unawaited(engine.speak( + text, + onDone: () { + if (gen != _generation || !_running || _disposed) return; + _listen(); + }, + )); + } + + /// Toggles the mic. Muting stops listening/speaking; unmuting re-listens. + void toggleMute() { + if (_disposed) return; + _muted = !_muted; + if (_muted) { + _generation++; // ignore any in-flight engine callbacks + unawaited(engine.stopListening()); + unawaited(engine.stopSpeaking()); + _set(status: AiLiveStatus.listening, amplitude: 0); + } else if (_running) { + _listen(); + } else { + notifyListeners(); + } + } + + /// Ends the session and releases the engine. + void stop() { + if (!_running) { + _set(status: AiLiveStatus.ended); + return; + } + _running = false; + _generation++; + unawaited(engine.stopListening()); + unawaited(engine.stopSpeaking()); + _set(status: AiLiveStatus.ended, amplitude: 0); + } + + bool _disposed = false; + + @override + void dispose() { + _disposed = true; + _running = false; + _generation++; + unawaited(engine.stopListening()); + unawaited(engine.stopSpeaking()); + unawaited(engine.dispose()); + super.dispose(); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_live_session.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_live_session.dart new file mode 100644 index 0000000..d4a9afa --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_live_session.dart @@ -0,0 +1,409 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui' show lerpDouble; + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; + +/// The phase of a live voice session. +enum AiLiveStatus { + /// Establishing the session. + connecting, + + /// Listening to the user. + listening, + + /// Processing. + thinking, + + /// The assistant is speaking. + speaking, + + /// The session has ended. + ended, +} + +/// A full-screen, engine-agnostic **Live voice** surface, modelled on modern +/// assistant voice modes: a luminous sky-orb that opens centered, then *drops +/// and shrinks* to dock above the controls while the [conversation] fades in +/// behind it so you can read along. The orb's interior is an animated, +/// cloud-lit sky that breathes and reacts to audio [amplitude]. +/// +/// Purely presentational: drive [status] and [amplitude] from your audio engine +/// (realtime STT/TTS) and handle the control callbacks. It paints its own dark, +/// immersive background and fills its parent — wrap it in a `Scaffold` for +/// full-screen use. +class AiLiveSession extends StatefulWidget { + /// Creates a live session surface. + const AiLiveSession({ + super.key, + this.status = AiLiveStatus.listening, + this.amplitude = 0, + this.transcript, + this.conversation, + this.muted = false, + this.onMute, + this.onKeyboard, + this.onEnd, + this.backgroundColor = const Color(0xFF000000), + }); + + /// The current phase. + final AiLiveStatus status; + + /// Normalized audio level (`0`–`1`) driving the orb's reaction. + final double amplitude; + + /// Live transcript text shown briefly under the centered orb (before docking). + final String? transcript; + + /// The scrolling conversation to reveal behind the docked orb. When non-null, + /// the orb drops and shrinks shortly after opening to make room for it. + final Widget? conversation; + + /// Whether the mic is muted. + final bool muted; + + /// Toggles mute. Hidden if `null`. + final VoidCallback? onMute; + + /// Switches back to the text composer. Hidden if `null`. + final VoidCallback? onKeyboard; + + /// Ends the session. Hidden if `null`. + final VoidCallback? onEnd; + + /// The immersive backdrop color. Defaults to black. The orb and overlay text + /// are tuned for a dark surface — pass a light color only if you also theme + /// the content accordingly. + final Color backgroundColor; + + @override + State createState() => _AiLiveSessionState(); +} + +class _AiLiveSessionState extends State + with TickerProviderStateMixin { + // Gentle pulse. + late final AnimationController _breathe = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 2600), + )..repeat(); + + // Opening pop (fade + scale-in). + late final AnimationController _intro = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 520), + ); + + // Centered → docked (drop + shrink) with the conversation revealed. + late final AnimationController _dock = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + ); + + Timer? _dockTimer; + + @override + void initState() { + super.initState(); + unawaited(_intro.forward()); + // The orb opens centered, then drops and shrinks to dock above the controls. + _dockTimer = Timer(const Duration(milliseconds: 600), () { + if (mounted) unawaited(_dock.forward()); + }); + } + + @override + void dispose() { + _dockTimer?.cancel(); + _breathe.dispose(); + _intro.dispose(); + _dock.dispose(); + super.dispose(); + } + + String get _label => switch (widget.status) { + AiLiveStatus.connecting => 'Connecting…', + AiLiveStatus.listening => 'Listening', + AiLiveStatus.thinking => 'Thinking…', + AiLiveStatus.speaking => 'Speaking', + AiLiveStatus.ended => '', + }; + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + final active = widget.status == AiLiveStatus.speaking || + widget.status == AiLiveStatus.listening; + + // Immersive dark surface (voice mode is a focused, dark experience). + return ColoredBox( + color: widget.backgroundColor, + child: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final w = constraints.maxWidth; + final h = constraints.maxHeight; + + // Static subtree — does NOT depend on the animation, so it is built + // once and handed to the AnimatedBuilder via `child:` instead of + // rebuilding at 60fps with the orb. + final controls = Positioned( + left: 0, + right: 0, + bottom: 28, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.onKeyboard != null) + _CircleButton( + icon: Icons.keyboard_outlined, + label: 'Keyboard', + onTap: widget.onKeyboard, + ), + if (widget.onMute != null) + _CircleButton( + icon: + widget.muted ? Icons.mic_off : Icons.mic_none_rounded, + label: widget.muted ? 'Unmute' : 'Mute', + onTap: widget.onMute, + ), + if (widget.onEnd != null) + _CircleButton( + icon: Icons.close, + label: 'End', + onTap: widget.onEnd, + filled: true, + ), + ], + ), + ); + + return AnimatedBuilder( + animation: Listenable.merge([_breathe, _intro, _dock]), + // The conversation/control subtree is the static child; only the + // orb and the animated opacities/positions are rebuilt per frame. + child: controls, + builder: (context, child) { + final intro = Curves.easeOut.transform(_intro.value); + final dock = Curves.easeOutCubic.transform(_dock.value); + final breathe = + 0.5 - 0.5 * math.cos(2 * math.pi * _breathe.value); + final amp = (widget.muted ? 0.0 : widget.amplitude).clamp(0, 1); + final react = (active ? amp : amp * 0.3).toDouble(); + + // Big and centered while listening; a small ball once docked. + final base = lerpDouble(240, 92, dock)!; + final orb = base * + (1 + 0.04 * breathe + 0.16 * react) * + (0.86 + 0.14 * intro); + final centerY = lerpDouble(h * 0.44, h * 0.70, dock)!; + final top = centerY - orb / 2; + + return Stack( + children: [ + // Readable area above the docked orb: the conversation if + // given, otherwise the live transcript. Fades in as it docks. + if (widget.conversation != null || + widget.transcript != null) + Positioned( + top: 52, + left: 0, + right: 0, + bottom: h - top + 12, + child: Opacity( + opacity: dock, + child: widget.conversation ?? + _TranscriptText(text: widget.transcript!), + ), + ), + // Status label near the top. + Positioned( + top: 14, + left: 0, + right: 0, + child: Opacity( + opacity: intro * (1 - dock), + child: Text( + _label, + textAlign: TextAlign.center, + style: theme.textStyle.copyWith( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Colors.white.withValues(alpha: 0.85), + ), + ), + ), + ), + // The sky orb. + Positioned( + left: (w - orb) / 2, + top: top, + width: orb, + height: orb, + child: Opacity( + opacity: intro, + child: RepaintBoundary( + child: _Orb( + breathe: breathe, + react: react, + color: theme.orbColor, + ), + ), + ), + ), + // Live transcript under the centered orb (pre-dock only). + if (widget.transcript != null) + Positioned( + left: 28, + right: 28, + top: top + orb + 28, + child: Opacity( + opacity: (intro * (1 - dock * 1.6)).clamp(0, 1), + child: Text( + widget.transcript!, + textAlign: TextAlign.center, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: theme.textStyle.copyWith( + fontSize: 18, + height: 1.4, + color: Colors.white, + ), + ), + ), + ), + // Controls (static child, not rebuilt per frame). + child!, + ], + ); + }, + ); + }, + ), + ), + ); + } +} + +/// A luminous nebula sphere: deep space lit by slowly drifting clouds of violet, +/// blue, cyan and magenta, with a bright galactic core, scattered twinkling +/// stars, an outer glow, and rim-shading for depth. +/// A simple, calm sky-blue sphere (ChatGPT-style) — a soft radial gradient with +/// a light top-left highlight and an audio-reactive outer glow. +class _Orb extends StatelessWidget { + const _Orb({ + required this.breathe, + required this.react, + required this.color, + }); + + /// Breathing value (`0`–`1`). + final double breathe; + + /// Audio reaction (`0`–`1`). + final double react; + + /// Base orb color (themed via [AiThemeExtension.orbColor]). + final Color color; + + @override + Widget build(BuildContext context) { + // Derive the radial stops from the themed base so any color reads well. + final highlight = Color.lerp(color, Colors.white, 0.85)!; + final light = Color.lerp(color, Colors.white, 0.45)!; + final deep = Color.lerp(color, Colors.black, 0.30)!; + return DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + center: const Alignment(-0.35, -0.45), + radius: 1.15, + colors: [highlight, light, color, deep], + stops: const [0.0, 0.4, 0.75, 1.0], + ), + boxShadow: [ + BoxShadow( + color: + color.withValues(alpha: 0.30 + 0.28 * react + 0.08 * breathe), + blurRadius: 40 + 36 * react, + spreadRadius: 2 + 6 * react, + ), + ], + ), + ); + } +} + +/// The live transcript shown above the docked orb when there's no conversation +/// to display — bottom-aligned so the latest words sit just over the orb. +class _TranscriptText extends StatelessWidget { + const _TranscriptText({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Align( + alignment: Alignment.bottomCenter, + child: SingleChildScrollView( + reverse: true, + child: Text( + text, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 19, + height: 1.4, + color: Colors.white, + ), + ), + ), + ), + ); + } +} + +class _CircleButton extends StatelessWidget { + const _CircleButton({ + required this.icon, + required this.label, + required this.onTap, + this.filled = false, + }); + + final IconData icon; + final String label; + final VoidCallback? onTap; + final bool filled; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Semantics( + button: true, + label: label, + child: GestureDetector( + onTap: onTap, + child: Container( + width: 60, + height: 60, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: + filled ? Colors.white : Colors.white.withValues(alpha: 0.14), + ), + child: Icon( + icon, + size: 26, + color: filled ? Colors.black : Colors.white, + ), + ), + ), + ), + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_loader.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_loader.dart new file mode 100644 index 0000000..7d356ad --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_loader.dart @@ -0,0 +1,93 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; + +/// A three-dot "thinking" indicator shown while the assistant is preparing a +/// response. +/// +/// The dots pulse in sequence using the theme's loader color and motion timing. +class AiLoader extends StatefulWidget { + /// Creates a loader. + const AiLoader({super.key, this.dotSize = 8, this.dotSpacing = 4}); + + /// Diameter of each dot. + final double dotSize; + + /// Horizontal gap between dots. + final double dotSpacing; + + @override + State createState() => _AiLoaderState(); +} + +class _AiLoaderState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1100), + ); + bool _reduceMotion = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _reduceMotion = MediaQuery.maybeDisableAnimationsOf(context) ?? false; + if (_reduceMotion) { + _controller.stop(); + } else if (!_controller.isAnimating) { + unawaited(_controller.repeat()); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + Row dots(double Function(int) opacity) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < 3; i++) + Padding( + padding: EdgeInsets.only( + right: i == 2 ? 0 : widget.dotSpacing, + ), + child: _dot(theme.loaderColor, opacity(i)), + ), + ], + ); + return Semantics( + label: AiLocalizations.of(context).thinking, + child: _reduceMotion + // Static dots — no pulse under reduce-motion (WCAG 2.3.3). + ? dots((_) => 0.6) + : AnimatedBuilder( + animation: _controller, + builder: (context, _) => dots(_opacityForDot), + ), + ); + } + + // Each dot is a third of a cycle out of phase with the previous one. + double _opacityForDot(int index) { + final phase = (_controller.value + index / 3) % 1.0; + // Triangle wave: 0 -> 1 -> 0 across the cycle. + final wave = phase < 0.5 ? phase * 2 : (1 - phase) * 2; + return 0.3 + 0.7 * wave; + } + + Widget _dot(Color color, double opacity) => Container( + width: widget.dotSize, + height: widget.dotSize, + decoration: BoxDecoration( + color: color.withValues(alpha: opacity), + shape: BoxShape.circle, + ), + ); +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_message_actions.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_message_actions.dart new file mode 100644 index 0000000..413dedc --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_message_actions.dart @@ -0,0 +1,235 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_ai_core/flutter_ai_core.dart'; +import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; + +/// The per-message actions, used to control ordering via +/// [AiMessageActions.order] and [AiMessageActions.trailing]. +enum AiMessageActionKind { + /// Copy the message text. + copy, + + /// Read the message aloud. + speak, + + /// Thumbs-up feedback. + good, + + /// Thumbs-down feedback. + bad, + + /// Share the message. + share, + + /// Regenerate the response. + regenerate, + + /// Edit the message. + edit, +} + +/// A compact row of per-message actions: copy, and optionally regenerate and +/// edit. +/// +/// Copy defaults to placing the message's text on the clipboard; override it via +/// [onCopy]. On mobile, prefer presenting these via [showAiMessageActions] from +/// a long-press rather than always-visible buttons. +/// +/// [order] controls the sequence; actions listed in [trailing] are pushed to the +/// far (end) side after a spacer — e.g. Gemini keeps 👍👎↻⧉⋮ on the left and +/// read-aloud on the right. Only actions with a non-null callback render (copy +/// always renders). +class AiMessageActions extends StatelessWidget { + /// Creates an actions row for [message]. + const AiMessageActions({ + super.key, + required this.message, + this.onCopy, + this.onSpeak, + this.onGood, + this.onBad, + this.onShare, + this.onRegenerate, + this.onEdit, + this.iconSize = 18, + this.order = const [ + AiMessageActionKind.copy, + AiMessageActionKind.speak, + AiMessageActionKind.good, + AiMessageActionKind.bad, + AiMessageActionKind.share, + AiMessageActionKind.regenerate, + AiMessageActionKind.edit, + ], + this.trailing = const {}, + }); + + /// The message these actions apply to. + final AiMessage message; + + /// Overrides the default copy-to-clipboard behavior. + final VoidCallback? onCopy; + + /// Shows a read-aloud action when non-null. + final VoidCallback? onSpeak; + + /// Shows a thumbs-up action when non-null. + final VoidCallback? onGood; + + /// Shows a thumbs-down action when non-null. + final VoidCallback? onBad; + + /// Shows a share action when non-null. + /// + /// The package ships no share implementation (it has no platform plugins); + /// wire your own, e.g. with `share_plus`: + /// `onShare: () => Share.share(message.text)`. + final VoidCallback? onShare; + + /// Shows a Regenerate action when non-null. + final VoidCallback? onRegenerate; + + /// Shows an Edit action when non-null. + final VoidCallback? onEdit; + + /// Size of the action icons. + final double iconSize; + + /// The order actions are rendered in. + final List order; + + /// Actions pushed to the far (end) side, after a spacer. When non-empty the + /// row expands to fill its width so the split is visible. + final Set trailing; + + void _copy() { + if (onCopy != null) { + onCopy!(); + } else { + unawaited(Clipboard.setData(ClipboardData(text: message.text))); + } + } + + @override + Widget build(BuildContext context) { + final l = AiLocalizations.of(context); + final color = DefaultTextStyle.of(context).style.color?.withValues( + alpha: 0.6, + ); + // Compact, evenly spaced icon buttons (ChatGPT-style): a uniform 36px target + // with tight, equal padding rather than the default ~48px IconButton gaps. + Widget button(IconData icon, String tooltip, VoidCallback onPressed) { + return IconButton( + icon: Icon(icon, size: iconSize), + color: color, + tooltip: tooltip, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(6), + constraints: const BoxConstraints(minWidth: 36, minHeight: 36), + style: const ButtonStyle( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: onPressed, + ); + } + + // Resolve each kind to a button, or null when its callback is absent (copy + // always renders, defaulting to clipboard). + Widget? forKind(AiMessageActionKind kind) => switch (kind) { + AiMessageActionKind.copy => button(Icons.copy_rounded, l.copy, _copy), + AiMessageActionKind.speak => onSpeak == null + ? null + : button(Icons.volume_up_outlined, l.readAloud, onSpeak!), + AiMessageActionKind.good => onGood == null + ? null + : button(Icons.thumb_up_outlined, l.goodResponse, onGood!), + AiMessageActionKind.bad => onBad == null + ? null + : button(Icons.thumb_down_outlined, l.badResponse, onBad!), + AiMessageActionKind.share => onShare == null + ? null + : button(Icons.ios_share_rounded, l.share, onShare!), + AiMessageActionKind.regenerate => onRegenerate == null + ? null + : button(Icons.refresh_rounded, l.regenerate, onRegenerate!), + AiMessageActionKind.edit => onEdit == null + ? null + : button(Icons.edit_outlined, l.edit, onEdit!), + }; + + final leading = []; + final tail = []; + for (final kind in order) { + final w = forKind(kind); + if (w == null) continue; + (trailing.contains(kind) ? tail : leading).add(w); + } + + if (tail.isEmpty) { + return Row(mainAxisSize: MainAxisSize.min, children: leading); + } + return Row(children: [...leading, const Spacer(), ...tail]); + } +} + +/// Presents the per-message actions in a native bottom sheet — the idiomatic +/// mobile pattern, triggered from a long-press on a message. +Future showAiMessageActions( + BuildContext context, { + required AiMessage message, + VoidCallback? onCopy, + VoidCallback? onRegenerate, + VoidCallback? onEdit, +}) { + final l = AiLocalizations.of(context); + return showModalBottomSheet( + context: context, + showDragHandle: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + builder: (sheetContext) => SafeArea( + // Scrollable so the actions never overflow in landscape / small heights. + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.copy), + title: Text(l.copy), + onTap: () { + if (onCopy != null) { + onCopy(); + } else { + unawaited( + Clipboard.setData(ClipboardData(text: message.text))); + } + Navigator.of(sheetContext).pop(); + }, + ), + if (onRegenerate != null) + ListTile( + leading: const Icon(Icons.refresh), + title: Text(l.regenerate), + onTap: () { + onRegenerate(); + Navigator.of(sheetContext).pop(); + }, + ), + if (onEdit != null) + ListTile( + leading: const Icon(Icons.edit_outlined), + title: Text(l.edit), + onTap: () { + onEdit(); + Navigator.of(sheetContext).pop(); + }, + ), + ], + ), + ), + ), + ); +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_message_bubble.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_message_bubble.dart new file mode 100644 index 0000000..71c62b7 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_message_bubble.dart @@ -0,0 +1,258 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ai_core/flutter_ai_core.dart'; +import 'package:flutter_ai_elements/src/rendering/ai_text_renderer.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; +import 'package:flutter_ai_elements/src/widgets/ai_attachment.dart'; +import 'package:flutter_ai_elements/src/widgets/ai_reasoning.dart'; +import 'package:flutter_ai_elements/src/widgets/ai_response.dart'; +import 'package:flutter_ai_elements/src/widgets/ai_shimmer.dart'; +import 'package:flutter_ai_elements/src/widgets/ai_tool_invocation.dart'; + +/// A single chat bubble that renders one [AiMessage]'s parts. +/// +/// Purely presentational — it takes data, not a controller — so it is trivially +/// testable and reusable. Styling comes entirely from [AiThemeExtension]. +/// +/// Each part type gets an appropriate widget: prose via the [textRenderer], +/// reasoning via `AiReasoning`, tool calls via `AiToolInvocation` (paired with +/// their results), files via `AiAttachment`, and sources as link chips. +/// +/// ### Accessibility while streaming +/// +/// Rapidly updating text floods screen readers. While [AiMessage.status] is +/// [AiMessageStatus.streaming] the bubble is wrapped in [ExcludeSemantics]; +/// once the message completes it becomes a live region so assistive technology +/// announces the finished answer exactly once. +class AiMessageBubble extends StatelessWidget { + /// Creates a message bubble. + const AiMessageBubble({ + super.key, + required this.message, + this.textRenderer = const MarkdownTextRenderer(), + }); + + /// The message to render. + final AiMessage message; + + /// How text and reasoning parts are rendered. Defaults to + /// [MarkdownTextRenderer]. + final AiTextRenderer textRenderer; + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + final isUser = message.role == AiRole.user; + final isStreaming = message.status == AiMessageStatus.streaming; + // The user is always bubbled; the assistant follows the theme (plain by + // default — full-width, like a modern AI assistant). + final bubbled = + isUser || theme.assistantMessageStyle == AiMessageStyle.bubble; + + final content = DefaultTextStyle.merge( + style: theme.textStyle.copyWith( + color: isUser ? theme.userTextColor : theme.assistantTextColor, + ), + child: _content(context, isStreaming), + ); + + final Widget body; + if (bubbled) { + // Size the bubble relative to its container (so it stays correct inside a + // centered, max-width column on tablets/desktop), not the whole screen. + body = LayoutBuilder( + builder: (context, constraints) { + // Only fall back to the window width when the incoming constraints are + // unbounded; in the common bounded case never subscribe to media size. + final available = constraints.maxWidth.isFinite + ? constraints.maxWidth + : MediaQuery.sizeOf(context).width; + return Align( + alignment: isUser + ? AlignmentDirectional.centerEnd + : AlignmentDirectional.centerStart, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: available * theme.maxBubbleWidthFraction, + ), + child: Container( + decoration: BoxDecoration( + color: isUser + ? theme.userBubbleColor + : theme.assistantBubbleColor, + borderRadius: theme.bubbleRadius, + boxShadow: theme.bubbleShadow, + ), + padding: theme.bubblePadding, + child: content, + ), + ), + ); + }, + ); + } else { + // Plain assistant: full-width, no container. + body = SizedBox(width: double.infinity, child: content); + } + + return Padding( + padding: EdgeInsets.only(bottom: theme.messageSpacing), + child: isStreaming + ? ExcludeSemantics(child: body) + : Semantics(liveRegion: true, child: body), + ); + } + + Widget _content(BuildContext context, bool isStreaming) { + // Pair tool results with their calls so each renders inside one card. + final results = { + for (final part in message.parts) + if (part is ToolResultPart) part.toolCallId: part, + }; + + final children = []; + for (final part in message.parts) { + switch (part) { + case TextPart(:final text): + children.add( + _CrossfadeText( + text: text, + isStreaming: isStreaming, + renderer: textRenderer, + ), + ); + case ReasoningPart(:final text): + children.add(AiReasoning(text: text)); + case ToolCallPart(): + children.add( + AiToolInvocation(call: part, result: results[part.toolCallId]), + ); + case ToolResultPart(): + // Rendered within its AiToolInvocation; skip the standalone part. + break; + case FilePart(): + children.add(AiAttachment(file: part)); + case SourcePart(:final url, :final title): + children.add(_SourceChip(url: url, title: title)); + case DataPart(:final dataType): + children.add(_DataChip(label: dataType)); + } + } + + if (children.isEmpty) return const SizedBox.shrink(); + if (children.length == 1) return children.first; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < children.length; i++) ...[ + if (i > 0) const SizedBox(height: 8), + children[i], + ], + ], + ); + } +} + +/// Crossfades from the streaming text view to the final rendered Markdown when +/// a message finishes streaming, avoiding a hard pop. Under reduce-motion it +/// swaps instantly (WCAG 2.3.3). +class _CrossfadeText extends StatelessWidget { + const _CrossfadeText({ + required this.text, + required this.isStreaming, + required this.renderer, + }); + + final String text; + final bool isStreaming; + final AiTextRenderer renderer; + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + final reduceMotion = MediaQuery.maybeDisableAnimationsOf(context) ?? false; + // Awaiting the first token: an assistant turn that is streaming but has no + // text yet. Show a skeleton shimmer that crossfades into the streamed text + // once the first delta lands. + final awaiting = isStreaming && text.isEmpty; + // Three phases the switcher crossfades between: shimmer → streaming → final. + final phase = awaiting ? 0 : (isStreaming ? 1 : 2); + final Widget rendered = awaiting + ? const AiShimmer() + : renderer.render(text, isStreaming: isStreaming); + final child = KeyedSubtree( + key: ValueKey(phase), + child: rendered, + ); + if (reduceMotion) return child; + return AnimatedSwitcher( + duration: theme.motionDuration, + switchInCurve: theme.motionCurve, + switchOutCurve: theme.motionCurve, + // Cross-fade in place; size to the incoming child so the answer doesn't + // jump when it settles into Markdown. + layoutBuilder: (currentChild, previousChildren) => Stack( + alignment: AlignmentDirectional.topStart, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ), + child: child, + ); + } +} + +class _SourceChip extends StatelessWidget { + const _SourceChip({required this.url, this.title}); + + final Uri url; + final String? title; + + @override + Widget build(BuildContext context) { + final color = DefaultTextStyle.of(context).style.color; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.link, size: 16, color: color), + const SizedBox(width: 6), + Flexible( + child: Text( + title ?? url.toString(), + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: color, + decoration: TextDecoration.underline, + ), + ), + ), + ], + ); + } +} + +class _DataChip extends StatelessWidget { + const _DataChip({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final color = DefaultTextStyle.of(context).style.color; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.widgets_outlined, size: 16, color: color), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: color), + ), + ), + ], + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_model_selector.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_model_selector.dart new file mode 100644 index 0000000..034edcc --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_model_selector.dart @@ -0,0 +1,153 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; + +/// A selectable model option. +@immutable +class AiModelOption { + /// Creates a model option. + const AiModelOption({ + required this.id, + required this.label, + this.description, + }); + + /// The stable identifier passed to the provider. + final String id; + + /// The display name. + final String label; + + /// An optional one-line description shown in the picker. + final String? description; +} + +/// A compact "model ▾" chip that opens a bottom sheet to switch models. +/// +/// Wire [onSelected] to `UseChatController.setOptions` (or your own state) to +/// change the active model. +class AiModelSelector extends StatelessWidget { + /// Creates a model selector. + const AiModelSelector({ + super.key, + required this.models, + required this.selectedId, + required this.onSelected, + this.labelStyle, + this.labelBuilder, + this.showBorder = true, + this.padding = const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + }); + + /// The available models. + final List models; + + /// The id of the currently selected model. + final String selectedId; + + /// Called with the chosen model id. + final ValueChanged onSelected; + + /// Style for the trigger's label text. Merged over the themed default (which + /// is `theme.textStyle` at size 13). Ignored when [labelBuilder] is set. + final TextStyle? labelStyle; + + /// Fully replaces the trigger's label+chevron with a custom widget (e.g. a + /// larger two-tone brand title). The chevron is *not* added automatically — + /// include your own. The picker sheet is still opened on tap. + final Widget Function(BuildContext context, AiModelOption selected)? + labelBuilder; + + /// Whether to draw the rounded border around the trigger. Turn off for a + /// borderless brand title. + final bool showBorder; + + /// Padding inside the trigger. + final EdgeInsets padding; + + AiModelOption? get _selected { + if (models.isEmpty) return null; + return models.firstWhere( + (m) => m.id == selectedId, + orElse: () => models.first, + ); + } + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + // Nothing to select yet (e.g. models still loading) — render nothing. + final selected = _selected; + if (selected == null) return const SizedBox.shrink(); + final color = DefaultTextStyle.of(context).style.color; + return Semantics( + button: true, + label: 'Select model, ${selected.label}', + child: GestureDetector( + onTap: () => unawaited(_open(context)), + child: Container( + padding: padding, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: showBorder ? Border.all(color: theme.borderColor) : null, + ), + child: labelBuilder != null + ? labelBuilder!(context, selected) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + selected.label, + style: theme.textStyle + .copyWith(fontSize: 13, color: color) + .merge(labelStyle), + ), + const SizedBox(width: 2), + Icon(Icons.expand_more, size: 16, color: color), + ], + ), + ), + ), + ); + } + + Future _open(BuildContext context) { + return showModalBottomSheet( + context: context, + showDragHandle: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + // Bounded + scrollable so a long model list (or landscape) doesn't + // overflow the sheet. + isScrollControlled: true, + builder: (sheetContext) => SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final model in models) + ListTile( + title: Text(model.label), + subtitle: model.description == null + ? null + : Text(model.description!), + trailing: model.id == selectedId + ? Icon( + Icons.check, + color: AiThemeExtension.of(sheetContext).successColor, + ) + : null, + onTap: () { + onSelected(model.id); + Navigator.of(sheetContext).pop(); + }, + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_orb.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_orb.dart new file mode 100644 index 0000000..aef3956 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_orb.dart @@ -0,0 +1,101 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; + +/// A small, calm voice/loading **orb** — a luminous sphere that gently breathes +/// and reacts to audio [amplitude]. The compact counterpart to the full-screen +/// orb in `AiLiveSession`, usable inline (e.g. in a composer or status row). +/// +/// Colors derive from [AiThemeExtension.orbColor] and the size from [size]; +/// both are fully themeable. Under reduce-motion the breathing stops and a +/// static sphere is shown (WCAG 2.3.3). +class AiOrb extends StatefulWidget { + /// Creates an orb of diameter [size]. + const AiOrb({super.key, this.size = 64, this.amplitude = 0}); + + /// Diameter of the orb in logical pixels. + final double size; + + /// Normalized audio level (`0`–`1`) the orb reacts to. `0` is calm. + final double amplitude; + + @override + State createState() => _AiOrbState(); +} + +class _AiOrbState extends State with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 2600), + ); + bool _reduceMotion = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _reduceMotion = MediaQuery.maybeDisableAnimationsOf(context) ?? false; + if (_reduceMotion) { + _controller.stop(); + } else if (!_controller.isAnimating) { + unawaited(_controller.repeat()); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + final react = widget.amplitude.clamp(0.0, 1.0); + if (_reduceMotion) { + return _sphere(theme.orbColor, breathe: 0, react: react); + } + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final breathe = 0.5 - 0.5 * math.cos(2 * math.pi * _controller.value); + return _sphere(theme.orbColor, breathe: breathe, react: react); + }, + ); + } + + Widget _sphere(Color base, {required double breathe, required double react}) { + final light = Color.lerp(base, Colors.white, 0.7)!; + final dark = Color.lerp(base, Colors.black, 0.35)!; + final d = widget.size * (1 + 0.04 * breathe + 0.16 * react); + return SizedBox( + width: widget.size, + height: widget.size, + child: Center( + child: Container( + width: d, + height: d, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + center: const Alignment(-0.35, -0.45), + radius: 1.15, + colors: [light, base, dark], + stops: const [0.0, 0.65, 1.0], + ), + boxShadow: [ + BoxShadow( + color: base.withValues( + alpha: 0.30 + 0.28 * react + 0.08 * breathe, + ), + blurRadius: widget.size * (0.4 + 0.4 * react), + spreadRadius: widget.size * 0.03 * (1 + react), + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_prompt_input.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_prompt_input.dart new file mode 100644 index 0000000..62f0561 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_prompt_input.dart @@ -0,0 +1,88 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_ai_client/flutter_ai_client.dart'; +import 'package:flutter_ai_elements/src/widgets/ai_composer.dart'; + +/// A composer bound to a [UseChatController]. +/// +/// Stages attachments (via [onPickAttachment]) to send with the next message, +/// offers voice dictation ([onVoice]) and a Live entry point ([onLive]). The +/// model selector lives in the app bar, not here. +/// +/// Pass a [textController] to read or write the field's text from the host — +/// e.g. so [onVoice] dictation can *insert* the recognized text for review +/// (`textController.text = recognized`) instead of dictate-and-send. +class AiPromptInput extends StatefulWidget { + /// Creates a prompt input bound to [controller]. + const AiPromptInput({ + super.key, + required this.controller, + this.hintText = 'Message', + this.onPickAttachment, + this.onVoice, + this.onLive, + this.textController, + }); + + /// The chat controller to drive. + final UseChatController controller; + + /// Placeholder text for the empty input. + final String hintText; + + /// Host-provided picker; when non-null an attach (+) button is shown. + final Future> Function()? onPickAttachment; + + /// Voice dictation; when non-null a mic button shows while the field is empty. + /// Combine with [textController] to write recognized speech into the field. + final VoidCallback? onVoice; + + /// Live voice mode; when non-null the main button is Live while the field is + /// empty (and Send once the user types). + final VoidCallback? onLive; + + /// Optional external controller for the text field. Own its lifecycle (create + /// and dispose it in the host). Lets dictation/quick-replies set the text. + final TextEditingController? textController; + + @override + State createState() => _AiPromptInputState(); +} + +class _AiPromptInputState extends State { + final List _attachments = []; + + void _send(String text) { + final staged = List.of(_attachments); + setState(_attachments.clear); + unawaited(widget.controller.sendText(text, attachments: staged)); + } + + Future _pick() async { + final picked = await widget.onPickAttachment!(); + if (picked.isNotEmpty && mounted) { + setState(() => _attachments.addAll(picked)); + } + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: widget.controller, + builder: (context, _) => AiComposer( + controller: widget.textController, + hintText: widget.hintText, + isBusy: widget.controller.status.isBusy, + onStop: widget.controller.stop, + onSend: _send, + onAttach: + widget.onPickAttachment == null ? null : () => unawaited(_pick()), + onVoice: widget.onVoice, + onLive: widget.onLive, + attachments: _attachments, + onRemoveAttachment: (f) => setState(() => _attachments.remove(f)), + ), + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_reasoning.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_reasoning.dart new file mode 100644 index 0000000..3b6742c --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_reasoning.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; + +/// A collapsible disclosure for the model's reasoning ("chain of thought"). +/// +/// Kept out of the main answer flow and collapsed by default so reasoning is +/// available without dominating the bubble. +class AiReasoning extends StatefulWidget { + /// Creates a reasoning disclosure for [text]. + const AiReasoning({ + super.key, + required this.text, + this.initiallyExpanded = false, + }); + + /// The reasoning content. + final String text; + + /// Whether the disclosure starts expanded. + final bool initiallyExpanded; + + @override + State createState() => _AiReasoningState(); +} + +class _AiReasoningState extends State { + late bool _expanded = widget.initiallyExpanded; + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + final color = DefaultTextStyle.of(context).style.color; + final subdued = color?.withValues(alpha: 0.6); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + button: true, + expanded: _expanded, + child: InkWell( + onTap: () => setState(() => _expanded = !_expanded), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.psychology_outlined, size: 16, color: subdued), + const SizedBox(width: 6), + Text( + AiLocalizations.of(context).reasoning, + style: TextStyle( + color: subdued, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + Icon( + _expanded ? Icons.expand_less : Icons.expand_more, + size: 18, + color: subdued, + ), + ], + ), + ), + ), + AnimatedSize( + duration: theme.motionDuration, + curve: theme.motionCurve, + alignment: Alignment.topCenter, + child: _expanded + ? Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + widget.text, + style: theme.textStyle.copyWith( + color: subdued, + fontSize: 14.5, + height: 1.45, + ), + ), + ) + : const SizedBox(width: double.infinity), + ), + ], + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_response.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_response.dart new file mode 100644 index 0000000..2320827 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_response.dart @@ -0,0 +1,662 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/src/rendering/ai_text_renderer.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; +import 'package:flutter_ai_elements/src/widgets/ai_animated_response.dart'; +import 'package:flutter_ai_elements/src/widgets/ai_code_block.dart'; + +/// Renders a useful subset of Markdown — headings, bold/italic, inline code, +/// fenced code blocks, ordered/unordered lists, blockquotes, and links — with +/// **no external dependency**. +/// +/// This is the content renderer for assistant answers. Inline links are styled +/// always and become tappable when [onLinkTap] is provided. +class AiResponse extends StatefulWidget { + /// Creates a Markdown response from [text]. + const AiResponse({ + super.key, + required this.text, + this.onLinkTap, + this.codeHighlighter, + }); + + /// The Markdown source to render. + final String text; + + /// Called when a link is tapped. If `null`, links render but aren't tappable. + final void Function(Uri url)? onLinkTap; + + /// Optional syntax highlighter for fenced code blocks. When `null`, code + /// renders as plain monospace. + final CodeHighlighter? codeHighlighter; + + @override + State createState() => _AiResponseState(); +} + +class _AiResponseState extends State { + // Heading font sizes by level; hoisted so we don't rebuild the map per block. + static const Map _headingSizes = {1: 24.0, 2: 20.0, 3: 17.0}; + + // Matches an alphanumeric char; used to skip intraword `_` emphasis. + static final RegExp _intraword = RegExp(r'[A-Za-z0-9]'); + + final List _recognizers = []; + + // The parsed/built content, computed once per unique (text, onLinkTap) — never + // in build(). Recognizers are created here and disposed when text changes. + List<_Block>? _blocks; + + // The theme/base style the cached widget was built against. If the inherited + // style changes we re-resolve in build() without re-parsing the Markdown. + AiThemeExtension? _builtTheme; + TextStyle? _builtBase; + Widget? _built; + + void _disposeRecognizers() { + for (final r in _recognizers) { + r.dispose(); + } + _recognizers.clear(); + } + + // Parses the Markdown source once and caches the block list. Recognizers from + // the previous parse are disposed first. Does NOT build widgets (those depend + // on the inherited theme, resolved lazily in build()). + void _parse() { + _disposeRecognizers(); + _blocks = _parseBlocks(widget.text); + // Invalidate the built widget so it's rebuilt against the current theme. + _built = null; + _builtTheme = null; + _builtBase = null; + } + + @override + void initState() { + super.initState(); + _parse(); + } + + @override + void didUpdateWidget(AiResponse oldWidget) { + super.didUpdateWidget(oldWidget); + // Re-parse (and rebuild recognizers) only when the inputs that affect them + // change — never every frame. + if (oldWidget.text != widget.text || + oldWidget.onLinkTap != widget.onLinkTap || + oldWidget.codeHighlighter != widget.codeHighlighter) { + _parse(); + } + } + + @override + void dispose() { + _disposeRecognizers(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + final base = DefaultTextStyle.of(context).style.merge(theme.textStyle); + + // Return the cached widget unless the inherited theme/base style changed. + if (_built != null && theme == _builtTheme && base == _builtBase) { + return _built!; + } + + final blocks = _blocks!; + final built = Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < blocks.length; i++) ...[ + if (i > 0) const SizedBox(height: 8), + _buildBlock(blocks[i], theme, base), + ], + ], + ); + _built = built; + _builtTheme = theme; + _builtBase = base; + return built; + } + + Widget _buildBlock(_Block block, AiThemeExtension theme, TextStyle base) { + switch (block.type) { + case _BlockType.heading: + final style = base.copyWith( + fontSize: _headingSizes[block.level] ?? 16, + fontWeight: FontWeight.w700, + height: 1.3, + ); + return Text.rich(TextSpan(children: _inline(block.text, style, theme))); + case _BlockType.code: + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: AiCodeBlock( + code: block.text, + language: block.language, + highlighter: widget.codeHighlighter, + ), + ); + case _BlockType.bullet: + case _BlockType.ordered: + final isTask = block.checks.isNotEmpty; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < block.items.length; i++) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 24, + child: isTask + ? Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + block.checks[i] + ? Icons.check_box_rounded + : Icons.check_box_outline_blank_rounded, + size: 16, + color: block.checks[i] + ? theme.successColor + : theme.borderColor, + ), + ) + : Text( + block.type == _BlockType.ordered + ? '${i + 1}.' + : '•', + style: base, + ), + ), + Expanded( + child: Text.rich( + TextSpan( + children: _inline(block.items[i], base, theme), + ), + ), + ), + ], + ), + ), + ], + ); + case _BlockType.rule: + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Divider(height: 1, thickness: 1, color: theme.borderColor), + ); + case _BlockType.quote: + return Container( + padding: const EdgeInsets.only(left: 12), + decoration: BoxDecoration( + border: Border( + left: BorderSide(color: theme.borderColor, width: 3), + ), + ), + child: Text.rich( + TextSpan( + children: _inline( + block.text, + base.copyWith(color: base.color?.withValues(alpha: 0.7)), + theme, + ), + ), + ), + ); + case _BlockType.table: + return _buildTable(block.rows, theme, base); + case _BlockType.paragraph: + return Text.rich(TextSpan(children: _inline(block.text, base, theme))); + } + } + + Widget _buildTable( + List> rows, + AiThemeExtension theme, + TextStyle base, + ) { + if (rows.isEmpty) return const SizedBox.shrink(); + final cols = rows.first.length; + final headerStyle = base.copyWith(fontWeight: FontWeight.w700); + // Horizontal scroll keeps wide tables from overflowing the bubble. + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Container( + decoration: BoxDecoration( + border: Border.all(color: theme.borderColor), + borderRadius: BorderRadius.circular(10), + ), + child: Table( + defaultColumnWidth: const IntrinsicColumnWidth(), + defaultVerticalAlignment: TableCellVerticalAlignment.middle, + border: TableBorder.symmetric( + inside: BorderSide(color: theme.borderColor), + ), + children: [ + for (var r = 0; r < rows.length; r++) + TableRow( + decoration: BoxDecoration( + color: r == 0 ? theme.assistantBubbleColor : null, + ), + children: [ + for (var c = 0; c < cols; c++) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Text.rich( + TextSpan( + children: _inline( + c < rows[r].length ? rows[r][c] : '', + r == 0 ? headerStyle : base, + theme, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + // Inline parsing: **bold**, *italic*/_italic_, `code`, [text](url). + List _inline( + String text, + TextStyle base, + AiThemeExtension theme, + ) { + final spans = []; + final buffer = StringBuffer(); + var i = 0; + + void flush() { + if (buffer.isNotEmpty) { + spans.add(TextSpan(text: buffer.toString(), style: base)); + buffer.clear(); + } + } + + while (i < text.length) { + if (text.startsWith('**', i)) { + final end = text.indexOf('**', i + 2); + if (end != -1) { + flush(); + spans.addAll( + _inline( + text.substring(i + 2, end), + base.copyWith(fontWeight: FontWeight.w700), + theme, + ), + ); + i = end + 2; + continue; + } + } + if (text.startsWith('~~', i)) { + final end = text.indexOf('~~', i + 2); + if (end != -1) { + flush(); + spans.addAll( + _inline( + text.substring(i + 2, end), + base.copyWith( + decoration: TextDecoration.lineThrough, + decorationColor: base.color, + ), + theme, + ), + ); + i = end + 2; + continue; + } + } + final char = text[i]; + if (char == '`') { + final end = text.indexOf('`', i + 1); + if (end != -1) { + flush(); + spans.add( + TextSpan( + text: text.substring(i + 1, end), + style: theme.codeStyle.copyWith(color: base.color), + ), + ); + i = end + 1; + continue; + } + } + if (char == '[') { + final close = text.indexOf(']', i + 1); + if (close != -1 && close + 1 < text.length && text[close + 1] == '(') { + final urlEnd = text.indexOf(')', close + 2); + if (urlEnd != -1) { + flush(); + spans.add( + _linkSpan( + text.substring(i + 1, close), + text.substring(close + 2, urlEnd), + base, + theme, + ), + ); + i = urlEnd + 1; + continue; + } + } + } + if (char == '*' || char == '_') { + final end = text.indexOf(char, i + 1); + // Avoid false emphasis on prose: require non-space right after the + // opening marker (so "2 * 3" isn't italic), and for `_` skip intraword + // use (so identifiers like `snake_case` aren't italicized). + final prev = i > 0 ? text[i - 1] : ' '; + final intraword = char == '_' && _intraword.hasMatch(prev); + if (!intraword && end > i + 1 && text[i + 1] != ' ') { + flush(); + spans.addAll( + _inline( + text.substring(i + 1, end), + base.copyWith(fontStyle: FontStyle.italic), + theme, + ), + ); + i = end + 1; + continue; + } + } + buffer.write(char); + i++; + } + flush(); + return spans; + } + + InlineSpan _linkSpan( + String label, + String url, + TextStyle base, + AiThemeExtension theme, + ) { + final style = base.copyWith( + color: theme.linkColor, + decoration: TextDecoration.underline, + ); + final onTap = widget.onLinkTap; + if (onTap == null) return TextSpan(text: label, style: style); + final recognizer = TapGestureRecognizer() + ..onTap = () => onTap(Uri.parse(url)); + _recognizers.add(recognizer); + return TextSpan(text: label, style: style, recognizer: recognizer); + } +} + +/// An [AiTextRenderer] that renders Markdown via [AiResponse]. The default +/// renderer for assistant content. +class MarkdownTextRenderer implements AiTextRenderer { + /// Creates a Markdown renderer. + const MarkdownTextRenderer({this.onLinkTap, this.codeHighlighter}); + + /// Forwarded to [AiResponse.onLinkTap]. + final void Function(Uri url)? onLinkTap; + + /// Forwarded to [AiResponse.codeHighlighter] for the completed message. + final CodeHighlighter? codeHighlighter; + + @override + Widget render(String text, {required bool isStreaming}) => isStreaming + ? AiAnimatedResponse(text: text, onLinkTap: onLinkTap) + : AiResponse( + text: text, + onLinkTap: onLinkTap, + codeHighlighter: codeHighlighter, + ); +} + +enum _BlockType { + paragraph, + heading, + code, + bullet, + ordered, + quote, + table, + rule +} + +class _Block { + _Block.paragraph(this.text) + : type = _BlockType.paragraph, + level = 0, + language = null, + items = const [], + checks = const [], + rows = const []; + _Block.heading(this.level, this.text) + : type = _BlockType.heading, + language = null, + items = const [], + checks = const [], + rows = const []; + _Block.code(this.text, this.language) + : type = _BlockType.code, + level = 0, + items = const [], + checks = const [], + rows = const []; + _Block.quote(this.text) + : type = _BlockType.quote, + level = 0, + language = null, + items = const [], + checks = const [], + rows = const []; + _Block.list(this.type, this.items, {this.checks = const []}) + : level = 0, + language = null, + text = '', + rows = const []; + _Block.rule() + : type = _BlockType.rule, + level = 0, + language = null, + text = '', + items = const [], + checks = const [], + rows = const []; + _Block.table(this.rows) + : type = _BlockType.table, + level = 0, + language = null, + text = '', + items = const [], + checks = const []; + + final _BlockType type; + final String text; + final int level; + final String? language; + final List items; + + /// For task lists: per-item checkbox state (`true`/`false`), or empty for a + /// plain bullet/ordered list. Parallel to [items]. + final List checks; + + /// Table cells, first row being the header. Empty for non-tables. + final List> rows; +} + +List<_Block> _parseBlocks(String source) { + final lines = source.replaceAll('\r\n', '\n').split('\n'); + final blocks = <_Block>[]; + var i = 0; + + while (i < lines.length) { + final line = lines[i]; + final trimmed = line.trim(); + + if (trimmed.isEmpty) { + i++; + continue; + } + + // Fenced code block. + if (trimmed.startsWith('```')) { + final language = trimmed.substring(3).trim(); + final codeLines = []; + i++; + while (i < lines.length && !lines[i].trim().startsWith('```')) { + codeLines.add(lines[i]); + i++; + } + if (i < lines.length) i++; // skip closing fence + blocks.add( + _Block.code(codeLines.join('\n'), language.isEmpty ? null : language), + ); + continue; + } + + // Horizontal rule: three or more -, * or _ (optionally spaced), alone. + if (RegExp(r'^(?:-\s*){3,}$|^(?:\*\s*){3,}$|^(?:_\s*){3,}$') + .hasMatch(trimmed)) { + blocks.add(_Block.rule()); + i++; + continue; + } + + // Heading. + final heading = RegExp(r'^(#{1,6})\s+(.*)$').firstMatch(trimmed); + if (heading != null) { + blocks.add(_Block.heading(heading.group(1)!.length, heading.group(2)!)); + i++; + continue; + } + + // GFM table: a header row, a `---|---` separator, then body rows. + if (_isTableHeaderAt(lines, i)) { + final rows = >[_splitTableRow(trimmed)]; + i += 2; // header + separator + while (i < lines.length && + lines[i].trim().isNotEmpty && + lines[i].contains('|')) { + rows.add(_splitTableRow(lines[i].trim())); + i++; + } + blocks.add(_Block.table(rows)); + continue; + } + + // Blockquote (consecutive > lines). + if (trimmed.startsWith('>')) { + final quoteLines = []; + while (i < lines.length && lines[i].trim().startsWith('>')) { + quoteLines.add(lines[i].trim().replaceFirst(RegExp(r'^>\s?'), '')); + i++; + } + blocks.add(_Block.quote(quoteLines.join(' '))); + continue; + } + + // Task list (GFM checkboxes): `- [ ] todo` / `- [x] done`. + final task = RegExp(r'^[-*+]\s+\[([ xX])\]\s+'); + if (task.hasMatch(trimmed)) { + final items = []; + final checks = []; + while (i < lines.length && task.hasMatch(lines[i].trim())) { + final t = lines[i].trim(); + final m = task.firstMatch(t)!; + checks.add(m.group(1) != ' '); + items.add(t.substring(m.end)); + i++; + } + blocks.add(_Block.list(_BlockType.bullet, items, checks: checks)); + continue; + } + + // Unordered list. + if (RegExp(r'^[-*+]\s+').hasMatch(trimmed)) { + final items = []; + while (i < lines.length && + RegExp(r'^[-*+]\s+').hasMatch(lines[i].trim()) && + !task.hasMatch(lines[i].trim())) { + items.add(lines[i].trim().replaceFirst(RegExp(r'^[-*+]\s+'), '')); + i++; + } + blocks.add(_Block.list(_BlockType.bullet, items)); + continue; + } + + // Ordered list. + if (RegExp(r'^\d+\.\s+').hasMatch(trimmed)) { + final items = []; + while ( + i < lines.length && RegExp(r'^\d+\.\s+').hasMatch(lines[i].trim())) { + items.add(lines[i].trim().replaceFirst(RegExp(r'^\d+\.\s+'), '')); + i++; + } + blocks.add(_Block.list(_BlockType.ordered, items)); + continue; + } + + // Paragraph (consecutive non-blank, non-special lines). + // + // The first line here was already rejected by every block detector above, + // so it is genuinely paragraph text — always consume it. Only *subsequent* + // lines may break the paragraph. Gating the break on a non-empty paragraph + // guarantees `i` advances every outer iteration, so a partial stream that + // ends mid-construct (e.g. a lone `#` before its space arrives) can never + // spin this loop forever. + final paragraph = []; + while (i < lines.length && lines[i].trim().isNotEmpty) { + final t = lines[i].trim(); + if (paragraph.isNotEmpty && + (t.startsWith('```') || + RegExp(r'^#{1,6}\s+').hasMatch(t) || + t.startsWith('>') || + _isTableHeaderAt(lines, i) || + RegExp(r'^(?:-\s*){3,}$|^(?:\*\s*){3,}$|^(?:_\s*){3,}$') + .hasMatch(t) || + RegExp(r'^[-*+]\s+').hasMatch(t) || + RegExp(r'^\d+\.\s+').hasMatch(t))) { + break; + } + paragraph.add(t); + i++; + } + if (paragraph.isNotEmpty) blocks.add(_Block.paragraph(paragraph.join(' '))); + } + + return blocks; +} + +/// True if line [i] is a table header (contains a pipe) followed by a +/// `---|:--:` separator row. +bool _isTableHeaderAt(List lines, int i) { + if (i + 1 >= lines.length) return false; + if (!lines[i].contains('|')) return false; + final sep = lines[i + 1].trim(); + return sep.contains('-') && + sep.contains('|') && + RegExp(r'^[\s|:-]+$').hasMatch(sep); +} + +/// Splits a `| a | b |` row into trimmed cells, dropping the outer pipes. +List _splitTableRow(String line) { + var s = line.trim(); + if (s.startsWith('|')) s = s.substring(1); + if (s.endsWith('|')) s = s.substring(0, s.length - 1); + return s.split('|').map((c) => c.trim()).toList(); +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_shimmer.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_shimmer.dart new file mode 100644 index 0000000..d1f2a51 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_shimmer.dart @@ -0,0 +1,101 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; + +/// An animated shimmer placeholder for pending content — a row of grey bars +/// with a highlight sweeping across them. +class AiShimmer extends StatefulWidget { + /// Creates a shimmer with [lines] placeholder bars. + const AiShimmer({super.key, this.lines = 3, this.spacing = 10}); + + /// Number of placeholder bars. + final int lines; + + /// Vertical gap between bars. + final double spacing; + + @override + State createState() => _AiShimmerState(); +} + +class _AiShimmerState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1300), + ); + bool _reduceMotion = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _reduceMotion = MediaQuery.maybeDisableAnimationsOf(context) ?? false; + if (_reduceMotion) { + _controller.stop(); + } else if (!_controller.isAnimating) { + unawaited(_controller.repeat()); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + final base = theme.borderColor; + // A clearly lighter sweep that works in both light and dark themes. + final highlight = Color.lerp(base, Colors.white, 0.5)!; + + final bars = Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < widget.lines; i++) ...[ + if (i > 0) SizedBox(height: widget.spacing), + FractionallySizedBox( + widthFactor: i == widget.lines - 1 ? 0.55 : 1, + child: Container( + height: 12, + decoration: BoxDecoration( + color: base, + borderRadius: BorderRadius.circular(6), + ), + ), + ), + ], + ], + ); + + return Semantics( + label: AiLocalizations.of(context).loading, + // Static grey bars (no sweep) under reduce-motion (WCAG 2.3.3). + child: _reduceMotion + ? bars + : AnimatedBuilder( + animation: _controller, + builder: (context, child) { + // Travel the highlight fully across (off-left → off-right) so + // the loop is seamless — it's off-screen at both ends. + final c = -1.5 + 3.0 * _controller.value; + return ShaderMask( + blendMode: BlendMode.srcATop, + shaderCallback: (rect) => LinearGradient( + begin: Alignment(c - 0.7, 0), + end: Alignment(c + 0.7, 0), + colors: [base, highlight, base], + stops: const [0.0, 0.5, 1.0], + ).createShader(rect), + child: child, + ); + }, + child: bars, + ), + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_sources.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_sources.dart new file mode 100644 index 0000000..bf2843d --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_sources.dart @@ -0,0 +1,229 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ai_core/flutter_ai_core.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; +import 'package:flutter_ai_elements/src/widgets/ai_haptics.dart'; + +/// A wrapped list of citation chips built from [SourcePart]s. +/// +/// Render it beneath an answer to show where the model's information came from. +/// Tapping a chip invokes [onTap] (wire it to a URL launcher). +/// +/// Grounded answers can return dozens of sources, so by default only the first +/// [maxVisible] chips are shown with a "+N more" toggle; tapping it reveals the +/// rest. Set [maxVisible] to `null` to always show every source. +class AiSources extends StatefulWidget { + /// Creates a sources strip. + const AiSources({ + super.key, + required this.sources, + this.onTap, + this.maxVisible = 6, + this.showFavicons = false, + }); + + /// The citations to display. + final List sources; + + /// Called with the tapped source. + final void Function(SourcePart source)? onTap; + + /// How many chips to show before collapsing the rest behind a "+N more" + /// toggle. `null` shows all sources. + final int? maxVisible; + + /// Whether to fetch and show a per-source favicon. + /// + /// Off by default: favicons are fetched from a third-party service + /// (Google's favicon endpoint), which makes a network request per host and + /// discloses the cited hosts to that service. Enable it only when that + /// trade-off is acceptable; chips always fall back to a link glyph offline. + final bool showFavicons; + + @override + State createState() => _AiSourcesState(); +} + +class _AiSourcesState extends State { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final sources = widget.sources; + if (sources.isEmpty) return const SizedBox.shrink(); + final theme = AiThemeExtension.of(context); + + final cap = widget.maxVisible; + final collapsible = cap != null && sources.length > cap; + final visible = + (collapsible && !_expanded) ? sources.take(cap).toList() : sources; + final hiddenCount = collapsible ? sources.length - cap : 0; + + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (var i = 0; i < visible.length; i++) + _SourceChip( + index: i + 1, + label: visible[i].title ?? visible[i].url.host, + url: widget.showFavicons ? visible[i].url : null, + theme: theme, + onTap: widget.onTap == null + ? null + : () { + aiLightHaptic(theme); + widget.onTap!(visible[i]); + }, + ), + if (collapsible) + _SourceChip( + label: _expanded ? 'Show less' : '+$hiddenCount more', + icon: _expanded + ? Icons.expand_less_rounded + : Icons.expand_more_rounded, + theme: theme, + onTap: () => setState(() => _expanded = !_expanded), + ), + ], + ); + } +} + +class _SourceChip extends StatefulWidget { + const _SourceChip({ + required this.label, + required this.theme, + required this.onTap, + this.index, + this.url, + this.icon = Icons.link, + }); + + final String label; + final AiThemeExtension theme; + final VoidCallback? onTap; + + /// 1-based citation index, shown as a leading badge. Null for the toggle. + final int? index; + + /// The source URL, used to fetch a favicon. Null falls back to [icon]. + final Uri? url; + final IconData icon; + + @override + State<_SourceChip> createState() => _SourceChipState(); +} + +class _SourceChipState extends State<_SourceChip> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final theme = widget.theme; + final fg = theme.assistantTextColor; + return Material( + // Subtle hover lift: blend toward the border color on pointer-over. + color: _hovered + ? Color.lerp(theme.assistantBubbleColor, theme.borderColor, 0.5) + : theme.assistantBubbleColor, + borderRadius: BorderRadius.circular(16), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: widget.onTap, + onHover: (h) => setState(() => _hovered = h), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.index != null) ...[ + _IndexBadge(index: widget.index!, theme: theme), + const SizedBox(width: 6), + ], + if (widget.url != null) + _Favicon(url: widget.url!, fallback: widget.icon, color: fg) + else + Icon(widget.icon, size: 14, color: fg), + const SizedBox(width: 6), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200), + child: Text( + widget.label, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: fg, fontSize: 13), + ), + ), + ], + ), + ), + ), + ); + } +} + +/// A small numeric badge for a citation's index. +class _IndexBadge extends StatelessWidget { + const _IndexBadge({required this.index, required this.theme}); + + final int index; + final AiThemeExtension theme; + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints(minWidth: 16), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: theme.accentColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(5), + ), + child: Text( + '$index', + textAlign: TextAlign.center, + style: theme.codeStyle.copyWith( + fontSize: 11, + height: 1.3, + color: theme.assistantTextColor, + ), + ), + ); + } +} + +/// Best-effort favicon for [url]'s host, degrading to [fallback] on any error +/// (offline, blocked, unknown host) so the chip always renders something. +class _Favicon extends StatelessWidget { + const _Favicon({ + required this.url, + required this.fallback, + required this.color, + }); + + final Uri url; + final IconData fallback; + final Color color; + + @override + Widget build(BuildContext context) { + final host = url.host; + final icon = Icon(fallback, size: 14, color: color); + if (host.isEmpty) return icon; + final src = Uri.https('www.google.com', '/s2/favicons', { + 'domain': host, + 'sz': '32', + }); + return ClipRRect( + borderRadius: BorderRadius.circular(3), + child: Image.network( + src.toString(), + width: 14, + height: 14, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => icon, + // Avoid a flash of broken layout while loading: keep the fallback until + // the first frame is available. + frameBuilder: (_, child, frame, ___) => frame == null ? icon : child, + ), + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_suggestions.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_suggestions.dart new file mode 100644 index 0000000..580bf6a --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_suggestions.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; +import 'package:flutter_ai_elements/src/widgets/ai_haptics.dart'; + +/// A horizontally scrolling row of tappable suggested prompts. +/// +/// Useful as a conversation starter or for follow-up suggestions; tapping a chip +/// invokes [onSelected] with its text. +class AiSuggestions extends StatelessWidget { + /// Creates a suggestions strip. + const AiSuggestions({ + super.key, + required this.suggestions, + required this.onSelected, + this.padding = const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + }); + + /// The prompt texts to offer. + final List suggestions; + + /// Called with the chosen suggestion. + final ValueChanged onSelected; + + /// Padding around the strip. + final EdgeInsets padding; + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: padding, + child: Row( + children: [ + for (var i = 0; i < suggestions.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + _Chip( + label: suggestions[i], + theme: theme, + onTap: () { + aiLightHaptic(theme); + onSelected(suggestions[i]); + }, + ), + ], + ], + ), + ); + } +} + +class _Chip extends StatelessWidget { + const _Chip({required this.label, required this.theme, required this.onTap}); + + final String label; + final AiThemeExtension theme; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: theme.effectiveChipColor, + borderRadius: BorderRadius.circular(20), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + child: Text( + label, + style: TextStyle(color: theme.assistantTextColor), + ), + ), + ), + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_task.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_task.dart new file mode 100644 index 0000000..bc4d444 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_task.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; + +/// The state of an [AiTaskItem]. +enum AiTaskStatus { + /// Not started yet. + pending, + + /// Currently running. + active, + + /// Finished successfully. + complete, + + /// Failed. + error, +} + +/// One line item within an [AiTask]. +@immutable +class AiTaskItem { + /// Creates a task item. + const AiTaskItem({required this.label, this.status = AiTaskStatus.pending}); + + /// The item text (a step, a file name, …). + final String label; + + /// The item's status, which selects its leading icon. + final AiTaskStatus status; +} + +/// A collapsible "task" card showing a titled checklist the agent works +/// through — each item with a pending/active/complete/error indicator. +class AiTask extends StatefulWidget { + /// Creates a task card. + const AiTask({ + super.key, + required this.title, + required this.items, + this.initiallyExpanded = true, + }); + + /// The task headline. + final String title; + + /// The checklist items. + final List items; + + /// Whether the card starts expanded. + final bool initiallyExpanded; + + @override + State createState() => _AiTaskState(); +} + +class _AiTaskState extends State { + late bool _expanded = widget.initiallyExpanded; + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + final color = DefaultTextStyle.of(context).style.color; + final done = widget.items.where((i) => i.status == AiTaskStatus.complete); + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.borderColor), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + button: true, + expanded: _expanded, + child: InkWell( + onTap: () => setState(() => _expanded = !_expanded), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(Icons.checklist_rtl, size: 16, color: color), + const SizedBox(width: 8), + Expanded( + child: Text( + widget.title, + style: theme.textStyle.copyWith( + color: color, + fontSize: 14.5, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + '${done.length}/${widget.items.length}', + style: theme.codeStyle.copyWith( + color: color?.withValues(alpha: 0.6), + fontSize: 12, + ), + ), + Icon( + _expanded ? Icons.expand_less : Icons.expand_more, + size: 18, + color: color?.withValues(alpha: 0.6), + ), + ], + ), + ), + ), + ), + AnimatedSize( + duration: theme.motionDuration, + curve: theme.motionCurve, + alignment: Alignment.topCenter, + child: _expanded + ? Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final item in widget.items) + _ItemRow(item: item, theme: theme, textColor: color), + ], + ), + ) + : const SizedBox(width: double.infinity), + ), + ], + ), + ); + } +} + +class _ItemRow extends StatelessWidget { + const _ItemRow({ + required this.item, + required this.theme, + required this.textColor, + }); + + final AiTaskItem item; + final AiThemeExtension theme; + final Color? textColor; + + @override + Widget build(BuildContext context) { + final (icon, color) = switch (item.status) { + AiTaskStatus.complete => ( + Icons.check_circle, + theme.successColor, + ), + AiTaskStatus.active => (Icons.adjust, theme.accentColor), + AiTaskStatus.error => (Icons.error, theme.errorColor), + AiTaskStatus.pending => ( + Icons.radio_button_unchecked, + textColor?.withValues(alpha: 0.4) ?? const Color(0xFF999999), + ), + }; + final faded = item.status == AiTaskStatus.pending; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 8), + Expanded( + child: Text( + item.label, + style: theme.textStyle.copyWith( + color: faded ? textColor?.withValues(alpha: 0.6) : textColor, + fontSize: 14, + ), + ), + ), + ], + ), + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_tool_group.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_tool_group.dart new file mode 100644 index 0000000..1ad21fc --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_tool_group.dart @@ -0,0 +1,45 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_ai_core/flutter_ai_core.dart'; +import 'package:flutter_ai_elements/src/widgets/ai_tool_invocation.dart'; + +/// A vertically stacked list of [AiToolInvocation] cards — the recommended way +/// to present parallel tool calls. +/// +/// Each call is paired with its result (by `toolCallId`) from [results], so the +/// user can inspect every action independently. +class AiToolGroup extends StatelessWidget { + /// Creates a tool group for [calls], pairing each with its result in + /// [results] (keyed by `toolCallId`). + const AiToolGroup({ + super.key, + required this.calls, + this.results = const {}, + this.spacing = 8, + }); + + /// The tool calls to display, in order. + final List calls; + + /// Results keyed by `toolCallId`. + final Map results; + + /// Vertical gap between cards. + final double spacing; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < calls.length; i++) ...[ + if (i > 0) SizedBox(height: spacing), + AiToolInvocation( + call: calls[i], + result: results[calls[i].toolCallId], + ), + ], + ], + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_tool_invocation.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_tool_invocation.dart new file mode 100644 index 0000000..ce4357e --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_tool_invocation.dart @@ -0,0 +1,190 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_ai_core/flutter_ai_core.dart'; +import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; + +/// A collapsible card showing a single tool call: its name, lifecycle state, +/// arguments, and (once available) result. +/// +/// Stacking several of these vertically is the intended way to present parallel +/// tool calls — see `AiToolGroup`. +class AiToolInvocation extends StatefulWidget { + /// Creates a tool-invocation card for [call] with an optional [result]. + const AiToolInvocation({ + super.key, + required this.call, + this.result, + this.initiallyExpanded = false, + }); + + /// The tool call to display. + final ToolCallPart call; + + /// The matching result, if it has arrived. + final ToolResultPart? result; + + /// Whether the card starts expanded. + final bool initiallyExpanded; + + @override + State createState() => _AiToolInvocationState(); +} + +class _AiToolInvocationState extends State { + late bool _expanded = widget.initiallyExpanded; + + @override + Widget build(BuildContext context) { + final theme = AiThemeExtension.of(context); + final baseColor = DefaultTextStyle.of(context).style.color; + final (icon, iconColor) = _statusVisual(context); + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: (baseColor ?? const Color(0xFF000000)).withValues(alpha: 0.18), + ), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + button: true, + expanded: _expanded, + child: InkWell( + onTap: () => setState(() => _expanded = !_expanded), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + Icon(icon, size: 16, color: iconColor), + const SizedBox(width: 8), + Expanded( + child: Text( + widget.call.toolName, + style: theme.codeStyle.copyWith(color: baseColor), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + _stateLabel(widget.call.state), + style: theme.codeStyle.copyWith( + color: baseColor?.withValues(alpha: 0.6), + fontSize: 12, + ), + ), + Icon( + _expanded ? Icons.expand_less : Icons.expand_more, + size: 18, + color: baseColor?.withValues(alpha: 0.6), + ), + ], + ), + ), + ), + ), + AnimatedSize( + duration: theme.motionDuration, + curve: theme.motionCurve, + alignment: Alignment.topCenter, + child: _expanded + ? Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Section( + label: 'Arguments', + body: _pretty(widget.call.args), + style: theme.codeStyle.copyWith(color: baseColor), + ), + if (widget.result != null) ...[ + const SizedBox(height: 8), + _Section( + label: widget.result!.isError ? 'Error' : 'Result', + body: _pretty(widget.result!.result), + style: theme.codeStyle.copyWith(color: baseColor), + ), + ], + ], + ), + ) + : const SizedBox(width: double.infinity), + ), + ], + ), + ); + } + + (IconData, Color) _statusVisual(BuildContext context) { + final theme = AiThemeExtension.of(context); + final base = + DefaultTextStyle.of(context).style.color ?? const Color(0xFF000000); + return switch (_effectiveState()) { + ToolCallState.error => (Icons.error_outline, theme.errorColor), + ToolCallState.outputAvailable => ( + Icons.check_circle_outline, + theme.successColor, + ), + _ => (Icons.build_outlined, base), + }; + } + + // A result marked error overrides the call's own state for display. + ToolCallState _effectiveState() { + if (widget.result?.isError ?? false) return ToolCallState.error; + return widget.call.state; + } + + static String _stateLabel(ToolCallState state) => switch (state) { + ToolCallState.inputStreaming => 'preparing…', + ToolCallState.inputAvailable => 'ready', + ToolCallState.executing => 'running…', + ToolCallState.outputAvailable => 'done', + ToolCallState.error => 'error', + }; + + static String _pretty(Object? value) { + try { + return const JsonEncoder.withIndent(' ').convert(value); + } on Object { + return '$value'; + } + } +} + +class _Section extends StatelessWidget { + const _Section({ + required this.label, + required this.body, + required this.style, + }); + + final String label; + final String body; + final TextStyle style; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: style.copyWith( + fontSize: 12, + fontWeight: FontWeight.w600, + color: style.color?.withValues(alpha: 0.6), + ), + ), + const SizedBox(height: 2), + Text(body, style: style), + ], + ); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/pubspec.yaml b/packages/flutter_ai/flutter_ai_elements/pubspec.yaml new file mode 100644 index 0000000..0188e23 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/pubspec.yaml @@ -0,0 +1,52 @@ +name: flutter_ai_elements +description: "Composable, themeable Flutter UI for AI chat: conversation view, message bubbles, a streaming-aware composer, and a loader, styled through a mobile-first theme extension." +version: 0.2.0 +homepage: https://github.com/ananmouaz/flutter_ai +repository: https://github.com/ananmouaz/flutter_ai/tree/main/packages/flutter_ai_elements +issue_tracker: https://github.com/ananmouaz/flutter_ai/issues + +topics: + - ai + - llm + - chat + - streaming + - widgets + +# Rendered on the pub.dev listing (up to 5). +screenshots: + - description: "A streamed assistant message with Markdown formatting." + path: screenshots/element_message_assistant.png + - description: "Collapsible chain-of-thought reasoning disclosure." + path: screenshots/element_reasoning.png + - description: "A tool invocation card with arguments and result." + path: screenshots/element_tool_invocation.png + - description: "Syntax-highlighted code block with a copy action." + path: screenshots/element_code_block.png + - description: "Grounded answer with source citations." + path: screenshots/element_sources.png + +environment: + sdk: ^3.6.0 + flutter: ">=3.27.0" + +# Pure Flutter widgets (no platform channels) — supported everywhere Flutter is. +platforms: + android: + ios: + linux: + macos: + web: + windows: + +resolution: workspace + +dependencies: + flutter: + sdk: flutter + flutter_ai_client: ^0.3.0 + flutter_ai_core: ^0.1.11 + +dev_dependencies: + flutter_test: + sdk: flutter + lints: ^5.0.0 diff --git a/packages/flutter_ai/flutter_ai_elements/screenshots/element_code_block.png b/packages/flutter_ai/flutter_ai_elements/screenshots/element_code_block.png new file mode 100644 index 0000000000000000000000000000000000000000..d819c45c874be935b60feb9dfd55a516dab92125 GIT binary patch literal 7158 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 literal 0 HcmV?d00001 diff --git a/packages/flutter_ai/flutter_ai_elements/screenshots/element_message_assistant.png b/packages/flutter_ai/flutter_ai_elements/screenshots/element_message_assistant.png new file mode 100644 index 0000000000000000000000000000000000000000..301ea8428584b831108db8a2a00123f7d3dca576 GIT binary patch literal 19230 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 DpejPB literal 0 HcmV?d00001 diff --git a/packages/flutter_ai/flutter_ai_elements/screenshots/element_reasoning.png b/packages/flutter_ai/flutter_ai_elements/screenshots/element_reasoning.png new file mode 100644 index 0000000000000000000000000000000000000000..3afa2b4d399a399e85a89585615a0cb7bd49f2cf GIT binary patch literal 9727 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 RUf@9sV61PVSEb_^^Iz>QgggKM literal 0 HcmV?d00001 diff --git a/packages/flutter_ai/flutter_ai_elements/screenshots/element_tool_invocation.png b/packages/flutter_ai/flutter_ai_elements/screenshots/element_tool_invocation.png new file mode 100644 index 0000000000000000000000000000000000000000..26922f63dafdcd93f8e521db65d881276794142d GIT binary patch literal 16093 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 TCaJ;m=@3aVxwl0kAHMt#IUMy} literal 0 HcmV?d00001 diff --git a/packages/flutter_ai/flutter_ai_elements/test/ai_chat_scroll_test.dart b/packages/flutter_ai/flutter_ai_elements/test/ai_chat_scroll_test.dart new file mode 100644 index 0000000..ad166ab --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/test/ai_chat_scroll_test.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/flutter_ai_elements.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A provider that streams a multi-line assistant reply so the chat has more +/// content than fits the viewport — the case where top-pinning matters. +class _StreamingProvider implements LlmProvider { + @override + Stream send( + AiConversation conversation, { + List? tools, + AiRequestOptions? options, + }) async* { + // A short reply: shorter than the viewport, so the chat must RESERVE + // trailing space for the anchored question to reach the top. + yield const MessageStarted(messageId: 'a-new', role: AiRole.assistant); + yield const TextDelta(messageId: 'a-new', delta: 'Short streamed answer.'); + yield const MessageFinished(messageId: 'a-new', reason: FinishReason.stop); + } +} + +Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); + +void main() { + group('AiChat top-pin scroll', () { + testWidgets('anchors the just-sent user message to the viewport top', + (tester) async { + // Fix the viewport to a small phone-ish size so prior content overflows + // and the anchor genuinely has to scroll to the top. + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + // A backlog of prior turns, each tall enough that the transcript is far + // taller than the 800px viewport. + final longText = List.filled( + 16, + 'This is a fairly long prior line of the conversation.', + ).join(' '); + final initial = AiConversation( + id: 'c', + messages: [ + for (var i = 0; i < 4; i++) ...[ + AiMessage( + id: 'u$i', + role: AiRole.user, + parts: [TextPart('Question $i')], + ), + AiMessage( + id: 'a$i', + role: AiRole.assistant, + parts: [TextPart(longText)], + status: AiMessageStatus.complete, + ), + ], + ], + ); + final controller = + UseChatController(provider: _StreamingProvider(), initial: initial); + addTearDown(controller.dispose); + + await tester.pumpWidget(_wrap(AiChat(controller: controller))); + await _settle(tester); + + // Send a new user turn; the assistant reply streams in below it. + await controller.sendText('the new question'); + await _settle(tester); + + final question = find.text('the new question'); + expect(question, findsOneWidget); + + // The anchored user message must be pinned at/near the top of the AiChat + // viewport (ChatGPT-style), NOT mid-screen with prior answers above it. + final chatTop = tester.getTopLeft(find.byType(AiChat)).dy; + final qTop = tester.getTopLeft(question).dy; + expect( + qTop - chatTop, + lessThan(80), + reason: 'newly-sent question should pin to the top, was ' + '${qTop - chatTop}px below the chat top', + ); + + // Trailing space must be reserved beneath the last item so the anchor can + // reach the top even though the streamed answer is shorter than the + // viewport. The reservation lives on AiConversationView.trailingSpace. + final view = tester.widget( + find.byType(AiConversationView), + ); + expect( + view.trailingSpace, + greaterThan(0), + reason: 'trailing space must be reserved so the anchor can reach the ' + 'top, was ${view.trailingSpace}', + ); + // The view must have an anchor wired up (the just-sent user message), + // matching the id of the controller's last user message. + final lastUserId = + controller.messages.lastWhere((m) => m.role == AiRole.user).id; + expect(view.anchorId, lastUserId); + }); + }); +} + +/// Pumps a bounded number of frames so [AiChat]'s post-frame `_settle()` retry +/// loop can run. We can't `pumpAndSettle` — the streaming caret blinks forever, +/// so the tree never reaches a steady state. +Future _settle(WidgetTester tester) async { + for (var i = 0; i < 20; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } +} diff --git a/packages/flutter_ai/flutter_ai_elements/test/dogfood_apis_test.dart b/packages/flutter_ai/flutter_ai_elements/test/dogfood_apis_test.dart new file mode 100644 index 0000000..c75f20e --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/test/dogfood_apis_test.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/flutter_ai_elements.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); + +/// Echoes a fixed assistant reply. +class _EchoProvider implements LlmProvider { + @override + Stream send( + AiConversation conversation, { + List? tools, + AiRequestOptions? options, + }) async* { + yield const MessageStarted(messageId: 'a1', role: AiRole.assistant); + yield const TextDelta(messageId: 'a1', delta: 'Echo reply'); + yield const MessageFinished(messageId: 'a1', reason: FinishReason.stop); + } +} + +/// A test double for [AiVoiceEngine] whose turns are advanced by the test. +class _FakeEngine implements AiVoiceEngine { + int listenCalls = 0; + int speakCalls = 0; + String? lastSpoken; + void Function(String finalText)? _onListenDone; + void Function()? _onSpeakDone; + + void finishListening(String text) => _onListenDone?.call(text); + void finishSpeaking() => _onSpeakDone?.call(); + + @override + Future startListening({ + required void Function(String text) onPartial, + required void Function(String finalText) onDone, + void Function(double level)? onLevel, + }) async { + listenCalls++; + _onListenDone = onDone; + } + + @override + Future speak(String text, {required void Function() onDone}) async { + speakCalls++; + lastSpoken = text; + _onSpeakDone = onDone; + } + + @override + Future stopListening() async {} + @override + Future stopSpeaking() async {} + @override + Future dispose() async {} +} + +void main() { + group('AiMessageActions ordering', () { + const message = AiMessage( + id: 'a1', + role: AiRole.assistant, + parts: [TextPart('hi')], + ); + + testWidgets('no trailing → compact row, no spacer', (tester) async { + await tester.pumpWidget(_wrap( + AiMessageActions(message: message, onSpeak: () {}, onRegenerate: () {}), + )); + expect(find.byType(Spacer), findsNothing); + }); + + testWidgets('trailing set pushes an action to the far side via a spacer', + (tester) async { + await tester.pumpWidget(_wrap( + AiMessageActions( + message: message, + onSpeak: () {}, + onGood: () {}, + trailing: const {AiMessageActionKind.speak}, + ), + )); + expect(find.byType(Spacer), findsOneWidget); + }); + }); + + group('AiModelSelector theming', () { + testWidgets('labelBuilder replaces the trigger label', (tester) async { + await tester.pumpWidget(_wrap( + AiModelSelector( + models: const [AiModelOption(id: 'pro', label: 'Pro')], + selectedId: 'pro', + onSelected: (_) {}, + showBorder: false, + labelBuilder: (context, selected) => Text('Gemini ${selected.label}'), + ), + )); + expect(find.text('Gemini Pro'), findsOneWidget); + }); + }); + + group('AiLiveController', () { + test('runs listen → send → speak → re-listen', () async { + final controller = UseChatController( + provider: _EchoProvider(), + scheduler: (cb) => cb(), + ); + addTearDown(controller.dispose); + final engine = _FakeEngine(); + final live = AiLiveController(controller: controller, engine: engine); + addTearDown(live.dispose); + + live.start(); + expect(engine.listenCalls, 1); + expect(live.status, AiLiveStatus.listening); + + // User finishes speaking → controller sends → assistant echoes. + engine.finishListening('hello there'); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(controller.messages.first.text, 'hello there'); + expect(live.status, AiLiveStatus.speaking); + expect(engine.speakCalls, 1); + expect(engine.lastSpoken, 'Echo reply'); + + // TTS finishes → back to listening. + engine.finishSpeaking(); + expect(live.status, AiLiveStatus.listening); + expect(engine.listenCalls, 2); + + live.stop(); + expect(live.status, AiLiveStatus.ended); + }); + + test('empty transcript re-listens without sending', () async { + final controller = UseChatController( + provider: _EchoProvider(), + scheduler: (cb) => cb(), + ); + addTearDown(controller.dispose); + final engine = _FakeEngine(); + final live = AiLiveController(controller: controller, engine: engine); + addTearDown(live.dispose); + + live.start(); + engine.finishListening(' '); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(controller.messages, isEmpty); + expect(engine.speakCalls, 0); + expect(engine.listenCalls, 2); // listened again + }); + }); +} diff --git a/packages/flutter_ai/flutter_ai_elements/test/widgets_test.dart b/packages/flutter_ai/flutter_ai_elements/test/widgets_test.dart new file mode 100644 index 0000000..7348bc2 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_elements/test/widgets_test.dart @@ -0,0 +1,1019 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_ai_elements/flutter_ai_elements.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); + +class _EchoProvider implements LlmProvider { + @override + Stream send( + AiConversation conversation, { + List? tools, + AiRequestOptions? options, + }) async* { + yield const MessageStarted(messageId: 'a1', role: AiRole.assistant); + yield const TextDelta(messageId: 'a1', delta: 'Echo'); + yield const MessageFinished(messageId: 'a1', reason: FinishReason.stop); + } +} + +void main() { + group('AiWidgetRegistry (generative UI)', () { + testWidgets('renders a registered dataType and falls back otherwise', + (tester) async { + final registry = AiWidgetRegistry() + ..register( + 'weather', + (context, data) => Text('It is ${data['temp']}°'), + ); + + await tester.pumpWidget( + _wrap( + AiDataView( + part: const DataPart(dataType: 'weather', data: {'temp': 21}), + registry: registry, + ), + ), + ); + expect(find.text('It is 21°'), findsOneWidget); + + // Unregistered type → fallback. + await tester.pumpWidget( + _wrap( + AiDataView( + part: const DataPart(dataType: 'unknown', data: {}), + registry: registry, + fallback: const Text('unsupported'), + ), + ), + ); + expect(find.text('unsupported'), findsOneWidget); + }); + }); + + group('AiLocalizations', () { + test('delegate serves provided strings and reloads on change', () async { + const custom = AiLocalizations(copy: 'Copier', send: 'Envoyer'); + const delegate = AiLocalizationsDelegate(custom); + expect(delegate.isSupported(const Locale('fr')), isTrue); + final loaded = await delegate.load(const Locale('fr')); + expect(loaded.copy, 'Copier'); + expect(loaded.send, 'Envoyer'); + expect(delegate.shouldReload(const AiLocalizationsDelegate()), isTrue); + }); + + testWidgets('widgets read overridden strings from the tree', + (tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: const [ + AiLocalizationsDelegate(AiLocalizations(retry: 'Réessayer')), + DefaultMaterialLocalizations.delegate, + DefaultWidgetsLocalizations.delegate, + ], + home: Scaffold( + body: AiErrorBanner(message: 'boom', onRetry: () {}), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Réessayer'), findsOneWidget); + }); + + testWidgets('AiLocalizationsScope overrides strings without a delegate', + (tester) async { + await tester.pumpWidget( + _wrap( + const AiLocalizationsScope( + strings: AiLocalizations(allow: 'Autoriser', deny: 'Refuser'), + child: AiConfirmation(title: 'Proceed?'), + ), + ), + ); + expect(find.text('Autoriser'), findsOneWidget); + expect(find.text('Refuser'), findsOneWidget); + }); + }); + + group('AiChatView', () { + testWidgets('composes transcript + input from a controller', + (tester) async { + final controller = UseChatController(provider: _EchoProvider()); + addTearDown(controller.dispose); + await tester.pumpWidget(_wrap(AiChatView(controller: controller))); + expect(find.byType(AiChat), findsOneWidget); + expect(find.byType(AiPromptInput), findsOneWidget); + }); + }); + + group('AiConversationList', () { + testWidgets('lists threads and fires select/new/delete', (tester) async { + ChatThread? selected; + var created = 0; + ChatThread? deleted; + await tester.pumpWidget( + _wrap( + AiConversationList( + threads: const [ + ChatThread(id: '1', title: 'Lisbon trip'), + ChatThread(id: '2', title: 'Dinner recipe'), + ], + selectedId: '1', + onSelect: (t) => selected = t, + onNew: () => created++, + onDelete: (t) => deleted = t, + ), + ), + ); + + expect(find.text('Lisbon trip'), findsOneWidget); + expect(find.text('Dinner recipe'), findsOneWidget); + + await tester.tap(find.text('New chat')); + expect(created, 1); + + await tester.tap(find.text('Dinner recipe')); + expect(selected?.id, '2'); + + await tester.tap(find.byIcon(Icons.delete_outline).first); + expect(deleted?.id, '1'); + }); + }); + + group('AiThemeExtension', () { + test('of returns the fallback when none is registered', () { + final fallback = AiThemeExtension.fallback(); + expect(fallback.enableHaptics, isTrue); + expect(fallback.maxBubbleWidthFraction, closeTo(0.80, 0.001)); + expect(fallback.maxContentWidth, closeTo(720, 0.001)); + }); + + test('lerp snaps an infinite reading width instead of producing NaN', () { + final a = AiThemeExtension.fallback(); + final b = a.copyWith(maxContentWidth: double.infinity); + final lerped = a.lerp(b, 0.7); + expect(lerped.maxContentWidth, double.infinity); // snaps to b past 0.5 + expect(lerped.maxContentWidth.isNaN, isFalse); + }); + + test('copyWith overrides only the given token', () { + final base = AiThemeExtension.fallback(); + final edited = base.copyWith(enableHaptics: false); + expect(edited.enableHaptics, isFalse); + expect(edited.userBubbleColor, base.userBubbleColor); + }); + + test('lerp interpolates continuous tokens and snaps discrete ones', () { + final a = AiThemeExtension.fallback(); + final b = a.copyWith(messageSpacing: 30, enableHaptics: false); + final lerped = a.lerp(b, 0.4); + expect(lerped.messageSpacing, closeTo(22.8, 0.001)); // 18 + 0.4 * 12 + expect(lerped.enableHaptics, isTrue); // snaps to `a` while t < 0.5 + }); + }); + + group('AiMessageBubble', () { + testWidgets('renders text and right-aligns the user', (tester) async { + await tester.pumpWidget( + _wrap( + const AiMessageBubble( + message: AiMessage( + id: 'm1', + role: AiRole.user, + parts: [TextPart('Hello there')], + ), + ), + ), + ); + expect(find.text('Hello there'), findsOneWidget); + final align = tester.widget(find.byType(Align).first); + // Directional so it mirrors correctly under RTL (end == right in LTR). + expect(align.alignment, AlignmentDirectional.centerEnd); + }); + + testWidgets('excludes semantics while streaming', (tester) async { + await tester.pumpWidget( + _wrap( + const AiMessageBubble( + message: AiMessage( + id: 'm1', + role: AiRole.assistant, + parts: [TextPart('partial')], + status: AiMessageStatus.streaming, + ), + ), + ), + ); + expect(find.byType(ExcludeSemantics), findsAtLeastNWidgets(1)); + }); + + testWidgets('renders a tool call line', (tester) async { + await tester.pumpWidget( + _wrap( + const AiMessageBubble( + message: AiMessage( + id: 'm1', + role: AiRole.assistant, + parts: [ + ToolCallPart( + toolCallId: 'c1', + toolName: 'get_weather', + state: ToolCallState.outputAvailable, + ), + ], + ), + ), + ), + ); + expect(find.textContaining('get_weather'), findsOneWidget); + }); + }); + + group('AiComposer', () { + testWidgets('sends trimmed text and clears the field', (tester) async { + String? sent; + await tester.pumpWidget( + _wrap(AiComposer(onSend: (t) => sent = t)), + ); + await tester.enterText(find.byType(TextField), ' hi '); + await tester.tap(find.byIcon(Icons.arrow_upward_rounded)); + await tester.pump(); + expect(sent, 'hi'); + expect(find.text(' hi '), findsNothing); + }); + + testWidgets('shows Stop while busy and calls onStop', (tester) async { + var stopped = false; + await tester.pumpWidget( + _wrap( + AiComposer( + onSend: (_) {}, + onStop: () => stopped = true, + isBusy: true, + ), + ), + ); + expect(find.byIcon(Icons.stop_rounded), findsOneWidget); + await tester.tap(find.byIcon(Icons.stop_rounded)); + expect(stopped, isTrue); + }); + }); + + group('AiChat (controller-bound)', () { + testWidgets('renders the streamed conversation', (tester) async { + final controller = UseChatController(provider: _EchoProvider()); + addTearDown(controller.dispose); + + await tester.pumpWidget(_wrap(AiChat(controller: controller))); + await controller.sendText('hi'); + await tester.pumpAndSettle(); + + expect(find.text('hi'), findsOneWidget); + expect(find.text('Echo'), findsOneWidget); + }); + + testWidgets('pins the just-sent question to the top of the viewport', + (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final longText = List.filled( + 16, + 'This is a fairly long prior line of the conversation.', + ).join(' '); + final initial = AiConversation( + id: 'c', + messages: [ + for (var i = 0; i < 4; i++) ...[ + AiMessage( + id: 'u$i', + role: AiRole.user, + parts: [TextPart('Question $i')], + ), + AiMessage( + id: 'a$i', + role: AiRole.assistant, + parts: [TextPart(longText)], + status: AiMessageStatus.complete, + ), + ], + ], + ); + final controller = + UseChatController(provider: _EchoProvider(), initial: initial); + addTearDown(controller.dispose); + + await tester.pumpWidget(_wrap(AiChat(controller: controller))); + await tester.pumpAndSettle(); + await controller.sendText('the new question'); + await tester.pumpAndSettle(); + + // The question must sit near the top of the chat (pinned), not mid-screen + // showing previous answers above it. + final chatTop = tester.getTopLeft(find.byType(AiChat)).dy; + final qTop = tester.getTopLeft(find.text('the new question')).dy; + expect(qTop - chatTop, lessThan(80), + reason: + 'question should be pinned to the top, was ${qTop - chatTop}px ' + 'below the top'); + }); + }); + + group('AiToolInvocation', () { + const call = ToolCallPart( + toolCallId: 'c1', + toolName: 'get_weather', + args: {'city': 'London'}, + state: ToolCallState.outputAvailable, + ); + + testWidgets('shows the tool name and is collapsed by default', + (tester) async { + await tester.pumpWidget(_wrap(const AiToolInvocation(call: call))); + expect(find.text('get_weather'), findsOneWidget); + expect(find.text('Arguments'), findsNothing); + }); + + testWidgets('reveals arguments and result when expanded', (tester) async { + await tester.pumpWidget( + _wrap( + const AiToolInvocation( + call: call, + result: ToolResultPart(toolCallId: 'c1', result: {'tempC': 21}), + initiallyExpanded: true, + ), + ), + ); + expect(find.text('Arguments'), findsOneWidget); + expect(find.text('Result'), findsOneWidget); + expect(find.textContaining('London'), findsOneWidget); + }); + + testWidgets('expands on tap', (tester) async { + await tester.pumpWidget(_wrap(const AiToolInvocation(call: call))); + await tester.tap(find.text('get_weather')); + await tester.pumpAndSettle(); + expect(find.text('Arguments'), findsOneWidget); + }); + }); + + group('AiReasoning', () { + testWidgets('hides text until expanded', (tester) async { + await tester.pumpWidget( + _wrap(const AiReasoning(text: 'step by step')), + ); + expect(find.text('Reasoning'), findsOneWidget); + expect(find.text('step by step'), findsNothing); + + await tester.tap(find.text('Reasoning')); + await tester.pumpAndSettle(); + expect(find.text('step by step'), findsOneWidget); + }); + }); + + group('AiAttachment', () { + testWidgets('renders a file chip for non-images', (tester) async { + await tester.pumpWidget( + _wrap( + const AiAttachment( + file: FilePart(mediaType: 'application/pdf', name: 'report.pdf'), + ), + ), + ); + expect(find.text('report.pdf'), findsOneWidget); + }); + }); + + group('AiToolGroup', () { + testWidgets('stacks one card per call', (tester) async { + await tester.pumpWidget( + _wrap( + const AiToolGroup( + calls: [ + ToolCallPart(toolCallId: 'c1', toolName: 'alpha'), + ToolCallPart(toolCallId: 'c2', toolName: 'beta'), + ], + ), + ), + ); + expect(find.byType(AiToolInvocation), findsNWidgets(2)); + expect(find.text('alpha'), findsOneWidget); + expect(find.text('beta'), findsOneWidget); + }); + }); + + group('expanded elements', () { + testWidgets('AiAvatar shows a role icon', (tester) async { + await tester.pumpWidget(_wrap(const AiAvatar(role: AiRole.assistant))); + expect(find.byIcon(Icons.auto_awesome), findsOneWidget); + }); + + testWidgets('AiEmptyState shows title and subtitle', (tester) async { + await tester.pumpWidget( + _wrap(const AiEmptyState(title: 'Hello', subtitle: 'Ask me anything')), + ); + expect(find.text('Hello'), findsOneWidget); + expect(find.text('Ask me anything'), findsOneWidget); + }); + + testWidgets('AiEmptyState fires the tapped suggestion', (tester) async { + String? chosen; + await tester.pumpWidget( + _wrap( + AiEmptyState( + title: 'Hi', + suggestions: const ['Plan a trip', 'Write code'], + onSuggestionTap: (s) => chosen = s, + ), + ), + ); + expect(find.text('Plan a trip'), findsOneWidget); + await tester.tap(find.text('Write code')); + expect(chosen, 'Write code'); + }); + + testWidgets('AiOrb builds and animates', (tester) async { + await tester.pumpWidget(_wrap(const AiOrb(size: 48))); + expect(find.byType(AiOrb), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + expect(tester.takeException(), isNull); + }); + + testWidgets('AiErrorBanner shows message and fires retry', (tester) async { + var retried = false; + await tester.pumpWidget( + _wrap( + AiErrorBanner(message: 'boom', onRetry: () => retried = true), + ), + ); + expect(find.text('boom'), findsOneWidget); + await tester.tap(find.text('Retry')); + expect(retried, isTrue); + }); + + testWidgets('AiSuggestions reports the chosen suggestion', (tester) async { + String? chosen; + await tester.pumpWidget( + _wrap( + AiSuggestions( + suggestions: const ['Summarize', 'Translate'], + onSelected: (s) => chosen = s, + ), + ), + ); + await tester.tap(find.text('Translate')); + expect(chosen, 'Translate'); + }); + + testWidgets('AiSources renders a chip per source', (tester) async { + await tester.pumpWidget( + _wrap( + AiSources( + sources: [ + SourcePart( + url: Uri.parse('https://flutter.dev'), + title: 'Flutter', + ), + SourcePart(url: Uri.parse('https://dart.dev')), + ], + ), + ), + ); + expect(find.text('Flutter'), findsOneWidget); + expect(find.text('dart.dev'), findsOneWidget); // falls back to host + // Numeric citation indices on each chip. + expect(find.text('1'), findsOneWidget); + expect(find.text('2'), findsOneWidget); + }); + + testWidgets('AiSources collapses past maxVisible and expands on tap', + (tester) async { + final sources = [ + for (var i = 0; i < 10; i++) + SourcePart(url: Uri.parse('https://site$i.example')), + ]; + await tester + .pumpWidget(_wrap(AiSources(sources: sources, maxVisible: 3))); + // Only the first 3 chips show, plus a "+7 more" toggle. + expect(find.text('site0.example'), findsOneWidget); + expect(find.text('site2.example'), findsOneWidget); + expect(find.text('site3.example'), findsNothing); + expect(find.text('+7 more'), findsOneWidget); + + await tester.tap(find.text('+7 more')); + await tester.pumpAndSettle(); + expect(find.text('site9.example'), findsOneWidget); + expect(find.text('Show less'), findsOneWidget); + }); + + testWidgets('AiCodeBlock shows code and a copy button', (tester) async { + await tester.pumpWidget( + _wrap(const AiCodeBlock(code: 'print("hi");', language: 'dart')), + ); + expect(find.text('dart'), findsOneWidget); + expect(find.text('print("hi");'), findsOneWidget); + expect(find.byIcon(Icons.copy), findsOneWidget); + }); + + testWidgets('AiMessageActions fires regenerate', (tester) async { + var regenerated = false; + await tester.pumpWidget( + _wrap( + AiMessageActions( + message: const AiMessage( + id: 'm1', + role: AiRole.assistant, + parts: [TextPart('hi')], + ), + onRegenerate: () => regenerated = true, + ), + ), + ); + await tester.tap(find.byIcon(Icons.refresh_rounded)); + expect(regenerated, isTrue); + }); + + testWidgets('AiAnimatedResponse reveals the full text over time', + (tester) async { + await tester.pumpWidget( + _wrap(const AiAnimatedResponse(text: 'Hello world')), + ); + // Advance the reveal to completion. (Can't pumpAndSettle — the streaming + // caret blinks forever.) The first tick has dt=0, so pump twice. + // Advance frame by frame to reveal + settle. (Can't pumpAndSettle — the + // streaming caret blinks forever.) + for (var i = 0; i < 40; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(find.textContaining('Hello world'), findsOneWidget); + }); + + testWidgets('AiAnimatedResponse accelerates to drain a large backlog', + (tester) async { + final long = List.filled(200, 'word').join(' '); // ~1000 chars + await tester.pumpWidget( + _wrap(SingleChildScrollView(child: AiAnimatedResponse(text: long))), + ); + int shownChars() => + (tester.state(find.byType(AiAnimatedResponse)) as dynamic).shownChars + as int; + await tester.pump(const Duration(milliseconds: 100)); // baseline tick + await tester.pump(const Duration(milliseconds: 100)); + // The 120 cps floor alone would reveal only ~24 chars in 200ms; the + // catch-up rate drains the large backlog far faster (~100 chars here) so + // the reveal never trails a fast stream by much. + expect(shownChars(), greaterThan(60)); + for (var i = 0; i < 60; i++) { + await tester.pump(const Duration(milliseconds: 100)); // drain fully + } + expect(shownChars(), long.length); + }); + + testWidgets('AiChat shows the empty state when idle and empty', + (tester) async { + final controller = UseChatController(provider: _EchoProvider()); + addTearDown(controller.dispose); + await tester.pumpWidget( + _wrap( + AiChat( + controller: controller, + emptyState: const AiEmptyState(title: 'Nothing yet'), + ), + ), + ); + expect(find.text('Nothing yet'), findsOneWidget); + }); + }); + + group('new components', () { + testWidgets('AiResponse renders markdown blocks', (tester) async { + await tester.pumpWidget( + _wrap( + const SingleChildScrollView( + child: AiResponse( + text: '# Title\n\nHello **world** and `code`.\n\n' + '```dart\nx();\n```\n\n- one\n- two', + ), + ), + ), + ); + expect(find.text('Title'), findsOneWidget); + expect(find.byType(AiCodeBlock), findsOneWidget); + expect(find.text('one'), findsOneWidget); + }); + + testWidgets('AiResponse applies a code highlighter when provided', + (tester) async { + String? seenCode; + String? seenLanguage; + List? highlight(String code, String? language, TextStyle base) { + seenCode = code; + seenLanguage = language; + return [TextSpan(text: code, style: base)]; + } + + await tester.pumpWidget( + _wrap( + SingleChildScrollView( + child: AiResponse( + text: '```dart\nfinal x = 1;\n```', + codeHighlighter: highlight, + ), + ), + ), + ); + + expect(seenCode, 'final x = 1;'); + expect(seenLanguage, 'dart'); + expect(find.byType(AiCodeBlock), findsOneWidget); + }); + + testWidgets('AiResponse renders strikethrough, a rule, and task lists', + (tester) async { + await tester.pumpWidget( + _wrap( + const SingleChildScrollView( + child: AiResponse( + text: 'has ~~struck~~ text\n\n---\n\n' + '- [x] done\n- [ ] todo', + ), + ), + ), + ); + // Strikethrough span present. + final rich = tester.widget(find.byType(RichText).first); + var sawStrike = false; + rich.text.visitChildren((span) { + if (span is TextSpan && + span.style?.decoration == TextDecoration.lineThrough) { + sawStrike = true; + } + return true; + }); + expect(sawStrike, isTrue); + // Horizontal rule renders a Divider. + expect(find.byType(Divider), findsOneWidget); + // Task list: a checked + an unchecked checkbox icon, with labels. + expect(find.byIcon(Icons.check_box_rounded), findsOneWidget); + expect( + find.byIcon(Icons.check_box_outline_blank_rounded), findsOneWidget); + expect(find.text('done'), findsOneWidget); + expect(find.text('todo'), findsOneWidget); + }); + + testWidgets('AiResponse colors links from the theme linkColor', + (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + extensions: [ + AiThemeExtension.fallback() + .copyWith(linkColor: const Color(0xFF00FF00)), + ], + ), + home: Scaffold( + body: SingleChildScrollView( + child: AiResponse( + text: 'a [link](https://example.com)', + onLinkTap: (_) {}, + ), + ), + ), + ), + ); + final rich = tester.widget(find.byType(RichText).first); + var sawLinkColor = false; + rich.text.visitChildren((span) { + if (span is TextSpan && span.style?.color == const Color(0xFF00FF00)) { + sawLinkColor = true; + } + return true; + }); + expect(sawLinkColor, isTrue); + }); + + testWidgets('AiResponse renders a Markdown table', (tester) async { + await tester.pumpWidget( + _wrap( + const SingleChildScrollView( + child: AiResponse( + text: '| Model | Speed |\n' + '| --- | --- |\n' + '| Flash | Fast |\n' + '| Pro | Slower |', + ), + ), + ), + ); + expect(find.byType(Table), findsOneWidget); + expect(find.text('Model'), findsOneWidget); // header cell + expect(find.text('Flash'), findsOneWidget); // body cell + expect(find.text('Slower'), findsOneWidget); + }); + + testWidgets('AiResponse updates when its text changes (cache refresh)', + (tester) async { + await tester.pumpWidget( + _wrap(const SingleChildScrollView(child: AiResponse(text: 'first'))), + ); + expect(find.text('first'), findsOneWidget); + // Re-pump with new text: the cached tree must be rebuilt (didUpdateWidget). + await tester.pumpWidget( + _wrap(const SingleChildScrollView(child: AiResponse(text: 'second'))), + ); + expect(find.text('first'), findsNothing); + expect(find.text('second'), findsOneWidget); + }); + + testWidgets('AiResponse renders a partial-heading prefix without hanging', + (tester) async { + // A streamed prefix can end on a lone `#` before its space/text arrive. + // The block parser must still make forward progress (no infinite loop / + // OOM) and treat it as text. + await tester.pumpWidget( + _wrap(const SingleChildScrollView(child: AiResponse(text: 'Intro\n#'))), + ); + expect(find.byType(AiResponse), findsOneWidget); + // The completed heading then renders as a heading once it arrives. + await tester.pumpWidget( + _wrap(const SingleChildScrollView( + child: AiResponse(text: 'Intro\n# Title'), + )), + ); + expect(find.text('Title'), findsOneWidget); + }); + + testWidgets('AiResponse does not italicize "2 * 3" or snake_case', + (tester) async { + var taps = 0; + await tester.pumpWidget( + _wrap( + SingleChildScrollView( + child: AiResponse( + text: 'compute 2 * 3 with snake_case and a ' + '[link](https://example.com)', + onLinkTap: (_) => taps++, + ), + ), + ), + ); + final rich = tester.widget(find.byType(RichText).first); + var sawItalic = false; + rich.text.visitChildren((span) { + if (span is TextSpan && span.style?.fontStyle == FontStyle.italic) { + sawItalic = true; + } + return true; + }); + expect(sawItalic, isFalse); + expect(taps, 0); // sanity: link present, callback wired but untapped + }); + + testWidgets('AiChainOfThought reveals steps when expanded', (tester) async { + await tester.pumpWidget( + _wrap( + const AiChainOfThought( + initiallyExpanded: true, + steps: [ + AiThoughtStep(label: 'Search'), + AiThoughtStep(label: 'Synthesize', isActive: true), + ], + ), + ), + ); + expect(find.text('Search'), findsOneWidget); + expect(find.text('Synthesize'), findsOneWidget); + }); + + testWidgets('AiTask shows title, count, and items', (tester) async { + await tester.pumpWidget( + _wrap( + const AiTask( + title: 'Refactor', + items: [ + AiTaskItem(label: 'Read files', status: AiTaskStatus.complete), + AiTaskItem(label: 'Apply edits', status: AiTaskStatus.active), + ], + ), + ), + ); + expect(find.text('Refactor'), findsOneWidget); + expect(find.text('1/2'), findsOneWidget); + expect(find.text('Read files'), findsOneWidget); + }); + + testWidgets('AiInlineCitation shows its number', (tester) async { + await tester.pumpWidget(_wrap(const AiInlineCitation(number: 3))); + expect(find.text('3'), findsOneWidget); + }); + + testWidgets('AiBranch shows position and hides when single', + (tester) async { + await tester.pumpWidget(_wrap(const AiBranch(index: 1, total: 3))); + expect(find.text('2/3'), findsOneWidget); + + await tester.pumpWidget(_wrap(const AiBranch(index: 0, total: 1))); + expect(find.text('1/1'), findsNothing); + }); + + testWidgets('AiImage builds with a url', (tester) async { + await tester.pumpWidget( + _wrap(AiImage(url: Uri.parse('https://example.com/a.png'))), + ); + expect(find.byType(AiImage), findsOneWidget); + }); + }); + + group('input & more', () { + testWidgets( + 'AiComposer with a staged attachment shows Send, not Live, as the ' + 'main button', (tester) async { + await tester.pumpWidget( + _wrap( + AiComposer( + onSend: (_) {}, + onAttach: () {}, + onVoice: () {}, + onLive: () {}, + attachments: const [ + FilePart(mediaType: 'application/pdf', name: 'a.pdf'), + ], + onRemoveAttachment: (_) {}, + ), + ), + ); + expect(find.byIcon(Icons.add), findsOneWidget); + expect(find.byIcon(Icons.mic_none_rounded), findsOneWidget); // secondary + // An attachment is sendable content, so the main button must be Send — + // tapping the prominent button must not launch full-screen voice mode. + expect(find.byIcon(Icons.arrow_upward_rounded), findsOneWidget); + expect(find.byIcon(Icons.graphic_eq), findsNothing); + expect(find.text('a.pdf'), findsOneWidget); // staged attachment preview + }); + + testWidgets('AiComposer shows Live only when truly empty', (tester) async { + await tester.pumpWidget( + _wrap(AiComposer(onSend: (_) {}, onLive: () {})), + ); + // No text and no attachments: Live is the main affordance. + expect(find.byIcon(Icons.graphic_eq), findsOneWidget); + expect(find.byIcon(Icons.arrow_upward_rounded), findsNothing); + }); + + testWidgets('AiComposer swaps Live for Send once typing', (tester) async { + await tester.pumpWidget( + _wrap(AiComposer(onSend: (_) {}, onVoice: () {}, onLive: () {})), + ); + expect(find.byIcon(Icons.graphic_eq), findsOneWidget); + await tester.enterText(find.byType(TextField), 'hello'); + await tester.pumpAndSettle(); // let the main-button icon morph finish + expect(find.byIcon(Icons.arrow_upward_rounded), findsOneWidget); + expect(find.byIcon(Icons.graphic_eq), findsNothing); + expect(find.byIcon(Icons.mic_none_rounded), findsNothing); // mic hidden + }); + + testWidgets('AiModelSelector shows selection and opens a picker', + (tester) async { + String? chosen; + await tester.pumpWidget( + _wrap( + AiModelSelector( + selectedId: 'fast', + onSelected: (id) => chosen = id, + models: const [ + AiModelOption(id: 'fast', label: 'Fast'), + AiModelOption(id: 'smart', label: 'Smart'), + ], + ), + ), + ); + expect(find.text('Fast'), findsOneWidget); + await tester.tap(find.text('Fast')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Smart')); + await tester.pumpAndSettle(); + expect(chosen, 'smart'); + }); + + testWidgets('AiModelSelector exposes a labelled button to a11y', + (tester) async { + final handle = tester.ensureSemantics(); + await tester.pumpWidget( + _wrap( + AiModelSelector( + selectedId: 'fast', + onSelected: (_) {}, + models: const [AiModelOption(id: 'fast', label: 'Fast')], + ), + ), + ); + expect( + tester.getSemantics(find.text('Fast')), + matchesSemantics( + isButton: true, + hasTapAction: true, + label: 'Select model, Fast\nFast', + ), + ); + handle.dispose(); + }); + + testWidgets('AiModelSelector renders nothing (no crash) with no models', + (tester) async { + await tester.pumpWidget( + _wrap( + AiModelSelector( + selectedId: 'x', onSelected: (_) {}, models: const []), + ), + ); + expect(tester.takeException(), isNull); + expect(find.byType(AiModelSelector), findsOneWidget); + }); + + testWidgets('AiConfirmation fires confirm/deny', (tester) async { + var allowed = false; + await tester.pumpWidget( + _wrap( + AiConfirmation( + title: 'Send the email?', + onConfirm: () => allowed = true, + ), + ), + ); + expect(find.text('Send the email?'), findsOneWidget); + await tester.tap(find.text('Allow')); + expect(allowed, isTrue); + }); + + testWidgets('AiConfirmation danger tone fills confirm with errorColor', + (tester) async { + final theme = AiThemeExtension.fallback(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(extensions: [theme]), + home: const Scaffold( + body: AiConfirmation( + title: 'Delete everything?', + tone: AiConfirmationTone.danger, + ), + ), + ), + ); + // The filled confirm button's Material uses the theme error color. + final materials = tester + .widgetList(find.byType(Material)) + .where((m) => m.color == theme.errorColor); + expect(materials, isNotEmpty); + }); + + testWidgets('AiContextMeter formats usage', (tester) async { + await tester.pumpWidget( + _wrap(const AiContextMeter(usedTokens: 12345, totalTokens: 128000)), + ); + expect(find.text('12.3k / 128.0k'), findsOneWidget); + }); + + testWidgets('AiShimmer builds', (tester) async { + await tester.pumpWidget(_wrap(const AiShimmer(lines: 2))); + expect(find.byType(AiShimmer), findsOneWidget); + }); + + testWidgets('AiLiveSession shows status and ends', (tester) async { + var ended = false; + await tester.pumpWidget( + _wrap( + SizedBox( + height: 500, + child: AiLiveSession( + onEnd: () => ended = true, + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('Listening'), findsOneWidget); + await tester.tap(find.byIcon(Icons.close)); + expect(ended, isTrue); + }); + }); + + group('AiConversationView', () { + testWidgets('renders a bubble per message plus a loader', (tester) async { + await tester.pumpWidget( + _wrap( + const AiConversationView( + showLoader: true, + messages: [ + AiMessage(id: 'm1', role: AiRole.user, parts: [TextPart('one')]), + AiMessage(id: 'm2', role: AiRole.user, parts: [TextPart('two')]), + ], + ), + ), + ); + expect(find.byType(AiMessageBubble), findsNWidgets(2)); + expect(find.byType(AiLoader), findsOneWidget); + }); + }); +} diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/CHANGELOG.md b/packages/flutter_ai/flutter_ai_provider_anthropic/CHANGELOG.md new file mode 100644 index 0000000..29d2819 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_provider_anthropic/CHANGELOG.md @@ -0,0 +1,111 @@ +# Changelog + +## 0.1.12 + +- Fix: `reasoningEffort` now emits adaptive thinking (`{type: adaptive}`) on + Claude 4.6+ models — including the default `claude-opus-4-8`, which rejects the + legacy `budget_tokens` shape with a 400. Claude 3.7 and 4.0–4.5 continue to use + the budgeted shape. An explicit `thinking` block in `extra` still takes + precedence. +- Fix: a mid-stream `error` event (e.g. `overloaded_error`) is no longer + overwritten by a synthetic successful finish — the message now settles as + errored. +- Fix: setting both `responseFormat` and `reasoningEffort` no longer sends an + invalid forced-tool-choice-plus-thinking request (a guaranteed 400). Thinking + is dropped when structured output is requested. + +## 0.1.11 + +- Map `AiRequestOptions.reasoningEffort` to extended thinking + (`thinking.budget_tokens`): raises `max_tokens` above the budget when needed + and drops `temperature` (the API rejects both together). An explicit + `thinking` block in `extra` takes precedence. Requires `flutter_ai_core` + ^0.1.13. + +## 0.1.10 + +- Fix (Web): the default HTTP client now streams token-by-token on Flutter Web. + `http.Client()` resolves to the XHR-backed `BrowserClient` on the web, which + buffers the entire response body before the stream emits — silently degrading + streaming to all-at-once. The default is now a `fetch`-based client (via a + conditional import) that reads the response `ReadableStream` incrementally. + Native platforms are unchanged. Inject your own `client` to override. + +## 0.1.9 + +- Fix: raise the `flutter_ai_core` lower bound to `^0.1.11` — the parser emits + `AiUsage` (added in core 0.1.3) and later APIs, so the old `^0.1.0` bound let + dependency downgrades resolve a core that couldn't compile. +- Docs: shortened the pubspec `description` into pub.dev's 60–180 character + window. + +## 0.1.8 + +- Docs: refreshed the README listing with a hero image, screenshot gallery, + and badges (consistent across the package family). No code changes. + +## 0.1.7 + +- Cost accuracy: `cache_creation_input_tokens` now map to + `AiUsage.cacheCreationTokens` (billed at the ~1.25x write rate) instead of + being folded into base input and billed wrong. +- Declares supported `platforms:` (all 6). + +## 0.1.6 + +- Throws typed `LlmException`s (auth/rate-limit/server/request) on HTTP errors + instead of a generic `Exception`; retries 408/409 too. + +## 0.1.5 + +- Replays signed `thinking` blocks before `tool_use` in the assistant turn, so + extended thinking + tools no longer 400 on Claude 4.x. +- A mid-stream stall surfaces a message-scoped `StreamErrorEvent` instead of + also finalizing (which masked the timeout). +- Asserts a non-empty `apiKey` with an actionable message. + +## 0.1.4 + +- Prompt caching: when `AiRequestOptions.cachePrompt` is set, marks the system + prompt and the last tool with `cache_control: ephemeral` (caches the stable + prefix for ~90% cheaper repeat input). + +## 0.1.3 + +- Structured output: maps `AiRequestOptions.responseFormat` to a forced tool + whose input is the schema; its streamed input is surfaced as the JSON answer + text and the turn finishes as `stop`. + +## 0.1.2 + +- Reports token usage: accumulates input (incl. cache read/creation) from + `message_start` and output from `message_delta` into `AiUsage` on + `MessageFinished`. + +## 0.1.1 + +- Docs: added a "Buy me a coffee" (Ko-fi) support section to the README. No code + changes. + +## 0.1.0 + +Initial release. + +- `AnthropicProvider` — an `LlmProvider` for the Anthropic Messages API + (`POST /v1/messages`), with an injectable HTTP client, a configurable default + model (`claude-opus-4-8`) and `max_tokens`. +- Maps conversations into the request: system messages fold into the top-level + `system` field, assistant tool calls become `tool_use` blocks, and tool + results become `tool_result` blocks. Streams text, extended thinking, tool + calls, and finish reasons back as `AiStreamEvent`s. +- `AnthropicEventParser` — the SSE-event→event mapping, unit-tested against + recorded events. +- Robustness: configurable connect + idle `timeout` (a stalled stream surfaces a + `StreamErrorEvent` instead of hanging); a wrong-shape event emits a + `StreamErrorEvent` instead of crashing the stream; `close()` only closes a + client it created; retry backoff is now capped and jittered; adjacent + same-role turns are merged so the API's strict alternation isn't violated. +- Re-exports `flutter_ai_core`. + +> The mapping is unit-tested against recorded SSE events; it has not been run +> against the live Anthropic API in this release. diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/LICENSE b/packages/flutter_ai/flutter_ai_provider_anthropic/LICENSE new file mode 100644 index 0000000..56023ee --- /dev/null +++ b/packages/flutter_ai/flutter_ai_provider_anthropic/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2026, The flutter_ai authors. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/README.md b/packages/flutter_ai/flutter_ai_provider_anthropic/README.md new file mode 100644 index 0000000..9b3062d --- /dev/null +++ b/packages/flutter_ai/flutter_ai_provider_anthropic/README.md @@ -0,0 +1,80 @@ +

flutter_ai_provider_anthropic

+ +

Anthropic (Claude) provider for flutter_ai — streams the Messages API with extended thinking and tool use, mapped to AiStreamEvents so the rest of the family works against Claude unchanged.

+ +

+ A streamed answer with reasoning, a tool call, and the final answer +

+ +

+ flutter_ai_provider_anthropic on pub.dev + pub points + License: BSD-3-Clause +

+ +

+ Family: flutter_ai · + core · client · elements · + openai · gemini
+ Recipes · Migrating from the Vercel AI SDK +

+ +--- + +An Anthropic (Claude) [`LlmProvider`](../flutter_ai_core) for the `flutter_ai` +family. It streams the Anthropic **Messages API** and maps each event to +`AiStreamEvent`s, so the controllers and UI in `flutter_ai_client` / +`flutter_ai_elements` work against Claude unchanged. + +- Streams text, **extended thinking**, **tool use**, and finish reasons. +- Maps `flutter_ai` conversations to Anthropic's wire format (system folded into + the top-level `system` field; assistant tool calls → `tool_use`; tool results + → `tool_result`). +- Injectable `http.Client` for testing and custom transport. + +## Usage + +```dart +import 'package:flutter_ai_provider_anthropic/flutter_ai_provider_anthropic.dart'; + +final provider = AnthropicProvider( + apiKey: const String.fromEnvironment('ANTHROPIC_API_KEY'), + // defaultModel: 'claude-opus-4-8', // override per request via AiRequestOptions +); + +await for (final event in provider.send(conversation, tools: tools)) { + // feed into a MessageProcessor / UseChatController +} +``` + +Wire it into a controller: + +```dart +final controller = UseChatController( + provider: AnthropicProvider(apiKey: myKey), + options: const AiRequestOptions(model: 'claude-opus-4-8'), +); +``` + +## Notes + +- **`max_tokens` is required** by the API. Set it via + `AiRequestOptions.maxOutputTokens`, or rely on `AnthropicProvider`'s + `defaultMaxTokens` (4096). +- **Sampling parameters** (`temperature`) are forwarded only when set. Newer + Claude models reject them — leave it unset for those. +- **Extended thinking**: pass it through `AiRequestOptions.extra`, e.g. + `extra: {'thinking': {'type': 'adaptive'}}`. Thinking text streams as + `ReasoningDelta` events (`AiReasoning` in the UI). +- **Images**: user-message image attachments (`FilePart` with an `image/*` + media type) are sent as base64 or URL image blocks. Other document types are + not yet sent. +- **Retry**: transient failures (429/5xx, network) are retried with backoff + honoring `Retry-After` (`maxRetries`, default 2). + +## Status + +The request/response mapping is unit-tested against recorded SSE events. Supply +an API key to use it against the live API. + +_If `flutter_ai` saves you time, you can [buy me a coffee ☕](https://ko-fi.com/ananmouaz)._ diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/analysis_options.yaml b/packages/flutter_ai/flutter_ai_provider_anthropic/analysis_options.yaml new file mode 100644 index 0000000..bddaa31 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_provider_anthropic/analysis_options.yaml @@ -0,0 +1,2 @@ +# Inherits the workspace-wide strict configuration. +include: ../../analysis_options.yaml diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/example/flutter_ai_provider_anthropic_example.dart b/packages/flutter_ai/flutter_ai_provider_anthropic/example/flutter_ai_provider_anthropic_example.dart new file mode 100644 index 0000000..46655fb --- /dev/null +++ b/packages/flutter_ai/flutter_ai_provider_anthropic/example/flutter_ai_provider_anthropic_example.dart @@ -0,0 +1,33 @@ +// Streams a single completion and prints the assembled reply. +// +// Run with: +// dart run --define=ANTHROPIC_API_KEY=... example/flutter_ai_provider_anthropic_example.dart +import 'package:flutter_ai_provider_anthropic/flutter_ai_provider_anthropic.dart'; + +Future main() async { + const apiKey = String.fromEnvironment('ANTHROPIC_API_KEY'); + if (apiKey.isEmpty) { + print('Set ANTHROPIC_API_KEY via --define to run against the live API.'); + return; + } + + final provider = AnthropicProvider(apiKey: apiKey); + final processor = MessageProcessor(); + + const conversation = AiConversation( + id: 'demo', + messages: [ + AiMessage( + id: 'u1', + role: AiRole.user, + parts: [TextPart('Say hello in one short sentence.')], + ), + ], + ); + + await for (final event in provider.send(conversation)) { + processor.apply(event); + } + print(processor.conversation.lastMessage?.text); + provider.close(); +} diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/lib/flutter_ai_provider_anthropic.dart b/packages/flutter_ai/flutter_ai_provider_anthropic/lib/flutter_ai_provider_anthropic.dart new file mode 100644 index 0000000..eab0aa7 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_provider_anthropic/lib/flutter_ai_provider_anthropic.dart @@ -0,0 +1,14 @@ +/// Anthropic (Claude) provider for the `flutter_ai` family. +/// +/// `AnthropicProvider` implements `LlmProvider` by streaming the Anthropic +/// Messages API and mapping each event to `AiStreamEvent`s via +/// `AnthropicEventParser`. Supports text, extended thinking, tool use, and +/// finish reasons over an injectable HTTP client. +/// +/// Re-exports `flutter_ai_core`. +library; + +export 'package:flutter_ai_core/flutter_ai_core.dart'; + +export 'src/anthropic_event_parser.dart'; +export 'src/anthropic_provider.dart'; diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/anthropic_event_parser.dart b/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/anthropic_event_parser.dart new file mode 100644 index 0000000..7225553 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/anthropic_event_parser.dart @@ -0,0 +1,199 @@ +import 'package:flutter_ai_core/flutter_ai_core.dart'; + +/// Translates Anthropic Messages API streaming events into [AiStreamEvent]s. +/// +/// Stateful across a single response: it tracks the message id, maps each +/// content block's `index` to a streamed tool call's id (later +/// `input_json_delta` fragments arrive carrying only the index), and remembers +/// the final `stop_reason` reported on `message_delta`. Kept separate from +/// transport so it can be unit-tested against recorded SSE events. +/// +/// Recognized event `type`s: `message_start`, `content_block_start`, +/// `content_block_delta` (text / thinking / tool-input), `content_block_stop`, +/// `message_delta`, `message_stop`, and `error`. `ping` and unknown types are +/// ignored. +class AnthropicEventParser { + /// Creates a parser. When [structuredToolName] is set (structured output via a + /// forced tool), that tool's streamed input is surfaced as [TextDelta]s — the + /// JSON answer — rather than as a tool call. + AnthropicEventParser({String? structuredToolName}) + : _structuredToolName = structuredToolName; + + final String? _structuredToolName; + int? _structuredIndex; + String _messageId = 'assistant'; + + /// The id of the assistant message being built (for error finalization). + String get messageId => _messageId; + final Map _toolCallIdByIndex = {}; + String _stopReason = 'end_turn'; + bool _started = false; + bool _finished = false; + int? _inputTokens; + int? _cachedInputTokens; + int? _cacheCreationTokens; + int? _outputTokens; + + /// Emits a terminal [MessageFinished] if the stream ended after starting but + /// without a `message_stop` (e.g. a dropped connection), so the message isn't + /// left streaming forever. Call once after the SSE stream completes. + List finalize() => _started && !_finished + ? [ + MessageFinished( + messageId: _messageId, + reason: _finishReason(), + usage: _buildUsage(), + ), + ] + : const []; + + AiUsage? _buildUsage() { + if (_inputTokens == null && _outputTokens == null) return null; + return AiUsage( + inputTokens: _inputTokens, + outputTokens: _outputTokens, + cachedInputTokens: _cachedInputTokens, + cacheCreationTokens: _cacheCreationTokens, + ); + } + + /// Returns the events implied by one decoded Anthropic stream event. + List parse(Map event) { + switch (event['type']) { + case 'message_start': + _started = true; + final message = (event['message'] as Map?)?.cast(); + final id = message?['id']; + if (id is String && id.isNotEmpty) _messageId = id; + final usage = (message?['usage'] as Map?)?.cast(); + if (usage != null) { + final input = (usage['input_tokens'] as int?) ?? 0; + final cacheRead = (usage['cache_read_input_tokens'] as int?) ?? 0; + final cacheCreate = + (usage['cache_creation_input_tokens'] as int?) ?? 0; + // cache_read and cache_creation are subsets of inputTokens (kept + // folded in here, billed separately in AiUsage.estimateCost). + _inputTokens = input + cacheRead + cacheCreate; + _cachedInputTokens = cacheRead == 0 ? null : cacheRead; + _cacheCreationTokens = cacheCreate == 0 ? null : cacheCreate; + _outputTokens = usage['output_tokens'] as int?; + } + return [MessageStarted(messageId: _messageId, role: AiRole.assistant)]; + + case 'content_block_start': + final index = (event['index'] as num?)?.toInt() ?? 0; + final block = (event['content_block'] as Map?)?.cast(); + if (block?['type'] == 'tool_use') { + final name = block?['name'] as String? ?? ''; + // Structured-output tool: capture its input as the JSON answer text + // rather than exposing it as a tool call. + if (_structuredToolName != null && name == _structuredToolName) { + _structuredIndex = index; + return const []; + } + final id = block?['id'] as String? ?? '$_messageId-tool-$index'; + _toolCallIdByIndex[index] = id; + return [ + ToolCallStarted( + messageId: _messageId, + toolCallId: id, + toolName: name, + ), + ]; + } + return const []; + + case 'content_block_delta': + final index = (event['index'] as num?)?.toInt() ?? 0; + final delta = + (event['delta'] as Map?)?.cast() ?? const {}; + switch (delta['type']) { + case 'text_delta': + final text = delta['text'] as String? ?? ''; + return text.isEmpty + ? const [] + : [TextDelta(messageId: _messageId, delta: text)]; + case 'thinking_delta': + final thinking = delta['thinking'] as String? ?? ''; + return thinking.isEmpty + ? const [] + : [ReasoningDelta(messageId: _messageId, delta: thinking)]; + case 'signature_delta': + // The signed proof of the thinking block; must be replayed verbatim + // on the next turn or the API rejects it. + final sig = delta['signature'] as String? ?? ''; + return sig.isEmpty + ? const [] + : [ + ReasoningDelta( + messageId: _messageId, delta: '', signature: sig) + ]; + case 'input_json_delta': + final partial = delta['partial_json'] as String? ?? ''; + if (index == _structuredIndex) { + return partial.isEmpty + ? const [] + : [TextDelta(messageId: _messageId, delta: partial)]; + } + final id = _toolCallIdByIndex[index]; + return (id == null || partial.isEmpty) + ? const [] + : [ToolCallDelta(toolCallId: id, argumentsDelta: partial)]; + } + return const []; + + case 'content_block_stop': + final index = (event['index'] as num?)?.toInt() ?? 0; + if (index == _structuredIndex) return const []; + final id = _toolCallIdByIndex[index]; + return id == null ? const [] : [ToolCallReady(toolCallId: id)]; + + case 'message_delta': + final delta = (event['delta'] as Map?)?.cast(); + final reason = delta?['stop_reason']; + if (reason is String) _stopReason = reason; + final usage = (event['usage'] as Map?)?.cast(); + final out = usage?['output_tokens'] as int?; + if (out != null) _outputTokens = out; + return const []; + + case 'message_stop': + _finished = true; + return [ + MessageFinished( + messageId: _messageId, + reason: _finishReason(), + usage: _buildUsage(), + ), + ]; + + case 'error': + // Anthropic closes the stream after an error event. Mark the message + // finished so finalize() doesn't paper over it with a synthetic + // MessageFinished(stop) that would overwrite the error status. + _finished = true; + final error = (event['error'] as Map?)?.cast(); + final message = + error?['message'] as String? ?? 'Anthropic stream error'; + return [StreamErrorEvent(error: message, messageId: _messageId)]; + + default: + return const []; // ping and unknown events carry no state for us. + } + } + + // Structured output forces a tool call, so `tool_use` really means "done". + FinishReason _finishReason() => + (_structuredIndex != null && _stopReason == 'tool_use') + ? FinishReason.stop + : _mapFinish(_stopReason); + + static FinishReason _mapFinish(String reason) => switch (reason) { + 'end_turn' => FinishReason.stop, + 'stop_sequence' => FinishReason.stop, + 'max_tokens' => FinishReason.length, + 'tool_use' => FinishReason.toolCalls, + 'refusal' => FinishReason.contentFilter, + _ => FinishReason.stop, + }; +} diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/anthropic_provider.dart b/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/anthropic_provider.dart new file mode 100644 index 0000000..c736f36 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/anthropic_provider.dart @@ -0,0 +1,366 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_ai_core/flutter_ai_core.dart'; +import 'package:flutter_ai_provider_anthropic/src/anthropic_event_parser.dart'; +import 'package:flutter_ai_provider_anthropic/src/default_http_client.dart'; +import 'package:flutter_ai_provider_anthropic/src/http_retry.dart'; +import 'package:http/http.dart' as http; + +/// An `LlmProvider` backed by the Anthropic **Messages API** +/// (`POST /v1/messages`). +/// +/// Streams text, extended-thinking, tool calls, and finish reasons as +/// `AiStreamEvent`s. The HTTP client is injectable for testing and custom +/// transport configuration. +/// +/// Notes: +/// * `max_tokens` is required by the API; [defaultMaxTokens] is used when +/// [AiRequestOptions.maxOutputTokens] is not set. +/// * System messages are folded into the top-level `system` field (Anthropic +/// has no `system` role inside `messages`). +/// * Assistant tool calls and tool results are mapped to Anthropic +/// `tool_use` / `tool_result` content blocks. +/// * `temperature` is forwarded only when set; newer Claude models reject +/// sampling parameters, so leave it unset for those. +/// * Set [AiRequestOptions.reasoningEffort] to enable extended thinking with a +/// mapped `budget_tokens` (max_tokens is raised above the budget when needed, +/// and `temperature` is dropped since the API rejects both together). For +/// full control, pass an explicit `thinking` block via +/// [AiRequestOptions.extra], which takes precedence. +/// +/// > The request/response mapping is unit-tested against recorded SSE events; +/// > supply an API key to use it against the live API. +class AnthropicProvider implements LlmProvider { + /// Creates a provider. + /// + /// [apiKey] authenticates requests (sent as `x-api-key`). [baseUrl] defaults + /// to the public Anthropic v1 endpoint; override it for a proxy or gateway. + /// [client] is injectable (defaults to a streaming-capable client — a + /// fetch-based client on the web, [http.Client] elsewhere). [defaultModel] + /// and [defaultMaxTokens] are used when [AiRequestOptions] omits them. + /// [timeout] bounds both the initial connection and the idle gap between + /// streamed chunks. + AnthropicProvider({ + required this.apiKey, + Uri? baseUrl, + http.Client? client, + this.defaultModel = 'claude-opus-4-8', + this.defaultMaxTokens = 4096, + this.anthropicVersion = '2023-06-01', + this.maxRetries = 2, + this.timeout = const Duration(seconds: 60), + }) : assert( + apiKey.isNotEmpty, + 'AnthropicProvider: apiKey is empty — pass a key or set ' + 'ANTHROPIC_API_KEY via --dart-define.', + ), + _baseUrl = baseUrl ?? Uri.parse('https://api.anthropic.com/v1'), + _ownsClient = client == null, + _client = client ?? createDefaultHttpClient(); + + /// The API key sent as the `x-api-key` header. + final String apiKey; + + /// The default model when options don't specify one. + final String defaultModel; + + /// The `max_tokens` used when options don't specify one (the API requires it). + final int defaultMaxTokens; + + /// The `anthropic-version` header value. + final String anthropicVersion; + + /// How many times to retry the initial connection on a transient failure + /// (network error, 429, or 5xx), with backoff honoring `Retry-After`. + final int maxRetries; + + /// Bounds the initial connection (a connect timeout is retried like a network + /// error) and the idle gap between streamed chunks (a mid-stream stall yields + /// a terminal error instead of hanging forever). + final Duration timeout; + + final Uri _baseUrl; + final http.Client _client; + + /// Whether this provider created [_client] itself (vs. an injected one). + /// [close] only closes a client it owns. + final bool _ownsClient; + + @override + Stream send( + AiConversation conversation, { + List? tools, + AiRequestOptions? options, + }) async* { + final (system, messages) = _buildMessages(conversation); + final responseFormat = options?.responseFormat; + // Anthropic has no response_format; structured output is a forced tool whose + // input is the schema (the parser surfaces its input as the JSON answer). + final toolList = >[ + if (tools != null) ..._buildTools(tools), + if (responseFormat != null) + { + 'name': responseFormat.name, + 'description': 'Respond with the structured result.', + 'input_schema': responseFormat.schema, + }, + ]; + // Prompt caching: mark the stable prefix (system + the last tool, which + // anchors the cached span covering all tools) with `cache_control`. + final cache = options?.cachePrompt ?? false; + const cacheControl = {'type': 'ephemeral'}; + if (cache && toolList.isNotEmpty) { + toolList[toolList.length - 1] = { + ...toolList.last, + 'cache_control': cacheControl, + }; + } + // Extended thinking: enable when reasoningEffort is set (unless the caller + // supplied an explicit `thinking` block via extra). Claude 4.6+ (including + // the default model) uses adaptive thinking and rejects `budget_tokens`; + // Claude 3.7 and 4.0–4.5 take the legacy budgeted shape. The API rejects + // `temperature` alongside thinking. + // + // Structured output forces a tool (`tool_choice: {type: tool}`), which the + // API rejects while thinking is enabled — so thinking is dropped when a + // responseFormat is set (structured output takes precedence). + final model = options?.model ?? defaultModel; + final effort = options?.reasoningEffort; + final thinkingEnabled = effort != null && + responseFormat == null && + !(options?.extra.containsKey('thinking') ?? false); + final useLegacyThinking = thinkingEnabled && _usesBudgetedThinking(model); + final budget = effort?.budgetTokens ?? 0; + var maxTokens = options?.maxOutputTokens ?? defaultMaxTokens; + // Only the legacy budgeted shape needs headroom above budget_tokens; + // adaptive thinking draws from max_tokens directly. + if (useLegacyThinking && maxTokens <= budget) { + maxTokens = budget + defaultMaxTokens; + } + final payload = { + if (options?.extra != null) ...options!.extra, + 'model': model, + 'max_tokens': maxTokens, + if (thinkingEnabled) + 'thinking': useLegacyThinking + ? {'type': 'enabled', 'budget_tokens': budget} + : {'type': 'adaptive'}, + 'stream': true, + 'messages': messages, + if (system != null && system.isNotEmpty) + 'system': cache + ? [ + { + 'type': 'text', + 'text': system, + 'cache_control': cacheControl, + }, + ] + : system, + if (options?.temperature != null && !thinkingEnabled) + 'temperature': options!.temperature, + if (toolList.isNotEmpty) 'tools': toolList, + if (responseFormat != null) + 'tool_choice': {'type': 'tool', 'name': responseFormat.name}, + }; + + final http.StreamedResponse response; + try { + response = await connectWithRetry( + client: _client, + maxRetries: maxRetries, + label: 'Anthropic', + timeout: timeout, + build: () => http.Request('POST', _endpoint()) + ..headers['x-api-key'] = apiKey + ..headers['anthropic-version'] = anthropicVersion + ..headers['content-type'] = 'application/json' + ..body = jsonEncode(payload), + ); + } on Object catch (error) { + yield StreamErrorEvent(error: error); + return; + } + + final parser = + AnthropicEventParser(structuredToolName: responseFormat?.name); + // Idle timeout: a stall longer than [timeout] between chunks aborts the + // `await for` with a TimeoutException instead of hanging forever. + final lines = response.stream + .transform(utf8.decoder) + .transform(const LineSplitter()) + .timeout(timeout); + try { + await for (final line in lines) { + final trimmed = line.trim(); + // Anthropic SSE interleaves `event:` and `data:` lines; the JSON on the + // `data:` line carries its own `type`, so we only need the data lines. + if (!trimmed.startsWith('data:')) continue; + final data = trimmed.substring(5).trim(); + if (data.isEmpty) continue; + try { + final Map chunk; + try { + chunk = (jsonDecode(data) as Map).cast(); + } on FormatException { + continue; // skip malformed keep-alive or partial lines + } + for (final event in parser.parse(chunk)) { + yield event; + } + } on Object catch (error) { + // A valid-JSON-but-wrong-shape chunk must not kill the whole stream; + // surface it as a StreamErrorEvent and skip the bad chunk. + yield StreamErrorEvent(error: error); + continue; + } + } + } on TimeoutException catch (error) { + // A mid-stream stall: mark the in-flight message errored. Don't also + // finalize() — that terminal MessageFinished would mask the timeout. + yield StreamErrorEvent(error: error, messageId: parser.messageId); + return; + } + // Stream ended — emit a terminal event if no `message_stop` arrived. + for (final event in parser.finalize()) { + yield event; + } + } + + /// Closes the underlying HTTP client, but only if this provider created it. + /// When a `client` was injected, `close` is a no-op so a shared client isn't + /// torn out from under its owner. + void close() { + if (_ownsClient) _client.close(); + } + + Uri _endpoint() { + final base = _baseUrl.toString().replaceAll(RegExp(r'/+$'), ''); + return Uri.parse('$base/messages'); + } + + /// Matches models that take the legacy budgeted extended-thinking shape + /// (`{type: 'enabled', budget_tokens: N}`): Claude 3.x and Claude 4.0–4.5. + static final RegExp _budgetedThinkingModel = + RegExp(r'^claude-3|^claude-(?:opus|sonnet|haiku)-4-[0-5](?![0-9])'); + + /// Whether [model] uses the legacy budgeted extended-thinking shape rather + /// than adaptive thinking. + /// + /// Claude 4.6+ (including the default `claude-opus-4-8`) removed + /// `budget_tokens` in favor of adaptive thinking (`{type: 'adaptive'}`); + /// sending the budgeted shape there is a 400. Unknown or newer model ids + /// default to adaptive, matching the current flagship models. + static bool _usesBudgetedThinking(String model) => + _budgetedThinkingModel.hasMatch(model); + + /// Builds the top-level `system` string and the `messages` array. Messages + /// with empty content are dropped (the API rejects them). + (String?, List>) _buildMessages( + AiConversation conversation, + ) { + final systemBuffer = StringBuffer(); + final messages = >[]; + + void addContent(String role, List> content) { + if (content.isEmpty) return; + messages.add({'role': role, 'content': content}); + } + + for (final message in conversation.messages) { + switch (message.role) { + case AiRole.system: + if (message.text.isEmpty) break; + if (systemBuffer.isNotEmpty) systemBuffer.write('\n\n'); + systemBuffer.write(message.text); + case AiRole.user: + addContent('user', [ + if (message.text.isNotEmpty) {'type': 'text', 'text': message.text}, + for (final image in _images(message)) + {'type': 'image', 'source': _imageSource(image)}, + ]); + case AiRole.assistant: + addContent('assistant', [ + // Replay signed thinking blocks first — required by extended + // thinking when the turn also has tool_use, or the API 400s. + for (final reasoning in message.parts.whereType()) + if (reasoning.signature != null) + { + 'type': 'thinking', + 'thinking': reasoning.text, + 'signature': reasoning.signature, + }, + if (message.text.isNotEmpty) {'type': 'text', 'text': message.text}, + for (final call in message.parts.whereType()) + { + 'type': 'tool_use', + 'id': call.toolCallId, + 'name': call.toolName, + 'input': call.args, + }, + ]); + case AiRole.tool: + addContent('user', [ + for (final result in message.parts.whereType()) + { + 'type': 'tool_result', + 'tool_use_id': result.toolCallId, + 'content': result.result is String + ? result.result as String + : jsonEncode(result.result), + if (result.isError) 'is_error': true, + }, + ]); + } + } + + final system = systemBuffer.isEmpty ? null : systemBuffer.toString(); + return (system, _mergeAdjacentRoles(messages)); + } + + /// Anthropic requires strict user/assistant alternation; consecutive entries + /// with the same `role` (e.g. a tool-result `user` turn following a normal + /// `user` turn, or two tool turns in a row) 400 with "roles must alternate". + /// Merge adjacent same-role messages by concatenating their `content` arrays. + static List> _mergeAdjacentRoles( + List> messages, + ) { + final merged = >[]; + for (final message in messages) { + if (merged.isNotEmpty && merged.last['role'] == message['role']) { + final content = [ + ...(merged.last['content']! as List), + ...(message['content']! as List), + ]; + merged.last['content'] = content; + } else { + merged.add({...message}); + } + } + return merged; + } + + static Iterable _images(AiMessage message) => message.parts + .whereType() + .where((f) => f.mediaType.startsWith('image/')); + + /// Anthropic image source: base64 for inline bytes, else a URL source. + static Map _imageSource(FilePart image) => + image.bytes != null + ? { + 'type': 'base64', + 'media_type': image.mediaType, + 'data': base64Encode(image.bytes!), + } + : {'type': 'url', 'url': image.url.toString()}; + + List> _buildTools(List tools) => [ + for (final tool in tools) + { + 'name': tool.name, + 'description': tool.description, + 'input_schema': tool.parametersSchema, + }, + ]; +} diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/default_http_client.dart b/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/default_http_client.dart new file mode 100644 index 0000000..392ff85 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/default_http_client.dart @@ -0,0 +1,4 @@ +// Provides `createDefaultHttpClient`, resolved per-platform via conditional +// import so streaming works everywhere. +export 'default_http_client_io.dart' + if (dart.library.js_interop) 'default_http_client_web.dart'; diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/default_http_client_io.dart b/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/default_http_client_io.dart new file mode 100644 index 0000000..bc63899 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/default_http_client_io.dart @@ -0,0 +1,5 @@ +import 'package:http/http.dart' as http; + +/// The default HTTP client on native platforms: a standard [http.Client], +/// which already delivers a streamed response body chunk-by-chunk. +http.Client createDefaultHttpClient() => http.Client(); diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/default_http_client_web.dart b/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/default_http_client_web.dart new file mode 100644 index 0000000..19d9654 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/default_http_client_web.dart @@ -0,0 +1,11 @@ +import 'package:fetch_client/fetch_client.dart'; +import 'package:http/http.dart' as http; + +/// The default HTTP client on the web: a [FetchClient] backed by the streaming +/// Fetch API, so SSE tokens arrive progressively. +/// +/// The `http.Client()` default resolves to `BrowserClient` on the web, which is +/// XHR-backed and buffers the entire response body before the stream emits — +/// silently degrading token-by-token streaming to all-at-once. `FetchClient` +/// reads the response `ReadableStream` incrementally, restoring real streaming. +http.Client createDefaultHttpClient() => FetchClient(mode: RequestMode.cors); diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/http_retry.dart b/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/http_retry.dart new file mode 100644 index 0000000..ee19a49 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/http_retry.dart @@ -0,0 +1,80 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter_ai_core/flutter_ai_core.dart'; +import 'package:http/http.dart' as http; + +/// Sends [build]'s request, retrying transient failures (network errors, +/// connect timeouts, HTTP 429, and 5xx) up to [maxRetries] times with +/// exponential backoff (capped and jittered) that honors a `Retry-After` +/// header. A fresh request is built per attempt. +/// +/// Each `send` attempt is bounded by [timeout]; a connect timeout is treated as +/// a transient failure and retried like a network error. +/// +/// Returns the `200` streamed response. Retries only happen *before* the body +/// is consumed — once a 200 stream starts, the caller owns it. Throws the +/// underlying error on a network failure, or a descriptive [Exception] +/// ("[label] request failed (status): body") on a non-retryable HTTP error; +/// callers surface these as a `StreamErrorEvent`. +Future connectWithRetry({ + required http.Client client, + required http.Request Function() build, + required int maxRetries, + required String label, + required Duration timeout, +}) async { + for (var attempt = 0;; attempt++) { + final http.StreamedResponse response; + try { + response = await client.send(build()).timeout(timeout); + } on Object { + if (attempt < maxRetries) { + await Future.delayed(_backoff(attempt)); + continue; + } + rethrow; + } + + if (response.statusCode == 200) return response; + + if (_isRetryable(response.statusCode) && attempt < maxRetries) { + final wait = + _retryAfter(response.headers['retry-after']) ?? _backoff(attempt); + await response.stream.drain(); + await Future.delayed(wait); + continue; + } + + final body = await response.stream.bytesToString(); + throw llmExceptionFor( + response.statusCode, + '$label: $body', + retryAfter: _retryAfter(response.headers['retry-after']), + ); + } +} + +bool _isRetryable(int code) => + code == 408 || code == 409 || code == 429 || (code >= 500 && code < 600); + +final _random = Random(); + +/// Exponential backoff, capped at 30s, with randomized jitter so retries from +/// many clients don't synchronize. The base doubles per attempt up to the cap, +/// then a random 0–100% jitter of the (capped) base is added on top. +Duration _backoff(int attempt) { + const base = Duration(milliseconds: 400); + const cap = Duration(seconds: 30); + // Guard against overflow on large attempt counts before comparing to the cap. + final shift = attempt.clamp(0, 30); + final scaledMs = base.inMilliseconds * (1 << shift); + final cappedMs = min(cap.inMilliseconds, scaledMs); + final jitterMs = _random.nextInt(cappedMs + 1); + return Duration(milliseconds: cappedMs + jitterMs); +} + +Duration? _retryAfter(String? header) { + final seconds = int.tryParse(header?.trim() ?? ''); + return seconds == null ? null : Duration(seconds: seconds); +} diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/pubspec.yaml b/packages/flutter_ai/flutter_ai_provider_anthropic/pubspec.yaml new file mode 100644 index 0000000..682a8a4 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_provider_anthropic/pubspec.yaml @@ -0,0 +1,35 @@ +name: flutter_ai_provider_anthropic +description: "Anthropic (Claude) LlmProvider for flutter_ai: streams the Messages API (text, extended thinking, tool use) as flutter_ai_core AiStreamEvents." +version: 0.1.12 +homepage: https://github.com/ananmouaz/flutter_ai +repository: https://github.com/ananmouaz/flutter_ai/tree/main/packages/flutter_ai_provider_anthropic +issue_tracker: https://github.com/ananmouaz/flutter_ai/issues +topics: + - ai + - llm + - anthropic + - claude + - chatbot + + +environment: + sdk: ^3.6.0 + +platforms: + android: + ios: + linux: + macos: + web: + windows: + +resolution: workspace + +dependencies: + fetch_client: ^1.2.1 + flutter_ai_core: ^0.1.13 + http: ^1.2.0 + +dev_dependencies: + lints: ^5.0.0 + test: ^1.25.0 diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/test/anthropic_provider_test.dart b/packages/flutter_ai/flutter_ai_provider_anthropic/test/anthropic_provider_test.dart new file mode 100644 index 0000000..09359ef --- /dev/null +++ b/packages/flutter_ai/flutter_ai_provider_anthropic/test/anthropic_provider_test.dart @@ -0,0 +1,652 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_ai_provider_anthropic/flutter_ai_provider_anthropic.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:test/test.dart'; + +/// Builds a streaming mock client that emits [lines] as an SSE body. +http.Client _sseClient(List lines, {int statusCode = 200}) { + return MockClient.streaming((request, bodyStream) async { + final body = lines.map((l) => '$l\n').join(); + return http.StreamedResponse( + Stream>.value(utf8.encode(body)), + statusCode, + ); + }); +} + +/// Wraps [data] objects as `data:` SSE lines (the provider ignores `event:`). +List _dataLines(List> data) => + [for (final d in data) 'data: ${jsonEncode(d)}']; + +void main() { + group('AnthropicEventParser', () { + test('emits start, text deltas, and finish', () { + final parser = AnthropicEventParser(); + final events = [ + ...parser.parse({ + 'type': 'message_start', + 'message': { + 'id': 'msg_1', + 'role': 'assistant', + 'usage': { + 'input_tokens': 10, + 'cache_read_input_tokens': 4, + 'cache_creation_input_tokens': 6, + 'output_tokens': 1, + }, + }, + }), + ...parser.parse({ + 'type': 'content_block_start', + 'index': 0, + 'content_block': {'type': 'text', 'text': ''}, + }), + ...parser.parse({ + 'type': 'content_block_delta', + 'index': 0, + 'delta': {'type': 'text_delta', 'text': 'Hello'}, + }), + ...parser.parse({ + 'type': 'content_block_delta', + 'index': 0, + 'delta': {'type': 'text_delta', 'text': ' world'}, + }), + ...parser.parse({ + 'type': 'message_delta', + 'delta': {'stop_reason': 'end_turn'}, + 'usage': {'output_tokens': 25}, + }), + ...parser.parse({'type': 'message_stop'}), + ]; + + expect(events.first, isA()); + expect((events.first as MessageStarted).messageId, 'msg_1'); + expect(events.whereType().map((e) => e.delta), [ + 'Hello', + ' world', + ]); + final finished = events.last as MessageFinished; + expect(finished.reason, FinishReason.stop); + // input folds in cache read + cache write subsets: 10 + 4 + 6. + expect(finished.usage?.inputTokens, 20); + expect(finished.usage?.cachedInputTokens, 4); + expect(finished.usage?.cacheCreationTokens, 6); + expect(finished.usage?.outputTokens, 25); + }); + + test('cache_creation_input_tokens lands in cacheCreationTokens only', () { + final parser = AnthropicEventParser(); + final events = [ + ...parser.parse({ + 'type': 'message_start', + 'message': { + 'id': 'msg_cc', + 'role': 'assistant', + 'usage': { + 'input_tokens': 50, + 'cache_creation_input_tokens': 30, + 'output_tokens': 1, + }, + }, + }), + ...parser.parse({'type': 'message_stop'}), + ]; + final usage = (events.last as MessageFinished).usage!; + expect(usage.cacheCreationTokens, 30); + expect(usage.cachedInputTokens, isNull); // no cache read reported + expect(usage.inputTokens, 80); // 50 + 30 cache write subset + }); + + test('maps thinking deltas to ReasoningDelta', () { + final parser = AnthropicEventParser(); + final events = parser.parse({ + 'type': 'content_block_delta', + 'index': 0, + 'delta': {'type': 'thinking_delta', 'thinking': 'Let me reason.'}, + }); + expect(events.single, isA()); + expect((events.single as ReasoningDelta).delta, 'Let me reason.'); + }); + + test('threads streamed tool calls by index and readies them', () { + final parser = AnthropicEventParser(); + final events = [ + ...parser.parse({ + 'type': 'content_block_start', + 'index': 1, + 'content_block': { + 'type': 'tool_use', + 'id': 'toolu_a', + 'name': 'get_weather', + }, + }), + ...parser.parse({ + 'type': 'content_block_delta', + 'index': 1, + 'delta': {'type': 'input_json_delta', 'partial_json': '{"ci'}, + }), + ...parser.parse({ + 'type': 'content_block_delta', + 'index': 1, + 'delta': { + 'type': 'input_json_delta', + 'partial_json': 'ty":"London"}' + }, + }), + ...parser.parse({'type': 'content_block_stop', 'index': 1}), + ...parser.parse({ + 'type': 'message_delta', + 'delta': {'stop_reason': 'tool_use'}, + }), + ...parser.parse({'type': 'message_stop'}), + ]; + + expect( + events.whereType().single.toolName, 'get_weather'); + expect(events.whereType().map((e) => e.argumentsDelta), [ + '{"ci', + 'ty":"London"}', + ]); + expect(events.whereType().single.toolCallId, 'toolu_a'); + expect( + events.whereType().single.reason, + FinishReason.toolCalls, + ); + }); + + test('surfaces the structured-output tool input as JSON text', () { + final parser = AnthropicEventParser(structuredToolName: 'result'); + final events = [ + ...parser.parse({ + 'type': 'message_start', + 'message': {'id': 'm', 'role': 'assistant'}, + }), + ...parser.parse({ + 'type': 'content_block_start', + 'index': 0, + 'content_block': {'type': 'tool_use', 'id': 't', 'name': 'result'}, + }), + ...parser.parse({ + 'type': 'content_block_delta', + 'index': 0, + 'delta': {'type': 'input_json_delta', 'partial_json': '{"x":1}'}, + }), + ...parser.parse({'type': 'content_block_stop', 'index': 0}), + ...parser.parse({ + 'type': 'message_delta', + 'delta': {'stop_reason': 'tool_use'}, + }), + ...parser.parse({'type': 'message_stop'}), + ]; + + // The forced tool surfaces as text, not a tool call, and finishes as stop. + expect(events.whereType(), isEmpty); + expect( + events.whereType().map((e) => e.delta).join(), + '{"x":1}', + ); + expect( + events.whereType().single.reason, + FinishReason.stop, + ); + }); + + test('maps an error event to StreamErrorEvent', () { + final parser = AnthropicEventParser(); + final events = parser.parse({ + 'type': 'error', + 'error': {'type': 'overloaded_error', 'message': 'Overloaded'}, + }); + expect(events.single, isA()); + expect((events.single as StreamErrorEvent).error, 'Overloaded'); + }); + + test('an error event suppresses the synthetic finalize (no fake success)', + () { + final parser = AnthropicEventParser(); + parser.parse({ + 'type': 'message_start', + 'message': {'id': 'a1'} + }); + parser.parse({ + 'type': 'error', + 'error': {'type': 'overloaded_error', 'message': 'Overloaded'}, + }); + // The stream closes after the error; finalize() must not emit a + // MessageFinished(stop) that would overwrite the error status. + expect(parser.finalize(), isEmpty); + }); + }); + + group('AnthropicProvider.send', () { + test('streams events end-to-end over a mock client', () async { + final provider = AnthropicProvider( + apiKey: 'test', + client: _sseClient([ + 'event: message_start', + ..._dataLines([ + { + 'type': 'message_start', + 'message': {'id': 'msg_1', 'role': 'assistant'}, + }, + ]), + 'event: content_block_delta', + ..._dataLines([ + { + 'type': 'content_block_delta', + 'index': 0, + 'delta': {'type': 'text_delta', 'text': 'Hi'}, + }, + { + 'type': 'content_block_delta', + 'index': 0, + 'delta': {'type': 'text_delta', 'text': '!'}, + }, + { + 'type': 'message_delta', + 'delta': {'stop_reason': 'end_turn'}, + }, + {'type': 'message_stop'}, + ]), + ]), + ); + + final events = await provider + .send(const AiConversation(id: 'c', messages: [])) + .toList(); + + final processor = MessageProcessor(); + for (final event in events) { + processor.apply(event); + } + expect(processor.conversation.messages.single.text, 'Hi!'); + expect( + processor.conversation.messages.single.status, + AiMessageStatus.complete, + ); + }); + + test('emits a StreamErrorEvent on a non-200 response', () async { + final provider = AnthropicProvider( + apiKey: 'bad', + client: _sseClient(['nope'], statusCode: 401), + ); + final events = await provider + .send(const AiConversation(id: 'c', messages: [])) + .toList(); + expect(events.single, isA()); + }); + + test('sends required headers, max_tokens, and folds system messages', + () async { + late http.Request captured; + final provider = AnthropicProvider( + apiKey: 'sk-test', + client: MockClient.streaming((request, bodyStream) async { + captured = request as http.Request; + return http.StreamedResponse( + Stream>.value( + utf8.encode('data: ${jsonEncode({'type': 'message_stop'})}\n'), + ), + 200, + ); + }), + ); + + await provider + .send( + const AiConversation( + id: 'c', + messages: [ + AiMessage( + id: 's', + role: AiRole.system, + parts: [TextPart('Be terse.')], + ), + AiMessage( + id: 'u', + role: AiRole.user, + parts: [TextPart('Hi')], + ), + ], + ), + ) + .toList(); + + expect(captured.headers['x-api-key'], 'sk-test'); + expect(captured.headers['anthropic-version'], '2023-06-01'); + final body = (jsonDecode(captured.body) as Map).cast(); + expect(body['system'], 'Be terse.'); + expect(body['max_tokens'], 4096); + expect(body['stream'], true); + final messages = body['messages']! as List; + expect(messages, hasLength(1)); // system is hoisted out of messages + expect((messages.single as Map)['role'], 'user'); + }); + + test('cachePrompt marks system and the last tool with cache_control', + () async { + late http.Request captured; + final provider = AnthropicProvider( + apiKey: 'sk', + client: MockClient.streaming((request, bodyStream) async { + captured = request as http.Request; + return http.StreamedResponse( + Stream>.value( + utf8.encode('data: ${jsonEncode({'type': 'message_stop'})}\n'), + ), + 200, + ); + }), + ); + + await provider + .send( + const AiConversation( + id: 'c', + messages: [ + AiMessage( + id: 's', + role: AiRole.system, + parts: [TextPart('Be terse.')], + ), + AiMessage(id: 'u', role: AiRole.user, parts: [TextPart('Hi')]), + ], + ), + tools: [ + const ToolDefinition( + name: 'get_weather', + description: 'w', + parametersSchema: {'type': 'object'}, + ), + ], + options: const AiRequestOptions(cachePrompt: true), + ) + .toList(); + + final body = (jsonDecode(captured.body) as Map).cast(); + final system = (body['system'] as List).cast>(); + expect(system.single['cache_control'], {'type': 'ephemeral'}); + final tools = (body['tools'] as List).cast>(); + expect(tools.last['cache_control'], {'type': 'ephemeral'}); + }); + + test( + 'replays a signed thinking block before tool_use in the assistant turn', + () async { + late http.Request captured; + final provider = AnthropicProvider( + apiKey: 'sk', + client: MockClient.streaming((request, bodyStream) async { + captured = request as http.Request; + return http.StreamedResponse( + Stream>.value( + utf8.encode('data: ${jsonEncode({'type': 'message_stop'})}\n'), + ), + 200, + ); + }), + ); + + await provider + .send( + const AiConversation( + id: 'c', + messages: [ + AiMessage(id: 'u', role: AiRole.user, parts: [TextPart('hi')]), + AiMessage( + id: 'a', + role: AiRole.assistant, + parts: [ + ReasoningPart('let me think', signature: 'sig-abc'), + ToolCallPart( + toolCallId: 't1', + toolName: 'get_weather', + args: {'city': 'Lisbon'}, + ), + ], + ), + AiMessage( + id: 'tr', + role: AiRole.tool, + parts: [ToolResultPart(toolCallId: 't1', result: 'sunny')], + ), + ], + ), + ) + .toList(); + + final body = (jsonDecode(captured.body) as Map).cast(); + final messages = (body['messages'] as List).cast>(); + final assistant = messages.firstWhere((m) => m['role'] == 'assistant'); + final blocks = + (assistant['content'] as List).cast>(); + // Thinking block (with signature) comes first, before tool_use. + expect(blocks.first['type'], 'thinking'); + expect(blocks.first['signature'], 'sig-abc'); + expect(blocks.any((b) => b['type'] == 'tool_use'), isTrue); + expect( + blocks.indexWhere((b) => b['type'] == 'thinking') < + blocks.indexWhere((b) => b['type'] == 'tool_use'), + isTrue, + ); + }); + + test('emits a StreamErrorEvent when the transport throws', () async { + final provider = AnthropicProvider( + apiKey: 'test', + client: MockClient.streaming((request, bodyStream) async { + throw const SocketException('connection refused'); + }), + ); + final events = await provider + .send(const AiConversation(id: 'c', messages: [])) + .toList(); + expect(events.single, isA()); + }); + + test('retries a transient 503 then succeeds', () async { + var calls = 0; + final provider = AnthropicProvider( + apiKey: 'k', + client: MockClient.streaming((request, _) async { + calls++; + if (calls == 1) { + return http.StreamedResponse( + Stream>.value(utf8.encode('busy')), + 503, + ); + } + return http.StreamedResponse( + Stream>.value( + utf8.encode('data: ${jsonEncode({'type': 'message_stop'})}\n'), + ), + 200, + ); + }), + ); + final events = await provider + .send(const AiConversation(id: 'c', messages: [])) + .toList(); + expect(calls, 2); + expect(events.whereType(), isEmpty); + }); + + test('emits a StreamErrorEvent on a wrong-shape chunk without throwing', + () async { + // Valid JSON, wrong shape: a content_block whose value is a String makes + // the parser's `as Map?` cast throw; the stream must continue, not die. + final provider = AnthropicProvider( + apiKey: 'k', + client: _sseClient(_dataLines([ + { + 'type': 'message_start', + 'message': {'id': 'msg_1', 'role': 'assistant'}, + }, + {'type': 'content_block_start', 'index': 0, 'content_block': 'oops'}, + { + 'type': 'content_block_delta', + 'index': 0, + 'delta': {'type': 'text_delta', 'text': 'ok'}, + }, + {'type': 'message_stop'}, + ])), + ); + final events = await provider + .send(const AiConversation(id: 'c', messages: [])) + .toList(); + expect(events.whereType(), isNotEmpty); + expect(events.whereType().map((e) => e.delta), contains('ok')); + }); + + test('finalizes a stream that ends without a message_stop', () async { + final provider = AnthropicProvider( + apiKey: 'k', + client: _sseClient(_dataLines([ + { + 'type': 'message_start', + 'message': {'id': 'msg_1', 'role': 'assistant'}, + }, + { + 'type': 'content_block_delta', + 'index': 0, + 'delta': {'type': 'text_delta', 'text': 'hi'}, + }, + ])), + ); + final processor = MessageProcessor(); + for (final e in await provider + .send(const AiConversation(id: 'c', messages: [])) + .toList()) { + processor.apply(e); + } + expect( + processor.conversation.messages.single.status, + AiMessageStatus.complete, + ); + }); + + test('surfaces a message-scoped StreamErrorEvent (no finalize) on a stall', + () async { + final controller = StreamController>(); + controller.add(utf8.encode(_dataLines([ + { + 'type': 'message_start', + 'message': {'id': 'msg_1', 'role': 'assistant'}, + }, + ]).map((l) => '$l\n').join())); + final provider = AnthropicProvider( + apiKey: 'k', + timeout: const Duration(milliseconds: 50), + client: MockClient.streaming((request, _) async { + return http.StreamedResponse(controller.stream, 200); + }), + ); + final events = await provider + .send(const AiConversation(id: 'c', messages: [])) + .toList(); + await controller.close(); + final errors = events.whereType().toList(); + expect(errors, isNotEmpty); + expect(errors.last.messageId, 'msg_1'); + expect(events.whereType(), isEmpty); + }); + + test('merges adjacent same-role turns so roles alternate', () async { + late http.Request captured; + final provider = AnthropicProvider( + apiKey: 'k', + client: MockClient.streaming((request, _) async { + captured = request as http.Request; + return http.StreamedResponse( + Stream>.value( + utf8.encode('data: ${jsonEncode({'type': 'message_stop'})}\n'), + ), + 200, + ); + }), + ); + // A normal user turn immediately followed by a tool-result turn (also + // mapped to role `user`) would otherwise produce two user turns in a row. + await provider + .send( + const AiConversation( + id: 'c', + messages: [ + AiMessage( + id: 'u', + role: AiRole.user, + parts: [TextPart('Hi')], + ), + AiMessage( + id: 't', + role: AiRole.tool, + parts: [ + ToolResultPart( + toolCallId: 'call_1', + result: 'done', + ), + ], + ), + ], + ), + ) + .toList(); + final body = (jsonDecode(captured.body) as Map).cast(); + final messages = (body['messages']! as List).cast>(); + expect(messages, hasLength(1)); + expect(messages.single['role'], 'user'); + // Both the text and the tool_result are concatenated into one content[]. + final content = messages.single['content'] as List; + expect(content.any((p) => (p as Map)['type'] == 'text'), isTrue); + expect(content.any((p) => (p as Map)['type'] == 'tool_result'), isTrue); + }); + }); + + test('encodes image attachments as base64 image blocks', () async { + late http.Request captured; + final provider = AnthropicProvider( + apiKey: 'k', + client: MockClient.streaming((request, _) async { + captured = request as http.Request; + return http.StreamedResponse( + Stream>.value( + utf8.encode('data: {"type":"message_stop"}\n')), + 200, + ); + }), + ); + await provider + .send( + AiConversation( + id: 'c', + messages: [ + AiMessage( + id: 'u', + role: AiRole.user, + parts: [ + const TextPart('what is this?'), + FilePart( + mediaType: 'image/png', + bytes: Uint8List.fromList([1, 2, 3])), + ], + ), + ], + ), + ) + .toList(); + final body = (jsonDecode(captured.body) as Map).cast(); + final content = + ((body['messages']! as List).first as Map)['content'] as List; + final image = + content.firstWhere((p) => (p as Map)['type'] == 'image') as Map; + final source = image['source'] as Map; + expect(source['media_type'], 'image/png'); + expect(source['data'], 'AQID'); + }); +} diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/test/default_http_client_test.dart b/packages/flutter_ai/flutter_ai_provider_anthropic/test/default_http_client_test.dart new file mode 100644 index 0000000..b45bba7 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_provider_anthropic/test/default_http_client_test.dart @@ -0,0 +1,11 @@ +import 'package:flutter_ai_provider_anthropic/src/default_http_client.dart'; +import 'package:http/http.dart' as http; +import 'package:test/test.dart'; + +void main() { + test('createDefaultHttpClient returns a usable client on this platform', () { + final client = createDefaultHttpClient(); + addTearDown(client.close); + expect(client, isA()); + }); +} diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/test/live_test.dart b/packages/flutter_ai/flutter_ai_provider_anthropic/test/live_test.dart new file mode 100644 index 0000000..812f13c --- /dev/null +++ b/packages/flutter_ai/flutter_ai_provider_anthropic/test/live_test.dart @@ -0,0 +1,34 @@ +import 'dart:io'; + +import 'package:flutter_ai_provider_anthropic/flutter_ai_provider_anthropic.dart'; +import 'package:test/test.dart'; + +/// Live smoke test against the real Anthropic API. Skipped unless ANTHROPIC_API_KEY +/// is set, so it's safe in CI. Run with: +/// ANTHROPIC_API_KEY=... dart test test/live_test.dart +void main() { + final key = Platform.environment['ANTHROPIC_API_KEY']; + final skip = key == null ? 'set ANTHROPIC_API_KEY to run live tests' : null; + + test('streams a short reply from the live API', () async { + final provider = AnthropicProvider(apiKey: key!); + final processor = MessageProcessor(); + await for (final event in provider.send( + const AiConversation( + id: 'c', + messages: [ + AiMessage( + id: 'u', + role: AiRole.user, + parts: [TextPart('Reply with a short friendly greeting.')], + ), + ], + ), + )) { + processor.apply(event); + } + final message = processor.conversation.messages.single; + expect(message.text.trim(), isNotEmpty); + expect(message.status, AiMessageStatus.complete); + }, skip: skip); +} diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/test/reasoning_effort_test.dart b/packages/flutter_ai/flutter_ai_provider_anthropic/test/reasoning_effort_test.dart new file mode 100644 index 0000000..fe5eb68 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_provider_anthropic/test/reasoning_effort_test.dart @@ -0,0 +1,98 @@ +import 'dart:convert'; + +import 'package:flutter_ai_provider_anthropic/flutter_ai_provider_anthropic.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:test/test.dart'; + +Future> _capture(AiRequestOptions options) async { + late Map payload; + final provider = AnthropicProvider( + apiKey: 'secret', + client: MockClient.streaming((request, bodyStream) async { + payload = (jsonDecode(await bodyStream.bytesToString()) as Map) + .cast(); + return http.StreamedResponse(const Stream>.empty(), 200); + }), + ); + await provider + .send( + const AiConversation( + id: 'c', + messages: [ + AiMessage(id: 'm', role: AiRole.user, parts: [TextPart('Hi')]), + ], + ), + options: options, + ) + .toList(); + return payload; +} + +void main() { + test('reasoningEffort enables adaptive thinking on 4.6+ (default model)', + () async { + // The default model (claude-opus-4-8) is adaptive-only: it rejects + // budget_tokens, so we must emit {type: adaptive}. + final payload = await _capture( + const AiRequestOptions(reasoningEffort: ReasoningEffort.low)); + final thinking = (payload['thinking'] as Map).cast(); + expect(thinking['type'], 'adaptive'); + expect(thinking.containsKey('budget_tokens'), isFalse); + }); + + test('reasoningEffort uses the budgeted shape on legacy models', () async { + final payload = await _capture(const AiRequestOptions( + model: 'claude-3-7-sonnet-latest', + reasoningEffort: ReasoningEffort.low, + )); + final thinking = (payload['thinking'] as Map).cast(); + expect(thinking['type'], 'enabled'); + expect(thinking['budget_tokens'], ReasoningEffort.low.budgetTokens); + }); + + test('raises max_tokens above the budget on legacy models', () async { + // high budget (24576) exceeds the default max_tokens (4096); only the + // budgeted shape needs the bump. + final payload = await _capture(const AiRequestOptions( + model: 'claude-sonnet-4-5', + reasoningEffort: ReasoningEffort.high, + )); + expect(payload['max_tokens'] as int, + greaterThan(ReasoningEffort.high.budgetTokens)); + }); + + test('drops temperature when thinking is enabled', () async { + final payload = await _capture(const AiRequestOptions( + reasoningEffort: ReasoningEffort.medium, + temperature: 0.7, + )); + expect(payload.containsKey('temperature'), isFalse); + expect(payload.containsKey('thinking'), isTrue); + }); + + test('drops thinking when a responseFormat is set (forced tool choice)', + () async { + final payload = await _capture(const AiRequestOptions( + reasoningEffort: ReasoningEffort.high, + responseFormat: AiResponseFormat( + name: 'result', + schema: {'type': 'object'}, + ), + )); + // Forced tool_choice + thinking is a 400; structured output wins. + expect(payload.containsKey('thinking'), isFalse); + expect((payload['tool_choice'] as Map)['type'], 'tool'); + }); + + test('an explicit thinking block in extra takes precedence', () async { + final payload = await _capture(const AiRequestOptions( + reasoningEffort: ReasoningEffort.high, + extra: { + 'thinking': {'type': 'enabled', 'budget_tokens': 5000}, + }, + )); + final thinking = (payload['thinking'] as Map).cast(); + expect(thinking['budget_tokens'], 5000); + }); +} diff --git a/packages/flutter_ai/flutter_ai_tools/CHANGELOG.md b/packages/flutter_ai/flutter_ai_tools/CHANGELOG.md new file mode 100644 index 0000000..ebf8b93 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_tools/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +## 0.1.4 + +- Docs: shortened the pubspec `description` into pub.dev's 60–180 character + window so it renders in full in search results. No code changes. + +## 0.1.3 + +- Docs: refreshed the README listing with a hero image, screenshot gallery, + and badges (consistent across the package family). No code changes. + +## 0.1.2 + +- Declares supported `platforms:` (all 6) for the pub.dev listing. + +## 0.1.1 + +- Docs: added a "Buy me a coffee" (Ko-fi) support section to the README. No code + changes. + +## 0.1.0 + +Initial release. + +- `ToolSpec` — a tool declaration (`name`, `description`, JSON-Schema + `parametersSchema`) plus an optional client-side `execute`; `toDefinition()` + yields the model-facing `ToolDefinition`. +- `ToolRegistry` — registers tools, exposes their `definitions` for a provider, + and `run`s a `ToolCallPart` into a `ToolResultPart`, capturing unknown tools + and thrown executors as error results instead of crashing. +- `WebSearchAdapter` + `webSearchTool` + `SearchResult` — expose any web-search + backend as a callable tool. +- Pure Dart; re-exports `flutter_ai_core`. diff --git a/packages/flutter_ai/flutter_ai_tools/LICENSE b/packages/flutter_ai/flutter_ai_tools/LICENSE new file mode 100644 index 0000000..56023ee --- /dev/null +++ b/packages/flutter_ai/flutter_ai_tools/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2026, The flutter_ai authors. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/flutter_ai/flutter_ai_tools/README.md b/packages/flutter_ai/flutter_ai_tools/README.md new file mode 100644 index 0000000..a27702e --- /dev/null +++ b/packages/flutter_ai/flutter_ai_tools/README.md @@ -0,0 +1,80 @@ +

flutter_ai_tools

+ +

Provider-neutral tool calling for flutter_ai — declare a ToolSpec, register it, and let the agent loop run it. Pure Dart, with a web-search adapter included.

+ +

+ Tool calls flowing through the agent loop +

+ +

+ flutter_ai_tools on pub.dev + pub points + License: BSD-3-Clause +

+ +

+ Family: flutter_ai · + core · client · elements · + mcp · voice
+ Recipes · Migrating from the Vercel AI SDK +

+ +--- + +Provider-neutral tool calling for the [`flutter_ai`](../../README.md) family. +Pure Dart, no Flutter dependency. + +## What it does + +- **`ToolSpec`** — declare a tool (name, description, JSON-Schema parameters) and + an optional client-side executor. +- **`ToolRegistry`** — collect tools, hand their `definitions` to a provider, and + `run` a `ToolCallPart` into a `ToolResultPart`. Unknown tools and thrown + executors become *error results*, never crashes. +- **Web search** — `webSearchTool(adapter)` turns any `WebSearchAdapter` + (Tavily, Brave, SerpAPI, custom) into a callable tool returning `SearchResult`s. + +## Usage + +```dart +final tools = ToolRegistry([ + ToolSpec( + name: 'get_weather', + description: 'Get the weather for a city', + parametersSchema: const { + 'type': 'object', + 'properties': {'city': {'type': 'string'}}, + 'required': ['city'], + }, + execute: (args) => weatherApi.fetch(args['city']! as String), + ), +]); + +// Advertise to a provider: +controller.setTools(tools.definitions); + +// Fulfill a call the model made: +final result = await tools.run(toolCallPart); // -> ToolResultPart +``` + +### Web search + +```dart +final tools = ToolRegistry([webSearchTool(MyTavilyAdapter())]); +``` + +```dart +class MyTavilyAdapter implements WebSearchAdapter { + @override + Future> search(String query, {int? maxResults}) async { + // call your search backend, map hits into SearchResult + } +} +``` + +## Status + +Published on pub.dev (see the CHANGELOG); depends on `flutter_ai_core`. +See [`example/`](example/). + +_If `flutter_ai` saves you time, you can [buy me a coffee ☕](https://ko-fi.com/ananmouaz)._ diff --git a/packages/flutter_ai/flutter_ai_tools/analysis_options.yaml b/packages/flutter_ai/flutter_ai_tools/analysis_options.yaml new file mode 100644 index 0000000..bddaa31 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_tools/analysis_options.yaml @@ -0,0 +1,2 @@ +# Inherits the workspace-wide strict configuration. +include: ../../analysis_options.yaml diff --git a/packages/flutter_ai/flutter_ai_tools/example/flutter_ai_tools_example.dart b/packages/flutter_ai/flutter_ai_tools/example/flutter_ai_tools_example.dart new file mode 100644 index 0000000..80d961d --- /dev/null +++ b/packages/flutter_ai/flutter_ai_tools/example/flutter_ai_tools_example.dart @@ -0,0 +1,39 @@ +// Declares a tool, advertises it, and fulfills a tool call. +// +// Run with: dart run example/flutter_ai_tools_example.dart +import 'package:flutter_ai_tools/flutter_ai_tools.dart'; + +Future main() async { + final tools = ToolRegistry([ + ToolSpec( + name: 'get_weather', + description: 'Get the current weather for a city', + parametersSchema: const { + 'type': 'object', + 'properties': { + 'city': {'type': 'string'}, + }, + 'required': ['city'], + }, + execute: (args) async { + final city = args['city']! as String; + // Pretend to call a weather API. + return {'city': city, 'tempC': 21, 'condition': 'Cloudy'}; + }, + ), + ]); + + // These definitions are what you pass to an LlmProvider / UseChatController. + print('Advertised tools: ${tools.definitions.map((d) => d.name).toList()}'); + + // Simulate a tool call the model emitted, then fulfill it. + const call = ToolCallPart( + toolCallId: 'call-1', + toolName: 'get_weather', + args: {'city': 'London'}, + state: ToolCallState.inputAvailable, + ); + + final result = await tools.run(call); + print('Result (isError=${result.isError}): ${result.result}'); +} diff --git a/packages/flutter_ai/flutter_ai_tools/lib/flutter_ai_tools.dart b/packages/flutter_ai/flutter_ai_tools/lib/flutter_ai_tools.dart new file mode 100644 index 0000000..426ff87 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_tools/lib/flutter_ai_tools.dart @@ -0,0 +1,15 @@ +/// Provider-neutral tool calling for the `flutter_ai` family. +/// +/// Declare tools with `ToolSpec`, collect them in a `ToolRegistry` (which both +/// advertises `ToolDefinition`s to a provider and executes incoming tool calls +/// into `ToolResultPart`s), and expose web search as a tool via `webSearchTool` +/// over a host-provided `WebSearchAdapter`. +/// +/// Pure Dart — no Flutter dependency. Re-exports `flutter_ai_core`. +library; + +export 'package:flutter_ai_core/flutter_ai_core.dart'; + +export 'src/tool_registry.dart'; +export 'src/tool_spec.dart'; +export 'src/web_search.dart'; diff --git a/packages/flutter_ai/flutter_ai_tools/lib/src/tool_registry.dart b/packages/flutter_ai/flutter_ai_tools/lib/src/tool_registry.dart new file mode 100644 index 0000000..6bbe383 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_tools/lib/src/tool_registry.dart @@ -0,0 +1,59 @@ +import 'package:flutter_ai_core/flutter_ai_core.dart'; +import 'package:flutter_ai_tools/src/tool_spec.dart'; + +/// A collection of [ToolSpec]s that can advertise themselves to a provider and +/// execute incoming tool calls. +/// +/// The registry is the optional "auto round-tripping" seam: feed it a +/// [ToolCallPart] and it returns the matching [ToolResultPart], catching any +/// failure as an error result rather than throwing — so a misbehaving tool can +/// never crash the chat loop. +class ToolRegistry { + /// Creates a registry seeded with [tools]. + ToolRegistry([Iterable tools = const []]) { + for (final tool in tools) { + register(tool); + } + } + + final Map _tools = {}; + + /// Registers [tool], replacing any existing tool with the same name. + void register(ToolSpec tool) => _tools[tool.name] = tool; + + /// The tool registered under [name], or `null`. + ToolSpec? operator [](String name) => _tools[name]; + + /// Whether no tools are registered. + bool get isEmpty => _tools.isEmpty; + + /// The model-facing declarations for every registered tool, suitable for + /// passing to an `LlmProvider`. + List get definitions => + [for (final tool in _tools.values) tool.toDefinition()]; + + /// Executes [call] against its registered tool and returns the result. + /// + /// If the tool is unknown or has no executor, or if the executor throws, an + /// error [ToolResultPart] is returned rather than throwing. + Future run(ToolCallPart call) async { + final tool = _tools[call.toolName]; + if (tool?.execute == null) { + return ToolResultPart( + toolCallId: call.toolCallId, + result: 'No executor registered for tool "${call.toolName}"', + isError: true, + ); + } + try { + final result = await tool!.execute!(call.args); + return ToolResultPart(toolCallId: call.toolCallId, result: result); + } on Object catch (error) { + return ToolResultPart( + toolCallId: call.toolCallId, + result: error.toString(), + isError: true, + ); + } + } +} diff --git a/packages/flutter_ai/flutter_ai_tools/lib/src/tool_spec.dart b/packages/flutter_ai/flutter_ai_tools/lib/src/tool_spec.dart new file mode 100644 index 0000000..9c882bc --- /dev/null +++ b/packages/flutter_ai/flutter_ai_tools/lib/src/tool_spec.dart @@ -0,0 +1,44 @@ +import 'dart:async'; + +import 'package:flutter_ai_core/flutter_ai_core.dart'; + +/// Runs a tool's logic given its decoded arguments, returning a JSON-encodable +/// result (or a [Future] of one). +typedef ToolExecutor = FutureOr Function(Map args); + +/// A tool the model can call, pairing a [ToolDefinition] with the client-side +/// [execute] logic that fulfills it. +/// +/// The declaration half ([name], [description], [parametersSchema]) is what the +/// model sees; [execute] is optional — omit it for tools the server runs. +final class ToolSpec { + /// Creates a tool specification. + const ToolSpec({ + required this.name, + required this.description, + this.parametersSchema = const {}, + this.execute, + }); + + /// The tool's unique name, referenced in tool calls. + final String name; + + /// Natural-language description the model uses to decide when to call it. + final String description; + + /// A JSON Schema object describing the tool's arguments. + final Map parametersSchema; + + /// Client-side implementation, or `null` if the tool executes elsewhere. + final ToolExecutor? execute; + + /// The model-facing declaration for this tool. + ToolDefinition toDefinition() => ToolDefinition( + name: name, + description: description, + parametersSchema: parametersSchema, + ); + + @override + String toString() => 'ToolSpec($name)'; +} diff --git a/packages/flutter_ai/flutter_ai_tools/lib/src/web_search.dart b/packages/flutter_ai/flutter_ai_tools/lib/src/web_search.dart new file mode 100644 index 0000000..b050888 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_tools/lib/src/web_search.dart @@ -0,0 +1,90 @@ +import 'package:flutter_ai_tools/src/tool_spec.dart'; + +/// A single web-search hit. +final class SearchResult { + /// Creates a search result. + const SearchResult({required this.title, required this.url, this.snippet}); + + /// Reconstructs a [SearchResult] from [json]. + factory SearchResult.fromJson(Map json) => SearchResult( + title: json['title']! as String, + url: Uri.parse(json['url']! as String), + snippet: json['snippet'] as String?, + ); + + /// The result's title. + final String title; + + /// The result's location. + final Uri url; + + /// A short snippet/summary, if available. + final String? snippet; + + /// Serializes this result. + Map toJson() => { + 'title': title, + 'url': url.toString(), + if (snippet != null) 'snippet': snippet, + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SearchResult && + other.title == title && + other.url == url && + other.snippet == snippet); + + @override + int get hashCode => Object.hash(title, url, snippet); + + @override + String toString() => 'SearchResult($title, $url)'; +} + +/// A backend that performs web searches (Tavily, Brave, SerpAPI, a custom +/// endpoint, …). Host apps provide the implementation; this package only knows +/// the contract. +abstract interface class WebSearchAdapter { + /// Returns up to [maxResults] hits for [query]; `null` lets the adapter pick + /// its own limit. + Future> search(String query, {int? maxResults}); +} + +/// Builds a [ToolSpec] that exposes [adapter] to the model as a callable tool. +/// +/// The tool takes a `query` string and returns `{ "results": [...] }`, where +/// each entry is a serialized [SearchResult]. Map those into `SourcePart`s in +/// your UI to render citations. +ToolSpec webSearchTool( + WebSearchAdapter adapter, { + String name = 'web_search', + String description = 'Search the web for up-to-date information.', + int maxResults = 5, +}) { + return ToolSpec( + name: name, + description: description, + parametersSchema: const { + 'type': 'object', + 'properties': { + 'query': { + 'type': 'string', + 'description': 'The search query.', + }, + }, + 'required': ['query'], + }, + execute: (args) async { + final query = (args['query'] as String?)?.trim() ?? ''; + if (query.isEmpty) { + return {'results': []}; + } + final results = await adapter.search(query, maxResults: maxResults); + return { + 'results': [for (final result in results) result.toJson()], + }; + }, + ); +} diff --git a/packages/flutter_ai/flutter_ai_tools/pubspec.yaml b/packages/flutter_ai/flutter_ai_tools/pubspec.yaml new file mode 100644 index 0000000..f310433 --- /dev/null +++ b/packages/flutter_ai/flutter_ai_tools/pubspec.yaml @@ -0,0 +1,32 @@ +name: flutter_ai_tools +description: "Provider-neutral tool calling for flutter_ai: declare tools with executors, run tool calls into results, and expose web search as a tool. Pure Dart." +version: 0.1.4 +homepage: https://github.com/ananmouaz/flutter_ai +repository: https://github.com/ananmouaz/flutter_ai/tree/main/packages/flutter_ai_tools +issue_tracker: https://github.com/ananmouaz/flutter_ai/issues +topics: + - ai + - llm + - tool-calling + - chat + - flutter + +environment: + sdk: ^3.6.0 + +platforms: + android: + ios: + linux: + macos: + web: + windows: + +resolution: workspace + +dependencies: + flutter_ai_core: ^0.1.0 + +dev_dependencies: + lints: ^5.0.0 + test: ^1.25.0 diff --git a/packages/flutter_ai/flutter_ai_tools/test/tools_test.dart b/packages/flutter_ai/flutter_ai_tools/test/tools_test.dart new file mode 100644 index 0000000..35cb9ee --- /dev/null +++ b/packages/flutter_ai/flutter_ai_tools/test/tools_test.dart @@ -0,0 +1,151 @@ +import 'package:flutter_ai_tools/flutter_ai_tools.dart'; +import 'package:test/test.dart'; + +class _FakeSearch implements WebSearchAdapter { + String? lastQuery; + int? lastMax; + + @override + Future> search(String query, {int? maxResults}) async { + lastQuery = query; + lastMax = maxResults; + return [ + SearchResult( + title: 'Flutter', + url: Uri.parse('https://flutter.dev'), + snippet: 'UI toolkit', + ), + ]; + } +} + +void main() { + group('ToolSpec', () { + test('toDefinition drops the executor', () { + final spec = ToolSpec( + name: 'noop', + description: 'does nothing', + parametersSchema: const {'type': 'object'}, + execute: (args) => null, + ); + final def = spec.toDefinition(); + expect(def.name, 'noop'); + expect(def.description, 'does nothing'); + expect(def.parametersSchema, {'type': 'object'}); + }); + }); + + group('ToolRegistry', () { + test('definitions lists all registered tools', () { + final registry = ToolRegistry([ + const ToolSpec(name: 'a', description: 'A'), + const ToolSpec(name: 'b', description: 'B'), + ]); + expect(registry.definitions.map((d) => d.name), ['a', 'b']); + expect(registry.isEmpty, isFalse); + }); + + test('register replaces a tool with the same name and [] looks it up', () { + final registry = ToolRegistry([ + const ToolSpec(name: 'a', description: 'first'), + ]) + ..register(const ToolSpec(name: 'a', description: 'second')); + expect(registry['a']?.description, 'second'); + expect(registry['missing'], isNull); + expect(registry.definitions, hasLength(1)); + }); + + test('an empty registry reports isEmpty', () { + expect(ToolRegistry().isEmpty, isTrue); + }); + + test('run executes the matching tool', () async { + final registry = ToolRegistry([ + ToolSpec( + name: 'add', + description: 'add', + execute: (args) => (args['a']! as int) + (args['b']! as int), + ), + ]); + final result = await registry.run( + const ToolCallPart( + toolCallId: 'c1', + toolName: 'add', + args: {'a': 2, 'b': 3}, + state: ToolCallState.inputAvailable, + ), + ); + expect(result.result, 5); + expect(result.isError, isFalse); + expect(result.toolCallId, 'c1'); + }); + + test('run returns an error result for an unknown tool', () async { + final registry = ToolRegistry(); + final result = await registry.run( + const ToolCallPart(toolCallId: 'c1', toolName: 'ghost'), + ); + expect(result.isError, isTrue); + }); + + test('run captures a thrown executor as an error result', () async { + final registry = ToolRegistry([ + ToolSpec( + name: 'boom', + description: 'throws', + execute: (args) => throw StateError('nope'), + ), + ]); + final result = await registry.run( + const ToolCallPart(toolCallId: 'c1', toolName: 'boom'), + ); + expect(result.isError, isTrue); + expect(result.result, contains('nope')); + }); + + test('run reports tools that have no executor', () async { + final registry = ToolRegistry([ + const ToolSpec(name: 'server_side', description: 'no exec'), + ]); + final result = await registry.run( + const ToolCallPart(toolCallId: 'c1', toolName: 'server_side'), + ); + expect(result.isError, isTrue); + }); + }); + + group('webSearchTool', () { + test('forwards the query and maps results', () async { + final adapter = _FakeSearch(); + final tool = webSearchTool(adapter, maxResults: 3); + final output = + await tool.execute!({'query': 'flutter'}) as Map; + + expect(adapter.lastQuery, 'flutter'); + expect(adapter.lastMax, 3); + final results = output['results']! as List; + expect(results, hasLength(1)); + expect((results.first as Map)['url'], 'https://flutter.dev'); + }); + + test('short-circuits an empty query', () async { + final adapter = _FakeSearch(); + final tool = webSearchTool(adapter); + final output = + await tool.execute!({'query': ' '}) as Map; + expect(output['results'], isEmpty); + expect(adapter.lastQuery, isNull); + }); + }); + + group('SearchResult', () { + test('round-trips through JSON', () { + final result = SearchResult( + title: 'T', + url: Uri.parse('https://x.test'), + snippet: 's', + ); + expect(SearchResult.fromJson(result.toJson()), result); + }); + }); +} diff --git a/pubspec.lock b/pubspec.lock index 2d912b1..df2237e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -176,39 +176,37 @@ packages: source: sdk version: "0.0.0" flutter_ai_client: - dependency: transitive + dependency: "direct main" description: - name: flutter_ai_client - sha256: e60a3a69c99070952ee203ed8aad709eeb704915cb6b79f94eeb5a4321556033 - url: "https://pub.flutter-io.cn" - source: hosted + path: "packages/flutter_ai/flutter_ai_client" + relative: true + source: path version: "0.3.0" flutter_ai_core: - dependency: transitive + dependency: "direct main" description: - name: flutter_ai_core - sha256: b188f83a4bf78f59c1c830dab348cfcd9be40e40ef6401b3d2d21bee1c7c3d04 - url: "https://pub.flutter-io.cn" - source: hosted + path: "packages/flutter_ai/flutter_ai_core" + relative: true + source: path version: "0.1.14" flutter_ai_elements: dependency: "direct main" description: - path: "../flutter_ai/packages/flutter_ai_elements" + path: "packages/flutter_ai/flutter_ai_elements" relative: true source: path version: "0.2.0" flutter_ai_provider_anthropic: dependency: "direct main" description: - path: "../flutter_ai/packages/flutter_ai_provider_anthropic" + path: "packages/flutter_ai/flutter_ai_provider_anthropic" relative: true source: path version: "0.1.12" flutter_ai_tools: dependency: "direct main" description: - path: "../flutter_ai/packages/flutter_ai_tools" + path: "packages/flutter_ai/flutter_ai_tools" relative: true source: path version: "0.1.4" diff --git a/pubspec.yaml b/pubspec.yaml index f255306..c3c80c7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,22 +38,27 @@ dependencies: dynamic_color: ">=1.7.0 <1.8.0" flutter: sdk: flutter - # AI chat UI: the flutter_ai library family, pulled in as path deps from the - # sibling repo. flutter_ai_elements transitively brings client + core. + # AI chat UI: the flutter_ai library family, vendored under packages/ + # (upstream ananmouaz/flutter_ai + local OHOS/tool-call fixes not yet + # published). + flutter_ai_client: + path: packages/flutter_ai/flutter_ai_client + flutter_ai_core: + path: packages/flutter_ai/flutter_ai_core flutter_ai_elements: - path: ../flutter_ai/packages/flutter_ai_elements + path: packages/flutter_ai/flutter_ai_elements flutter_ai_provider_anthropic: - path: ../flutter_ai/packages/flutter_ai_provider_anthropic + path: packages/flutter_ai/flutter_ai_provider_anthropic flutter_ai_tools: - path: ../flutter_ai/packages/flutter_ai_tools - # Syntax highlighting for AI code blocks (port of highlight.js, pure Dart). - highlight: ^0.7.0 + path: packages/flutter_ai/flutter_ai_tools flutter_secure_storage: ^9.2.4 flutter_secure_storage_ohos: # HarmonyOS 安全存储 git: url: https://gitcode.com/openharmony-sig/fluttertpc_flutter_secure_storage.git path: flutter_secure_storage_ohos ref: br_v9.2.2_ohos + # Syntax highlighting for AI code blocks (port of highlight.js, pure Dart). + highlight: ^0.7.0 http: ^1.4.0 intl: ^0.20.2 open_filex: ^4.5.0 @@ -67,17 +72,6 @@ dependencies: url: https://gitcode.com/openharmony-tpc/flutter_packages.git path: packages/webview_flutter/webview_flutter ref: br_webview_flutter-v4.13.0_ohos - # flutter_ai element showcase (ported from ../flutter_ai demo). All three - # packages are pure Dart/Flutter (no platform channels) so they run on every - # techpie target incl. OHOS with no extra override. `highlight` is used by the - # demo's code_highlighter.dart and is also pure Dart. - flutter_ai_core: - path: ../flutter_ai/packages/flutter_ai_core - flutter_ai_client: - path: ../flutter_ai/packages/flutter_ai_client - flutter_ai_elements: - path: ../flutter_ai/packages/flutter_ai_elements - highlight: ^0.7.0 dependency_overrides: # Linux: use forked desktop_webview_window (WebKitGTK) for webview @@ -88,6 +82,14 @@ dependency_overrides: url: https://github.com/HeZeBang/desktop_webview_window_fork.git ref: master + # The flutter_ai packages declare transitive deps on flutter_ai_core/client + # as hosted (pub.dev) ranges, but we consume them from the vendored copies. + # Force every transitive reference to the local path so version solving agrees. + flutter_ai_client: + path: packages/flutter_ai/flutter_ai_client + flutter_ai_core: + path: packages/flutter_ai/flutter_ai_core + # OpenHarmony-SIG forks add OHOS platform implementations for plugins # whose upstream pub.dev releases don't ship them. Without these, the # MethodChannel calls on OHOS throw MissingPluginException at boot. @@ -108,13 +110,6 @@ dependency_overrides: url: https://gitcode.com/openharmony-tpc/flutter_packages.git path: packages/webview_flutter/webview_flutter_platform_interface ref: br_webview_flutter-v4.13.0_ohos - # The flutter_ai packages declare transitive deps on flutter_ai_core/client - # as hosted (pub.dev) ranges, but we consume them from a local path checkout. - # Force every transitive reference to the local path so version solving agrees. - flutter_ai_core: - path: ../flutter_ai/packages/flutter_ai_core - flutter_ai_client: - path: ../flutter_ai/packages/flutter_ai_client # flutter_secure_storage: # git: # url: https://gitcode.com/openharmony-sig/fluttertpc_flutter_secure_storage.git From 864bcccf1bbdc522e9c7d8a210d9b61f42b6a112 Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Sun, 26 Jul 2026 18:59:23 +0800 Subject: [PATCH 10/15] style: fix analyzer lints in AI pages/tests Trailing commas, const constructors, import ordering, unnecessary imports, pubspec dependency sorting, and discarded_futures (await or unawaited as appropriate). Co-Authored-By: Claude Fable 5 --- lib/models/ai_chat.dart | 3 +-- lib/pages/ai_assistant_page.dart | 6 +++--- lib/pages/ai_config_page.dart | 1 - lib/pages/ai_demo/ai_demo_page.dart | 30 ++++++++++++++-------------- lib/pages/ai_demo/demo_data.dart | 4 ++-- lib/pages/ai_demo/demo_provider.dart | 4 ++-- lib/services/ai_service.dart | 1 - test/ai_service_test.dart | 14 ++++++------- 8 files changed, 30 insertions(+), 33 deletions(-) diff --git a/lib/models/ai_chat.dart b/lib/models/ai_chat.dart index 5feeff9..ad3e683 100644 --- a/lib/models/ai_chat.dart +++ b/lib/models/ai_chat.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_ai_core/flutter_ai_core.dart'; // Re-export the library's chat-domain types so the rest of TechPie keeps a // single import (`ai_chat.dart`) for everything AI — the parts-based @@ -13,8 +14,6 @@ export 'package:flutter_ai_core/flutter_ai_core.dart' AiPart, TextPart; -import 'package:flutter_ai_core/flutter_ai_core.dart'; - /// A persisted conversation thread. /// /// The library's [AiConversation] only carries `{id, messages}` — it has no diff --git a/lib/pages/ai_assistant_page.dart b/lib/pages/ai_assistant_page.dart index 7b3cdcb..b03caf6 100644 --- a/lib/pages/ai_assistant_page.dart +++ b/lib/pages/ai_assistant_page.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter_ai_client/flutter_ai_client.dart'; import 'package:flutter_ai_elements/flutter_ai_elements.dart'; import '../models/ai_chat.dart'; @@ -203,8 +202,9 @@ class _AiAssistantPageState extends State { padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), child: AiErrorBanner( message: error, - onRetry: - aiService.isStreaming ? null : () => _retry(aiService), + onRetry: aiService.isStreaming + ? null + : () => unawaited(_retry(aiService)), onDismiss: () => _dismissError(aiService), ), ); diff --git a/lib/pages/ai_config_page.dart b/lib/pages/ai_config_page.dart index d196343..f7fa0b2 100644 --- a/lib/pages/ai_config_page.dart +++ b/lib/pages/ai_config_page.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter_ai_core/flutter_ai_core.dart'; import 'package:flutter_ai_provider_anthropic/flutter_ai_provider_anthropic.dart'; import '../models/ai_chat.dart'; diff --git a/lib/pages/ai_demo/ai_demo_page.dart b/lib/pages/ai_demo/ai_demo_page.dart index 5b541ae..743c0b1 100644 --- a/lib/pages/ai_demo/ai_demo_page.dart +++ b/lib/pages/ai_demo/ai_demo_page.dart @@ -270,7 +270,7 @@ class _GalleryButton extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.grid_view_rounded, - size: 14, color: theme.onAccentColor), + size: 14, color: theme.onAccentColor,), const SizedBox(width: 6), Text( 'Every element', @@ -462,9 +462,9 @@ class _ChatScreenState extends State { const SizedBox(width: 8), Text('flutter_ai', style: TextStyle( - fontSize: 13, fontWeight: FontWeight.w600, color: subdued)), + fontSize: 13, fontWeight: FontWeight.w600, color: subdued,),), ], - )); + ),); for (final part in message.parts) { switch (part) { @@ -479,7 +479,7 @@ class _ChatScreenState extends State { toolCalls.length > 1 ? AiToolGroup(calls: toolCalls, results: results) : AiToolInvocation( - call: part, result: results[part.toolCallId]), + call: part, result: results[part.toolCallId],), ); } case ToolResultPart(): @@ -489,8 +489,8 @@ class _ChatScreenState extends State { add(SizedBox( width: 260, child: AiImage( - url: part.url, bytes: part.bytes, aspectRatio: 16 / 9), - )); + url: part.url, bytes: part.bytes, aspectRatio: 16 / 9,), + ),); } else { add(AiAttachment(file: part)); } @@ -512,15 +512,15 @@ class _ChatScreenState extends State { toolRunner.resolveConfirmation(call.toolCallId, approved: true), onDeny: () => toolRunner.resolveConfirmation(call.toolCallId, approved: false), - )); + ),); } if (sources.isNotEmpty) { add(AiSources( sources: sources, onTap: (source) => unawaited( - launchUrl(source.url, mode: LaunchMode.externalApplication)), - )); + launchUrl(source.url, mode: LaunchMode.externalApplication),), + ),); } if (message.status == AiMessageStatus.complete) { @@ -545,7 +545,7 @@ class _ChatScreenState extends State { onNext: () => controller.selectBranch(controller.branchIndex + 1), ), ], - )); + ),); } return Padding( @@ -591,7 +591,7 @@ class _ChatScreenState extends State { unawaited( controller.sendText('Summarize this article', attachments: const [ FilePart(mediaType: 'application/pdf', name: 'article.pdf'), - ]), + ],), ); } else { unawaited(controller.sendText(text)); @@ -640,10 +640,10 @@ class _EditMessageDialogState extends State<_EditMessageDialog> { actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('Cancel')), + child: const Text('Cancel'),), FilledButton( onPressed: () => Navigator.pop(context, _field.text), - child: const Text('Save')), + child: const Text('Save'),), ], ); } @@ -680,7 +680,7 @@ class _BrandGlyph extends StatelessWidget { ], ), child: Icon(Icons.auto_awesome, - size: size * 0.5, color: theme.onAccentColor), + size: size * 0.5, color: theme.onAccentColor,), ); } } @@ -710,7 +710,7 @@ class GalleryScreen extends StatelessWidget { fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF9893A8), - letterSpacing: 0.2)), + letterSpacing: 0.2,),), const SizedBox(height: 10), item.child, ], diff --git a/lib/pages/ai_demo/demo_data.dart b/lib/pages/ai_demo/demo_data.dart index 39e1473..81d8b4c 100644 --- a/lib/pages/ai_demo/demo_data.dart +++ b/lib/pages/ai_demo/demo_data.dart @@ -189,7 +189,7 @@ List galleryItems() => [ initiallyExpanded: true, steps: [ AiThoughtStep( - label: 'Search the web', detail: 'flutter stream tokens'), + label: 'Search the web', detail: 'flutter stream tokens',), AiThoughtStep(label: 'Read top results'), AiThoughtStep(label: 'Synthesize an answer', isActive: true), ], @@ -287,7 +287,7 @@ List galleryItems() => [ suggestions: const [ 'Summarize this', 'Translate to French', - 'Explain' + 'Explain', ], onSelected: (_) {}, ), diff --git a/lib/pages/ai_demo/demo_provider.dart b/lib/pages/ai_demo/demo_provider.dart index dcbd803..0004dbd 100644 --- a/lib/pages/ai_demo/demo_provider.dart +++ b/lib/pages/ai_demo/demo_provider.dart @@ -47,7 +47,7 @@ class DemoChatProvider implements LlmProvider { } Stream _textChunks(String id, String text, - {Duration? chunkDelay}) async* { + {Duration? chunkDelay,}) async* { final words = text.split(' '); final d = chunkDelay ?? const Duration(milliseconds: 40); for (var i = 0; i < words.length; i++) { @@ -119,7 +119,7 @@ class DemoChatProvider implements LlmProvider { 'count': 12, 'topRate': 210, }, - tag: 't2'); + tag: 't2',); yield* _textChunks( id, '## Day 1\n' diff --git a/lib/services/ai_service.dart b/lib/services/ai_service.dart index b711612..453702d 100644 --- a/lib/services/ai_service.dart +++ b/lib/services/ai_service.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter_ai_client/flutter_ai_client.dart'; -import 'package:flutter_ai_core/flutter_ai_core.dart'; import 'package:flutter_ai_provider_anthropic/flutter_ai_provider_anthropic.dart'; import 'package:flutter_ai_tools/flutter_ai_tools.dart'; diff --git a/test/ai_service_test.dart b/test/ai_service_test.dart index e369f9d..9d6b001 100644 --- a/test/ai_service_test.dart +++ b/test/ai_service_test.dart @@ -60,7 +60,7 @@ void main() { title: 'hello', updatedAt: DateTime.utc(2026, 7, 26), messages: [ - AiMessage( + const AiMessage( id: 'm1', role: AiRole.user, parts: [TextPart('hi there')], @@ -86,10 +86,10 @@ void main() { 'messages': [ {'id': 'a', 'role': 'user', 'status': 'complete', 'parts': [ {'type': 'text', 'text': ''}, - ]}, + ],}, {'id': 'b', 'role': 'assistant', 'status': 'complete', 'parts': [ {'type': 'text', 'text': 'real reply'}, - ]}, + ],}, ], }; final restored = AiThread.fromJson(raw); @@ -159,17 +159,17 @@ void main() { expect(created.id, isNot(first)); }); - test('renameConversation updates title and is reflected in the list', () { + test('renameConversation updates title and is reflected in the list', () async { final id = ai.currentConversation!.id; - ai.renameConversation(id, '我的对话'); + await ai.renameConversation(id, '我的对话'); expect(ai.currentConversation!.title, '我的对话'); }); - test('deleteConversation on the current thread re-points to another', () { + test('deleteConversation on the current thread re-points to another', () async { final a = ai.newConversation(); final b = ai.newConversation(); expect(ai.currentConversation!.id, b.id); - ai.deleteConversation(b.id); + await ai.deleteConversation(b.id); expect(ai.conversations.any((c) => c.id == b.id), isFalse); expect(ai.currentConversation, isNotNull); expect(ai.currentConversation!.id, isNot(b.id)); From 278ecfd354a4379e7a13513543cca7f987f554be Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Sun, 26 Jul 2026 19:02:50 +0800 Subject: [PATCH 11/15] refactor: consume flutter_ai from the HeZeBang/flutter_ai fork The local fixes (OHOS haptics compile, dangling tool-call settlement) are now pushed to the fork, so replace the vendored copies with git dependencies pinned to commit 589a47d. Drops packages/flutter_ai and its analyzer exclude. Co-Authored-By: Claude Fable 5 --- analysis_options.yaml | 3 - .../flutter_ai/flutter_ai_client/CHANGELOG.md | 166 -- packages/flutter_ai/flutter_ai_client/LICENSE | 29 - .../flutter_ai/flutter_ai_client/README.md | 97 - .../flutter_ai_client/analysis_options.yaml | 2 - .../example/flutter_ai_client_example.dart | 107 -- .../lib/flutter_ai_client.dart | 19 - .../lib/src/chat_observer.dart | 50 - .../lib/src/chat_status.dart | 23 - .../flutter_ai_client/lib/src/chat_store.dart | 270 --- .../lib/src/context_strategy.dart | 154 -- .../flutter_ai_client/lib/src/follow_ups.dart | 61 - .../lib/src/use_chat_controller.dart | 944 ---------- .../flutter_ai/flutter_ai_client/pubspec.yaml | 39 - .../test/context_strategy_test.dart | 76 - .../test/follow_ups_and_store_test.dart | 134 -- .../test/use_chat_controller_test.dart | 1614 ----------------- .../flutter_ai/flutter_ai_core/CHANGELOG.md | 128 -- packages/flutter_ai/flutter_ai_core/LICENSE | 29 - packages/flutter_ai/flutter_ai_core/README.md | 84 - .../flutter_ai_core/analysis_options.yaml | 3 - .../example/flutter_ai_core_example.dart | 91 - .../flutter_ai_core/lib/flutter_ai_core.dart | 38 - .../lib/src/internal/equality.dart | 53 - .../lib/src/models/ai_conversation.dart | 84 - .../lib/src/models/ai_message.dart | 184 -- .../lib/src/models/ai_part.dart | 497 ----- .../lib/src/models/ai_role.dart | 35 - .../lib/src/models/finish_reason.dart | 39 - .../lib/src/models/tool_call_state.dart | 38 - .../lib/src/models/tool_definition.dart | 57 - .../flutter_ai_core/lib/src/models/usage.dart | 136 -- .../lib/src/provider/ai_capabilities.dart | 69 - .../lib/src/provider/ai_request_options.dart | 132 -- .../lib/src/provider/ai_response_format.dart | 40 - .../lib/src/provider/generate_object.dart | 129 -- .../lib/src/provider/llm_exception.dart | 57 - .../lib/src/provider/llm_provider.dart | 33 - .../lib/src/rendering/text_renderer.dart | 18 - .../lib/src/streaming/ai_stream_event.dart | 497 ----- .../lib/src/streaming/json_accumulator.dart | 261 --- .../lib/src/streaming/message_processor.dart | 357 ---- .../lib/src/streaming/mutation_result.dart | 28 - .../lib/src/tools/json_schema_validator.dart | 190 -- .../flutter_ai/flutter_ai_core/pubspec.yaml | 34 - .../test/ai_capabilities_test.dart | 139 -- .../test/ai_stream_event_test.dart | 51 - .../test/json_accumulator_test.dart | 134 -- .../test/json_schema_validator_test.dart | 103 -- .../test/message_processor_perf_test.dart | 45 - .../test/message_processor_test.dart | 396 ---- .../flutter_ai_core/test/models_test.dart | 187 -- .../flutter_ai_core/test/usage_test.dart | 130 -- .../flutter_ai_elements/CHANGELOG.md | 215 --- .../flutter_ai/flutter_ai_elements/LICENSE | 29 - .../flutter_ai/flutter_ai_elements/README.md | 168 -- .../flutter_ai_elements/analysis_options.yaml | 2 - .../example/flutter_ai_elements_example.dart | 73 - .../lib/flutter_ai_elements.dart | 56 - .../src/generative_ui/ai_widget_registry.dart | 66 - .../lib/src/l10n/ai_localizations.dart | 201 -- .../lib/src/rendering/ai_text_renderer.dart | 24 - .../lib/src/theme/ai_theme_extension.dart | 358 ---- .../lib/src/widgets/ai_animated_response.dart | 327 ---- .../lib/src/widgets/ai_attachment.dart | 98 - .../lib/src/widgets/ai_avatar.dart | 53 - .../lib/src/widgets/ai_branch.dart | 99 - .../lib/src/widgets/ai_chain_of_thought.dart | 190 -- .../lib/src/widgets/ai_chat.dart | 364 ---- .../lib/src/widgets/ai_chat_view.dart | 88 - .../lib/src/widgets/ai_code_block.dart | 98 - .../lib/src/widgets/ai_composer.dart | 518 ------ .../lib/src/widgets/ai_confirmation.dart | 198 -- .../lib/src/widgets/ai_context_meter.dart | 84 - .../lib/src/widgets/ai_conversation_list.dart | 117 -- .../lib/src/widgets/ai_conversation_view.dart | 165 -- .../lib/src/widgets/ai_empty_state.dart | 154 -- .../lib/src/widgets/ai_error_banner.dart | 61 - .../lib/src/widgets/ai_haptics.dart | 23 - .../lib/src/widgets/ai_image.dart | 129 -- .../lib/src/widgets/ai_inline_citation.dart | 49 - .../lib/src/widgets/ai_live_controller.dart | 203 --- .../lib/src/widgets/ai_live_session.dart | 409 ----- .../lib/src/widgets/ai_loader.dart | 93 - .../lib/src/widgets/ai_message_actions.dart | 235 --- .../lib/src/widgets/ai_message_bubble.dart | 258 --- .../lib/src/widgets/ai_model_selector.dart | 153 -- .../lib/src/widgets/ai_orb.dart | 101 -- .../lib/src/widgets/ai_prompt_input.dart | 88 - .../lib/src/widgets/ai_reasoning.dart | 88 - .../lib/src/widgets/ai_response.dart | 662 ------- .../lib/src/widgets/ai_shimmer.dart | 101 -- .../lib/src/widgets/ai_sources.dart | 229 --- .../lib/src/widgets/ai_suggestions.dart | 77 - .../lib/src/widgets/ai_task.dart | 184 -- .../lib/src/widgets/ai_tool_group.dart | 45 - .../lib/src/widgets/ai_tool_invocation.dart | 190 -- .../flutter_ai_elements/pubspec.yaml | 52 - .../screenshots/element_code_block.png | Bin 7158 -> 0 bytes .../screenshots/element_message_assistant.png | Bin 19230 -> 0 bytes .../screenshots/element_reasoning.png | Bin 9727 -> 0 bytes .../screenshots/element_sources.png | Bin 4518 -> 0 bytes .../screenshots/element_tool_invocation.png | Bin 16093 -> 0 bytes .../test/ai_chat_scroll_test.dart | 112 -- .../test/dogfood_apis_test.dart | 153 -- .../test/widgets_test.dart | 1019 ----------- .../CHANGELOG.md | 111 -- .../flutter_ai_provider_anthropic/LICENSE | 29 - .../flutter_ai_provider_anthropic/README.md | 80 - .../analysis_options.yaml | 2 - ...flutter_ai_provider_anthropic_example.dart | 33 - .../lib/flutter_ai_provider_anthropic.dart | 14 - .../lib/src/anthropic_event_parser.dart | 199 -- .../lib/src/anthropic_provider.dart | 366 ---- .../lib/src/default_http_client.dart | 4 - .../lib/src/default_http_client_io.dart | 5 - .../lib/src/default_http_client_web.dart | 11 - .../lib/src/http_retry.dart | 80 - .../pubspec.yaml | 35 - .../test/anthropic_provider_test.dart | 652 ------- .../test/default_http_client_test.dart | 11 - .../test/live_test.dart | 34 - .../test/reasoning_effort_test.dart | 98 - .../flutter_ai/flutter_ai_tools/CHANGELOG.md | 34 - packages/flutter_ai/flutter_ai_tools/LICENSE | 29 - .../flutter_ai/flutter_ai_tools/README.md | 80 - .../flutter_ai_tools/analysis_options.yaml | 2 - .../example/flutter_ai_tools_example.dart | 39 - .../lib/flutter_ai_tools.dart | 15 - .../lib/src/tool_registry.dart | 59 - .../flutter_ai_tools/lib/src/tool_spec.dart | 44 - .../flutter_ai_tools/lib/src/web_search.dart | 90 - .../flutter_ai/flutter_ai_tools/pubspec.yaml | 32 - .../flutter_ai_tools/test/tools_test.dart | 151 -- pubspec.lock | 40 +- pubspec.yaml | 45 +- 136 files changed, 58 insertions(+), 19139 deletions(-) delete mode 100644 packages/flutter_ai/flutter_ai_client/CHANGELOG.md delete mode 100644 packages/flutter_ai/flutter_ai_client/LICENSE delete mode 100644 packages/flutter_ai/flutter_ai_client/README.md delete mode 100644 packages/flutter_ai/flutter_ai_client/analysis_options.yaml delete mode 100644 packages/flutter_ai/flutter_ai_client/example/flutter_ai_client_example.dart delete mode 100644 packages/flutter_ai/flutter_ai_client/lib/flutter_ai_client.dart delete mode 100644 packages/flutter_ai/flutter_ai_client/lib/src/chat_observer.dart delete mode 100644 packages/flutter_ai/flutter_ai_client/lib/src/chat_status.dart delete mode 100644 packages/flutter_ai/flutter_ai_client/lib/src/chat_store.dart delete mode 100644 packages/flutter_ai/flutter_ai_client/lib/src/context_strategy.dart delete mode 100644 packages/flutter_ai/flutter_ai_client/lib/src/follow_ups.dart delete mode 100644 packages/flutter_ai/flutter_ai_client/lib/src/use_chat_controller.dart delete mode 100644 packages/flutter_ai/flutter_ai_client/pubspec.yaml delete mode 100644 packages/flutter_ai/flutter_ai_client/test/context_strategy_test.dart delete mode 100644 packages/flutter_ai/flutter_ai_client/test/follow_ups_and_store_test.dart delete mode 100644 packages/flutter_ai/flutter_ai_client/test/use_chat_controller_test.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/CHANGELOG.md delete mode 100644 packages/flutter_ai/flutter_ai_core/LICENSE delete mode 100644 packages/flutter_ai/flutter_ai_core/README.md delete mode 100644 packages/flutter_ai/flutter_ai_core/analysis_options.yaml delete mode 100644 packages/flutter_ai/flutter_ai_core/example/flutter_ai_core_example.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/lib/flutter_ai_core.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/internal/equality.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/models/ai_conversation.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/models/ai_message.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/models/ai_part.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/models/ai_role.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/models/finish_reason.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/models/tool_call_state.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/models/tool_definition.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/models/usage.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/provider/ai_capabilities.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/provider/ai_request_options.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/provider/ai_response_format.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/provider/generate_object.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/provider/llm_exception.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/provider/llm_provider.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/rendering/text_renderer.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/streaming/ai_stream_event.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/streaming/json_accumulator.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/streaming/message_processor.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/streaming/mutation_result.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/lib/src/tools/json_schema_validator.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/pubspec.yaml delete mode 100644 packages/flutter_ai/flutter_ai_core/test/ai_capabilities_test.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/test/ai_stream_event_test.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/test/json_accumulator_test.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/test/json_schema_validator_test.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/test/message_processor_perf_test.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/test/message_processor_test.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/test/models_test.dart delete mode 100644 packages/flutter_ai/flutter_ai_core/test/usage_test.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/CHANGELOG.md delete mode 100644 packages/flutter_ai/flutter_ai_elements/LICENSE delete mode 100644 packages/flutter_ai/flutter_ai_elements/README.md delete mode 100644 packages/flutter_ai/flutter_ai_elements/analysis_options.yaml delete mode 100644 packages/flutter_ai/flutter_ai_elements/example/flutter_ai_elements_example.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/flutter_ai_elements.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/generative_ui/ai_widget_registry.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/l10n/ai_localizations.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/rendering/ai_text_renderer.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/theme/ai_theme_extension.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_animated_response.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_attachment.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_avatar.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_branch.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_chain_of_thought.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_chat.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_chat_view.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_code_block.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_composer.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_confirmation.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_context_meter.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_conversation_list.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_conversation_view.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_empty_state.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_error_banner.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_haptics.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_image.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_inline_citation.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_live_controller.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_live_session.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_loader.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_message_actions.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_message_bubble.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_model_selector.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_orb.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_prompt_input.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_reasoning.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_response.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_shimmer.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_sources.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_suggestions.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_task.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_tool_group.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_tool_invocation.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/pubspec.yaml delete mode 100644 packages/flutter_ai/flutter_ai_elements/screenshots/element_code_block.png delete mode 100644 packages/flutter_ai/flutter_ai_elements/screenshots/element_message_assistant.png delete mode 100644 packages/flutter_ai/flutter_ai_elements/screenshots/element_reasoning.png delete mode 100644 packages/flutter_ai/flutter_ai_elements/screenshots/element_sources.png delete mode 100644 packages/flutter_ai/flutter_ai_elements/screenshots/element_tool_invocation.png delete mode 100644 packages/flutter_ai/flutter_ai_elements/test/ai_chat_scroll_test.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/test/dogfood_apis_test.dart delete mode 100644 packages/flutter_ai/flutter_ai_elements/test/widgets_test.dart delete mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/CHANGELOG.md delete mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/LICENSE delete mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/README.md delete mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/analysis_options.yaml delete mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/example/flutter_ai_provider_anthropic_example.dart delete mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/lib/flutter_ai_provider_anthropic.dart delete mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/anthropic_event_parser.dart delete mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/anthropic_provider.dart delete mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/default_http_client.dart delete mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/default_http_client_io.dart delete mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/default_http_client_web.dart delete mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/http_retry.dart delete mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/pubspec.yaml delete mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/test/anthropic_provider_test.dart delete mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/test/default_http_client_test.dart delete mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/test/live_test.dart delete mode 100644 packages/flutter_ai/flutter_ai_provider_anthropic/test/reasoning_effort_test.dart delete mode 100644 packages/flutter_ai/flutter_ai_tools/CHANGELOG.md delete mode 100644 packages/flutter_ai/flutter_ai_tools/LICENSE delete mode 100644 packages/flutter_ai/flutter_ai_tools/README.md delete mode 100644 packages/flutter_ai/flutter_ai_tools/analysis_options.yaml delete mode 100644 packages/flutter_ai/flutter_ai_tools/example/flutter_ai_tools_example.dart delete mode 100644 packages/flutter_ai/flutter_ai_tools/lib/flutter_ai_tools.dart delete mode 100644 packages/flutter_ai/flutter_ai_tools/lib/src/tool_registry.dart delete mode 100644 packages/flutter_ai/flutter_ai_tools/lib/src/tool_spec.dart delete mode 100644 packages/flutter_ai/flutter_ai_tools/lib/src/web_search.dart delete mode 100644 packages/flutter_ai/flutter_ai_tools/pubspec.yaml delete mode 100644 packages/flutter_ai/flutter_ai_tools/test/tools_test.dart diff --git a/analysis_options.yaml b/analysis_options.yaml index 9aa05d5..73e3edc 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -4,9 +4,6 @@ analyzer: exclude: - build/** - .dart_tool/** - # Vendored upstream code (ananmouaz/flutter_ai + local fixes); keeps its - # own style, not held to techpie's lint set. - - packages/flutter_ai/** language: strict-casts: true strict-inference: true diff --git a/packages/flutter_ai/flutter_ai_client/CHANGELOG.md b/packages/flutter_ai/flutter_ai_client/CHANGELOG.md deleted file mode 100644 index c2cab4b..0000000 --- a/packages/flutter_ai/flutter_ai_client/CHANGELOG.md +++ /dev/null @@ -1,166 +0,0 @@ -# Changelog - -## 0.3.0 - -- Add `UseChatController.load(AiConversation)` — swaps the transcript in place - (cancelling any in-flight turn) so hosts can switch threads without disposing - and recreating the controller. (#136) -- Add `KeyValueChatThreadStore`, a persistent `ChatThreadStore` backed by any - `KeyValueStore` you supply (`shared_preferences`, a file, secure storage, …), - so chat history survives app restarts without the package depending on a - storage plugin. (#137) -- Add `suggestFollowUps(conversation, provider, {count, options})` — generates - contextual follow-up prompts (for the `AiSuggestions` strip) via a one-off - model call, so follow-ups can be dynamic instead of a static set. (#140) - -## 0.2.5 - -- Fix: the controller no longer reports `idle` while the agent loop runs its tool - executor between model calls. A new `ChatStatus.executingTools` (included in - `isBusy`) keeps the turn marked busy, so UIs don't re-enable input mid-turn and - `attachStore` doesn't persist a transcript with unanswered tool calls. -- Fix: `selectBranch` is now a no-op whenever a turn is in flight (including the - tool-execution phase), preventing a mid-loop branch switch from corrupting the - transcript. -- Fix: the default message-id generator now uses a per-controller random prefix - (`msg--`) instead of restarting at `msg-0`, so seeding a controller - with a rehydrated `ChatStore` transcript no longer produces colliding ids. Pass - a custom `idGenerator` to override. -- Fix: interrupting a stream with `submit`/`addToolResults`, and an in-band - `StreamErrorEvent` with no `messageId`, no longer leave the interrupted message - stuck in the `streaming` state (a permanent typing indicator that also got - persisted). The trailing message is now finalized. -- Fix: a synchronous throw from `provider.send` or a `trimHistory` callback is - now caught and surfaced as `ChatStatus.error` (with the turn future - completing), instead of escaping — which left the status stuck at `submitted`, - or became an unhandled zone error inside the agent loop. - -## 0.2.4 - -- `keepLastWithSummary`: a context strategy that folds a caller-supplied rolling - summary of older turns into the request (as a synthetic `system` message) - instead of silently dropping them — compaction that preserves load-bearing - context. Your app owns producing/persisting the summary (any model or - heuristic, saved via `ChatStore`); the strategy just injects it and windows - the recent messages. No memory service baked in. - -## 0.2.3 - -- Observability: `UseChatController(observer:)` accepts a `ChatObserver` that - receives the agent lifecycle — turn start, each model request, response with - token `AiUsage` + finish reason, tool calls/results, errors, and turn end. - Shaped after the OpenTelemetry GenAI semantic conventions, with no OTel - dependency — map the callbacks onto your own tracer or analytics sink. Opt-in - and no-op by default. - -## 0.2.2 - -- Agent guardrail: `maxIdenticalToolCalls` (opt-in, 0 = off) halts the agent - loop with a typed `AgentLoopException` when the model keeps requesting the - same tool call (identical name + args) after it has already run that many - times in a turn — a runaway-loop guard that stops before burning tokens up to - `maxSteps`. Complements the existing `tokenBudget` token ceiling. - -## 0.2.1 - -- Fix: raise the `flutter_ai_core` lower bound to `^0.1.11` — the controller - uses `AiUsage` (added in core 0.1.3) and later APIs, so the old `^0.1.0` - bound let dependency downgrades resolve a core that couldn't compile. -- Docs: shortened the pubspec `description` into pub.dev's 60–180 character - window. - -## 0.2.0 - -- **BREAKING**: `onToolCalls` now receives a second argument, an - `AiToolCallSignal`. The controller cancels it when the turn is stopped, - replaced, or disposed while the executor is still running, so long-running - tools can abort in-flight work instead of finishing only to have their result - discarded. Observe it via `signal.isCancelled`, `await signal.whenCancelled`, - or `signal.throwIfCancelled()`. - - Migration: change `onToolCalls: (calls) async { ... }` to - `onToolCalls: (calls, signal) async { ... }`. Honoring the signal is optional; - adding the parameter is required. - -## 0.1.8 - -- Docs: refreshed the README listing with a hero image, screenshot gallery, - and badges (consistent across the package family). No code changes. - -## 0.1.7 - -- Tool-argument validation (`validateToolArgs`, default on): the agent loop - validates each model-produced tool call against the tool's - `parametersSchema` before running it. Calls with invalid args are not - executed — an error `ToolResultPart` describing the violations is fed back so - the model can self-correct (bounded by `maxSteps`). Opt out with - `validateToolArgs: false`. -- History trimming (`trimHistory`): a pluggable strategy that maps the full - conversation to the (smaller) conversation actually sent to the provider; the - stored transcript is never trimmed. Ships with `keepLastMessages(n)` and - `trimToApproxTokenBudget(maxTokens)` strategies (both preserve the system - prefix and avoid orphaning tool results). - -## 0.1.6 - -- Declare supported platforms (Android/iOS/web/macOS/Windows/Linux) for the - pub.dev listing. - -## 0.1.5 - -- Turn-sequence guard: a late event from a cancelled stream can no longer mutate - the conversation or leak onto the `events` stream after a new turn starts. -- `maxBranches` (default 20) caps retained regenerations so a long chat can't - grow without bound. -- `tokenBudget`: stop the agent loop once cumulative usage exceeds the budget (a - cost ceiling on top of `maxSteps`). - -## 0.1.4 - -- Thread management: `ChatThread`, a `ChatThreadStore` (list/delete on top of - `ChatStore`), `autoTitle(conversation)`, and an `InMemoryChatThreadStore` for - demos/tests — enough to drive a multi-conversation sidebar. - -## 0.1.3 - -- `totalUsage` getter on `UseChatController`: summed `AiUsage` across the - conversation (feed an `AiContextMeter` or estimate cost). - -## 0.1.2 - -- Agent loop: pass `onToolCalls` (and optional `maxSteps`, default 8) to - `UseChatController` and it becomes an automatic agent — when a model turn ends - with unanswered tool calls it runs the executor, feeds the results back, and - re-prompts until there are no pending calls or `maxSteps` model calls have run. - Without `onToolCalls` behavior is unchanged (the host drives tools manually via - `addToolResults`). Cancellation/stop aborts the loop mid-flight. - -## 0.1.1 - -- `editMessage(id, text)` / `editLastUserMessage(text)`: edit a sent user - message (keeping attachments), discard everything after it, and re-run from - that point — starting a fresh branch set. Closes the previously dead "edit" - affordance in `AiMessageActions`. -- Persistence seam: a `ChatStore` interface (`load`/`save`) plus an - `attachStore(controller, store, id)` helper that debounce-auto-saves the - conversation once each turn settles. History is still in memory by default; - this makes saving/restoring a thread a few lines. `AiConversation` is already - JSON-serializable, so a store is just encode/decode around your storage. - -## 0.1.0 - -Initial release. - -- `UseChatController` — a `ChangeNotifier` wrapping any `LlmProvider`: - - optimistic, synchronous user-message append - - `sendText` / `submit` / `stop` / `regenerate` / `clear` - - live model/provider switching (`setProvider`, `setOptions`, `setTools`) - - coalesced, injectable notification scheduling (frame-batched streaming) - - raw `events` stream escape hatch -- `ChatStatus` (idle / submitted / streaming / error). -- Exposes `stackTrace` alongside `error` so failures can be reported with full - context. -- A fatal (message-scoped) `StreamErrorEvent` tears down the active turn so a - misbehaving provider can't keep mutating the conversation after a fatal error; - tool-scoped errors remain non-fatal. -- Re-exports `flutter_ai_core`. diff --git a/packages/flutter_ai/flutter_ai_client/LICENSE b/packages/flutter_ai/flutter_ai_client/LICENSE deleted file mode 100644 index 56023ee..0000000 --- a/packages/flutter_ai/flutter_ai_client/LICENSE +++ /dev/null @@ -1,29 +0,0 @@ -BSD 3-Clause License - -Copyright (c) 2026, The flutter_ai authors. -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/flutter_ai/flutter_ai_client/README.md b/packages/flutter_ai/flutter_ai_client/README.md deleted file mode 100644 index 050088d..0000000 --- a/packages/flutter_ai/flutter_ai_client/README.md +++ /dev/null @@ -1,97 +0,0 @@ -

flutter_ai_client

- -

The useChat controller for Flutter — wrap any LlmProvider and get optimistic send, batched streaming, cancel, and regenerate as a plain Listenable. No state-manager lock-in.

- -

- flutter_ai: a streaming answer with chain-of-thought and a generative-UI task card -

- -

- flutter_ai_client on pub.dev - pub points - License: BSD-3-Clause -

- -

- Family: flutter_ai · - core · elements · - openai · anthropic · gemini · - tools · mcp · voice
- Recipes · Migrating from the Vercel AI SDK -

- -

The transcript above is driven by this package's UseChatController (rendered with flutter_ai_elements).

- ---- - -Provider-agnostic chat controller for the [`flutter_ai`](../../README.md) family. - -`UseChatController` wraps any `LlmProvider` (from `flutter_ai_core`) and exposes -conversation state as a plain `Listenable` — so it drops into `ListenableBuilder` -and adapts cleanly to Bloc, Riverpod, or Provider. **It bundles no state-manager -of its own.** - -## Features - -- **Optimistic send** — the user's message paints synchronously, before the - request is dispatched. -- **Streaming, batched** — events are folded by `flutter_ai_core`'s - `MessageProcessor`; notifications are coalesced (injectable scheduler) so high - token rates don't drop frames. -- **Full control** — `sendText`, `submit`, `stop`, `regenerate`, `clear`. -- **Provider/model switching** — `setProvider`, `setOptions`, `setTools` take - effect on the next turn without touching the UI. -- **Escape hatch** — a raw `events` stream for custom state layers. - -## Usage - -```dart -final controller = UseChatController( - provider: myProvider, // any LlmProvider - options: const AiRequestOptions(model: 'gpt-4o'), -); - -// Bind to the UI — rebuilds when the conversation changes. -ListenableBuilder( - listenable: controller, - builder: (context, _) => ListView( - children: [ - for (final m in controller.messages) Text('${m.role.name}: ${m.text}'), - ], - ), -); - -// Send / stop. -controller.sendText('Hello'); -if (controller.status.isBusy) controller.stop(); - -// Switch model live. -controller.setOptions(const AiRequestOptions(model: 'gpt-4o-mini')); -``` - -See [`example/`](example/) for a minimal end-to-end widget. - -## Implementing a provider - -A provider maps your backend's stream onto `flutter_ai_core`'s `AiStreamEvent`s: - -```dart -class MyProvider implements LlmProvider { - @override - Stream send( - AiConversation conversation, { - List? tools, - AiRequestOptions? options, - }) async* { - yield const MessageStarted(messageId: 'a1', role: AiRole.assistant); - yield const TextDelta(messageId: 'a1', delta: 'Hello!'); - yield const MessageFinished(messageId: 'a1', reason: FinishReason.stop); - } -} -``` - -## Status - -Published on pub.dev (see the CHANGELOG for versions); depends on `flutter_ai_core`. - -_If `flutter_ai` saves you time, you can [buy me a coffee ☕](https://ko-fi.com/ananmouaz)._ diff --git a/packages/flutter_ai/flutter_ai_client/analysis_options.yaml b/packages/flutter_ai/flutter_ai_client/analysis_options.yaml deleted file mode 100644 index bddaa31..0000000 --- a/packages/flutter_ai/flutter_ai_client/analysis_options.yaml +++ /dev/null @@ -1,2 +0,0 @@ -# Inherits the workspace-wide strict configuration. -include: ../../analysis_options.yaml diff --git a/packages/flutter_ai/flutter_ai_client/example/flutter_ai_client_example.dart b/packages/flutter_ai/flutter_ai_client/example/flutter_ai_client_example.dart deleted file mode 100644 index db266aa..0000000 --- a/packages/flutter_ai/flutter_ai_client/example/flutter_ai_client_example.dart +++ /dev/null @@ -1,107 +0,0 @@ -// A minimal chat UI bound to UseChatController via ListenableBuilder. -// -// The provider here echoes the user's text back one word at a time to simulate -// streaming. Swap in a real LlmProvider to talk to a model. -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_ai_client/flutter_ai_client.dart'; - -void main() => runApp(const _ExampleApp()); - -/// Echoes the user's last message back, streamed word by word. -class _EchoProvider implements LlmProvider { - @override - Stream send( - AiConversation conversation, { - List? tools, - AiRequestOptions? options, - }) async* { - const id = 'assistant'; - final prompt = conversation.lastMessage?.text ?? ''; - yield const MessageStarted(messageId: id, role: AiRole.assistant); - for (final word in prompt.split(' ')) { - await Future.delayed(const Duration(milliseconds: 60)); - yield TextDelta(messageId: id, delta: '$word '); - } - yield const MessageFinished(messageId: id, reason: FinishReason.stop); - } -} - -class _ExampleApp extends StatefulWidget { - const _ExampleApp(); - - @override - State<_ExampleApp> createState() => _ExampleAppState(); -} - -class _ExampleAppState extends State<_ExampleApp> { - late final UseChatController _controller = - UseChatController(provider: _EchoProvider()); - final TextEditingController _input = TextEditingController(); - - @override - void dispose() { - _controller.dispose(); - _input.dispose(); - super.dispose(); - } - - void _send() { - final text = _input.text.trim(); - if (text.isEmpty) return; - _input.clear(); - unawaited(_controller.sendText(text)); - } - - @override - Widget build(BuildContext context) { - return MaterialApp( - home: Scaffold( - appBar: AppBar(title: const Text('flutter_ai_client')), - body: Column( - children: [ - Expanded( - child: ListenableBuilder( - listenable: _controller, - builder: (context, _) => ListView( - children: [ - for (final message in _controller.messages) - ListTile( - title: Text(message.role.name), - subtitle: Text(message.text), - ), - ], - ), - ), - ), - Padding( - padding: const EdgeInsets.all(8), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _input, - onSubmitted: (_) => _send(), - ), - ), - // Swap Send for Stop while a response streams. - ListenableBuilder( - listenable: _controller, - builder: (context, _) => IconButton( - icon: Icon( - _controller.status.isBusy ? Icons.stop : Icons.send, - ), - onPressed: - _controller.status.isBusy ? _controller.stop : _send, - ), - ), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_client/lib/flutter_ai_client.dart b/packages/flutter_ai/flutter_ai_client/lib/flutter_ai_client.dart deleted file mode 100644 index 2f67880..0000000 --- a/packages/flutter_ai/flutter_ai_client/lib/flutter_ai_client.dart +++ /dev/null @@ -1,19 +0,0 @@ -/// Provider-agnostic chat controller for the `flutter_ai` family. -/// -/// Exposes `UseChatController`, a `ChangeNotifier` that wraps any -/// `LlmProvider` from `flutter_ai_core` with optimistic send, cancellation, -/// regeneration, model/provider switching, and frame-batched streaming — -/// without imposing a state-management library. -/// -/// Re-exports `flutter_ai_core` so consumers get the model and provider types -/// from a single import. -library; - -export 'package:flutter_ai_core/flutter_ai_core.dart'; - -export 'src/chat_observer.dart'; -export 'src/chat_status.dart'; -export 'src/chat_store.dart'; -export 'src/context_strategy.dart'; -export 'src/follow_ups.dart'; -export 'src/use_chat_controller.dart'; diff --git a/packages/flutter_ai/flutter_ai_client/lib/src/chat_observer.dart b/packages/flutter_ai/flutter_ai_client/lib/src/chat_observer.dart deleted file mode 100644 index 4067f34..0000000 --- a/packages/flutter_ai/flutter_ai_client/lib/src/chat_observer.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:flutter_ai_core/flutter_ai_core.dart'; - -/// Observes the agent lifecycle of a `UseChatController` for tracing, metrics, -/// and logging. -/// -/// The callbacks are shaped after the OpenTelemetry **GenAI semantic -/// conventions** — a turn wraps one or more model requests, each of which -/// finishes with a reason and token [AiUsage], with tool executions in between -/// — but this carries **no OpenTelemetry dependency**. Map the callbacks onto -/// your own tracer, span exporter, or analytics sink. Stamp your own timing on -/// receipt; the controller does not impose a clock. -/// -/// Every method has a no-op default, so subclasses override only what they -/// need. Callbacks are invoked synchronously from the controller; keep them -/// cheap (enqueue, don't block). -abstract class ChatObserver { - /// Const constructor for subclasses. - const ChatObserver(); - - /// A turn began: the user submitted, regenerated, retried, or edited. - /// [conversation] is the transcript at that moment. - void onTurnStart(AiConversation conversation) {} - - /// A model request is about to be dispatched. [step] is 1-based within the - /// turn (it increments for each tool-loop re-prompt). - void onModelRequest(int step) {} - - /// A model response finished streaming cleanly for [step]. [usage] and - /// [finishReason] are provided when the provider reported them. - void onModelResponse({ - required int step, - AiUsage? usage, - FinishReason? finishReason, - }) {} - - /// A batch of tool [calls] is about to be executed by the agent loop. - void onToolCalls(List calls) {} - - /// Tool [results] were produced (executed results plus any validation-error - /// results) and fed back to the model. - void onToolResults(List results) {} - - /// The turn failed with [error] (and [stackTrace] when available). Followed - /// by [onTurnEnd]. - void onError(Object error, StackTrace? stackTrace) {} - - /// The turn ended — success, stop, or error. [totalUsage] is the summed usage - /// across the whole conversation, or null if none was reported. - void onTurnEnd({AiUsage? totalUsage}) {} -} diff --git a/packages/flutter_ai/flutter_ai_client/lib/src/chat_status.dart b/packages/flutter_ai/flutter_ai_client/lib/src/chat_status.dart deleted file mode 100644 index 617b839..0000000 --- a/packages/flutter_ai/flutter_ai_client/lib/src/chat_status.dart +++ /dev/null @@ -1,23 +0,0 @@ -/// The lifecycle state of a chat turn driven by a controller. -enum ChatStatus { - /// No request is in flight. - idle, - - /// A request has been sent but no events have arrived yet. - submitted, - - /// Events are actively streaming in. - streaming, - - /// The model's stream finished with tool calls and the agent loop is running - /// the tool executor before re-prompting. The turn is still in flight. - executingTools, - - /// The last request failed. - error; - - /// Whether a turn is currently in flight ([submitted], [streaming], or - /// [executingTools]) — i.e. the model or its tools are still working. - bool get isBusy => - this == submitted || this == streaming || this == executingTools; -} diff --git a/packages/flutter_ai/flutter_ai_client/lib/src/chat_store.dart b/packages/flutter_ai/flutter_ai_client/lib/src/chat_store.dart deleted file mode 100644 index 9cc1fec..0000000 --- a/packages/flutter_ai/flutter_ai_client/lib/src/chat_store.dart +++ /dev/null @@ -1,270 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter_ai_client/src/use_chat_controller.dart'; -import 'package:flutter_ai_core/flutter_ai_core.dart'; - -/// Persists and restores [AiConversation]s so a chat survives app restarts. -/// -/// [UseChatController] keeps history in memory only; implement this against -/// whatever storage you like (a file, `shared_preferences`, SQLite, an HTTP -/// API, …) and pair it with [attachStore] to auto-save, seeding new -/// controllers from [load]: -/// -/// ```dart -/// final store = MyChatStore(); -/// final controller = UseChatController( -/// provider: provider, -/// initial: await store.load('thread-42'), -/// ); -/// final detach = attachStore(controller, store, 'thread-42'); -/// // ...later, before controller.dispose(): -/// detach(); -/// ``` -/// -/// [AiConversation] (and every [AiMessage]/[AiPart]) is JSON-serializable via -/// `toJson`/`fromJson`, so a minimal store is just an encode/decode around your -/// storage layer. -abstract interface class ChatStore { - /// Returns the stored conversation for [id], or `null` if none exists. - Future load(String id); - - /// Writes [conversation] for [id], replacing any previous value. - Future save(String id, AiConversation conversation); -} - -/// Auto-saves [controller]'s conversation to [store] under [id] whenever it -/// changes and the turn has settled, coalescing rapid changes over [debounce]. -/// -/// Returns a disposer that detaches the listener; call it before disposing the -/// controller. If a save is pending when you detach, it is flushed immediately -/// so the latest state is not lost. -/// -/// Saves are skipped while a turn is in flight (streaming) — the conversation -/// is persisted once it settles, avoiding a write per streamed frame. Loading -/// is the caller's job: pass `await store.load(id)` as the controller's -/// `initial`. -VoidCallback attachStore( - UseChatController controller, - ChatStore store, - String id, { - Duration debounce = const Duration(milliseconds: 400), -}) { - Timer? timer; - void save() => unawaited(store.save(id, controller.conversation)); - void listener() { - timer?.cancel(); - timer = Timer(debounce, () { - // Wait for the turn to settle; the settling notification reschedules us. - if (controller.status.isBusy) return; - save(); - }); - } - - controller.addListener(listener); - return () { - controller.removeListener(listener); - if (timer?.isActive ?? false) { - timer!.cancel(); - save(); - } - }; -} - -/// A lightweight summary of a stored conversation, for a thread list / sidebar. -class ChatThread { - /// Creates a thread summary. - const ChatThread({required this.id, required this.title, this.updatedAt}); - - /// The conversation id (pass to [ChatStore.load]). - final String id; - - /// A human-readable title (see [autoTitle]). - final String title; - - /// When the thread was last saved, if tracked. Newest-first ordering. - final DateTime? updatedAt; -} - -/// A [ChatStore] that can also enumerate and delete threads — enough to drive a -/// conversation list / sidebar. -abstract interface class ChatThreadStore implements ChatStore { - /// All stored threads, newest first. - Future> listThreads(); - - /// Removes the thread [id] (no-op if absent). - Future delete(String id); -} - -/// Derives a short title from a conversation's first user message, falling back -/// to [fallback]. Trims to [maxLength] characters. -String autoTitle( - AiConversation conversation, { - String fallback = 'New chat', - int maxLength = 40, -}) { - final firstUser = conversation.messages - .where((m) => m.role == AiRole.user) - .map((m) => m.text.trim()) - .firstWhere((t) => t.isNotEmpty, orElse: () => ''); - if (firstUser.isEmpty) return fallback; - final oneLine = firstUser.replaceAll(RegExp(r'\s+'), ' '); - return oneLine.length <= maxLength - ? oneLine - : '${oneLine.substring(0, maxLength).trimRight()}…'; -} - -/// An in-memory [ChatThreadStore] — handy for demos, tests, and prototyping -/// before wiring real storage. Titles are derived via [autoTitle] on save. -class InMemoryChatThreadStore implements ChatThreadStore { - final Map _conversations = {}; - final Map _threads = {}; - - @override - Future load(String id) async => _conversations[id]; - - @override - Future save(String id, AiConversation conversation) async { - _conversations[id] = conversation; - _threads[id] = ChatThread( - id: id, - title: autoTitle(conversation), - updatedAt: DateTime.now(), - ); - } - - @override - Future> listThreads() async { - final threads = _threads.values.toList(); - threads.sort((a, b) { - final at = a.updatedAt, bt = b.updatedAt; - if (at == null || bt == null) return 0; - return bt.compareTo(at); // newest first - }); - return threads; - } - - @override - Future delete(String id) async { - _conversations.remove(id); - _threads.remove(id); - } -} - -/// A minimal async key→string storage — the seam a [KeyValueChatThreadStore] -/// persists through. Keeps the package plugin-free: back it with -/// `shared_preferences`, a file, secure storage, or an HTTP API in a few lines: -/// -/// ```dart -/// class PrefsStore implements KeyValueStore { -/// PrefsStore(this._prefs); -/// final SharedPreferences _prefs; -/// @override -/// Future read(String key) async => _prefs.getString(key); -/// @override -/// Future write(String key, String value) async => -/// _prefs.setString(key, value); -/// @override -/// Future remove(String key) async => _prefs.remove(key); -/// } -/// ``` -abstract interface class KeyValueStore { - /// Returns the value for [key], or `null` if unset. - Future read(String key); - - /// Stores [value] under [key], replacing any previous value. - Future write(String key, String value); - - /// Removes [key] (no-op if absent). - Future remove(String key); -} - -/// A persistent [ChatThreadStore] backed by any [KeyValueStore], so a chat -/// drawer survives app restarts without pulling a storage plugin into the -/// package. Each conversation is stored as JSON under `"$prefix$id"`, with a -/// small index under `"${prefix}index"` for [listThreads]. Titles are derived -/// via [autoTitle] on save. -class KeyValueChatThreadStore implements ChatThreadStore { - /// Creates a store over [store]. [prefix] namespaces all keys it owns. - KeyValueChatThreadStore(this.store, {this.prefix = 'flutter_ai_chat/'}); - - /// The backing key→string storage. - final KeyValueStore store; - - /// Key namespace for everything this store writes. - final String prefix; - - String get _indexKey => '${prefix}index'; - String _threadKey(String id) => '$prefix$id'; - - Future> _readIndex() async { - final raw = await store.read(_indexKey); - if (raw == null || raw.isEmpty) return []; - final list = (jsonDecode(raw) as List).cast>(); - return [ - for (final e in list) - ChatThread( - id: e['id']! as String, - title: e['title']! as String, - updatedAt: e['updatedAt'] == null - ? null - : DateTime.tryParse(e['updatedAt']! as String), - ), - ]; - } - - Future _writeIndex(List threads) => store.write( - _indexKey, - jsonEncode([ - for (final t in threads) - { - 'id': t.id, - 'title': t.title, - 'updatedAt': t.updatedAt?.toIso8601String(), - }, - ]), - ); - - @override - Future load(String id) async { - final raw = await store.read(_threadKey(id)); - if (raw == null) return null; - return AiConversation.fromJson( - (jsonDecode(raw) as Map).cast(), - ); - } - - @override - Future save(String id, AiConversation conversation) async { - await store.write(_threadKey(id), jsonEncode(conversation.toJson())); - final thread = ChatThread( - id: id, - title: autoTitle(conversation), - updatedAt: DateTime.now(), - ); - final index = await _readIndex() - ..removeWhere((t) => t.id == id) - ..insert(0, thread); - await _writeIndex(index); - } - - @override - Future> listThreads() async { - final threads = await _readIndex(); - threads.sort((a, b) { - final at = a.updatedAt, bt = b.updatedAt; - if (at == null || bt == null) return 0; - return bt.compareTo(at); // newest first - }); - return threads; - } - - @override - Future delete(String id) async { - await store.remove(_threadKey(id)); - final index = await _readIndex() - ..removeWhere((t) => t.id == id); - await _writeIndex(index); - } -} diff --git a/packages/flutter_ai/flutter_ai_client/lib/src/context_strategy.dart b/packages/flutter_ai/flutter_ai_client/lib/src/context_strategy.dart deleted file mode 100644 index c00a937..0000000 --- a/packages/flutter_ai/flutter_ai_client/lib/src/context_strategy.dart +++ /dev/null @@ -1,154 +0,0 @@ -import 'package:flutter_ai_core/flutter_ai_core.dart'; - -/// History-trimming strategies for `UseChatController.trimHistory`. -/// -/// A strategy maps the full stored conversation to the (smaller) conversation -/// sent to the provider. The controller never trims its stored transcript, so -/// these only bound what each request costs — the UI keeps the full history. -/// -/// Both built-ins always preserve leading `system` messages and avoid starting -/// the kept window on an orphaned `tool` result (which strict providers -/// reject). Conversations with deeply interleaved tool calls may still need a -/// bespoke strategy — these are pragmatic defaults, not a general solution. - -/// Keeps the system prefix plus the most recent [count] non-system messages. -/// -/// If the kept window would begin on a `tool` message (a result whose -/// originating assistant tool-call would be trimmed away), the window is -/// advanced forward past it so no orphaned tool result is sent. -AiConversation Function(AiConversation) keepLastMessages(int count) { - assert(count >= 0, 'count must be >= 0'); - return (conversation) { - final messages = conversation.messages; - final system = [ - for (final m in messages) - if (m.role == AiRole.system) m, - ]; - final rest = [ - for (final m in messages) - if (m.role != AiRole.system) m, - ]; - if (rest.length <= count) return conversation; - - var start = rest.length - count; - while (start < rest.length && rest[start].role == AiRole.tool) { - start++; - } - return conversation.copyWith(messages: [...system, ...rest.sublist(start)]); - }; -} - -/// Keeps the system prefix plus a rolling **summary** of older turns, plus the -/// most recent [count] non-system messages. -/// -/// This is the compaction counterpart to [keepLastMessages]: instead of -/// silently dropping older context (losing load-bearing facts — "context rot"), -/// it folds a caller-supplied summary of the trimmed span into the request as a -/// synthetic `system` message, right after any real system messages. -/// -/// [summary] is called on each request and should return the current rolling -/// summary text (empty to inject nothing). The controller does **not** produce -/// the summary — your app owns that, exactly as the roadmap intends: run your -/// own periodic summarization (any model, or a cheap heuristic), persist the -/// result with the conversation via `ChatStore`, and return it here. That keeps -/// durable, cross-session memory without this package owning a memory service. -/// -/// As with [keepLastMessages], leading `tool` results in the kept window are -/// skipped so none is orphaned. The stored transcript is never modified — only -/// what each request sends. -AiConversation Function(AiConversation) keepLastWithSummary({ - required String Function() summary, - required int count, - String summaryLabel = 'Summary of earlier conversation:', -}) { - assert(count >= 0, 'count must be >= 0'); - return (conversation) { - final messages = conversation.messages; - final system = [ - for (final m in messages) - if (m.role == AiRole.system) m, - ]; - final rest = [ - for (final m in messages) - if (m.role != AiRole.system) m, - ]; - - var start = rest.length <= count ? 0 : rest.length - count; - while (start < rest.length && rest[start].role == AiRole.tool) { - start++; - } - final kept = rest.sublist(start); - - // Only inject a summary when something was actually dropped and the app - // supplied non-empty text. - final summaryText = start > 0 ? summary().trim() : ''; - final summaryMessages = summaryText.isEmpty - ? const [] - : [ - AiMessage( - id: 'summary', - role: AiRole.system, - parts: [TextPart('$summaryLabel\n$summaryText')], - ), - ]; - - if (summaryMessages.isEmpty && kept.length == rest.length) { - return conversation; - } - return conversation.copyWith( - messages: [...system, ...summaryMessages, ...kept], - ); - }; -} - -/// Keeps the system prefix plus as many of the most recent non-system messages -/// as fit within [maxTokens], estimated from text length. -/// -/// Token counts are approximated as `ceil(textLength / charsPerToken)` per -/// message (default ~4 characters per token — a reasonable English heuristic; -/// use a provider `countTokens` for exact budgeting). System messages are -/// always kept and counted. As with [keepLastMessages], the window is advanced -/// past a leading `tool` result so none is orphaned. -AiConversation Function(AiConversation) trimToApproxTokenBudget( - int maxTokens, { - int charsPerToken = 4, -}) { - assert(maxTokens >= 0, 'maxTokens must be >= 0'); - assert(charsPerToken >= 1, 'charsPerToken must be >= 1'); - int estimate(AiMessage m) => (m.text.length / charsPerToken).ceil(); - - return (conversation) { - final messages = conversation.messages; - final system = [ - for (final m in messages) - if (m.role == AiRole.system) m, - ]; - final rest = [ - for (final m in messages) - if (m.role != AiRole.system) m, - ]; - - var budget = maxTokens; - for (final m in system) { - budget -= estimate(m); - } - - // Walk newest -> oldest, keeping messages until the budget is exhausted. - final keptReversed = []; - for (var i = rest.length - 1; i >= 0; i--) { - final cost = estimate(rest[i]); - if (keptReversed.isNotEmpty && budget - cost < 0) break; - budget -= cost; - keptReversed.add(rest[i]); - } - var kept = keptReversed.reversed.toList(); - - // Don't begin on an orphaned tool result. - while (kept.isNotEmpty && kept.first.role == AiRole.tool) { - kept = kept.sublist(1); - } - - if (kept.length == rest.length) return conversation; - return conversation.copyWith(messages: [...system, ...kept]); - }; -} diff --git a/packages/flutter_ai/flutter_ai_client/lib/src/follow_ups.dart b/packages/flutter_ai/flutter_ai_client/lib/src/follow_ups.dart deleted file mode 100644 index e959a9c..0000000 --- a/packages/flutter_ai/flutter_ai_client/lib/src/follow_ups.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:flutter_ai_core/flutter_ai_core.dart'; - -/// Generates up to [count] short follow-up prompts a user might send next, given -/// the current [conversation], via a one-off call to [provider]. -/// -/// This is the model call the presentational `AiSuggestions` strip needs to show -/// *contextual* follow-ups (it renders whatever list you give it). Returns an -/// empty list if the model produces nothing usable; it never throws for an empty -/// or malformed reply. -/// -/// ```dart -/// final followUps = await suggestFollowUps(controller.conversation, provider); -/// // → feed into AiSuggestions(suggestions: followUps, onSelected: ...) -/// ``` -/// -/// Pass [options] to pick a cheaper/faster model for this side call (e.g. a -/// flash/mini model) independent of the main chat model. -Future> suggestFollowUps( - AiConversation conversation, - LlmProvider provider, { - int count = 3, - AiRequestOptions? options, -}) async { - if (conversation.messages.isEmpty) return const []; - - final prompt = AiMessage.text( - id: 'follow-ups-prompt', - role: AiRole.user, - text: 'Based on the conversation so far, suggest $count brief follow-up ' - 'questions I might ask next. Keep each under 8 words. ' - 'Respond with one question per line, no numbering, bullets, or quotes.', - ); - final request = conversation.copyWith( - messages: [...conversation.messages, prompt], - ); - - final buffer = StringBuffer(); - await for (final event in provider.send(request, options: options)) { - if (event is TextDelta) buffer.write(event.delta); - } - - return _parseLines(buffer.toString(), count); -} - -/// Splits the model reply into clean one-line suggestions, stripping any -/// leftover numbering/bullets/quotes and dropping blanks. -List _parseLines(String reply, int count) { - final cleaned = []; - for (final raw in reply.split('\n')) { - var line = raw.trim(); - if (line.isEmpty) continue; - // Strip a leading "1.", "1)", "-", "*", "•" list marker. - line = line.replaceFirst(RegExp(r'^\s*(\d+[.)]|[-*•])\s*'), ''); - // Strip surrounding quotes. - line = line.replaceAll(RegExp(r'''^["']+|["']+$'''), '').trim(); - if (line.isEmpty) continue; - cleaned.add(line); - if (cleaned.length >= count) break; - } - return cleaned; -} diff --git a/packages/flutter_ai/flutter_ai_client/lib/src/use_chat_controller.dart b/packages/flutter_ai/flutter_ai_client/lib/src/use_chat_controller.dart deleted file mode 100644 index 588e38e..0000000 --- a/packages/flutter_ai/flutter_ai_client/lib/src/use_chat_controller.dart +++ /dev/null @@ -1,944 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:math'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter_ai_client/src/chat_observer.dart'; -import 'package:flutter_ai_client/src/chat_status.dart'; -import 'package:flutter_ai_core/flutter_ai_core.dart'; - -/// Drives a chat conversation against any [LlmProvider], exposing state as a -/// [Listenable] (this class is a [ChangeNotifier]). -/// -/// This is the Dart analogue of the web `useChat` hook. It is deliberately -/// **un-opinionated about state management**: bind it with `ListenableBuilder`, -/// or adapt it to Bloc / Riverpod / Provider — the controller imposes nothing. -/// The raw [events] stream is available as an escape hatch for custom state -/// layers. -/// -/// ### Streaming performance -/// -/// Incoming events are folded by an internal [MessageProcessor], and -/// [notifyListeners] is **coalesced**: many events arriving in one turn trigger -/// a single notification. The coalescing strategy is injectable via `scheduler` -/// (defaulting to [scheduleMicrotask]); combined with Flutter's per-frame -/// rebuild pipeline this keeps high token rates from dropping frames. A host -/// that wants strict frame alignment can pass a scheduler backed by -/// `SchedulerBinding.addPostFrameCallback`. -/// -/// ### History -/// -/// The full message history is retained so the user can scroll the whole -/// session; trimming for token budgets is a provider/server concern, not the -/// controller's. -class UseChatController extends ChangeNotifier { - /// Creates a controller bound to [provider]. - /// - /// [initial] seeds the conversation. [tools] and [options] are forwarded to - /// the provider on every request. [scheduler] customizes notification - /// batching (defaults to [scheduleMicrotask]). [idGenerator] supplies ids for - /// locally-created user messages (defaults to a sequential generator). - /// - /// ### Agent loop - /// - /// Provide [onToolCalls] to turn the controller into an automatic agent: when - /// a model turn ends with tool calls that have no results yet, the controller - /// invokes [onToolCalls], appends the returned [ToolResultPart]s, and - /// re-prompts the model — repeating until a turn has no pending tool calls or - /// [maxSteps] model calls have run. Without [onToolCalls] the behavior is - /// unchanged: the turn ends with the tool calls and the host drives execution - /// manually via [addToolResults]. - /// - /// [onToolCalls] receives an [AiToolCallSignal] as its second argument. The - /// controller cancels it if the turn is stopped, replaced, or disposed while - /// the executor is still running, so a long-running tool can abort in-flight - /// work (e.g. cancel an HTTP request via [AiToolCallSignal.whenCancelled]) - /// instead of running to completion only to have its result discarded. - /// - /// ### Tool-argument validation - /// - /// When [validateToolArgs] is true (the default) and a tool's - /// [ToolDefinition.parametersSchema] is non-empty, the controller validates - /// each model-produced call's arguments against that schema *before* running - /// [onToolCalls]. A call whose args violate the schema is not executed; - /// instead an error [ToolResultPart] describing the violations is fed back to - /// the model, which then gets a chance to correct itself (still bounded by - /// [maxSteps]). Tools with no schema, and calls for unknown tool names, skip - /// validation. - /// - /// ### Runaway-loop guard - /// - /// [maxIdenticalToolCalls] (0 = off, the default) halts the agent loop if the - /// model requests the same tool call — identical name **and** arguments — - /// after it has already run that many times in the turn. Instead of looping - /// up to [maxSteps] and spending tokens, the turn ends with [error] set to an - /// [AgentLoopException]. Complements [tokenBudget], which caps total tokens. - /// - /// ### Observability - /// - /// Pass a [ChatObserver] to receive lifecycle callbacks (turn start, each - /// model request, response + token usage, tool calls/results, errors, turn - /// end) shaped after the OpenTelemetry GenAI semantic conventions — with no - /// OpenTelemetry dependency. Map them onto your own tracer or analytics sink. - /// - /// ### History trimming - /// - /// [trimHistory], when provided, maps the full conversation to the (smaller) - /// conversation actually sent to the provider on each request. The stored - /// transcript is never trimmed — [conversation]/[messages] still return - /// everything — so the UI keeps the full history while requests stay within a - /// token budget. See `keepLastMessages` and `trimToApproxTokenBudget` for - /// ready-made strategies. - UseChatController({ - required LlmProvider provider, - AiConversation? initial, - List tools = const [], - AiRequestOptions? options, - Future> Function( - List calls, - AiToolCallSignal signal, - )? onToolCalls, - int maxSteps = 8, - int maxBranches = 20, - int? tokenBudget, - int maxIdenticalToolCalls = 0, - bool validateToolArgs = true, - ChatObserver? observer, - AiConversation Function(AiConversation conversation)? trimHistory, - void Function(VoidCallback callback)? scheduler, - String Function()? idGenerator, - }) : assert(maxSteps >= 1, 'maxSteps must be at least 1'), - assert(maxBranches >= 1, 'maxBranches must be at least 1'), - assert(maxIdenticalToolCalls >= 0, - 'maxIdenticalToolCalls must be >= 0 (0 disables loop detection)'), - _provider = provider, - _tools = List.unmodifiable(tools), - _options = options, - _onToolCalls = onToolCalls, - _maxSteps = maxSteps, - _maxBranches = maxBranches, - _tokenBudget = tokenBudget, - _maxIdenticalToolCalls = maxIdenticalToolCalls, - _observer = observer, - _validateToolArgs = validateToolArgs, - _trimHistory = trimHistory, - _scheduler = scheduler ?? scheduleMicrotask, - _newId = idGenerator ?? _sequentialIdGenerator(), - _processor = MessageProcessor(conversation: initial); - - final MessageProcessor _processor; - final void Function(VoidCallback callback) _scheduler; - final String Function() _newId; - final Future> Function( - List, - AiToolCallSignal, - )? _onToolCalls; - // The signal for the tool batch currently executing, cancelled if the turn is - // torn down (stop/replace/dispose) while the executor runs. - AiToolCallSignal? _activeToolSignal; - final int _maxSteps; - final int _maxBranches; - final bool _validateToolArgs; - final AiConversation Function(AiConversation)? _trimHistory; - final int? _tokenBudget; // stop the agent loop once cumulative tokens exceed - // Halt the agent loop if the model requests the same (toolName, args) call - // this many times in one turn — a runaway-loop guard. 0 disables it. - final int _maxIdenticalToolCalls; - // Per-turn count of executed tool-call signatures, for loop detection. - final Map _toolCallCounts = {}; - // Optional lifecycle observer for tracing/metrics. - final ChatObserver? _observer; - // The finish reason from the most recent MessageFinished, for the observer. - FinishReason? _lastFinishReason; - final StreamController _events = - StreamController.broadcast(); - - LlmProvider _provider; - List _tools; - AiRequestOptions? _options; - - // Regeneration branches for the latest turn: each version is the slice of - // messages after the last user message. `regenerate` appends a version; - // navigating swaps which one is shown. - List> _branches = []; - int _branchIndex = 0; - _Capture _capture = _Capture.reset; - - ChatStatus _status = ChatStatus.idle; - Object? _error; - StackTrace? _stackTrace; - StreamSubscription? _subscription; - Completer? _turn; - int _step = 0; // model calls executed so far in the current agent turn - int _turnSeq = 0; // bumped whenever a turn is torn down/replaced - bool _notifyScheduled = false; - bool _disposed = false; - - /// The full conversation transcript. - AiConversation get conversation => _processor.conversation; - - /// The messages in the conversation. - List get messages => _processor.conversation.messages; - - /// The current turn status. - ChatStatus get status => _status; - - /// The error from the last failed turn, or `null`. - Object? get error => _error; - - /// The stack trace captured alongside [error], or `null`. - StackTrace? get stackTrace => _stackTrace; - - /// How many regenerated versions exist for the latest turn (1 = no - /// alternatives). Drive an `AiBranch` with this and [branchIndex]. - int get branchCount => _branches.length; - - /// The 0-based index of the version currently shown for the latest turn. - int get branchIndex => _branchIndex; - - /// The summed token usage across every message in the conversation that - /// reported it, or `null` if none did. Feed an `AiContextMeter` or estimate - /// cost with [AiUsage.estimateCost]. - AiUsage? get totalUsage { - AiUsage? total; - for (final message in _processor.conversation.messages) { - final usage = message.usage; - if (usage != null) total = total == null ? usage : total + usage; - } - return total; - } - - /// A broadcast stream of every event applied to the conversation. - /// - /// An escape hatch for hosts that want to react to raw events (analytics, - /// custom state). Most callers should rely on [conversation] plus listener - /// notifications instead. - Stream get events => _events.stream; - - /// Sends a user message composed of [text] and optional [attachments]. - /// - /// Returns a future that completes when the resulting turn finishes (or is - /// stopped). A no-op if [text] is empty and there are no [attachments]. - Future sendText( - String text, { - List attachments = const [], - }) { - final parts = [ - ...attachments, - if (text.isNotEmpty) TextPart(text), - ]; - if (parts.isEmpty) return Future.value(); - return submit(AiMessage(id: _newId(), role: AiRole.user, parts: parts)); - } - - /// Appends [userMessage] optimistically and streams the model's response. - /// - /// The append happens **synchronously** before the request is dispatched, so - /// the user's message paints immediately. Any in-flight turn is cancelled - /// first. Returns a future that completes when the new turn finishes, errors, - /// or is stopped. - Future submit(AiMessage userMessage) { - _stopActiveStream(); - _error = null; - _stackTrace = null; - _capture = _Capture.reset; // a new user turn starts a fresh branch set - _step = 0; - _toolCallCounts.clear(); - _processor.reset( - _settleDanglingToolCalls(_processor.conversation).append(userMessage), - ); - _status = ChatStatus.submitted; - _scheduleNotify(); - return _beginTurn(); - } - - /// Re-runs the model from the most recent user message, discarding everything - /// after it. A no-op if there is no user message. - Future regenerate() { - final all = _processor.conversation.messages; - final lastUser = all.lastIndexWhere((m) => m.role == AiRole.user); - if (lastUser == -1) return Future.value(); - _stopActiveStream(); - _error = null; - _stackTrace = null; - _capture = _Capture.append; // keep the prior version, add a new one - _step = 0; - _toolCallCounts.clear(); - _processor.reset( - _settleDanglingToolCalls( - _processor.conversation - .copyWith(messages: all.sublist(0, lastUser + 1)), - ), - ); - _status = ChatStatus.submitted; - _scheduleNotify(); - return _beginTurn(); - } - - /// Edits the user message [messageId] to [text] — keeping any non-text parts - /// such as attachments — discards every message after it, and re-runs the - /// model from that point. A no-op if [messageId] is not a user message in the - /// transcript, or if the edit would leave the message empty. - /// - /// A reworded prompt starts a fresh branch set (the previous answer was to a - /// different question), like editing a sent message in a typical chat UI. - Future editMessage(String messageId, String text) { - final all = _processor.conversation.messages; - final index = all.indexWhere((m) => m.id == messageId); - if (index == -1 || all[index].role != AiRole.user) { - return Future.value(); - } - final original = all[index]; - // Replace the first text part in place (preserving attachment order); drop - // any other text parts. Append the new text if the message had none. - final parts = []; - var replaced = false; - for (final part in original.parts) { - if (part is TextPart) { - if (!replaced && text.isNotEmpty) { - parts.add(TextPart(text)); - replaced = true; - } - } else { - parts.add(part); - } - } - if (!replaced && text.isNotEmpty) parts.add(TextPart(text)); - if (parts.isEmpty) return Future.value(); - - _stopActiveStream(); - _error = null; - _stackTrace = null; - _capture = _Capture.reset; - _step = 0; - _toolCallCounts.clear(); - _processor.reset( - _settleDanglingToolCalls( - _processor.conversation.copyWith( - messages: [ - ...all.sublist(0, index), - original.copyWith(parts: parts, status: AiMessageStatus.complete), - ], - ), - ), - ); - _status = ChatStatus.submitted; - _scheduleNotify(); - return _beginTurn(); - } - - /// Edits the most recent user message to [text] and re-runs from it. A no-op - /// if there is no user message. See [editMessage]. - Future editLastUserMessage(String text) { - final lastUser = _lastUserIndex(); - if (lastUser == -1) return Future.value(); - return editMessage(_processor.conversation.messages[lastUser].id, text); - } - - /// Switches the latest turn to regenerated version [index] (0-based). A no-op - /// out of range, while a turn is in flight, or if already showing it. - void selectBranch(int index) { - if (index < 0 || index >= _branches.length || index == _branchIndex) return; - // Never switch branches while a turn is in flight — including the agent - // loop's tool-execution phase, whose live continuation would otherwise - // append tool results onto the swapped transcript and corrupt it. - if (_turn != null) return; - final lastUser = _lastUserIndex(); - if (lastUser == -1) return; - final head = _processor.conversation.messages.sublist(0, lastUser + 1); - _branchIndex = index; - _processor.reset( - _processor.conversation - .copyWith(messages: [...head, ..._branches[index]]), - ); - _scheduleNotify(); - } - - /// Appends tool [results] as an [AiRole.tool] message and streams the model's - /// continuation — call this after executing the tool calls the model - /// requested. A no-op if [results] is empty. - /// - /// Every [ToolCallPart] in the preceding assistant message should have a - /// matching [ToolResultPart] here before continuing, as providers require a - /// result per call. When an `onToolCalls` executor is configured the - /// controller calls this for you (the agent loop); use it directly only for - /// manual tool handling. - Future addToolResults(List results) { - if (results.isEmpty) return Future.value(); - _stopActiveStream(); - _error = null; - _stackTrace = null; - _capture = _Capture.update; // continuation of the current version's turn - _processor.reset( - _processor.conversation.append( - AiMessage( - id: _newId(), - role: AiRole.tool, - parts: List.of(results), - ), - ), - ); - _status = ChatStatus.submitted; - _scheduleNotify(); - return _beginTurn(); - } - - /// Cancels the in-flight turn, finalizing the streaming message as stopped. - void stop() { - _stopActiveStream(); // finalizes the trailing streaming message - _status = ChatStatus.idle; - _scheduleNotify(); - } - - /// Switches the active provider. Does not affect the current transcript or - /// interrupt an in-flight turn. - void setProvider(LlmProvider provider) { - _provider = provider; - _scheduleNotify(); - } - - /// Replaces the request options applied to subsequent turns (for example, to - /// change the model). - void setOptions(AiRequestOptions? options) { - _options = options; - _scheduleNotify(); - } - - /// Replaces the tools advertised to the provider on subsequent turns. - void setTools(List tools) { - _tools = List.unmodifiable(tools); - _scheduleNotify(); - } - - /// Clears the conversation and cancels any in-flight turn. - void clear() { - _stopActiveStream(); - _processor.reset(AiConversation.empty(_processor.conversation.id)); - _error = null; - _stackTrace = null; - _status = ChatStatus.idle; - _branches = []; - _branchIndex = 0; - _capture = _Capture.reset; - _scheduleNotify(); - } - - /// Swaps the transcript to [conversation] in place, cancelling any in-flight - /// turn — the way to switch threads without disposing and recreating the - /// controller. Rehydrate a thread with `controller.load(await store.load(id))` - /// and, if the target thread differs, re-point `attachStore` to its id. - /// - /// Branch/regeneration history is reset to the loaded turn. - void load(AiConversation conversation) { - _stopActiveStream(); - _processor.reset(conversation); - _error = null; - _stackTrace = null; - _status = ChatStatus.idle; - _branches = []; - _branchIndex = 0; - _capture = _Capture.reset; - _scheduleNotify(); - } - - int _lastUserIndex() => _processor.conversation.messages - .lastIndexWhere((m) => m.role == AiRole.user); - - /// Snapshots the post-user-message tail as the current branch version. Called - /// on each successful turn completion; the [_capture] mode decides whether to - /// start fresh, append a new version, or update the in-progress one. - void _captureBranch() { - final lastUser = _lastUserIndex(); - if (lastUser == -1) return; - final tail = _processor.conversation.messages.sublist(lastUser + 1); - if (tail.isEmpty) return; - switch (_capture) { - case _Capture.reset: - _branches = [tail]; - _branchIndex = 0; - case _Capture.append: - _branches.add(tail); - // Cap retained regenerations so a long-running chat can't grow without - // bound; drop the oldest version(s) and keep the index aligned. - while (_branches.length > _maxBranches) { - _branches.removeAt(0); - } - _branchIndex = _branches.length - 1; - case _Capture.update: - if (_branches.isEmpty) { - _branches = [tail]; - _branchIndex = 0; - } else { - _branches[_branchIndex] = tail; - } - } - // Further completions in the same turn (tool rounds) update this version. - _capture = _Capture.update; - } - - /// Opens a fresh turn future and dispatches the first model call. The future - /// completes when the whole turn ends — including any automatic agent-loop - /// continuations. - Future _beginTurn() { - final completer = Completer(); - _turn = completer; - _observer?.onTurnStart(_processor.conversation); - _dispatch(); - return completer.future; - } - - /// Subscribes to one provider stream, folding events into the conversation. - void _dispatch() { - _step++; // one model call - _observer?.onModelRequest(_step); - // Capture the turn this subscription belongs to. A late event from a - // cancelled stream (a microtask already queued when the turn was torn down) - // must not mutate the conversation or leak onto the events stream after a - // new turn started. - final seq = _turnSeq; - // Building the request can throw synchronously: trimHistory is a - // caller-supplied callback, and the LlmProvider contract permits send() to - // throw for unrecoverable transport faults. Without this guard the thrown - // error escapes (leaving status stuck at `submitted` and the turn future - // never completing, or an unhandled zone error inside the agent loop). - final Stream stream; - try { - // The provider sees the (optionally trimmed) conversation; the stored - // transcript is never trimmed. - final outgoing = _trimHistory?.call(_processor.conversation) ?? - _processor.conversation; - stream = _provider.send(outgoing, tools: _tools, options: _options); - } catch (error, stackTrace) { - _failTurn(error, stackTrace); - return; - } - _subscription = stream.listen( - (event) { - if (_disposed || seq != _turnSeq) return; - _processor.apply(event); - if (!_events.isClosed) _events.add(event); - if (event is MessageFinished) _lastFinishReason = event.reason; - // A message-scoped error event is fatal: record the error and tear the - // turn down so a misbehaving provider cannot keep mutating the - // conversation past the failure. A tool-scoped error is left to the - // tool result instead and streaming continues. - if (event is StreamErrorEvent && event.toolCallId == null) { - _failTurn(event.error, null); - return; - } else if (_status == ChatStatus.submitted) { - _status = ChatStatus.streaming; - } - _scheduleNotify(); - }, - onError: (Object error, StackTrace stackTrace) { - if (_disposed || seq != _turnSeq) return; - _failTurn(error, stackTrace); - }, - onDone: () { - if (_disposed || seq != _turnSeq) return; - _onStreamDone(); - }, - cancelOnError: true, - ); - } - - /// A provider stream completed cleanly. Captures the branch, then either runs - /// the agent loop (execute pending tool calls and re-prompt) or ends the turn. - void _onStreamDone() { - _subscription = null; - if (_status == ChatStatus.error) { - _completeTurn(); - _scheduleNotify(); - return; - } - _captureBranch(); - _observer?.onModelResponse( - step: _step, - usage: _processor.conversation.lastMessage?.usage, - finishReason: _lastFinishReason, - ); - - final pending = _pendingToolCalls(); - // Runaway-loop guard: if the model keeps requesting a tool call it has - // already run `maxIdenticalToolCalls` times with identical args, halt with - // a typed error instead of looping (and burning tokens) to `maxSteps`. - if (_onToolCalls != null && - pending.isNotEmpty && - _maxIdenticalToolCalls > 0) { - for (final call in pending) { - if ((_toolCallCounts[_toolCallSignature(call)] ?? 0) >= - _maxIdenticalToolCalls) { - _error = AgentLoopException(call.toolName, _maxIdenticalToolCalls); - _status = ChatStatus.error; - _completeTurn(); - _scheduleNotify(); - return; - } - } - } - final overBudget = _tokenBudget != null && - (totalUsage?.resolvedTotal ?? 0) >= _tokenBudget; - if (_onToolCalls != null && - pending.isNotEmpty && - _step < _maxSteps && - !overBudget) { - // The turn stays in flight while the tool executor runs; keep the - // controller busy so UIs don't re-enable input and stores don't persist a - // mid-turn transcript with unanswered tool calls (see selectBranch). - _status = ChatStatus.executingTools; - _scheduleNotify(); - unawaited(_continueWithTools(pending, _turn)); - return; - } - _status = ChatStatus.idle; - _completeTurn(); - _scheduleNotify(); - } - - /// Runs [_onToolCalls] for [calls] and feeds the results back into the model, - /// continuing the same [turn]. Aborts silently if the turn was stopped or - /// replaced while the executor ran. - Future _continueWithTools( - List calls, - Completer? turn, - ) async { - // Split off calls whose arguments violate the tool's parametersSchema: - // those are answered with an error result (so the model can retry) instead - // of being handed to the executor. - _observer?.onToolCalls(calls); - final (valid, validationErrors) = _validateToolArgs - ? _splitInvalidCalls(calls) - : (calls, const []); - - // Record what we're about to run so the loop guard in _onStreamDone can spot - // the model re-requesting an identical call. - if (_maxIdenticalToolCalls > 0) { - for (final call in valid) { - final sig = _toolCallSignature(call); - _toolCallCounts[sig] = (_toolCallCounts[sig] ?? 0) + 1; - } - } - - List executed = const []; - if (valid.isNotEmpty) { - final signal = AiToolCallSignal(); - _activeToolSignal = signal; - try { - executed = await _onToolCalls!(valid, signal); - } catch (error, stackTrace) { - if (identical(_activeToolSignal, signal)) _activeToolSignal = null; - if (_disposed || !identical(_turn, turn)) return; - _error = error; - _stackTrace = stackTrace; - _status = ChatStatus.error; - _observer?.onError(error, stackTrace); - _completeTurn(); - _scheduleNotify(); - return; - } - if (identical(_activeToolSignal, signal)) _activeToolSignal = null; - } - if (_disposed || !identical(_turn, turn) || turn == null) return; - final results = [...validationErrors, ...executed]; - _observer?.onToolResults(results); - if (results.isEmpty) { - _status = ChatStatus.idle; - _completeTurn(); - _scheduleNotify(); - return; - } - _capture = _Capture.update; - _processor.reset( - _processor.conversation.append( - AiMessage( - id: _newId(), - role: AiRole.tool, - parts: List.of(results), - ), - ), - ); - _status = ChatStatus.submitted; - _scheduleNotify(); - _dispatch(); - } - - /// Settles tool calls left unanswered anywhere in the transcript — a turn - /// stopped/replaced mid agent-loop, `maxSteps` cutting a loop short, or a - /// dirty transcript rehydrated from storage — by inserting a synthesized - /// error [ToolResultPart] message directly after each affected assistant - /// message. Providers reject a history containing a tool call with no - /// result in the immediately-following turn (so the fix-up must be inserted - /// in place, not appended at the end), and without it every subsequent - /// [submit] on the conversation fails with a request error. - AiConversation _settleDanglingToolCalls(AiConversation conversation) { - final msgs = conversation.messages; - final answered = { - for (final m in msgs) - for (final p in m.parts) - if (p is ToolResultPart) p.toolCallId, - }; - var changed = false; - final out = []; - for (final m in msgs) { - out.add(m); - if (m.role != AiRole.assistant) continue; - final dangling = m.parts - .whereType() - .where((c) => !answered.contains(c.toolCallId)) - .toList(); - if (dangling.isEmpty) continue; - changed = true; - out.add( - AiMessage( - id: _newId(), - role: AiRole.tool, - parts: [ - for (final call in dangling) - ToolResultPart( - toolCallId: call.toolCallId, - isError: true, - result: - 'Cancelled: the turn was interrupted before this tool ' - 'call produced a result.', - ), - ], - ), - ); - } - return changed ? conversation.copyWith(messages: out) : conversation; - } - - /// Tool calls in the latest assistant message that have no matching - /// [ToolResultPart] anywhere in the transcript yet. - /// A stable identity for a tool call — name plus JSON-encoded args — used to - /// detect the model re-requesting the exact same call. - String _toolCallSignature(ToolCallPart call) => - '${call.toolName}(${jsonEncode(call.args)})'; - - List _pendingToolCalls() { - final msgs = _processor.conversation.messages; - final lastAssistant = - msgs.lastIndexWhere((m) => m.role == AiRole.assistant); - if (lastAssistant == -1) return const []; - final calls = msgs[lastAssistant].parts.whereType().toList(); - if (calls.isEmpty) return const []; - final answered = { - for (final m in msgs) - for (final p in m.parts) - if (p is ToolResultPart) p.toolCallId, - }; - return calls.where((c) => !answered.contains(c.toolCallId)).toList(); - } - - /// Partitions [calls] into those whose arguments satisfy the matching tool's - /// [ToolDefinition.parametersSchema] and, for the rest, an error - /// [ToolResultPart] describing the schema violations. Calls for tools with no - /// schema, or for tool names not in [_tools], are treated as valid (nothing - /// to validate against). - (List, List) _splitInvalidCalls( - List calls, - ) { - final valid = []; - final errors = []; - for (final call in calls) { - Map schema = const {}; - for (final t in _tools) { - if (t.name == call.toolName) { - schema = t.parametersSchema; - break; - } - } - final violations = schema.isEmpty - ? const [] - : validateJsonSchema(call.args, schema); - if (violations.isEmpty) { - valid.add(call); - } else { - errors.add( - ToolResultPart( - toolCallId: call.toolCallId, - isError: true, - result: { - 'error': 'invalid_arguments', - 'message': 'Arguments for "${call.toolName}" failed validation. ' - 'Fix them and call the tool again.', - 'violations': violations, - }, - ), - ); - } - } - return (valid, errors); - } - - /// Cancels the active subscription (if any) and completes its turn future. - /// The cancel itself is fire-and-forget — a new stream is started right after, - /// and `StreamSubscription.cancel` stops delivery immediately. - void _stopActiveStream() { - _turnSeq++; // invalidate any in-flight subscription's late events - // Tell a tool executor running between streams to abort its in-flight work. - _activeToolSignal?._cancel(); - _activeToolSignal = null; - final sub = _subscription; - _subscription = null; - if (sub != null) unawaited(sub.cancel()); - // Finalize a still-streaming message so it doesn't linger in the transcript - // as a permanent typing indicator (and get persisted that way). Callers that - // discard the message afterwards (clear/regenerate/editMessage) are - // unaffected; submit/addToolResults keep it, so it must settle here. - final last = _processor.conversation.lastMessage; - if (last != null && last.status == AiMessageStatus.streaming) { - _processor.apply( - MessageFinished(messageId: last.id, reason: FinishReason.stop), - ); - } - _completeTurn(); - } - - /// Routes a turn-fatal [error] to the error state: records it, notifies the - /// observer, finalizes any trailing streaming message, cancels the active - /// stream, and completes the turn. Shared by the async `onError`, a fatal - /// in-band `StreamErrorEvent`, and a synchronous throw from - /// `provider.send`/`trimHistory`. - void _failTurn(Object error, StackTrace? stackTrace) { - _error = error; - _stackTrace = stackTrace; - _status = ChatStatus.error; - _observer?.onError(error, stackTrace); - final last = _processor.conversation.lastMessage; - if (last != null && last.status == AiMessageStatus.streaming) { - _processor.apply(StreamErrorEvent(error: error, messageId: last.id)); - } - final sub = _subscription; - _subscription = null; - if (sub != null) unawaited(sub.cancel()); - _completeTurn(); - _scheduleNotify(); - } - - void _completeTurn() { - final turn = _turn; - _turn = null; - if (turn != null && !turn.isCompleted) { - turn.complete(); - _observer?.onTurnEnd(totalUsage: totalUsage); - } - } - - void _scheduleNotify() { - if (_notifyScheduled || _disposed) return; - _notifyScheduled = true; - _scheduler(() { - _notifyScheduled = false; - if (!_disposed) notifyListeners(); - }); - } - - @override - void dispose() { - _disposed = true; - _activeToolSignal?._cancel(); - _activeToolSignal = null; - unawaited(_subscription?.cancel()); - _completeTurn(); - unawaited(_events.close()); - super.dispose(); - } - - /// The default message-id generator: a per-controller random prefix plus an - /// incrementing counter, e.g. `msg-k3f9a1-0`. - /// - /// The random prefix is what makes ids collision-resistant. A plain `msg-N` - /// counter restarts at 0 for every controller, so seeding a controller with a - /// rehydrated transcript (`ChatStore.load`, which already contains `msg-0…N`) - /// would make the first new message reuse an existing id — silently corrupting - /// `messageById`/`replace`/`editMessage` and producing duplicate widget keys. - /// The prefix also keeps two controllers writing to the same store from - /// colliding. Pass a custom `idGenerator` to override. - static String Function() _sequentialIdGenerator() { - final prefix = (Random().nextInt(1 << 32)).toRadixString(36); - var n = 0; - return () => 'msg-$prefix-${n++}'; - } -} - -/// How [UseChatController] folds the next completed turn into branch history. -enum _Capture { reset, append, update } - -/// A cancellation signal handed to an `onToolCalls` executor as its second -/// argument. -/// -/// [UseChatController] cancels it when the turn that launched the tool batch is -/// stopped, replaced (a new turn started), or the controller is disposed while -/// the executor is still running. A long-running tool should observe it and -/// abort its in-flight work — its returned results are discarded once the turn -/// is gone anyway, so honoring cancellation just frees resources sooner. -/// -/// Three ways to consume it: -/// ```dart -/// onToolCalls: (calls, signal) async { -/// // 1) Race cancellable I/O against cancellation: -/// final res = await Future.any([httpCall(), signal.whenCancelled]); -/// if (signal.isCancelled) return const []; // 2) poll before/after work -/// signal.throwIfCancelled(); // 3) bail between steps -/// ... -/// } -/// ``` -class AiToolCallSignal { - /// Creates an uncancelled signal. The controller constructs one per tool - /// batch; hosts rarely need to create their own. - AiToolCallSignal(); - - final Completer _completer = Completer(); - bool _cancelled = false; - - /// Whether the owning turn has been cancelled. - bool get isCancelled => _cancelled; - - /// Completes when the owning turn is cancelled (never with an error). Race it - /// against cancellable work with [Future.any]. - Future get whenCancelled => _completer.future; - - /// Throws [AiToolCallCancelled] if [isCancelled]. Call between steps of a - /// long tool to bail out promptly. - void throwIfCancelled() { - if (_cancelled) throw const AiToolCallCancelled(); - } - - void _cancel() { - if (_cancelled) return; - _cancelled = true; - if (!_completer.isCompleted) _completer.complete(); - } -} - -/// Thrown by [AiToolCallSignal.throwIfCancelled] when the turn was cancelled. -class AiToolCallCancelled implements Exception { - /// Creates the exception. - const AiToolCallCancelled(); - - @override - String toString() => - 'AiToolCallCancelled: the tool-call batch was cancelled (the turn was ' - 'stopped, replaced, or disposed).'; -} - -/// Surfaced on [UseChatController.error] when the agent loop is halted because -/// the model requested the same tool call (identical name + args) more than the -/// controller's `maxIdenticalToolCalls` limit — a runaway-loop guard that stops -/// the turn instead of looping (and spending tokens) up to `maxSteps`. -class AgentLoopException implements Exception { - /// Creates the exception for [toolName] after hitting [limit] identical calls. - const AgentLoopException(this.toolName, this.limit); - - /// The tool whose repeated identical calls tripped the guard. - final String toolName; - - /// The configured `maxIdenticalToolCalls` limit that was reached. - final int limit; - - @override - String toString() => - 'AgentLoopException: tool "$toolName" was requested with identical ' - 'arguments more than $limit times; halting the agent loop.'; -} diff --git a/packages/flutter_ai/flutter_ai_client/pubspec.yaml b/packages/flutter_ai/flutter_ai_client/pubspec.yaml deleted file mode 100644 index e11716f..0000000 --- a/packages/flutter_ai/flutter_ai_client/pubspec.yaml +++ /dev/null @@ -1,39 +0,0 @@ -name: flutter_ai_client -description: "Provider-agnostic chat controller for flutter_ai: wraps any LlmProvider with optimistic send, cancellation, regeneration, and frame-batched streaming." -version: 0.3.0 -repository: https://github.com/ananmouaz/flutter_ai/tree/main/packages/flutter_ai_client -issue_tracker: https://github.com/ananmouaz/flutter_ai/issues -homepage: https://github.com/ananmouaz/flutter_ai - -topics: - - ai - - llm - - chat - - streaming - - flutter - -environment: - sdk: ^3.6.0 - flutter: ">=3.27.0" - -# No platform channels — supported everywhere Flutter is. -platforms: - android: - ios: - linux: - macos: - web: - windows: - -# Part of the flutter_ai workspace. -resolution: workspace - -dependencies: - flutter: - sdk: flutter - flutter_ai_core: ^0.1.11 - -dev_dependencies: - flutter_test: - sdk: flutter - lints: ^5.0.0 diff --git a/packages/flutter_ai/flutter_ai_client/test/context_strategy_test.dart b/packages/flutter_ai/flutter_ai_client/test/context_strategy_test.dart deleted file mode 100644 index 63ea000..0000000 --- a/packages/flutter_ai/flutter_ai_client/test/context_strategy_test.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flutter_ai_client/flutter_ai_client.dart'; -import 'package:flutter_test/flutter_test.dart'; - -AiConversation _conv(List messages) => - AiConversation(id: 'c', messages: messages); - -AiMessage _m(String id, AiRole role, String text) => - AiMessage(id: id, role: role, parts: [TextPart(text)]); - -void main() { - group('keepLastWithSummary', () { - final base = _conv([ - _m('s', AiRole.system, 'sys'), - _m('u1', AiRole.user, 'one'), - _m('a1', AiRole.assistant, '1'), - _m('u2', AiRole.user, 'two'), - _m('a2', AiRole.assistant, '2'), - _m('u3', AiRole.user, 'three'), - ]); - - test('injects the summary as a system message when older turns are dropped', - () { - final trimmed = keepLastWithSummary( - summary: () => 'user greeted and asked two things', - count: 2, - )(base); - - final roles = trimmed.messages.map((m) => m.role).toList(); - // real system, injected summary system message, then last 2 (a2, u3). - expect(roles, [ - AiRole.system, - AiRole.system, - AiRole.assistant, - AiRole.user, - ]); - expect(trimmed.messages[1].text, contains('user greeted')); - expect(trimmed.messages.last.id, 'u3'); - }); - - test('injects nothing when nothing is dropped', () { - final trimmed = keepLastWithSummary( - summary: () => 'should not appear', - count: 10, - )(base); - expect(trimmed, same(base)); - }); - - test('injects nothing when the summary is empty', () { - final trimmed = keepLastWithSummary( - summary: () => ' ', - count: 1, - )(base); - expect( - trimmed.messages.where((m) => m.role == AiRole.system), - hasLength(1), // only the real system message - ); - expect(trimmed.messages.last.id, 'u3'); - }); - - test('does not begin the kept window on an orphaned tool result', () { - final conv = _conv([ - _m('u1', AiRole.user, 'q'), - _m('a1', AiRole.assistant, 'call'), - _m('t1', AiRole.tool, 'result'), - _m('a2', AiRole.assistant, 'answer'), - ]); - final trimmed = keepLastWithSummary( - summary: () => 'summary', - count: 2, // would start on the tool result; must advance past it - )(conv); - expect(trimmed.messages.any((m) => m.role == AiRole.tool), isFalse); - expect(trimmed.messages.last.id, 'a2'); - expect(trimmed.messages.any((m) => m.id == 't1'), isFalse); - }); - }); -} diff --git a/packages/flutter_ai/flutter_ai_client/test/follow_ups_and_store_test.dart b/packages/flutter_ai/flutter_ai_client/test/follow_ups_and_store_test.dart deleted file mode 100644 index be770f3..0000000 --- a/packages/flutter_ai/flutter_ai_client/test/follow_ups_and_store_test.dart +++ /dev/null @@ -1,134 +0,0 @@ -import 'package:flutter_ai_client/flutter_ai_client.dart'; -import 'package:flutter_test/flutter_test.dart'; - -/// A provider that replays fixed events, recording the conversation it saw. -class ScriptedProvider implements LlmProvider { - ScriptedProvider(this.events); - - final List events; - AiConversation? lastConversation; - - @override - Stream send( - AiConversation conversation, { - List? tools, - AiRequestOptions? options, - }) async* { - lastConversation = conversation; - for (final event in events) { - yield event; - } - } -} - -/// An in-memory [KeyValueStore] standing in for shared_preferences/a file. -class MapKeyValueStore implements KeyValueStore { - final Map data = {}; - - @override - Future read(String key) async => data[key]; - - @override - Future write(String key, String value) async => data[key] = value; - - @override - Future remove(String key) async => data.remove(key); -} - -AiConversation _conv(String id, List userTexts) => AiConversation( - id: id, - messages: [ - for (var i = 0; i < userTexts.length; i++) - AiMessage.text(id: 'm$i', role: AiRole.user, text: userTexts[i]), - ], - ); - -void main() { - group('suggestFollowUps', () { - test('parses one-per-line, strips markers, and caps at count', () async { - final provider = ScriptedProvider([ - const MessageStarted(messageId: 'a1', role: AiRole.assistant), - const TextDelta(messageId: 'a1', delta: '1. What about pricing?\n'), - const TextDelta(messageId: 'a1', delta: '- How do I deploy?\n'), - const TextDelta(messageId: 'a1', delta: '"Any alternatives?"\n'), - const TextDelta(messageId: 'a1', delta: 'Extra one that is dropped\n'), - const MessageFinished(messageId: 'a1', reason: FinishReason.stop), - ]); - - final result = await suggestFollowUps( - _conv('c1', ['Tell me about the product']), - provider, - count: 3, - ); - - expect(result, - ['What about pricing?', 'How do I deploy?', 'Any alternatives?']); - // The follow-up instruction is appended to the sent conversation. - expect(provider.lastConversation!.messages.length, 2); - }); - - test('returns empty for an empty conversation without calling send', - () async { - final provider = ScriptedProvider([]); - final result = - await suggestFollowUps(const AiConversation.empty('c0'), provider); - expect(result, isEmpty); - expect(provider.lastConversation, isNull); - }); - }); - - group('KeyValueChatThreadStore', () { - test('round-trips conversations and maintains a newest-first index', - () async { - final kv = MapKeyValueStore(); - final store = KeyValueChatThreadStore(kv); - - await store.save('t1', _conv('t1', ['First thread hello'])); - await store.save('t2', _conv('t2', ['Second thread hi'])); - - final loaded = await store.load('t1'); - expect(loaded, isNotNull); - expect(loaded!.messages.single.text, 'First thread hello'); - - final threads = await store.listThreads(); - expect(threads.map((t) => t.id), ['t2', 't1']); // newest first - expect(threads.first.title, 'Second thread hi'); - - await store.delete('t1'); - expect(await store.load('t1'), isNull); - expect((await store.listThreads()).map((t) => t.id), ['t2']); - }); - - test('survives a fresh store instance over the same backing storage', - () async { - final kv = MapKeyValueStore(); - await KeyValueChatThreadStore(kv).save('t1', _conv('t1', ['persisted'])); - - // A new app launch: a new store over the same storage. - final reopened = KeyValueChatThreadStore(kv); - expect((await reopened.load('t1'))!.messages.single.text, 'persisted'); - expect((await reopened.listThreads()).single.id, 't1'); - }); - }); - - group('UseChatController.load', () { - void syncScheduler(void Function() callback) => callback(); - - test('swaps the transcript in place and resets branch state', () { - final controller = UseChatController( - provider: ScriptedProvider([]), - initial: _conv('a', ['old thread']), - scheduler: syncScheduler, - ); - addTearDown(controller.dispose); - - controller.load(_conv('b', ['new thread', 'and more'])); - - expect(controller.conversation.id, 'b'); - expect(controller.messages.length, 2); - expect(controller.messages.first.text, 'new thread'); - expect(controller.status, ChatStatus.idle); - expect(controller.branchCount, 0); - }); - }); -} diff --git a/packages/flutter_ai/flutter_ai_client/test/use_chat_controller_test.dart b/packages/flutter_ai/flutter_ai_client/test/use_chat_controller_test.dart deleted file mode 100644 index 23839f2..0000000 --- a/packages/flutter_ai/flutter_ai_client/test/use_chat_controller_test.dart +++ /dev/null @@ -1,1614 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_ai_client/flutter_ai_client.dart'; -import 'package:flutter_test/flutter_test.dart'; - -/// A provider whose stream is driven manually by the test. -class ManualProvider implements LlmProvider { - StreamController? _controller; - - /// The controller backing the most recent [send] call. - StreamController get current => _controller!; - - @override - Stream send( - AiConversation conversation, { - List? tools, - AiRequestOptions? options, - }) { - // ignore: close_sinks — test fixture; closed indirectly via controller.stop. - final controller = StreamController(); - _controller = controller; - return controller.stream; - } -} - -/// A provider that replays a fixed list of events, then closes. -class ScriptedProvider implements LlmProvider { - ScriptedProvider(this.events); - - final List events; - int sendCount = 0; - AiConversation? lastConversation; - - @override - Stream send( - AiConversation conversation, { - List? tools, - AiRequestOptions? options, - }) async* { - sendCount++; - lastConversation = conversation; - for (final event in events) { - yield event; - } - } -} - -void main() { - // Run scheduled notifications synchronously for deterministic assertions. - void syncScheduler(void Function() callback) => callback(); - - group('message ids', () { - test('default generator does not collide with a rehydrated transcript', () { - // A transcript persisted by a previous session, using the old msg-N ids. - const rehydrated = AiConversation( - id: 'thread-1', - messages: [ - AiMessage(id: 'msg-0', role: AiRole.user, parts: [TextPart('hi')]), - AiMessage( - id: 'msg-1', - role: AiRole.assistant, - parts: [TextPart('hello')], - status: AiMessageStatus.complete, - ), - ], - ); - final controller = UseChatController( - provider: ManualProvider(), - scheduler: syncScheduler, - initial: rehydrated, - // No idGenerator override: exercise the real default. - ); - addTearDown(controller.dispose); - - unawaited(controller.sendText('second question')); - - final ids = controller.messages.map((m) => m.id).toList(); - expect(ids.toSet(), hasLength(ids.length), reason: 'ids must be unique'); - expect(ids, containsAll(['msg-0', 'msg-1'])); - // The newly appended user message must not reuse a rehydrated id. - expect(ids.where((id) => id == 'msg-0' || id == 'msg-1'), hasLength(2)); - }); - }); - - group('sendText / submit', () { - test('appends the user message optimistically before any response', () { - final provider = ManualProvider(); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: () => 'u1', - ); - addTearDown(controller.dispose); - - unawaited(controller.sendText('Hello')); - - expect(controller.messages, hasLength(1)); - expect(controller.messages.single.role, AiRole.user); - expect(controller.messages.single.text, 'Hello'); - expect(controller.status, ChatStatus.submitted); - }); - - test('folds streamed events into an assistant message', () async { - final provider = ScriptedProvider(const [ - MessageStarted(messageId: 'a1', role: AiRole.assistant), - TextDelta(messageId: 'a1', delta: 'Hi '), - TextDelta(messageId: 'a1', delta: 'there'), - MessageFinished(messageId: 'a1', reason: FinishReason.stop), - ]); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: () => 'u1', - ); - addTearDown(controller.dispose); - - await controller.sendText('Hello'); - - expect(controller.status, ChatStatus.idle); - expect(controller.messages.map((m) => m.role), [ - AiRole.user, - AiRole.assistant, - ]); - expect(controller.messages.last.text, 'Hi there'); - expect(controller.messages.last.status, AiMessageStatus.complete); - }); - - test('sendText with empty text and no attachments is a no-op', () async { - final provider = ScriptedProvider(const []); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - ); - addTearDown(controller.dispose); - - await controller.sendText(''); - expect(controller.messages, isEmpty); - expect(provider.sendCount, 0); - }); - - test('notifies listeners as the turn progresses', () async { - final provider = ScriptedProvider(const [ - TextDelta(messageId: 'a1', delta: 'x'), - MessageFinished(messageId: 'a1', reason: FinishReason.stop), - ]); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - ); - addTearDown(controller.dispose); - - var notifications = 0; - controller.addListener(() => notifications++); - - await controller.sendText('hi'); - expect(notifications, greaterThan(0)); - }); - }); - - group('events stream', () { - test('re-emits applied events', () async { - final provider = ScriptedProvider(const [ - TextDelta(messageId: 'a1', delta: 'one'), - MessageFinished(messageId: 'a1', reason: FinishReason.stop), - ]); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - ); - addTearDown(controller.dispose); - - final seen = []; - final sub = controller.events.listen(seen.add); - addTearDown(sub.cancel); - - await controller.sendText('hi'); - expect(seen, hasLength(2)); - expect(seen.first, isA()); - }); - }); - - group('stop', () { - test('cancels streaming and finalizes the message as stopped', () async { - final provider = ManualProvider(); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: () => 'u1', - ); - addTearDown(controller.dispose); - - final turn = controller.sendText('Hello'); - provider.current.add( - const MessageStarted(messageId: 'a1', role: AiRole.assistant), - ); - provider.current.add(const TextDelta(messageId: 'a1', delta: 'partial')); - await Future.delayed(Duration.zero); - - controller.stop(); - await turn; // stop completes the in-flight turn future - - expect(controller.status, ChatStatus.idle); - final assistant = controller.messages.last; - expect(assistant.status, AiMessageStatus.complete); - expect(assistant.finishReason, FinishReason.stop); - }); - }); - - group('interrupting a stream finalizes the trailing message', () { - test('submit mid-stream settles the interrupted assistant message', - () async { - final provider = ManualProvider(); - var n = 0; - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: () => 'u${n++}', - ); - addTearDown(controller.dispose); - - unawaited(controller.sendText('Hello')); - provider.current - ..add(const MessageStarted(messageId: 'a1', role: AiRole.assistant)) - ..add(const TextDelta(messageId: 'a1', delta: 'partial')); - await Future.delayed(Duration.zero); - expect(controller.messages.firstWhere((m) => m.id == 'a1').status, - AiMessageStatus.streaming); - - // Start a new turn before the first finished. - unawaited(controller.sendText('Again')); - - // The interrupted assistant message must not linger as a typing - // indicator (which would also be persisted by attachStore). - final a1 = controller.messages.firstWhere((m) => m.id == 'a1'); - expect(a1.status, isNot(AiMessageStatus.streaming)); - }); - - test('an in-band messageId-less error settles the streaming message', - () async { - final provider = ManualProvider(); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: () => 'u1', - ); - addTearDown(controller.dispose); - - final turn = controller.sendText('Hello'); - provider.current - ..add(const MessageStarted(messageId: 'a1', role: AiRole.assistant)) - ..add(const TextDelta(messageId: 'a1', delta: 'partial')) - ..add(const StreamErrorEvent(error: 'boom')); // messageId: null - await turn; - - expect(controller.status, ChatStatus.error); - final a1 = controller.messages.firstWhere((m) => m.id == 'a1'); - expect(a1.status, AiMessageStatus.error); - }); - }); - - group('regenerate', () { - test('drops the prior assistant turn and re-runs from the user message', - () async { - final provider = ScriptedProvider(const [ - MessageStarted(messageId: 'a1', role: AiRole.assistant), - TextDelta(messageId: 'a1', delta: 'answer'), - MessageFinished(messageId: 'a1', reason: FinishReason.stop), - ]); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: () => 'u1', - ); - addTearDown(controller.dispose); - - await controller.sendText('question'); - expect(controller.messages, hasLength(2)); - - await controller.regenerate(); - expect(provider.sendCount, 2); - // Still exactly one user + one assistant; the old assistant was dropped. - expect(controller.messages.map((m) => m.role), [ - AiRole.user, - AiRole.assistant, - ]); - }); - - test('is a no-op with no user message', () async { - final provider = ScriptedProvider(const []); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - ); - addTearDown(controller.dispose); - - await controller.regenerate(); - expect(provider.sendCount, 0); - }); - }); - - group('error handling', () { - test('surfaces a thrown provider error as error status', () async { - final provider = _ThrowingProvider(); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - ); - addTearDown(controller.dispose); - - await controller.sendText('hi'); - expect(controller.status, ChatStatus.error); - expect(controller.error, isNotNull); - }); - - test('surfaces an in-band StreamErrorEvent as error status', () async { - final provider = ScriptedProvider(const [ - MessageStarted(messageId: 'a1', role: AiRole.assistant), - StreamErrorEvent(error: 'upstream timeout', messageId: 'a1'), - ]); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - ); - addTearDown(controller.dispose); - - await controller.sendText('hi'); - expect(controller.status, ChatStatus.error); - expect(controller.error, 'upstream timeout'); - }); - - test('a synchronous throw from provider.send fails the turn cleanly', - () async { - final controller = UseChatController( - provider: _SyncThrowingProvider(), - scheduler: syncScheduler, - ); - addTearDown(controller.dispose); - - // Must complete (not hang) and land in error, not stay `submitted`. - await controller.sendText('hi'); - expect(controller.status, ChatStatus.error); - expect(controller.error, isA()); - }); - - test('a synchronous throw from trimHistory fails the turn cleanly', - () async { - final controller = UseChatController( - provider: ManualProvider(), - scheduler: syncScheduler, - trimHistory: (_) => throw StateError('trim boom'), - ); - addTearDown(controller.dispose); - - await controller.sendText('hi'); - expect(controller.status, ChatStatus.error); - expect(controller.error, isA()); - }); - - test('captures the stack trace alongside a thrown provider error', - () async { - final provider = _ThrowingProvider(); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - ); - addTearDown(controller.dispose); - - await controller.sendText('hi'); - expect(controller.error, isNotNull); - expect(controller.stackTrace, isNotNull); - - // A new turn resets both error and stack trace. - controller.setProvider(ManualProvider()); - unawaited(controller.sendText('again')); - expect(controller.error, isNull); - expect(controller.stackTrace, isNull); - }); - - test('a fatal in-band error tears down the turn and ignores later deltas', - () async { - // Message-scoped error, followed by more deltas the provider keeps - // pushing. The fatal error must cancel the subscription so the later - // deltas never reach the conversation, and the turn future must complete. - final provider = ManualProvider(); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: () => 'u1', - ); - addTearDown(controller.dispose); - - final turn = controller.sendText('hi'); - provider.current - ..add(const MessageStarted(messageId: 'a1', role: AiRole.assistant)) - ..add(const TextDelta(messageId: 'a1', delta: 'before')); - await Future.delayed(Duration.zero); - - provider.current - ..add(const StreamErrorEvent(error: 'fatal', messageId: 'a1')) - // These arrive after the fatal error and must be ignored. - ..add(const TextDelta(messageId: 'a1', delta: ' AFTER')) - ..add( - const MessageFinished(messageId: 'a1', reason: FinishReason.stop)); - - // The turn future completes despite the stream never closing. - await turn; - - expect(controller.status, ChatStatus.error); - expect(controller.error, 'fatal'); - expect(controller.messages.last.text, 'before'); - expect(controller.messages.last.text, isNot(contains('AFTER'))); - }); - }); - - group('addToolResults', () { - test('appends a tool message and continues the turn', () async { - final provider = ScriptedProvider(const [ - MessageStarted(messageId: 'a2', role: AiRole.assistant), - TextDelta(messageId: 'a2', delta: 'done'), - MessageFinished(messageId: 'a2', reason: FinishReason.stop), - ]); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: () => 't1', - ); - addTearDown(controller.dispose); - - await controller.addToolResults(const [ - ToolResultPart(toolCallId: 'c1', result: 'ok'), - ]); - - expect(provider.sendCount, 1); - expect(controller.messages.first.role, AiRole.tool); - expect(controller.messages.last.role, AiRole.assistant); - expect(controller.messages.last.text, 'done'); - }); - - test('is a no-op with empty results', () async { - final provider = ScriptedProvider(const []); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - ); - addTearDown(controller.dispose); - - await controller.addToolResults(const []); - expect(provider.sendCount, 0); - expect(controller.messages, isEmpty); - }); - }); - - group('configuration', () { - test('setOptions forwards new options to the provider', () async { - final provider = _OptionsCapturingProvider(); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - ); - addTearDown(controller.dispose); - - controller.setOptions(const AiRequestOptions(model: 'gpt-4o-mini')); - await controller.sendText('hi'); - expect(provider.lastOptions?.model, 'gpt-4o-mini'); - }); - - test('setTools forwards new tools to the provider', () async { - final provider = _ToolsCapturingProvider(); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - ); - addTearDown(controller.dispose); - - controller.setTools(const [ - ToolDefinition(name: 'lookup', description: 'Looks something up'), - ]); - await controller.sendText('hi'); - expect(provider.lastTools, hasLength(1)); - expect(provider.lastTools?.single.name, 'lookup'); - }); - - test('clear empties the transcript', () async { - final provider = ScriptedProvider(const [ - MessageFinished(messageId: 'a1', reason: FinishReason.stop), - ]); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - ); - addTearDown(controller.dispose); - - await controller.sendText('hi'); - expect(controller.messages, isNotEmpty); - controller.clear(); - expect(controller.messages, isEmpty); - expect(controller.status, ChatStatus.idle); - }); - }); - - group('regeneration branches', () { - test('regenerate keeps prior versions; selectBranch navigates them', - () async { - final provider = _CountingProvider(); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: () => 'u1', - ); - addTearDown(controller.dispose); - - await controller.sendText('hi'); - expect(controller.branchCount, 1); - expect(controller.messages.last.text, 'reply 1'); - - await controller.regenerate(); - expect(controller.branchCount, 2); - expect(controller.branchIndex, 1); - expect(controller.messages.last.text, 'reply 2'); - - // Navigate back to the first version. - controller.selectBranch(0); - expect(controller.branchIndex, 0); - expect(controller.messages.last.text, 'reply 1'); - - // A new user message resets the branch set. - await controller.sendText('again'); - expect(controller.branchCount, 1); - expect(controller.branchIndex, 0); - }); - }); - - group('editMessage', () { - String Function() seqIds() { - var n = 0; - return () => 'u${n++}'; - } - - test('rewrites a user message, drops what follows, and re-runs', () async { - final provider = _CountingProvider(); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: seqIds(), - ); - addTearDown(controller.dispose); - - await controller.sendText('first'); - await controller.sendText('second'); - expect(controller.messages, hasLength(4)); // 2 user + 2 assistant - - final firstUserId = - controller.messages.firstWhere((m) => m.role == AiRole.user).id; - await controller.editMessage(firstUserId, 'first edited'); - - final users = - controller.messages.where((m) => m.role == AiRole.user).toList(); - expect(users, hasLength(1)); // 'second' and its answer were discarded - expect(users.single.text, 'first edited'); - expect(controller.messages, hasLength(2)); // edited user + fresh reply - expect(controller.branchCount, 1); // a reworded prompt resets branches - expect(provider.lastConversation?.messages.last.text, 'first edited'); - }); - - test('editLastUserMessage edits the most recent user turn', () async { - final provider = _CountingProvider(); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: seqIds(), - ); - addTearDown(controller.dispose); - - await controller.sendText('first'); - await controller.sendText('second'); - await controller.editLastUserMessage('second edited'); - - final users = - controller.messages.where((m) => m.role == AiRole.user).toList(); - expect(users, hasLength(2)); - expect(users.last.text, 'second edited'); - }); - - test('preserves non-text parts (attachments) when editing text', () async { - final provider = _CountingProvider(); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: seqIds(), - ); - addTearDown(controller.dispose); - - final image = FilePart( - mediaType: 'image/png', - url: Uri.parse('https://example.com/cat.png'), - ); - await controller.sendText('look', attachments: [image]); - final userId = - controller.messages.firstWhere((m) => m.role == AiRole.user).id; - - await controller.editMessage(userId, 'look again'); - - final edited = - controller.messages.firstWhere((m) => m.role == AiRole.user); - expect(edited.text, 'look again'); - expect(edited.parts.whereType(), hasLength(1)); - }); - - test('is a no-op for an unknown id or a non-user message', () async { - final provider = _CountingProvider(); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: seqIds(), - ); - addTearDown(controller.dispose); - - await controller.sendText('hi'); - final assistantId = - controller.messages.firstWhere((m) => m.role == AiRole.assistant).id; - - await controller.editMessage('does-not-exist', 'x'); - await controller.editMessage(assistantId, 'x'); - expect(controller.messages, hasLength(2)); - expect(controller.messages.first.text, 'hi'); // unchanged - }); - }); - - group('branch memory', () { - test('caps retained regenerations at maxBranches', () async { - final provider = _CountingProvider(); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: () => 'u1', - maxBranches: 3, - ); - addTearDown(controller.dispose); - - await controller.sendText('hi'); - for (var i = 0; i < 6; i++) { - await controller.regenerate(); - } - expect(controller.branchCount, 3); // oldest versions evicted - expect(controller.branchIndex, 2); - }); - }); - - group('agent loop (onToolCalls)', () { - String Function() seqIds() { - var n = 0; - return () => 'm${n++}'; - } - - test('auto-executes tools and continues to a final answer', () async { - final provider = _ToolThenTextProvider(); - var executed = 0; - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: seqIds(), - onToolCalls: (calls, signal) async { - executed++; - return [ - for (final c in calls) - ToolResultPart(toolCallId: c.toolCallId, result: {'temp': 25}), - ]; - }, - ); - addTearDown(controller.dispose); - - await controller.sendText('weather in Lisbon?'); - - expect(executed, 1); - expect(provider.sendCount, 2); // tool call, then final answer - expect(controller.status, ChatStatus.idle); - expect(controller.messages.last.role, AiRole.assistant); - expect(controller.messages.last.text, 'It is sunny.'); - // The tool result message is in the transcript between the two assistant - // turns. - expect( - controller.messages.where((m) => m.role == AiRole.tool), - hasLength(1), - ); - }); - - test('stays busy (executingTools) while the tool executor runs', () async { - final provider = _ToolThenTextProvider(); - ChatStatus? statusDuringExecutor; - late UseChatController controller; - controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: seqIds(), - onToolCalls: (calls, signal) async { - statusDuringExecutor = controller.status; - return [ - for (final c in calls) - ToolResultPart(toolCallId: c.toolCallId, result: {'temp': 25}), - ]; - }, - ); - addTearDown(controller.dispose); - - await controller.sendText('weather in Lisbon?'); - - // The turn must not appear idle between the model's tool call and the - // executor's results — that flicker re-enables input and lets stores - // persist a mid-turn transcript. - expect(statusDuringExecutor, ChatStatus.executingTools); - expect(statusDuringExecutor!.isBusy, isTrue); - expect(controller.status, ChatStatus.idle); - }); - - test('selectBranch is a no-op while the tool executor runs', () async { - final provider = _ToolThenTextProvider(); - var branchAttemptRejected = false; - late UseChatController controller; - controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: seqIds(), - onToolCalls: (calls, signal) async { - // Attempting a branch switch mid-loop must be rejected (no _turn is - // ever null here), preventing transcript corruption. - final before = controller.messages.length; - controller.selectBranch(0); - branchAttemptRejected = controller.messages.length == before; - return [ - for (final c in calls) - ToolResultPart(toolCallId: c.toolCallId, result: {'temp': 25}), - ]; - }, - ); - addTearDown(controller.dispose); - - await controller.sendText('weather in Lisbon?'); - - expect(branchAttemptRejected, isTrue); - expect(controller.status, ChatStatus.idle); - }); - - test('stops at maxSteps when tools never resolve', () async { - final provider = _AlwaysToolProvider(); - var executed = 0; - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: seqIds(), - maxSteps: 3, - onToolCalls: (calls, signal) async { - executed++; - return [ - for (final c in calls) - ToolResultPart(toolCallId: c.toolCallId, result: 'ok'), - ]; - }, - ); - addTearDown(controller.dispose); - - await controller.sendText('loop forever'); - - expect(provider.sendCount, 3); // bounded by maxSteps model calls - expect(executed, 2); // tools run between calls (3 calls -> 2 rounds) - expect(controller.status, ChatStatus.idle); - }); - - test('halts with AgentLoopException on a runaway identical-call loop', - () async { - final provider = _AlwaysToolProvider(); // always requests ping({}) - var executed = 0; - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: seqIds(), - maxSteps: 20, - maxIdenticalToolCalls: 2, - onToolCalls: (calls, signal) async { - executed++; - return [ - for (final c in calls) - ToolResultPart(toolCallId: c.toolCallId, result: 'ok'), - ]; - }, - ); - addTearDown(controller.dispose); - - await controller.sendText('go'); - - // ping({}) runs twice (counts 1, 2); the third request trips the guard - // well before maxSteps (20). - expect(executed, 2); - expect(provider.sendCount, 3); - expect(controller.status, ChatStatus.error); - expect(controller.error, isA()); - expect((controller.error as AgentLoopException).toolName, 'ping'); - }); - - test('does not trip the loop guard when it is disabled (default)', - () async { - final provider = _AlwaysToolProvider(); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: seqIds(), - maxSteps: 3, // bounded by maxSteps, not the loop guard - onToolCalls: (calls, signal) async => [ - for (final c in calls) - ToolResultPart(toolCallId: c.toolCallId, result: 'ok'), - ], - ); - addTearDown(controller.dispose); - - await controller.sendText('go'); - - expect(controller.status, ChatStatus.idle); - expect(controller.error, isNull); - expect(provider.sendCount, 3); - }); - - test('ChatObserver receives the full lifecycle across a tool loop', - () async { - final provider = _ToolThenTextProvider(); - final observer = _RecordingObserver(); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: seqIds(), - observer: observer, - onToolCalls: (calls, signal) async => [ - for (final c in calls) - ToolResultPart(toolCallId: c.toolCallId, result: {'temp': 25}), - ], - ); - addTearDown(controller.dispose); - - await controller.sendText('weather?'); - - expect(observer.events, [ - 'turnStart', - 'request:1', - 'response:1:toolCalls', - 'toolCalls:1', - 'toolResults:1', - 'request:2', - 'response:2:stop', - 'turnEnd', - ]); - }); - - test('ChatObserver.onError fires before onTurnEnd on a failed turn', - () async { - final provider = ScriptedProvider([ - const StreamErrorEvent(error: 'boom'), - ]); - final observer = _RecordingObserver(); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: seqIds(), - observer: observer, - ); - addTearDown(controller.dispose); - - await controller.sendText('go'); - - expect(observer.events, ['turnStart', 'request:1', 'error', 'turnEnd']); - }); - - test('stops the loop once the token budget is exceeded', () async { - final provider = _AlwaysToolProvider(); // 100 output tokens per turn - var executed = 0; - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: seqIds(), - maxSteps: 10, - tokenBudget: 150, - onToolCalls: (calls, signal) async { - executed++; - return [ - for (final c in calls) - ToolResultPart(toolCallId: c.toolCallId, result: 'ok'), - ]; - }, - ); - addTearDown(controller.dispose); - - await controller.sendText('go'); - - // turn1 (100 < 150) continues; after turn2 (200 >= 150) the loop stops. - expect(provider.sendCount, 2); - expect(executed, 1); - }); - - test('stop() cancels the in-flight tool-call signal', () async { - final provider = _ToolThenTextProvider(); - final gate = Completer(); // holds the executor open - var observedCancel = false; - AiToolCallSignal? captured; - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: seqIds(), - onToolCalls: (calls, signal) async { - captured = signal; - unawaited(signal.whenCancelled.then((_) => observedCancel = true)); - await gate.future; // long-running tool work - return [ - for (final c in calls) - ToolResultPart(toolCallId: c.toolCallId, result: 'ok'), - ]; - }, - ); - addTearDown(() { - if (!gate.isCompleted) gate.complete(); - controller.dispose(); - }); - - unawaited(controller.sendText('weather?')); - // Let the first stream finish and the executor start awaiting the gate. - for (var i = 0; i < 10 && captured == null; i++) { - await Future.delayed(Duration.zero); - } - expect(captured, isNotNull); - expect(captured!.isCancelled, isFalse); - - controller.stop(); - await Future.delayed(Duration.zero); // let whenCancelled fire - - expect(captured!.isCancelled, isTrue); - expect(observedCancel, isTrue); - // throwIfCancelled now throws for the executor. - expect(captured!.throwIfCancelled, throwsA(isA())); - expect(controller.status, ChatStatus.idle); - }); - - test('without onToolCalls, the turn ends with the tool call (manual mode)', - () async { - final provider = _ToolThenTextProvider(); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: seqIds(), - ); - addTearDown(controller.dispose); - - await controller.sendText('weather?'); - - expect(provider.sendCount, 1); // no auto-continue - expect( - controller.messages.last.parts.whereType(), - hasLength(1), - ); - }); - - test('submit settles dangling tool calls with synthesized error results', - () async { - // Turn 1 ends with an unanswered tool call (manual mode, never - // executed). Submitting a new user message must first append error - // results for it — providers reject a history containing a tool call - // with no following result. - final provider = _ToolThenTextProvider(); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: seqIds(), - ); - addTearDown(controller.dispose); - - await controller.sendText('weather?'); - await controller.sendText('never mind'); - - final messages = controller.messages; - // user, assistant(tool call), tool(synthesized error), user, assistant. - expect(messages.map((m) => m.role).toList(), [ - AiRole.user, - AiRole.assistant, - AiRole.tool, - AiRole.user, - AiRole.assistant, - ]); - final settled = - messages[2].parts.whereType().single; - expect(settled.toolCallId, 'c1'); - expect(settled.isError, isTrue); - }); - - test('submit settles dangling tool calls buried mid-history', () async { - // A rehydrated transcript where an interrupted agent loop left an - // unanswered tool call in the MIDDLE of the history (later turns - // completed normally). The settle must insert the synthesized result - // directly after the affected assistant message — providers require the - // result in the immediately-following turn, so appending at the end - // would not fix the request. - const dirty = AiConversation( - id: 'thread-dirty', - messages: [ - AiMessage(id: 'u1', role: AiRole.user, parts: [TextPart('q1')]), - AiMessage( - id: 'a1', - role: AiRole.assistant, - parts: [ - ToolCallPart( - toolCallId: 'c9', - toolName: 'get_week_schedule', - args: {}, - ), - ], - status: AiMessageStatus.complete, - ), - AiMessage(id: 'u2', role: AiRole.user, parts: [TextPart('q2')]), - AiMessage( - id: 'a2', - role: AiRole.assistant, - parts: [TextPart('answer 2')], - status: AiMessageStatus.complete, - ), - ], - ); - final provider = ScriptedProvider(const [ - MessageStarted(messageId: 'a3', role: AiRole.assistant), - TextDelta(messageId: 'a3', delta: 'answer 3'), - MessageFinished(messageId: 'a3', reason: FinishReason.stop), - ]); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: seqIds(), - initial: dirty, - ); - addTearDown(controller.dispose); - - await controller.sendText('q3'); - - final messages = controller.messages; - // u1, a1(tool call), tool(synthesized), u2, a2, u3, a3. - expect(messages.map((m) => m.role).toList(), [ - AiRole.user, - AiRole.assistant, - AiRole.tool, - AiRole.user, - AiRole.assistant, - AiRole.user, - AiRole.assistant, - ]); - final settled = messages[2].parts.whereType().single; - expect(settled.toolCallId, 'c9'); - expect(settled.isError, isTrue); - // The provider must have been sent the settled history too. - final sent = provider.lastConversation!.messages; - expect(sent[2].role, AiRole.tool); - }); - }); - - group('threads', () { - test('autoTitle uses the first user message, trimmed', () { - const convo = AiConversation( - id: 't', - messages: [ - AiMessage(id: 'a', role: AiRole.assistant, parts: [TextPart('hi')]), - AiMessage( - id: 'u', - role: AiRole.user, - parts: [TextPart(' Plan a weekend in Lisbon please ')], - ), - ], - ); - expect(autoTitle(convo), 'Plan a weekend in Lisbon please'); - expect( - autoTitle(const AiConversation(id: 'e', messages: [])), 'New chat'); - }); - - test('InMemoryChatThreadStore saves, lists, loads, and deletes', () async { - final store = InMemoryChatThreadStore(); - const a = AiConversation( - id: 'a', - messages: [ - AiMessage(id: 'u', role: AiRole.user, parts: [TextPart('First')]), - ], - ); - const b = AiConversation( - id: 'b', - messages: [ - AiMessage(id: 'u', role: AiRole.user, parts: [TextPart('Second')]), - ], - ); - await store.save('a', a); - await store.save('b', b); - - final threads = await store.listThreads(); - expect(threads.map((t) => t.title), containsAll(['First', 'Second'])); - expect((await store.load('a'))?.messages.single.text, 'First'); - - await store.delete('a'); - expect(await store.load('a'), isNull); - expect((await store.listThreads()).map((t) => t.id), ['b']); - }); - }); - - group('usage', () { - test('totalUsage sums reported usage across messages', () async { - final provider = ScriptedProvider(const [ - MessageStarted(messageId: 'a1', role: AiRole.assistant), - TextDelta(messageId: 'a1', delta: 'hi'), - MessageFinished( - messageId: 'a1', - reason: FinishReason.stop, - usage: AiUsage(inputTokens: 10, outputTokens: 5), - ), - ]); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: () => 'u1', - ); - addTearDown(controller.dispose); - - await controller.sendText('hello'); - - expect(controller.totalUsage?.inputTokens, 10); - expect(controller.totalUsage?.outputTokens, 5); - expect(controller.messages.last.usage?.outputTokens, 5); - }); - }); - - group('attachStore / ChatStore', () { - test('auto-saves the settled conversation and can be reloaded', () async { - final store = FakeChatStore(); - final provider = ScriptedProvider(const [ - MessageStarted(messageId: 'a1', role: AiRole.assistant), - TextDelta(messageId: 'a1', delta: 'hi'), - MessageFinished(messageId: 'a1', reason: FinishReason.stop), - ]); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: () => 'u1', - ); - final detach = attachStore( - controller, - store, - 'thread-1', - debounce: const Duration(milliseconds: 5), - ); - addTearDown(controller.dispose); - - await controller.sendText('hello'); - // Let the debounce timer fire after the turn has settled. - await Future.delayed(const Duration(milliseconds: 20)); - - final saved = store.saves['thread-1']; - expect(saved, isNotNull); - expect(saved!.messages, hasLength(2)); - expect(saved.messages.last.text, 'hi'); - - // A fresh controller seeded from the store restores the transcript. - final restored = UseChatController( - provider: provider, - scheduler: syncScheduler, - initial: await store.load('thread-1'), - ); - addTearDown(restored.dispose); - expect(restored.messages, hasLength(2)); - expect(restored.messages.last.text, 'hi'); - - detach(); - }); - - test('detach flushes a pending save without waiting for the debounce', - () async { - final store = FakeChatStore(); - final provider = ScriptedProvider(const [ - MessageStarted(messageId: 'a1', role: AiRole.assistant), - TextDelta(messageId: 'a1', delta: 'hi'), - MessageFinished(messageId: 'a1', reason: FinishReason.stop), - ]); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: () => 'u1', - ); - // Long debounce: the save is scheduled but won't fire on its own here. - final detach = attachStore( - controller, - store, - 'thread-2', - debounce: const Duration(seconds: 5), - ); - addTearDown(controller.dispose); - - await controller.sendText('hello'); - expect(store.saves['thread-2'], isNull); // debounce hasn't elapsed - - detach(); // flushes the pending save synchronously - expect(store.saves['thread-2'], isNotNull); - expect(store.saves['thread-2']!.messages.last.text, 'hi'); - }); - - test('skips saving mid-stream, then saves once the turn settles', () async { - final store = FakeChatStore(); - final provider = ManualProvider(); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: () => 'u1', - ); - final detach = attachStore( - controller, - store, - 'thread-3', - debounce: const Duration(milliseconds: 5), - ); - addTearDown(controller.dispose); - - unawaited(controller.sendText('hello')); - provider.current.add( - const MessageStarted(messageId: 'a1', role: AiRole.assistant), - ); - provider.current.add(const TextDelta(messageId: 'a1', delta: 'partial')); - await Future.delayed(const Duration(milliseconds: 20)); - expect(store.saves['thread-3'], isNull); // still streaming - - controller.stop(); // turn settles - await Future.delayed(const Duration(milliseconds: 20)); - expect(store.saves['thread-3'], isNotNull); - - detach(); - }); - }); - - group('tool-argument validation', () { - String Function() seqIds() { - var n = 0; - return () => 'm${n++}'; - } - - const weatherTool = ToolDefinition( - name: 'get_weather', - description: 'weather', - parametersSchema: { - 'type': 'object', - 'properties': { - 'city': {'type': 'string'}, - }, - 'required': ['city'], - }, - ); - - test('invalid args are not executed and an error result is fed back', - () async { - final provider = _BadThenGoodToolProvider(); - final executedArgs = >[]; - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: seqIds(), - tools: const [weatherTool], - onToolCalls: (calls, signal) async { - for (final c in calls) { - executedArgs.add(c.args); - } - return [ - for (final c in calls) - ToolResultPart(toolCallId: c.toolCallId, result: {'temp': 25}), - ]; - }, - ); - addTearDown(controller.dispose); - - await controller.sendText('weather?'); - - // The executor only ever saw the corrected (valid) call. - expect(executedArgs, [ - {'city': 'Lisbon'} - ]); - // Three model calls: bad call, corrected call, final text. - expect(provider.sendCount, 3); - expect(controller.messages.last.text, 'It is sunny.'); - - // An error tool result for the bad call is in the transcript. - final errorResults = [ - for (final m in controller.messages) - for (final p in m.parts) - if (p is ToolResultPart && p.isError) p, - ]; - expect(errorResults, hasLength(1)); - expect( - (errorResults.single.result! as Map)['error'], - 'invalid_arguments', - ); - }); - - test( - 'validateToolArgs: false hands malformed args straight to the executor', - () async { - final provider = _BadThenGoodToolProvider(); - final executedArgs = >[]; - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: seqIds(), - tools: const [weatherTool], - validateToolArgs: false, - onToolCalls: (calls, signal) async { - for (final c in calls) { - executedArgs.add(c.args); - } - return [ - for (final c in calls) - ToolResultPart(toolCallId: c.toolCallId, result: 'ok'), - ]; - }, - ); - addTearDown(controller.dispose); - - await controller.sendText('weather?'); - - // The bad args (city is an int) reached the executor unchallenged. - expect(executedArgs.first['city'], 123); - }); - }); - - group('history trimming (trimHistory)', () { - test('the provider sees a trimmed conversation; the store keeps all', - () async { - final provider = _ConversationCapturingProvider(); - final controller = UseChatController( - provider: provider, - scheduler: syncScheduler, - idGenerator: () => 'u-new', - initial: const AiConversation( - id: 'c', - messages: [ - AiMessage(id: 's', role: AiRole.system, parts: [TextPart('sys')]), - AiMessage(id: 'u1', role: AiRole.user, parts: [TextPart('one')]), - AiMessage(id: 'a1', role: AiRole.assistant, parts: [TextPart('1')]), - AiMessage(id: 'u2', role: AiRole.user, parts: [TextPart('two')]), - AiMessage(id: 'a2', role: AiRole.assistant, parts: [TextPart('2')]), - ], - ), - trimHistory: keepLastMessages(1), - ); - addTearDown(controller.dispose); - - await controller.sendText('three'); - - // Provider saw: system + only the most recent non-system message before - // this turn's user message... plus the new user message. - final sentRoles = - provider.lastConversation!.messages.map((m) => m.role).toList(); - expect(sentRoles.first, AiRole.system); - // Far fewer than the full transcript. - expect( - provider.lastConversation!.messages.length, - lessThan(controller.messages.length), - ); - // Full transcript is retained on the controller. - expect(controller.messages.first.id, 's'); - expect(controller.messages.any((m) => m.id == 'u1'), isTrue); - }); - }); -} - -/// An in-memory [ChatStore] that records the latest save per id. -class FakeChatStore implements ChatStore { - final Map saves = {}; - - @override - Future load(String id) async => saves[id]; - - @override - Future save(String id, AiConversation conversation) async { - saves[id] = conversation; - } -} - -/// A provider whose reply text increments on every call, so regenerated -/// versions are distinguishable. -class _CountingProvider implements LlmProvider { - int _n = 0; - AiConversation? lastConversation; - - @override - Stream send( - AiConversation conversation, { - List? tools, - AiRequestOptions? options, - }) async* { - lastConversation = conversation; - _n++; - final id = 'a$_n'; - yield MessageStarted(messageId: id, role: AiRole.assistant); - yield TextDelta(messageId: id, delta: 'reply $_n'); - yield MessageFinished(messageId: id, reason: FinishReason.stop); - } -} - -class _ThrowingProvider implements LlmProvider { - @override - Stream send( - AiConversation conversation, { - List? tools, - AiRequestOptions? options, - }) async* { - throw StateError('provider exploded'); - } -} - -/// Throws synchronously from `send` (not via the stream), as the LlmProvider -/// contract permits for unrecoverable transport faults. -class _SyncThrowingProvider implements LlmProvider { - @override - Stream send( - AiConversation conversation, { - List? tools, - AiRequestOptions? options, - }) { - throw StateError('sync boom'); - } -} - -class _OptionsCapturingProvider implements LlmProvider { - AiRequestOptions? lastOptions; - - @override - Stream send( - AiConversation conversation, { - List? tools, - AiRequestOptions? options, - }) async* { - lastOptions = options; - yield const MessageFinished(messageId: 'a1', reason: FinishReason.stop); - } -} - -class _ToolsCapturingProvider implements LlmProvider { - List? lastTools; - - @override - Stream send( - AiConversation conversation, { - List? tools, - AiRequestOptions? options, - }) async* { - lastTools = tools; - yield const MessageFinished(messageId: 'a1', reason: FinishReason.stop); - } -} - -/// First send: a tool call. Second send: a final text answer. -class _ToolThenTextProvider implements LlmProvider { - int sendCount = 0; - - @override - Stream send( - AiConversation conversation, { - List? tools, - AiRequestOptions? options, - }) async* { - sendCount++; - if (sendCount == 1) { - yield const MessageStarted(messageId: 'a1', role: AiRole.assistant); - yield const ToolCallStarted( - messageId: 'a1', - toolCallId: 'c1', - toolName: 'get_weather', - ); - yield const ToolCallDelta( - toolCallId: 'c1', - argumentsDelta: '{"city":"Lisbon"}', - ); - yield const ToolCallReady(toolCallId: 'c1'); - yield const MessageFinished( - messageId: 'a1', - reason: FinishReason.toolCalls, - ); - } else { - yield const MessageStarted(messageId: 'a2', role: AiRole.assistant); - yield const TextDelta(messageId: 'a2', delta: 'It is sunny.'); - yield const MessageFinished(messageId: 'a2', reason: FinishReason.stop); - } - } -} - -/// Records the observer callbacks it receives as compact strings, for -/// order-sensitive assertions. -class _RecordingObserver extends ChatObserver { - final List events = []; - - @override - void onTurnStart(AiConversation conversation) => events.add('turnStart'); - - @override - void onModelRequest(int step) => events.add('request:$step'); - - @override - void onModelResponse({ - required int step, - AiUsage? usage, - FinishReason? finishReason, - }) => - events.add('response:$step:${finishReason?.name}'); - - @override - void onToolCalls(List calls) => - events.add('toolCalls:${calls.length}'); - - @override - void onToolResults(List results) => - events.add('toolResults:${results.length}'); - - @override - void onError(Object error, StackTrace? stackTrace) => events.add('error'); - - @override - void onTurnEnd({AiUsage? totalUsage}) => events.add('turnEnd'); -} - -/// Every send returns a fresh tool call, so the agent loop only stops at -/// maxSteps. Each assistant message gets a unique id/tool-call id. -class _AlwaysToolProvider implements LlmProvider { - int sendCount = 0; - - @override - Stream send( - AiConversation conversation, { - List? tools, - AiRequestOptions? options, - }) async* { - sendCount++; - final id = 'a$sendCount'; - final callId = 'c$sendCount'; - yield MessageStarted(messageId: id, role: AiRole.assistant); - yield ToolCallStarted(messageId: id, toolCallId: callId, toolName: 'ping'); - yield ToolCallDelta(toolCallId: callId, argumentsDelta: '{}'); - yield ToolCallReady(toolCallId: callId); - yield MessageFinished( - messageId: id, - reason: FinishReason.toolCalls, - usage: const AiUsage(outputTokens: 100), - ); - } -} - -/// First emits a `get_weather` call with a type-invalid `city` (an int), then a -/// corrected call, then a final text answer — exercising arg validation + -/// model self-correction. -class _BadThenGoodToolProvider implements LlmProvider { - int sendCount = 0; - - @override - Stream send( - AiConversation conversation, { - List? tools, - AiRequestOptions? options, - }) async* { - sendCount++; - if (sendCount == 1) { - yield const MessageStarted(messageId: 'a1', role: AiRole.assistant); - yield const ToolCallStarted( - messageId: 'a1', - toolCallId: 'c1', - toolName: 'get_weather', - ); - yield const ToolCallDelta( - toolCallId: 'c1', - argumentsDelta: '{"city":123}', - ); - yield const ToolCallReady(toolCallId: 'c1'); - yield const MessageFinished( - messageId: 'a1', - reason: FinishReason.toolCalls, - ); - } else if (sendCount == 2) { - yield const MessageStarted(messageId: 'a2', role: AiRole.assistant); - yield const ToolCallStarted( - messageId: 'a2', - toolCallId: 'c2', - toolName: 'get_weather', - ); - yield const ToolCallDelta( - toolCallId: 'c2', - argumentsDelta: '{"city":"Lisbon"}', - ); - yield const ToolCallReady(toolCallId: 'c2'); - yield const MessageFinished( - messageId: 'a2', - reason: FinishReason.toolCalls, - ); - } else { - yield const MessageStarted(messageId: 'a3', role: AiRole.assistant); - yield const TextDelta(messageId: 'a3', delta: 'It is sunny.'); - yield const MessageFinished(messageId: 'a3', reason: FinishReason.stop); - } - } -} - -/// Records the conversation passed to it, so trimming can be asserted. -class _ConversationCapturingProvider implements LlmProvider { - AiConversation? lastConversation; - - @override - Stream send( - AiConversation conversation, { - List? tools, - AiRequestOptions? options, - }) async* { - lastConversation = conversation; - yield const MessageStarted(messageId: 'r1', role: AiRole.assistant); - yield const TextDelta(messageId: 'r1', delta: 'ok'); - yield const MessageFinished(messageId: 'r1', reason: FinishReason.stop); - } -} diff --git a/packages/flutter_ai/flutter_ai_core/CHANGELOG.md b/packages/flutter_ai/flutter_ai_core/CHANGELOG.md deleted file mode 100644 index 009a3d8..0000000 --- a/packages/flutter_ai/flutter_ai_core/CHANGELOG.md +++ /dev/null @@ -1,128 +0,0 @@ -# Changelog - -## 0.1.14 - -- Fix: streamed `TextPart`/`ReasoningPart` now freeze at the buffer prefix - captured when each snapshot was produced, so a previously returned - conversation no longer mutates retroactively and value equality holds - mid-stream. Equality-based state management (Bloc `Equatable`, Riverpod - `select`, `distinct()`) now observes streaming updates. Accumulation stays - O(delta); a finished message materializes its buffer into a plain part. - -## 0.1.13 - -- `ReasoningEffort` (minimal/low/medium/high) + `AiRequestOptions.reasoningEffort`: - a provider-neutral knob for how hard a reasoning model should think. Exposes - `budgetTokens` (a canonical effort→budget heuristic) and `openAiValue` for - providers to map onto their native control. Additive and opt-in. - -## 0.1.12 - -- Docs: shortened the pubspec `description` into pub.dev's 60–180 character - window so it renders in full in search results. No code changes. - -## 0.1.11 - -- Docs: refreshed the README listing with a hero image, screenshot gallery, - and badges (consistent across the package family). No code changes. - -## 0.1.10 - -- New AI primitives (opt-in, additive): - - `EmbeddingProvider` / `AiEmbedding` and `TokenCounter` capability - interfaces a provider may implement (check with `provider is ...`). - - `GenerateObject` extension on `LlmProvider`: `generateObject` returns a - decoded `Map` constrained to an `AiResponseFormat`; `streamObject` yields - the evolving partial object as it streams (via `JsonAccumulator`). - -## 0.1.9 - -- `validateJsonSchema`: a tiny, dependency-free validator for the JSON-Schema - subset LLM tool declarations use (`type`, `properties`, `required`, `items`, - `enum`, `additionalProperties: false`, numeric/string/array bounds, union - types). Returns human-readable violation messages. `UseChatController` uses it - to validate tool-call args before execution. - -## 0.1.8 - -- Perf: streaming text/reasoning deltas accumulate into a per-part - `StringBuffer` and materialize the `String` lazily, instead of - `last.text + delta` reallocating the whole answer on every token (was - quadratic on long responses — the hottest path in the stack). Observably - identical: `TextPart.text`/`ReasoningPart.text` still return a plain `String`. -- `AiUsage.cacheCreationTokens`: carries prompt-cache **write** tokens (a subset - of `inputTokens`) distinctly; `estimateCost` bills them at `cacheWritePer1M` - (defaulting to `1.25 * inputPer1M`) so cache writes aren't billed at the base - input rate. -- Declares supported `platforms:` (all 6). - -## 0.1.7 - -- Typed errors: `LlmException` hierarchy (`LlmAuthException`, - `LlmRateLimitException`, `LlmServerException`, `LlmRequestException`) + a - `llmExceptionFor` mapper, surfaced on `StreamErrorEvent.error` so hosts can - branch on the failure type instead of string-matching. - -## 0.1.6 - -- `ReasoningPart` / `ReasoningDelta` gain an optional `signature` (preserved and - replayed so providers like Anthropic accept thinking blocks on tool rounds). -- `MessageProcessor` keeps the last good partial tool-call args instead of - clobbering them to `{}` mid-stream. - -## 0.1.5 - -- `AiRequestOptions.cachePrompt`: hint that the stable prompt prefix (system + - tools) should be cached. Anthropic applies `cache_control`; OpenAI/Gemini cache - automatically (no-op). - -## 0.1.4 - -- `AiResponseFormat` (+ `AiRequestOptions.responseFormat`): request structured - output constrained to a JSON schema. Providers route it to their native - mechanism; the assistant's text is the JSON object. - -## 0.1.3 - -- `AiUsage` model (input/output/cached/reasoning/total tokens) with `+` to - accumulate and `estimateCost(...)` for cost from per-million prices. Carried on - `MessageFinished` and stored on the completed `AiMessage`; the processor - applies it on finish. - -## 0.1.2 - -- Docs: added a "Buy me a coffee" (Ko-fi) support section to the README. No code - changes. - -## 0.1.1 - -Bug fixes in `MessageProcessor`: -- Zero-argument tool calls (a `ToolCallReady` with no streamed arguments) now - resolve to empty args + `inputAvailable` instead of being marked errored. -- A `ToolResultReceived` whose `messageId` differs from the call's message (the - normal case — results arrive in a separate tool-role message) now correctly - advances the original call to `outputAvailable`. -- A tool-scoped `StreamErrorEvent` (with `toolCallId`) now marks only that call - errored and lets generation continue, instead of failing the whole message — - matching `UseChatController`. -- Doc fix: corrected a stale reference to `flutter_markdown_plus`. -- `JsonAccumulator` no longer surfaces an unterminated trailing number/keyword - (e.g. `1234` from `{"n": 1234`) as a complete value — a literal must be - delimiter-terminated, preserving the "a partial is always a prefix" contract. -- `MessageProcessor` resolves a tool result to its owning call by scanning the - conversation when the in-memory map misses (after `reset()`/rehydration). -- `deepHash` uses order-independent hashing for maps (better distribution). - -## 0.1.0 - -Initial release. - -- Models: `AiConversation`, `AiMessage`, `AiMessageStatus`, `AiRole`, - `FinishReason`, and the sealed `AiPart` hierarchy (`TextPart`, - `ReasoningPart`, `ToolCallPart`, `ToolResultPart`, `FilePart`, `SourcePart`, - `DataPart`) with manual JSON serialization and value equality. -- Streaming: sealed `AiStreamEvent` set, `MessageProcessor` reducer with granular - `MutationResult`s, and the tolerant `JsonAccumulator` for partial tool-call - arguments. -- Contracts: `LlmProvider`, `TextRenderer`, `AiRequestOptions`, `ToolDefinition`. -- Zero runtime dependencies (`dart:core` + `dart:convert` only). diff --git a/packages/flutter_ai/flutter_ai_core/LICENSE b/packages/flutter_ai/flutter_ai_core/LICENSE deleted file mode 100644 index 56023ee..0000000 --- a/packages/flutter_ai/flutter_ai_core/LICENSE +++ /dev/null @@ -1,29 +0,0 @@ -BSD 3-Clause License - -Copyright (c) 2026, The flutter_ai authors. -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/flutter_ai/flutter_ai_core/README.md b/packages/flutter_ai/flutter_ai_core/README.md deleted file mode 100644 index d8972f7..0000000 --- a/packages/flutter_ai/flutter_ai_core/README.md +++ /dev/null @@ -1,84 +0,0 @@ -

flutter_ai_core

- -

The dependency-free Dart engine under flutter_ai — immutable conversation models, a streaming-event reducer, and the LlmProvider contract every provider speaks.

- -

- flutter_ai: a streaming answer with chain-of-thought and a generative-UI task card -

- -

- flutter_ai_core on pub.dev - pub points - License: BSD-3-Clause -

- -

- Family: flutter_ai · - client · elements · - openai · anthropic · gemini · - tools · mcp · voice
- Recipes · Migrating from the Vercel AI SDK -

- -

The transcript above is produced by this package's MessageProcessor folding provider events into messages (rendered with flutter_ai_elements).

- ---- - -Dependency-free Dart foundation for building AI chat experiences — the shared -contract layer of the [`flutter_ai`](../../README.md) package family. - -`flutter_ai_core` has **no runtime dependencies** beyond `dart:core` and -`dart:convert`: no Flutter, no code generation, no `build_runner`. That keeps it -safe to depend on from anywhere and free of version conflicts. - -## What's inside - -- **Models** — `AiConversation`, `AiMessage`, and the sealed `AiPart` hierarchy - (`TextPart`, `ReasoningPart`, `ToolCallPart`, `ToolResultPart`, `FilePart`, - `SourcePart`, `DataPart`). All immutable value types with manual, hand-written - JSON. -- **Streaming** — the sealed `AiStreamEvent` set and a `MessageProcessor` that - folds events into conversation state, reporting exactly which messages changed - so a UI can rebuild only those nodes. -- **Tolerant JSON** — `JsonAccumulator` parses partial tool-call arguments as - they stream, repairing incomplete JSON without ever throwing. -- **Contracts** — `LlmProvider` (provider abstraction) and `TextRenderer` - (pluggable text rendering), with `AiRequestOptions` and `ToolDefinition`. - -## Design principles - -- **Un-opinionated.** No bundled state manager. The processor is a pure, - synchronous reducer; batching updates to the frame boundary is the consumer's - job, which keeps this package UI-agnostic and trivially testable. -- **Granular by construction.** `MutationResult.changedMessageIds` lets the UI - avoid rebuilding the whole transcript on every token. -- **Fails soft.** Malformed streamed tool arguments mark a single call errored - rather than crashing the stream. - -## Example - -```dart -import 'package:flutter_ai_core/flutter_ai_core.dart'; - -void main() { - final processor = MessageProcessor(); - - // Events would normally come from an LlmProvider's stream. - processor.apply(const MessageStarted(messageId: 'a1', role: AiRole.assistant)); - processor.apply(const TextDelta(messageId: 'a1', delta: 'Hello, ')); - final result = processor.apply(const TextDelta(messageId: 'a1', delta: 'world!')); - - print(result.conversation.messageById('a1')!.text); // Hello, world! - print(result.changedMessageIds); // {a1} -} -``` - -See [`example/`](example/) for a fuller walkthrough including tool calls. - -## Status - -Part of the `flutter_ai` ecosystem; the UI layer (`flutter_ai_elements`) and -provider/controller layer (`flutter_ai_client`) build on these types. See the -CHANGELOG for version history. - -_If `flutter_ai` saves you time, you can [buy me a coffee ☕](https://ko-fi.com/ananmouaz)._ diff --git a/packages/flutter_ai/flutter_ai_core/analysis_options.yaml b/packages/flutter_ai/flutter_ai_core/analysis_options.yaml deleted file mode 100644 index 8b97ccc..0000000 --- a/packages/flutter_ai/flutter_ai_core/analysis_options.yaml +++ /dev/null @@ -1,3 +0,0 @@ -# Inherits the workspace-wide strict configuration. Package-specific overrides, -# if ever needed, go below. -include: ../../analysis_options.yaml diff --git a/packages/flutter_ai/flutter_ai_core/example/flutter_ai_core_example.dart b/packages/flutter_ai/flutter_ai_core/example/flutter_ai_core_example.dart deleted file mode 100644 index aed38b4..0000000 --- a/packages/flutter_ai/flutter_ai_core/example/flutter_ai_core_example.dart +++ /dev/null @@ -1,91 +0,0 @@ -// Demonstrates folding a provider's event stream into conversation state with -// MessageProcessor, including streamed tool-call arguments. -// -// Run with: dart run example/flutter_ai_core_example.dart -import 'package:flutter_ai_core/flutter_ai_core.dart'; - -/// A trivial in-memory provider that replays a scripted stream of events. -/// -/// A real provider would translate an SSE / gRPC / local-callback protocol into -/// these same [AiStreamEvent]s. -class ScriptedProvider implements LlmProvider { - @override - Stream send( - AiConversation conversation, { - List? tools, - AiRequestOptions? options, - }) async* { - const id = 'assistant-1'; - yield const MessageStarted(messageId: id, role: AiRole.assistant); - yield const TextDelta(messageId: id, delta: 'Let me check the weather. '); - - // A tool call whose arguments stream in as partial JSON. - yield const ToolCallStarted( - messageId: id, - toolCallId: 'call-1', - toolName: 'get_weather', - ); - yield const ToolCallDelta(toolCallId: 'call-1', argumentsDelta: '{"city":'); - yield const ToolCallDelta( - toolCallId: 'call-1', - argumentsDelta: '"London"}', - ); - yield const ToolCallReady(toolCallId: 'call-1'); - - // The tool result, then the model's final answer. - yield const ToolResultReceived( - messageId: id, - toolCallId: 'call-1', - result: {'tempC': 21, 'condition': 'Cloudy'}, - ); - yield const TextDelta(messageId: id, delta: "It's 21°C and cloudy."); - yield const MessageFinished(messageId: id, reason: FinishReason.stop); - } -} - -Future main() async { - final processor = MessageProcessor( - conversation: const AiConversation( - id: 'demo', - messages: [ - AiMessage( - id: 'user-1', - role: AiRole.user, - parts: [TextPart('What is the weather in London?')], - ), - ], - ), - ); - - final provider = ScriptedProvider(); - await for (final event in provider.send(processor.conversation)) { - final result = processor.apply(event); - // A UI would batch these changed ids to the frame boundary; here we just - // log them to show the granularity. - if (result.hasChanges) { - print('changed: ${result.changedMessageIds}'); - } - } - - print('\n--- final transcript ---'); - for (final message in processor.conversation.messages) { - print('${message.role.name}: ${_describe(message)}'); - } -} - -String _describe(AiMessage message) { - final buffer = StringBuffer(); - for (final part in message.parts) { - switch (part) { - case TextPart(:final text): - buffer.write(text); - case ToolCallPart(:final toolName, :final args, :final state): - buffer.write('[tool $toolName($args) ${state.name}] '); - case ToolResultPart(:final result): - buffer.write('[result $result] '); - case ReasoningPart() || FilePart() || SourcePart() || DataPart(): - buffer.write('[${part.runtimeType}] '); - } - } - return buffer.toString(); -} diff --git a/packages/flutter_ai/flutter_ai_core/lib/flutter_ai_core.dart b/packages/flutter_ai/flutter_ai_core/lib/flutter_ai_core.dart deleted file mode 100644 index beb5e3c..0000000 --- a/packages/flutter_ai/flutter_ai_core/lib/flutter_ai_core.dart +++ /dev/null @@ -1,38 +0,0 @@ -/// Dependency-free Dart foundation for AI chat experiences. -/// -/// `flutter_ai_core` defines the shared vocabulary the rest of the `flutter_ai` -/// family builds on: -/// -/// * **Models** — `AiConversation`, `AiMessage`, and the sealed `AiPart` -/// hierarchy (`TextPart`, `ReasoningPart`, `ToolCallPart`, `ToolResultPart`, -/// `FilePart`, `SourcePart`, `DataPart`). -/// * **Streaming** — the sealed `AiStreamEvent` set and a `MessageProcessor` -/// that folds events into state with granular `MutationResult`s, plus a -/// tolerant `JsonAccumulator` for partial tool-call arguments. -/// * **Contracts** — `LlmProvider` for provider abstraction and `TextRenderer` -/// for pluggable text rendering, with `AiRequestOptions` and `ToolDefinition`. -/// -/// It depends only on `dart:core` and `dart:convert` — no Flutter, no code -/// generation — so downstream apps never face build-tool or version conflicts. -library; - -export 'src/models/ai_conversation.dart'; -export 'src/models/ai_message.dart'; -export 'src/models/ai_part.dart'; -export 'src/models/ai_role.dart'; -export 'src/models/finish_reason.dart'; -export 'src/models/tool_call_state.dart'; -export 'src/models/tool_definition.dart'; -export 'src/models/usage.dart'; -export 'src/provider/ai_capabilities.dart'; -export 'src/provider/ai_request_options.dart'; -export 'src/provider/ai_response_format.dart'; -export 'src/provider/generate_object.dart'; -export 'src/provider/llm_exception.dart'; -export 'src/provider/llm_provider.dart'; -export 'src/rendering/text_renderer.dart'; -export 'src/streaming/ai_stream_event.dart'; -export 'src/streaming/json_accumulator.dart'; -export 'src/streaming/message_processor.dart'; -export 'src/streaming/mutation_result.dart'; -export 'src/tools/json_schema_validator.dart'; diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/internal/equality.dart b/packages/flutter_ai/flutter_ai_core/lib/src/internal/equality.dart deleted file mode 100644 index 091cf0e..0000000 --- a/packages/flutter_ai/flutter_ai_core/lib/src/internal/equality.dart +++ /dev/null @@ -1,53 +0,0 @@ -/// Structural equality and hashing for JSON-like values. -/// -/// The core models carry decoded JSON (`Map`, `List`, -/// and scalars) in fields such as tool-call arguments and data payloads. Value -/// equality on those models therefore needs deep, structural comparison rather -/// than identity. These helpers provide it without depending on -/// `package:collection`, honoring the package's dependency-free contract. -library; - -/// Returns whether [a] and [b] are structurally equal. -/// -/// Scalars are compared with `==`. [List]s are compared element-wise and in -/// order. [Map]s are compared by key set and per-key values, independent of -/// insertion order. Comparison recurses through nested lists and maps. -bool deepEquals(Object? a, Object? b) { - if (identical(a, b)) return true; - if (a is List && b is List) { - if (a.length != b.length) return false; - for (var i = 0; i < a.length; i++) { - if (!deepEquals(a[i], b[i])) return false; - } - return true; - } - if (a is Map && b is Map) { - if (a.length != b.length) return false; - for (final entry in a.entries) { - if (!b.containsKey(entry.key) || !deepEquals(entry.value, b[entry.key])) { - return false; - } - } - return true; - } - return a == b; -} - -/// Returns a hash code for [value] consistent with [deepEquals]. -/// -/// Lists hash in order; maps hash independent of insertion order so that two -/// equal maps with different orderings produce the same hash. -int deepHash(Object? value) { - if (value is List) { - return Object.hashAll(value.map(deepHash)); - } - if (value is Map) { - // Hash the per-entry pairs unordered so insertion order does not matter, - // while giving better distribution than XOR-folding the entry hashes. - return Object.hashAllUnordered([ - for (final entry in value.entries) - Object.hash(deepHash(entry.key), deepHash(entry.value)), - ]); - } - return value.hashCode; -} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/models/ai_conversation.dart b/packages/flutter_ai/flutter_ai_core/lib/src/models/ai_conversation.dart deleted file mode 100644 index dcaf03b..0000000 --- a/packages/flutter_ai/flutter_ai_core/lib/src/models/ai_conversation.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:flutter_ai_core/src/internal/equality.dart'; -import 'package:flutter_ai_core/src/models/ai_message.dart'; - -/// An ordered, immutable transcript of [AiMessage]s. -/// -/// The conversation retains the **full** message history; trimming for token -/// budgets is deliberately out of scope and belongs to the server or provider -/// integration, so the user can always scroll back through the entire session. -/// -/// Every mutating helper returns a new instance, preserving value semantics. -final class AiConversation { - /// Creates a conversation. - const AiConversation({required this.id, this.messages = const []}); - - /// An empty conversation with the given [id]. - const AiConversation.empty(String id) : this(id: id); - - /// Reconstructs a conversation from [json]. - factory AiConversation.fromJson(Map json) { - final rawMessages = (json['messages'] as List?) ?? const []; - return AiConversation( - id: json['id']! as String, - messages: [ - for (final message in rawMessages) - AiMessage.fromJson((message! as Map).cast()), - ], - ); - } - - /// A stable, unique identifier for this conversation. - final String id; - - /// The full ordered transcript. - final List messages; - - /// The most recent message, or `null` if the conversation is empty. - AiMessage? get lastMessage => messages.isEmpty ? null : messages.last; - - /// Returns the message with the given [messageId], or `null` if absent. - AiMessage? messageById(String messageId) { - for (final message in messages) { - if (message.id == messageId) return message; - } - return null; - } - - /// Returns a copy with [message] appended. - AiConversation append(AiMessage message) => - copyWith(messages: [...messages, message]); - - /// Returns a copy in which the message sharing [message]'s id is replaced. - /// - /// If no message has that id, [message] is appended instead, making this safe - /// to call as an upsert during streaming. - AiConversation replace(AiMessage message) { - final index = messages.indexWhere((m) => m.id == message.id); - if (index == -1) return append(message); - final next = [...messages]..[index] = message; - return copyWith(messages: next); - } - - /// Returns a copy with the given fields replaced. - AiConversation copyWith({String? id, List? messages}) => - AiConversation(id: id ?? this.id, messages: messages ?? this.messages); - - /// Serializes this conversation. - Map toJson() => { - 'id': id, - 'messages': [for (final message in messages) message.toJson()], - }; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is AiConversation && - other.id == id && - deepEquals(other.messages, messages)); - - @override - int get hashCode => Object.hash(id, Object.hashAll(messages)); - - @override - String toString() => 'AiConversation(id: $id, messages: ${messages.length})'; -} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/models/ai_message.dart b/packages/flutter_ai/flutter_ai_core/lib/src/models/ai_message.dart deleted file mode 100644 index 7dca336..0000000 --- a/packages/flutter_ai/flutter_ai_core/lib/src/models/ai_message.dart +++ /dev/null @@ -1,184 +0,0 @@ -import 'package:flutter_ai_core/src/internal/equality.dart'; -import 'package:flutter_ai_core/src/models/ai_part.dart'; -import 'package:flutter_ai_core/src/models/ai_role.dart'; -import 'package:flutter_ai_core/src/models/finish_reason.dart'; -import 'package:flutter_ai_core/src/models/usage.dart'; - -/// The delivery state of an [AiMessage]. -enum AiMessageStatus { - /// Created locally and awaiting a response; no content yet. - pending('pending'), - - /// Content is actively streaming in. - streaming('streaming'), - - /// Fully received. - complete('complete'), - - /// Terminated by an error. - error('error'); - - const AiMessageStatus(this.wireName); - - /// The stable string used on the wire and in JSON. - final String wireName; - - /// Parses a [wireName] into its [AiMessageStatus]. - /// - /// Throws a [FormatException] if [value] is not a known status. - static AiMessageStatus fromJson(String value) { - for (final status in values) { - if (status.wireName == value) return status; - } - throw FormatException('Unknown AiMessageStatus: "$value"'); - } - - /// The wire representation of this status. - String toJson() => wireName; -} - -/// A single turn in a conversation, authored by one [AiRole]. -/// -/// A message is an ordered, immutable list of [parts]; mutations during -/// streaming produce new [AiMessage] instances via [copyWith] rather than -/// editing in place, preserving value semantics. -final class AiMessage { - /// Creates a message. - const AiMessage({ - required this.id, - required this.role, - this.parts = const [], - this.status = AiMessageStatus.complete, - this.finishReason, - this.createdAt, - this.usage, - }); - - /// Convenience constructor for a plain-text message. - AiMessage.text({ - required String id, - required AiRole role, - required String text, - AiMessageStatus status = AiMessageStatus.complete, - DateTime? createdAt, - }) : this( - id: id, - role: role, - parts: [TextPart(text)], - status: status, - createdAt: createdAt, - ); - - /// Reconstructs a message from [json]. - factory AiMessage.fromJson(Map json) { - final rawParts = (json['parts'] as List?) ?? const []; - final createdAt = json['createdAt'] as String?; - final finishReason = json['finishReason'] as String?; - final usage = json['usage']; - return AiMessage( - id: json['id']! as String, - role: AiRole.fromJson(json['role']! as String), - parts: [ - for (final part in rawParts) - AiPart.fromJson((part! as Map).cast()), - ], - status: AiMessageStatus.fromJson(json['status']! as String), - finishReason: - finishReason == null ? null : FinishReason.fromJson(finishReason), - createdAt: createdAt == null ? null : DateTime.parse(createdAt), - usage: usage == null - ? null - : AiUsage.fromJson((usage as Map).cast()), - ); - } - - /// A stable, unique identifier for this message. - final String id; - - /// Who authored the message. - final AiRole role; - - /// The ordered content of the message. - final List parts; - - /// The current delivery state. - final AiMessageStatus status; - - /// Why generation stopped, once [status] is terminal. `null` while pending or - /// streaming. - final FinishReason? finishReason; - - /// When the message was created, if tracked. - final DateTime? createdAt; - - /// Token usage for this message's turn, if the provider reported it. Set on - /// the assistant message when its turn finishes. - final AiUsage? usage; - - /// The concatenated text of every [TextPart], ignoring other part types. - /// - /// A convenience for the common case of reading a message's prose. - String get text => parts.whereType().map((p) => p.text).join(); - - /// Returns a copy with the given fields replaced. - /// - /// Passing [finishReason] or [createdAt] cannot clear them to `null`; that is - /// an intentional trade-off favoring the common "set or keep" case. - AiMessage copyWith({ - String? id, - AiRole? role, - List? parts, - AiMessageStatus? status, - FinishReason? finishReason, - DateTime? createdAt, - AiUsage? usage, - }) => - AiMessage( - id: id ?? this.id, - role: role ?? this.role, - parts: parts ?? this.parts, - status: status ?? this.status, - finishReason: finishReason ?? this.finishReason, - createdAt: createdAt ?? this.createdAt, - usage: usage ?? this.usage, - ); - - /// Serializes this message. - Map toJson() => { - 'id': id, - 'role': role.toJson(), - 'parts': [for (final part in parts) part.toJson()], - 'status': status.toJson(), - if (finishReason != null) 'finishReason': finishReason!.toJson(), - if (createdAt != null) 'createdAt': createdAt!.toIso8601String(), - if (usage != null) 'usage': usage!.toJson(), - }; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is AiMessage && - other.id == id && - other.role == role && - other.status == status && - other.finishReason == finishReason && - other.createdAt == createdAt && - other.usage == usage && - deepEquals(other.parts, parts)); - - @override - int get hashCode => Object.hash( - id, - role, - status, - finishReason, - createdAt, - usage, - Object.hashAll(parts), - ); - - @override - String toString() => - 'AiMessage(id: $id, role: ${role.name}, status: ${status.name}, ' - 'parts: ${parts.length})'; -} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/models/ai_part.dart b/packages/flutter_ai/flutter_ai_core/lib/src/models/ai_part.dart deleted file mode 100644 index 4e15b7d..0000000 --- a/packages/flutter_ai/flutter_ai_core/lib/src/models/ai_part.dart +++ /dev/null @@ -1,497 +0,0 @@ -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:flutter_ai_core/src/internal/equality.dart'; -import 'package:flutter_ai_core/src/models/tool_call_state.dart'; - -/// A single typed segment of an `AiMessage`. -/// -/// A message is an ordered list of parts, mirroring the "parts" model used by -/// modern AI SDKs: a turn may interleave prose, reasoning, tool calls and their -/// results, files, and citations. [AiPart] is `sealed`, so a `switch` over a -/// part is exhaustively checked at compile time — adding a new part type forces -/// every consumer to handle it. -/// -/// Every part serializes with a `type` discriminator. [AiPart.fromJson] -/// dispatches on that field; subclasses round-trip their own payload. -/// -/// See also `AiMessage`, which owns an ordered list of parts. -sealed class AiPart { - /// Const base constructor for subclasses. - const AiPart(); - - /// Reconstructs a part from its [json] map by dispatching on `type`. - /// - /// Throws a [FormatException] if `type` is missing or unrecognized. - factory AiPart.fromJson(Map json) { - final type = json['type']; - return switch (type) { - 'text' => TextPart.fromJson(json), - 'reasoning' => ReasoningPart.fromJson(json), - 'tool-call' => ToolCallPart.fromJson(json), - 'tool-result' => ToolResultPart.fromJson(json), - 'file' => FilePart.fromJson(json), - 'source' => SourcePart.fromJson(json), - 'data' => DataPart.fromJson(json), - _ => throw FormatException('Unknown AiPart type: "$type"'), - }; - } - - /// Serializes this part, including its `type` discriminator. - Map toJson(); -} - -/// Human- or model-authored prose, typically rendered as Markdown. -final class TextPart extends AiPart { - /// Creates a text part holding [text]. - const TextPart(String text) - : _text = text, - _buffer = null, - _bufferLength = 0; - - /// Reconstructs a [TextPart] from [json]. - factory TextPart.fromJson(Map json) => - TextPart(json['text']! as String); - - /// Creates a text part whose content is accumulated in [buffer], materialized - /// to a [String] lazily on first read of [text]. - /// - /// Internal to the streaming reducer: appending deltas to one shared - /// [StringBuffer] keeps accumulation linear (O(total length)) instead of - /// reallocating the whole string on every delta. The expensive `toString()` - /// happens only when a consumer actually reads the text (e.g. at a frame - /// boundary), not once per token. - /// - /// This wrapper freezes at the buffer's length *at construction time* (see - /// [text]): the reducer appends the next delta to the same buffer and wraps it - /// in a *new* `TextPart`, so a previously returned conversation snapshot never - /// observes the later appends. Value equality therefore holds mid-stream — - /// two snapshots taken at different points compare unequal. Do not construct - /// or read the [buffer] outside the reducer. - TextPart.buffered(StringBuffer buffer) - : _text = null, - _buffer = buffer, - _bufferLength = buffer.length; - - final String? _text; - final StringBuffer? _buffer; - - /// The buffer's length (in UTF-16 code units) captured when this wrapper was - /// created, freezing the prefix this part represents. See [TextPart.buffered]. - final int _bufferLength; - - /// The textual content. - /// - /// For a [TextPart.buffered] this materializes the backing buffer on demand, - /// truncated to the prefix captured at construction so later appends to the - /// shared buffer (which belong to newer snapshots) are never observed. - String get text { - final text = _text; - if (text != null) return text; - final buffer = _buffer!; - final materialized = buffer.toString(); - return materialized.length == _bufferLength - ? materialized - : materialized.substring(0, _bufferLength); - } - - /// The live accumulation buffer backing this part, or `null` for an ordinary - /// part. Internal to the streaming reducer, which appends the next delta in - /// place rather than rebuilding the string. - StringBuffer? get buffer => _buffer; - - /// Returns a copy with [text] replaced. - TextPart copyWith({String? text}) => TextPart(text ?? this.text); - - @override - Map toJson() => {'type': 'text', 'text': text}; - - @override - bool operator ==(Object other) => - identical(this, other) || (other is TextPart && other.text == text); - - @override - int get hashCode => text.hashCode; - - @override - String toString() => 'TextPart(${text.length} chars)'; -} - -/// The model's intermediate reasoning ("chain of thought"). -/// -/// Surfaced separately from prose so the UI can disclose it in a collapsible -/// region rather than mixing it into the answer. -final class ReasoningPart extends AiPart { - /// Creates a reasoning part holding [text]. - const ReasoningPart(String text, {this.signature}) - : _text = text, - _buffer = null, - _bufferLength = 0; - - /// Reconstructs a [ReasoningPart] from [json]. - factory ReasoningPart.fromJson(Map json) => ReasoningPart( - json['text']! as String, - signature: json['signature'] as String?, - ); - - /// Creates a reasoning part whose content is accumulated in [buffer], - /// materialized lazily on first read of [text]. - /// - /// See [TextPart.buffered]: this keeps reasoning-delta accumulation linear - /// rather than reallocating the whole string per delta, and freezes at the - /// buffer length captured here so previously returned snapshots never observe - /// later appends. - ReasoningPart.buffered(StringBuffer buffer, {this.signature}) - : _text = null, - _buffer = buffer, - _bufferLength = buffer.length; - - final String? _text; - final StringBuffer? _buffer; - - /// The buffer's length (in UTF-16 code units) captured when this wrapper was - /// created, freezing the prefix this part represents. See [TextPart.buffered]. - final int _bufferLength; - - /// The reasoning content. - /// - /// For a [ReasoningPart.buffered] this materializes the backing buffer on - /// demand, truncated to the prefix captured at construction. - String get text { - final text = _text; - if (text != null) return text; - final buffer = _buffer!; - final materialized = buffer.toString(); - return materialized.length == _bufferLength - ? materialized - : materialized.substring(0, _bufferLength); - } - - /// The live accumulation buffer backing this part, or `null` for an ordinary - /// part. Internal to the streaming reducer. - StringBuffer? get buffer => _buffer; - - /// An opaque provider signature for this reasoning block, when the provider - /// supplies one (e.g. Anthropic extended thinking). It must be preserved and - /// replayed verbatim on subsequent turns or the API rejects the request. - final String? signature; - - /// Returns a copy with the given fields replaced. - ReasoningPart copyWith({String? text, String? signature}) => - ReasoningPart(text ?? this.text, signature: signature ?? this.signature); - - @override - Map toJson() => { - 'type': 'reasoning', - 'text': text, - if (signature != null) 'signature': signature, - }; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is ReasoningPart && - other.text == text && - other.signature == signature); - - @override - int get hashCode => Object.hash(text, signature); - - @override - String toString() => 'ReasoningPart(${text.length} chars)'; -} - -/// A request from the model to invoke a tool. -/// -/// During streaming, [args] fills in incrementally and [state] advances from -/// [ToolCallState.inputStreaming] to [ToolCallState.inputAvailable]. The -/// matching output arrives later as a [ToolResultPart] carrying the same -/// [toolCallId]. -final class ToolCallPart extends AiPart { - /// Creates a tool-call part. - const ToolCallPart({ - required this.toolCallId, - required this.toolName, - this.args = const {}, - this.state = ToolCallState.inputStreaming, - }); - - /// Reconstructs a [ToolCallPart] from [json]. - factory ToolCallPart.fromJson(Map json) => ToolCallPart( - toolCallId: json['toolCallId']! as String, - toolName: json['toolName']! as String, - args: (json['args'] as Map?)?.cast() ?? const {}, - state: ToolCallState.fromJson(json['state']! as String), - ); - - /// Correlates this call with its [ToolResultPart]. - final String toolCallId; - - /// The name of the tool being invoked. - final String toolName; - - /// The (possibly partial) arguments decoded from the model's JSON. - final Map args; - - /// The lifecycle stage of this call. - final ToolCallState state; - - /// Returns a copy with the given fields replaced. - ToolCallPart copyWith({ - String? toolCallId, - String? toolName, - Map? args, - ToolCallState? state, - }) => - ToolCallPart( - toolCallId: toolCallId ?? this.toolCallId, - toolName: toolName ?? this.toolName, - args: args ?? this.args, - state: state ?? this.state, - ); - - @override - Map toJson() => { - 'type': 'tool-call', - 'toolCallId': toolCallId, - 'toolName': toolName, - 'args': args, - 'state': state.toJson(), - }; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is ToolCallPart && - other.toolCallId == toolCallId && - other.toolName == toolName && - other.state == state && - deepEquals(other.args, args)); - - @override - int get hashCode => Object.hash(toolCallId, toolName, state, deepHash(args)); - - @override - String toString() => - 'ToolCallPart($toolName, id: $toolCallId, state: ${state.name})'; -} - -/// The output of a tool, fed back to the model and shown to the user. -final class ToolResultPart extends AiPart { - /// Creates a tool-result part. - const ToolResultPart({ - required this.toolCallId, - required this.result, - this.isError = false, - }); - - /// Reconstructs a [ToolResultPart] from [json]. - factory ToolResultPart.fromJson(Map json) => ToolResultPart( - toolCallId: json['toolCallId']! as String, - result: json['result'], - isError: json['isError'] as bool? ?? false, - ); - - /// The id of the [ToolCallPart] this result answers. - final String toolCallId; - - /// The tool's output. Any JSON-encodable value, or `null`. - final Object? result; - - /// Whether [result] represents an error rather than a success payload. - final bool isError; - - /// Returns a copy with the given fields replaced. - ToolResultPart copyWith({ - String? toolCallId, - Object? result, - bool? isError, - }) => - ToolResultPart( - toolCallId: toolCallId ?? this.toolCallId, - result: result ?? this.result, - isError: isError ?? this.isError, - ); - - @override - Map toJson() => { - 'type': 'tool-result', - 'toolCallId': toolCallId, - 'result': result, - 'isError': isError, - }; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is ToolResultPart && - other.toolCallId == toolCallId && - other.isError == isError && - deepEquals(other.result, result)); - - @override - int get hashCode => Object.hash(toolCallId, isError, deepHash(result)); - - @override - String toString() => 'ToolResultPart(id: $toolCallId, isError: $isError)'; -} - -/// A file attachment: an image, document, or audio clip. -/// -/// Carries either a [url] (hosted/remote) or inline [bytes]. Document text -/// extraction is deliberately out of scope here — that is a backend concern, to -/// keep it off the UI thread. -final class FilePart extends AiPart { - /// Creates a file part. Provide a [url], [bytes], or both. - const FilePart({ - required this.mediaType, - this.url, - this.bytes, - this.name, - }); - - /// Reconstructs a [FilePart] from [json]. - /// - /// Inline [bytes] are expected as a base64 string under `bytes`. - factory FilePart.fromJson(Map json) { - final encoded = json['bytes'] as String?; - final url = json['url'] as String?; - return FilePart( - mediaType: json['mediaType']! as String, - url: url == null ? null : Uri.parse(url), - bytes: encoded == null ? null : base64Decode(encoded), - name: json['name'] as String?, - ); - } - - /// The IANA media type, e.g. `image/png` or `application/pdf`. - final String mediaType; - - /// The remote location of the file, if hosted. - final Uri? url; - - /// The inline contents of the file, if embedded. - final Uint8List? bytes; - - /// A human-readable file name, if known. - final String? name; - - /// Returns a copy with the given fields replaced. - FilePart copyWith({ - String? mediaType, - Uri? url, - Uint8List? bytes, - String? name, - }) => - FilePart( - mediaType: mediaType ?? this.mediaType, - url: url ?? this.url, - bytes: bytes ?? this.bytes, - name: name ?? this.name, - ); - - @override - Map toJson() => { - 'type': 'file', - 'mediaType': mediaType, - if (url != null) 'url': url.toString(), - if (bytes != null) 'bytes': base64Encode(bytes!), - if (name != null) 'name': name, - }; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is FilePart && - other.mediaType == mediaType && - other.url == url && - other.name == name && - deepEquals(other.bytes, bytes)); - - @override - int get hashCode => - Object.hash(mediaType, url, name, bytes == null ? null : deepHash(bytes)); - - @override - String toString() => 'FilePart($mediaType${name != null ? ', $name' : ''})'; -} - -/// A citation or source referenced by the model, rendered as a link or chip. -final class SourcePart extends AiPart { - /// Creates a source part pointing at [url]. - const SourcePart({required this.url, this.title}); - - /// Reconstructs a [SourcePart] from [json]. - factory SourcePart.fromJson(Map json) => SourcePart( - url: Uri.parse(json['url']! as String), - title: json['title'] as String?, - ); - - /// The source location. - final Uri url; - - /// A human-readable title for the source, if known. - final String? title; - - @override - Map toJson() => { - 'type': 'source', - 'url': url.toString(), - if (title != null) 'title': title, - }; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is SourcePart && other.url == url && other.title == title); - - @override - int get hashCode => Object.hash(url, title); - - @override - String toString() => 'SourcePart($url)'; -} - -/// A structured data payload that drives generative UI. -/// -/// The model emits a [dataType] naming a developer-registered widget plus a -/// [data] map of its inputs. Rendering is resolved against a strict catalog in -/// the UI layer — never via reflection — so only explicitly registered widgets -/// can be instantiated. -final class DataPart extends AiPart { - /// Creates a data part of the given [dataType] carrying [data]. - const DataPart({required this.dataType, this.data = const {}}); - - /// Reconstructs a [DataPart] from [json]. - factory DataPart.fromJson(Map json) => DataPart( - dataType: json['dataType']! as String, - data: (json['data'] as Map?)?.cast() ?? const {}, - ); - - /// Names the registered widget this payload targets. - final String dataType; - - /// The widget's inputs. - final Map data; - - /// Returns a copy with the given fields replaced. - DataPart copyWith({String? dataType, Map? data}) => - DataPart(dataType: dataType ?? this.dataType, data: data ?? this.data); - - @override - Map toJson() => - {'type': 'data', 'dataType': dataType, 'data': data}; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is DataPart && - other.dataType == dataType && - deepEquals(other.data, data)); - - @override - int get hashCode => Object.hash(dataType, deepHash(data)); - - @override - String toString() => 'DataPart($dataType)'; -} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/models/ai_role.dart b/packages/flutter_ai/flutter_ai_core/lib/src/models/ai_role.dart deleted file mode 100644 index c6a470a..0000000 --- a/packages/flutter_ai/flutter_ai_core/lib/src/models/ai_role.dart +++ /dev/null @@ -1,35 +0,0 @@ -/// The author of a message in a conversation. -enum AiRole { - /// System or developer instructions that condition the model's behavior. - system('system'), - - /// A human end user. - user('user'), - - /// The model. - assistant('assistant'), - - /// Output produced by a tool and fed back to the model. - tool('tool'); - - const AiRole(this.wireName); - - /// The stable string used on the wire and in JSON. - /// - /// Decoupled from `Enum.name` so renaming a Dart identifier never silently - /// changes the serialized form. - final String wireName; - - /// Parses a [wireName] into its [AiRole]. - /// - /// Throws a [FormatException] if [value] does not match a known role. - static AiRole fromJson(String value) { - for (final role in values) { - if (role.wireName == value) return role; - } - throw FormatException('Unknown AiRole: "$value"'); - } - - /// The wire representation of this role. - String toJson() => wireName; -} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/models/finish_reason.dart b/packages/flutter_ai/flutter_ai_core/lib/src/models/finish_reason.dart deleted file mode 100644 index 207f900..0000000 --- a/packages/flutter_ai/flutter_ai_core/lib/src/models/finish_reason.dart +++ /dev/null @@ -1,39 +0,0 @@ -/// Why the model stopped generating a message. -/// -/// Surfaced on the terminal stream event so the UI can react (for example, -/// announcing the final text to assistive technologies once generation is -/// complete). -enum FinishReason { - /// The model emitted a natural stopping point or a stop sequence. - stop('stop'), - - /// Generation was truncated by the maximum output token limit. - length('length'), - - /// The model paused to call one or more tools. - toolCalls('tool-calls'), - - /// Output was withheld or truncated by a content filter. - contentFilter('content-filter'), - - /// Generation ended because of an error. - error('error'); - - const FinishReason(this.wireName); - - /// The stable string used on the wire and in JSON. - final String wireName; - - /// Parses a [wireName] into its [FinishReason]. - /// - /// Throws a [FormatException] if [value] is not a known reason. - static FinishReason fromJson(String value) { - for (final reason in values) { - if (reason.wireName == value) return reason; - } - throw FormatException('Unknown FinishReason: "$value"'); - } - - /// The wire representation of this reason. - String toJson() => wireName; -} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/models/tool_call_state.dart b/packages/flutter_ai/flutter_ai_core/lib/src/models/tool_call_state.dart deleted file mode 100644 index 4e97043..0000000 --- a/packages/flutter_ai/flutter_ai_core/lib/src/models/tool_call_state.dart +++ /dev/null @@ -1,38 +0,0 @@ -/// The lifecycle stage of a single tool call. -/// -/// A call advances monotonically: its arguments stream in, become complete and -/// valid, the tool executes, and finally a result (or an error) is available. -enum ToolCallState { - /// The model is still streaming the call's arguments; the JSON is partial. - inputStreaming('input-streaming'), - - /// Arguments have fully arrived and parsed into valid JSON. - inputAvailable('input-available'), - - /// The tool is executing. - executing('executing'), - - /// The tool finished and produced a result. - outputAvailable('output-available'), - - /// The call failed — argument validation or execution raised an error. - error('error'); - - const ToolCallState(this.wireName); - - /// The stable string used on the wire and in JSON. - final String wireName; - - /// Parses a [wireName] into its [ToolCallState]. - /// - /// Throws a [FormatException] if [value] is not a known state. - static ToolCallState fromJson(String value) { - for (final state in values) { - if (state.wireName == value) return state; - } - throw FormatException('Unknown ToolCallState: "$value"'); - } - - /// The wire representation of this state. - String toJson() => wireName; -} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/models/tool_definition.dart b/packages/flutter_ai/flutter_ai_core/lib/src/models/tool_definition.dart deleted file mode 100644 index 30265b1..0000000 --- a/packages/flutter_ai/flutter_ai_core/lib/src/models/tool_definition.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:flutter_ai_core/src/internal/equality.dart'; - -/// A declaration of a tool the model may call: its name, purpose, and the -/// JSON Schema describing its arguments. -/// -/// This is pure data — it carries no executor. The `flutter_ai_tools` package -/// builds on it to add client-side execution. Keeping the declaration in the -/// core lets [provider contracts](LlmProvider) accept tools without depending on -/// the tools package. -final class ToolDefinition { - /// Creates a tool definition. - const ToolDefinition({ - required this.name, - required this.description, - this.parametersSchema = const {}, - }); - - /// Reconstructs a [ToolDefinition] from [json]. - factory ToolDefinition.fromJson(Map json) => ToolDefinition( - name: json['name']! as String, - description: json['description']! as String, - parametersSchema: - (json['parametersSchema'] as Map?)?.cast() ?? - const {}, - ); - - /// The tool's unique name, as referenced in tool calls. - final String name; - - /// A natural-language description the model uses to decide when to call it. - final String description; - - /// A JSON Schema object describing the tool's arguments. - final Map parametersSchema; - - /// Serializes this definition. - Map toJson() => { - 'name': name, - 'description': description, - 'parametersSchema': parametersSchema, - }; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is ToolDefinition && - other.name == name && - other.description == description && - deepEquals(other.parametersSchema, parametersSchema)); - - @override - int get hashCode => - Object.hash(name, description, deepHash(parametersSchema)); - - @override - String toString() => 'ToolDefinition($name)'; -} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/models/usage.dart b/packages/flutter_ai/flutter_ai_core/lib/src/models/usage.dart deleted file mode 100644 index 95f9c78..0000000 --- a/packages/flutter_ai/flutter_ai_core/lib/src/models/usage.dart +++ /dev/null @@ -1,136 +0,0 @@ -/// Token usage for a model turn, with an optional cost estimate. -/// -/// Every field is nullable because providers report different subsets (and some -/// only at the end of a stream). [cachedInputTokens] is the portion of -/// [inputTokens] served from a prompt cache; [cacheCreationTokens] is the -/// portion of [inputTokens] written to a prompt cache (billed at a premium); -/// [reasoningTokens] is the portion of [outputTokens] spent on extended -/// thinking. -final class AiUsage { - /// Creates a usage record. - const AiUsage({ - this.inputTokens, - this.outputTokens, - this.cachedInputTokens, - this.cacheCreationTokens, - this.reasoningTokens, - this.totalTokens, - }); - - /// Reconstructs usage from [json]. - factory AiUsage.fromJson(Map json) => AiUsage( - inputTokens: json['inputTokens'] as int?, - outputTokens: json['outputTokens'] as int?, - cachedInputTokens: json['cachedInputTokens'] as int?, - cacheCreationTokens: json['cacheCreationTokens'] as int?, - reasoningTokens: json['reasoningTokens'] as int?, - totalTokens: json['totalTokens'] as int?, - ); - - /// Prompt tokens billed at the input rate (includes [cachedInputTokens]). - final int? inputTokens; - - /// Generated tokens billed at the output rate (includes [reasoningTokens]). - final int? outputTokens; - - /// Portion of [inputTokens] served from a prompt cache (cheaper). - final int? cachedInputTokens; - - /// Portion of [inputTokens] written to a prompt cache. Providers (e.g. - /// Anthropic) bill these at a premium over the base input rate (~1.25x). - final int? cacheCreationTokens; - - /// Portion of [outputTokens] spent on extended thinking. - final int? reasoningTokens; - - /// Total tokens, if the provider reports it directly. Otherwise derive it via - /// [resolvedTotal]. - final int? totalTokens; - - /// [totalTokens] if present, else `inputTokens + outputTokens` when both are - /// known, else `null`. - int? get resolvedTotal { - if (totalTokens != null) return totalTokens; - if (inputTokens == null && outputTokens == null) return null; - return (inputTokens ?? 0) + (outputTokens ?? 0); - } - - /// Merges two partial usages, summing each field. Useful for accumulating - /// across streamed events or summing a whole session. - AiUsage operator +(AiUsage other) => AiUsage( - inputTokens: _add(inputTokens, other.inputTokens), - outputTokens: _add(outputTokens, other.outputTokens), - cachedInputTokens: _add(cachedInputTokens, other.cachedInputTokens), - cacheCreationTokens: - _add(cacheCreationTokens, other.cacheCreationTokens), - reasoningTokens: _add(reasoningTokens, other.reasoningTokens), - totalTokens: _add(totalTokens, other.totalTokens), - ); - - /// Estimates cost given per-million-token prices (typically USD). Returns - /// `null` when neither token count is known. - /// - /// [cachedInputTokens] and [cacheCreationTokens] are subsets of - /// [inputTokens]; they are subtracted out and billed separately so they are - /// never double-counted at the base rate. The remaining uncached, non-cache- - /// write input is billed at [inputPer1M]; cache reads at [cachedInputPer1M] - /// when given (else [inputPer1M]); cache writes at [cacheWritePer1M] when - /// given (else `1.25 * inputPer1M`, the Anthropic convention); all output - /// (including reasoning) at [outputPer1M]. - double? estimateCost({ - required double inputPer1M, - required double outputPer1M, - double? cachedInputPer1M, - double? cacheWritePer1M, - }) { - if (inputTokens == null && outputTokens == null) return null; - final cached = cachedInputTokens ?? 0; - final cacheWrite = cacheCreationTokens ?? 0; - final uncachedInput = (inputTokens ?? 0) - cached - cacheWrite; - final inputCost = uncachedInput * inputPer1M / 1e6 + - cached * (cachedInputPer1M ?? inputPer1M) / 1e6 + - cacheWrite * (cacheWritePer1M ?? inputPer1M * 1.25) / 1e6; - final outputCost = (outputTokens ?? 0) * outputPer1M / 1e6; - return inputCost + outputCost; - } - - /// Serializes this usage, omitting null fields. - Map toJson() => { - if (inputTokens != null) 'inputTokens': inputTokens, - if (outputTokens != null) 'outputTokens': outputTokens, - if (cachedInputTokens != null) 'cachedInputTokens': cachedInputTokens, - if (cacheCreationTokens != null) - 'cacheCreationTokens': cacheCreationTokens, - if (reasoningTokens != null) 'reasoningTokens': reasoningTokens, - if (totalTokens != null) 'totalTokens': totalTokens, - }; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is AiUsage && - other.inputTokens == inputTokens && - other.outputTokens == outputTokens && - other.cachedInputTokens == cachedInputTokens && - other.cacheCreationTokens == cacheCreationTokens && - other.reasoningTokens == reasoningTokens && - other.totalTokens == totalTokens); - - @override - int get hashCode => Object.hash( - inputTokens, - outputTokens, - cachedInputTokens, - cacheCreationTokens, - reasoningTokens, - totalTokens, - ); - - @override - String toString() => 'AiUsage(in: $inputTokens, out: $outputTokens, cached: ' - '$cachedInputTokens, cacheWrite: $cacheCreationTokens, reasoning: ' - '$reasoningTokens, total: $resolvedTotal)'; - - static int? _add(int? a, int? b) => - (a == null && b == null) ? null : (a ?? 0) + (b ?? 0); -} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/provider/ai_capabilities.dart b/packages/flutter_ai/flutter_ai_core/lib/src/provider/ai_capabilities.dart deleted file mode 100644 index 7a14433..0000000 --- a/packages/flutter_ai/flutter_ai_core/lib/src/provider/ai_capabilities.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:flutter_ai_core/src/internal/equality.dart'; -import 'package:flutter_ai_core/src/models/ai_conversation.dart'; -import 'package:flutter_ai_core/src/models/tool_definition.dart'; -import 'package:flutter_ai_core/src/provider/ai_request_options.dart'; -import 'package:flutter_ai_core/src/provider/llm_provider.dart'; - -/// A single embedding vector produced by an [EmbeddingProvider]. -/// -/// [values] is the dense vector for one input string; [index] is that input's -/// position in the batch passed to [EmbeddingProvider.embed], so callers can -/// re-associate vectors with their source text when a provider returns them out -/// of order (or simply confirm alignment). -final class AiEmbedding { - /// Creates an embedding holding [values] at batch position [index]. - const AiEmbedding(this.values, {this.index}); - - /// The dense embedding vector. - final List values; - - /// The position of the source input in the request batch, if reported. - final int? index; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is AiEmbedding && - other.index == index && - deepEquals(other.values, values)); - - @override - int get hashCode => Object.hash(index, deepHash(values)); - - @override - String toString() => 'AiEmbedding(${values.length} dims, index: $index)'; -} - -/// An **optional** capability a provider MAY implement to turn text into -/// embedding vectors (for semantic search, clustering, and RAG retrieval). -/// -/// This is an opt-in mixin interface, separate from [LlmProvider]: a backend -/// that supports embeddings implements it in addition to (or instead of) -/// generation. Check support at runtime with `provider is EmbeddingProvider` -/// before calling [embed]; providers without an embeddings endpoint simply do -/// not implement it. -abstract interface class EmbeddingProvider { - /// Embeds each string in [inputs], returning one [AiEmbedding] per input. - /// - /// [model] selects the embedding model; when `null` the implementation uses - /// its own default. The returned list aligns with [inputs] by - /// [AiEmbedding.index] (and typically by position). - Future> embed(List inputs, {String? model}); -} - -/// An **optional** capability a provider MAY implement to count the tokens a -/// request would consume *before* sending it. -/// -/// Useful for pre-flight budget checks, context-window guards, and cost -/// estimates. Like [EmbeddingProvider] this is an opt-in mixin interface: -/// check support at runtime with `provider is TokenCounter`. Providers without -/// a token-count endpoint simply do not implement it. -abstract interface class TokenCounter { - /// Returns the number of tokens [conversation] (plus any [tools] and - /// [options]) would occupy in a generation request. - Future countTokens( - AiConversation conversation, { - List tools, - AiRequestOptions? options, - }); -} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/provider/ai_request_options.dart b/packages/flutter_ai/flutter_ai_core/lib/src/provider/ai_request_options.dart deleted file mode 100644 index aae8657..0000000 --- a/packages/flutter_ai/flutter_ai_core/lib/src/provider/ai_request_options.dart +++ /dev/null @@ -1,132 +0,0 @@ -import 'package:flutter_ai_core/src/internal/equality.dart'; -import 'package:flutter_ai_core/src/provider/ai_response_format.dart'; - -/// How much effort a reasoning-capable model should spend on internal thinking -/// before answering. -/// -/// A provider-neutral knob. Providers that expose an effort setting map it -/// directly (OpenAI `reasoning_effort`); providers that use a token budget map -/// it through [budgetTokens] (Anthropic `thinking.budget_tokens`, Gemini -/// `thinkingConfig.thinkingBudget`). Providers that don't support it ignore it. -enum ReasoningEffort { - /// The least thinking the model/provider allows. - minimal, - - /// Light reasoning. - low, - - /// Moderate reasoning. - medium, - - /// Deep reasoning. - high; - - /// A canonical thinking-token budget for providers that take one instead of - /// an effort level. A documented heuristic — pass an exact budget via - /// [AiRequestOptions.extra] when you need provider-specific precision. - int get budgetTokens => switch (this) { - ReasoningEffort.minimal => 1024, - ReasoningEffort.low => 2048, - ReasoningEffort.medium => 8192, - ReasoningEffort.high => 24576, - }; - - /// The wire value OpenAI's `reasoning_effort` expects. - String get openAiValue => name; -} - -/// Provider-neutral knobs for a generation request. -/// -/// Common parameters are first-class; anything provider-specific rides in -/// [extra], which a concrete provider passes through to its backend. Switching -/// models is as simple as constructing options with a different [model]. -final class AiRequestOptions { - /// Creates request options. - const AiRequestOptions({ - this.model, - this.temperature, - this.maxOutputTokens, - this.responseFormat, - this.reasoningEffort, - this.cachePrompt = false, - this.extra = const {}, - }); - - /// The model identifier, e.g. `gpt-4o` or `gemini-2.0-flash`. - final String? model; - - /// Sampling temperature, typically in the range `0.0`–`2.0`. - final double? temperature; - - /// An upper bound on the number of tokens to generate. - final int? maxOutputTokens; - - /// When set, requests structured output constrained to a JSON schema. See - /// [AiResponseFormat]. - final AiResponseFormat? responseFormat; - - /// How hard a reasoning-capable model should think before answering. Maps to - /// each provider's native control (OpenAI `reasoning_effort`, Anthropic - /// `thinking.budget_tokens`, Gemini `thinkingConfig.thinkingBudget`) and is - /// ignored by providers that don't support it. An explicit value in [extra] - /// takes precedence. See [ReasoningEffort]. - final ReasoningEffort? reasoningEffort; - - /// Hints that the stable prompt prefix (system instructions + tools) should be - /// cached to cut cost and latency on repeated context. - /// - /// Anthropic applies explicit `cache_control` markers; OpenAI and Gemini cache - /// automatically, so this is a no-op there. Off by default. - final bool cachePrompt; - - /// Provider-specific parameters passed through verbatim. - final Map extra; - - /// Returns a copy with the given fields replaced. - AiRequestOptions copyWith({ - String? model, - double? temperature, - int? maxOutputTokens, - AiResponseFormat? responseFormat, - ReasoningEffort? reasoningEffort, - bool? cachePrompt, - Map? extra, - }) => - AiRequestOptions( - model: model ?? this.model, - temperature: temperature ?? this.temperature, - maxOutputTokens: maxOutputTokens ?? this.maxOutputTokens, - responseFormat: responseFormat ?? this.responseFormat, - reasoningEffort: reasoningEffort ?? this.reasoningEffort, - cachePrompt: cachePrompt ?? this.cachePrompt, - extra: extra ?? this.extra, - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is AiRequestOptions && - other.model == model && - other.temperature == temperature && - other.maxOutputTokens == maxOutputTokens && - other.responseFormat == responseFormat && - other.reasoningEffort == reasoningEffort && - other.cachePrompt == cachePrompt && - deepEquals(other.extra, extra)); - - @override - int get hashCode => Object.hash( - model, - temperature, - maxOutputTokens, - responseFormat, - reasoningEffort, - cachePrompt, - deepHash(extra), - ); - - @override - String toString() => - 'AiRequestOptions(model: $model, temperature: $temperature, ' - 'maxOutputTokens: $maxOutputTokens)'; -} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/provider/ai_response_format.dart b/packages/flutter_ai/flutter_ai_core/lib/src/provider/ai_response_format.dart deleted file mode 100644 index 4c7719d..0000000 --- a/packages/flutter_ai/flutter_ai_core/lib/src/provider/ai_response_format.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:flutter_ai_core/src/internal/equality.dart'; - -/// Requests structured output constrained to a JSON [schema]. -/// -/// Providers route this to their native mechanism: OpenAI `response_format` -/// (`json_schema`, [strict]), Gemini `responseSchema`, and Anthropic a forced -/// tool whose input is [schema] (its result is surfaced as the JSON answer). In -/// every case the assistant's text is the JSON object, which you can decode and -/// validate against [schema]. -final class AiResponseFormat { - /// Creates a structured-output request for [schema] (a JSON Schema object). - const AiResponseFormat({ - required this.schema, - this.name = 'response', - this.strict = true, - }); - - /// The JSON Schema the output must conform to. - final Map schema; - - /// A short name for the schema (used by providers that require one). - final String name; - - /// Whether to enforce the schema strictly where the provider supports it. - final bool strict; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is AiResponseFormat && - other.name == name && - other.strict == strict && - deepEquals(other.schema, schema)); - - @override - int get hashCode => Object.hash(name, strict, deepHash(schema)); - - @override - String toString() => 'AiResponseFormat(name: $name, strict: $strict)'; -} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/provider/generate_object.dart b/packages/flutter_ai/flutter_ai_core/lib/src/provider/generate_object.dart deleted file mode 100644 index 6df5d0f..0000000 --- a/packages/flutter_ai/flutter_ai_core/lib/src/provider/generate_object.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_ai_core/src/internal/equality.dart'; -import 'package:flutter_ai_core/src/models/ai_conversation.dart'; -import 'package:flutter_ai_core/src/models/tool_definition.dart'; -import 'package:flutter_ai_core/src/provider/ai_request_options.dart'; -import 'package:flutter_ai_core/src/provider/ai_response_format.dart'; -import 'package:flutter_ai_core/src/provider/llm_provider.dart'; -import 'package:flutter_ai_core/src/streaming/ai_stream_event.dart'; -import 'package:flutter_ai_core/src/streaming/json_accumulator.dart'; - -/// Structured-output helpers layered on top of any [LlmProvider]. -/// -/// These build on the existing streaming contract: they send the conversation -/// with [AiRequestOptions.responseFormat] set to the requested -/// [AiResponseFormat], collect the assistant's streamed text (which is the JSON -/// object), and surface it as a decoded `Map`. No provider changes are needed — -/// every backend that honors `responseFormat` gets typed objects for free. -extension GenerateObject on LlmProvider { - /// Generates a single structured object constrained to [format]. - /// - /// Sends [conversation] with [options] merged so its - /// [AiRequestOptions.responseFormat] is [format], collects the streamed - /// assistant text, and JSON-decodes it to a `Map`. - /// - /// Throws a [FormatException] (carrying the raw text) if the response is not a - /// JSON object. Soft schema issues do not throw — the decoded object is - /// returned as-is. - Future> generateObject( - AiConversation conversation, { - required AiResponseFormat format, - List tools = const [], - AiRequestOptions? options, - }) async { - final buffer = StringBuffer(); - await for (final event in send( - conversation, - tools: tools, - options: _withFormat(options, format), - )) { - switch (event) { - case TextDelta(:final delta): - buffer.write(delta); - case StreamErrorEvent(:final error): - throw FormatException('generateObject failed: $error'); - case _: - break; - } - } - - final raw = buffer.toString(); - final Object? decoded; - try { - decoded = jsonDecode(raw); - } on FormatException catch (e) { - throw FormatException( - 'generateObject: response was not valid JSON (${e.message})', - raw, - ); - } - if (decoded is! Map) { - throw FormatException( - 'generateObject: expected a JSON object but got ' - '${decoded.runtimeType}', - raw, - ); - } - return decoded.cast(); - } - - /// Generates a structured object, yielding the evolving partial object as it - /// streams. - /// - /// Sends the same request as [generateObject] but feeds each [TextDelta] into - /// a [JsonAccumulator] and yields the best complete-prefix `Map` whenever it - /// advances, ending with the final complete object. Because [JsonAccumulator] - /// only ever surfaces a valid prefix of the document, intermediate yields are - /// growing prefixes of the final object. - Stream> streamObject( - AiConversation conversation, { - required AiResponseFormat format, - List tools = const [], - AiRequestOptions? options, - }) async* { - final accumulator = JsonAccumulator(); - Map? last; - - await for (final event in send( - conversation, - tools: tools, - options: _withFormat(options, format), - )) { - switch (event) { - case TextDelta(:final delta): - accumulator.add(delta); - final partial = accumulator.parsePartial(); - if (partial is Map) { - final next = partial.cast(); - // Only yield when the value actually advanced, so identical - // re-parses between deltas don't emit duplicate frames. - if (last == null || !deepEquals(last, next)) { - last = next; - yield next; - } - } - case StreamErrorEvent(:final error): - throw FormatException('streamObject failed: $error'); - case _: - break; - } - } - - // Surface the final, strictly-parsed object if it differs from the last - // partial (e.g. a trailing token only completed at the end). - final complete = accumulator.tryParseComplete(); - if (complete is Map) { - final next = complete.cast(); - if (last == null || !deepEquals(last, next)) { - yield next; - } - } - } - - AiRequestOptions _withFormat( - AiRequestOptions? options, - AiResponseFormat format, - ) => - (options ?? const AiRequestOptions()).copyWith(responseFormat: format); -} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/provider/llm_exception.dart b/packages/flutter_ai/flutter_ai_core/lib/src/provider/llm_exception.dart deleted file mode 100644 index 718e6eb..0000000 --- a/packages/flutter_ai/flutter_ai_core/lib/src/provider/llm_exception.dart +++ /dev/null @@ -1,57 +0,0 @@ -/// A failed provider HTTP request, surfaced on -/// [StreamErrorEvent.error](../streaming/ai_stream_event.dart) so hosts can -/// branch on the *type* (auth vs. rate-limit vs. server) instead of -/// string-matching a message. -sealed class LlmException implements Exception { - /// Creates a provider exception. - const LlmException(this.statusCode, this.body, {this.retryAfter}); - - /// The HTTP status code. - final int statusCode; - - /// The (truncated) response body, for diagnostics. - final String body; - - /// The server-advised retry delay (from `Retry-After`), if any. - final Duration? retryAfter; - - @override - String toString() => '$runtimeType($statusCode): $body'; -} - -/// Authentication/authorization failure (HTTP 401/403) — usually a bad or -/// missing API key. -final class LlmAuthException extends LlmException { - /// Creates an auth exception. - const LlmAuthException(super.statusCode, super.body); -} - -/// Rate limited (HTTP 429). Honor [retryAfter] before retrying. -final class LlmRateLimitException extends LlmException { - /// Creates a rate-limit exception. - const LlmRateLimitException(super.statusCode, super.body, {super.retryAfter}); -} - -/// Server-side failure (HTTP 5xx, incl. Anthropic 529 overloaded). -final class LlmServerException extends LlmException { - /// Creates a server exception. - const LlmServerException(super.statusCode, super.body, {super.retryAfter}); -} - -/// A non-retryable client error (other 4xx) — e.g. a malformed request. -final class LlmRequestException extends LlmException { - /// Creates a request exception. - const LlmRequestException(super.statusCode, super.body); -} - -/// Maps an HTTP [status] to the matching [LlmException] subtype. -LlmException llmExceptionFor(int status, String body, {Duration? retryAfter}) { - if (status == 401 || status == 403) return LlmAuthException(status, body); - if (status == 429) { - return LlmRateLimitException(status, body, retryAfter: retryAfter); - } - if (status >= 500) { - return LlmServerException(status, body, retryAfter: retryAfter); - } - return LlmRequestException(status, body); -} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/provider/llm_provider.dart b/packages/flutter_ai/flutter_ai_core/lib/src/provider/llm_provider.dart deleted file mode 100644 index e7bf94f..0000000 --- a/packages/flutter_ai/flutter_ai_core/lib/src/provider/llm_provider.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter_ai_core/src/models/ai_conversation.dart'; -import 'package:flutter_ai_core/src/models/tool_definition.dart'; -import 'package:flutter_ai_core/src/provider/ai_request_options.dart'; -import 'package:flutter_ai_core/src/streaming/ai_stream_event.dart'; - -/// The contract every model backend implements: turn a conversation into a -/// stream of incremental [AiStreamEvent]s. -/// -/// This is the single seam that makes the ecosystem provider-agnostic. A -/// concrete provider (OpenAI, Anthropic, Gemini, an on-device model, or a -/// custom backend) maps its native protocol onto these events; everything above -/// it — controllers, UI — is written once against this interface. -/// -/// Implementations should: -/// -/// * emit a terminal [MessageFinished] (or [StreamErrorEvent]) for each assistant -/// message they produce, so consumers can finalize state and accessibility; -/// * surface failures as a [StreamErrorEvent] event where possible, reserving thrown -/// exceptions for programming errors and unrecoverable transport faults; -/// * stop work promptly when the returned stream's subscription is cancelled. -abstract interface class LlmProvider { - /// Generates a response to [conversation]. - /// - /// [tools] advertises the tools the model may call; `null` or empty means - /// none. [options] carries model selection and sampling parameters; `null` - /// means provider defaults. The returned stream is single-subscription; - /// cancelling its subscription must cancel the request. - Stream send( - AiConversation conversation, { - List? tools, - AiRequestOptions? options, - }); -} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/rendering/text_renderer.dart b/packages/flutter_ai/flutter_ai_core/lib/src/rendering/text_renderer.dart deleted file mode 100644 index 6650bca..0000000 --- a/packages/flutter_ai/flutter_ai_core/lib/src/rendering/text_renderer.dart +++ /dev/null @@ -1,18 +0,0 @@ -/// A strategy for turning message text into a rendered representation. -/// -/// Declared in the core — without a Flutter dependency — so models and contracts -/// can reference the seam, while UI packages provide the concrete widget- -/// producing implementation (the default in `flutter_ai_elements` is a -/// dependency-free Markdown renderer). Hosts inject a custom [TextRenderer] to -/// swap in their own parser or to support dialects such as LaTeX or custom tags. -/// -/// The type parameter [T] is the rendered output — a `Widget` in the UI layer, -/// or any representation in non-UI contexts (tests, server-side rendering). -abstract interface class TextRenderer { - /// Renders [text] into a [T]. - /// - /// [isStreaming] is `true` while the text is still arriving, letting an - /// implementation defer expensive parsing or suppress live-region semantics - /// until generation completes. - T render(String text, {required bool isStreaming}); -} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/streaming/ai_stream_event.dart b/packages/flutter_ai/flutter_ai_core/lib/src/streaming/ai_stream_event.dart deleted file mode 100644 index 1830974..0000000 --- a/packages/flutter_ai/flutter_ai_core/lib/src/streaming/ai_stream_event.dart +++ /dev/null @@ -1,497 +0,0 @@ -import 'package:flutter_ai_core/src/internal/equality.dart'; -import 'package:flutter_ai_core/src/models/ai_part.dart'; -import 'package:flutter_ai_core/src/models/ai_role.dart'; -import 'package:flutter_ai_core/src/models/finish_reason.dart'; -import 'package:flutter_ai_core/src/models/usage.dart'; - -/// A single incremental update emitted by an `LlmProvider` during generation. -/// -/// Providers translate their native protocol (SSE, gRPC, a local callback) into -/// this sealed set of events; a `MessageProcessor` folds them into conversation -/// state. Because the type is `sealed`, a `switch` over an event is exhaustively -/// checked, and adding an event forces every consumer to handle it. -/// -/// Events round-trip through JSON so a generic transport can serialize them and -/// tests can replay recorded streams. [AiStreamEvent.fromJson] dispatches on the -/// `type` discriminator. -sealed class AiStreamEvent { - /// Const base constructor for subclasses. - const AiStreamEvent(); - - /// Reconstructs an event from [json] by dispatching on `type`. - /// - /// Throws a [FormatException] if `type` is missing or unrecognized. - factory AiStreamEvent.fromJson(Map json) { - final type = json['type']; - return switch (type) { - 'message-started' => MessageStarted.fromJson(json), - 'text-delta' => TextDelta.fromJson(json), - 'reasoning-delta' => ReasoningDelta.fromJson(json), - 'tool-call-started' => ToolCallStarted.fromJson(json), - 'tool-call-delta' => ToolCallDelta.fromJson(json), - 'tool-call-ready' => ToolCallReady.fromJson(json), - 'tool-result' => ToolResultReceived.fromJson(json), - 'part-received' => PartReceived.fromJson(json), - 'message-finished' => MessageFinished.fromJson(json), - 'error' => StreamErrorEvent.fromJson(json), - _ => throw FormatException('Unknown AiStreamEvent type: "$type"'), - }; - } - - /// Serializes this event, including its `type` discriminator. - Map toJson(); -} - -/// Announces a new message and its author, before any content arrives. -/// -/// Optional: a processor will lazily create an assistant message on the first -/// content event if no start was sent. -final class MessageStarted extends AiStreamEvent { - /// Creates a message-started event. - const MessageStarted({required this.messageId, required this.role}); - - /// Reconstructs a [MessageStarted] from [json]. - factory MessageStarted.fromJson(Map json) => MessageStarted( - messageId: json['messageId']! as String, - role: AiRole.fromJson(json['role']! as String), - ); - - /// The id of the message being started. - final String messageId; - - /// Who authors the message. - final AiRole role; - - @override - Map toJson() => { - 'type': 'message-started', - 'messageId': messageId, - 'role': role.toJson(), - }; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is MessageStarted && - other.messageId == messageId && - other.role == role); - - @override - int get hashCode => Object.hash(messageId, role); - - @override - String toString() => 'MessageStarted($messageId, ${role.name})'; -} - -/// Appends [delta] to the prose of message [messageId]. -final class TextDelta extends AiStreamEvent { - /// Creates a text-delta event. - const TextDelta({required this.messageId, required this.delta}); - - /// Reconstructs a [TextDelta] from [json]. - factory TextDelta.fromJson(Map json) => TextDelta( - messageId: json['messageId']! as String, - delta: json['delta']! as String, - ); - - /// The message receiving the text. - final String messageId; - - /// The text fragment to append. - final String delta; - - @override - Map toJson() => - {'type': 'text-delta', 'messageId': messageId, 'delta': delta}; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is TextDelta && - other.messageId == messageId && - other.delta == delta); - - @override - int get hashCode => Object.hash(messageId, delta); - - @override - String toString() => 'TextDelta($messageId, ${delta.length} chars)'; -} - -/// Appends [delta] to the reasoning of message [messageId]. -final class ReasoningDelta extends AiStreamEvent { - /// Creates a reasoning-delta event. - const ReasoningDelta({ - required this.messageId, - required this.delta, - this.signature, - }); - - /// Reconstructs a [ReasoningDelta] from [json]. - factory ReasoningDelta.fromJson(Map json) => ReasoningDelta( - messageId: json['messageId']! as String, - delta: json['delta']! as String, - signature: json['signature'] as String?, - ); - - /// The message receiving the reasoning. - final String messageId; - - /// The reasoning fragment to append. - final String delta; - - /// An opaque provider signature for the reasoning block (set on the - /// [ReasoningPart] when present); see [ReasoningPart.signature]. - final String? signature; - - @override - Map toJson() => { - 'type': 'reasoning-delta', - 'messageId': messageId, - 'delta': delta, - if (signature != null) 'signature': signature, - }; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is ReasoningDelta && - other.messageId == messageId && - other.delta == delta && - other.signature == signature); - - @override - int get hashCode => Object.hash(messageId, delta, signature); - - @override - String toString() => 'ReasoningDelta($messageId, ${delta.length} chars)'; -} - -/// Opens a tool call within message [messageId]. -/// -/// Followed by zero or more [ToolCallDelta]s carrying the argument JSON, then a -/// [ToolCallReady] once the arguments are complete. -final class ToolCallStarted extends AiStreamEvent { - /// Creates a tool-call-started event. - const ToolCallStarted({ - required this.messageId, - required this.toolCallId, - required this.toolName, - }); - - /// Reconstructs a [ToolCallStarted] from [json]. - factory ToolCallStarted.fromJson(Map json) => - ToolCallStarted( - messageId: json['messageId']! as String, - toolCallId: json['toolCallId']! as String, - toolName: json['toolName']! as String, - ); - - /// The message the call belongs to. - final String messageId; - - /// The id correlating this call with its result. - final String toolCallId; - - /// The name of the tool being invoked. - final String toolName; - - @override - Map toJson() => { - 'type': 'tool-call-started', - 'messageId': messageId, - 'toolCallId': toolCallId, - 'toolName': toolName, - }; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is ToolCallStarted && - other.messageId == messageId && - other.toolCallId == toolCallId && - other.toolName == toolName); - - @override - int get hashCode => Object.hash(messageId, toolCallId, toolName); - - @override - String toString() => 'ToolCallStarted($toolName, id: $toolCallId)'; -} - -/// Appends a fragment of argument JSON to tool call [toolCallId]. -/// -/// The fragments accumulate; the JSON is partial until [ToolCallReady]. -final class ToolCallDelta extends AiStreamEvent { - /// Creates a tool-call-delta event. - const ToolCallDelta({ - required this.toolCallId, - required this.argumentsDelta, - }); - - /// Reconstructs a [ToolCallDelta] from [json]. - factory ToolCallDelta.fromJson(Map json) => ToolCallDelta( - toolCallId: json['toolCallId']! as String, - argumentsDelta: json['argumentsDelta']! as String, - ); - - /// The call whose arguments are growing. - final String toolCallId; - - /// A fragment of the arguments JSON. - final String argumentsDelta; - - @override - Map toJson() => { - 'type': 'tool-call-delta', - 'toolCallId': toolCallId, - 'argumentsDelta': argumentsDelta, - }; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is ToolCallDelta && - other.toolCallId == toolCallId && - other.argumentsDelta == argumentsDelta); - - @override - int get hashCode => Object.hash(toolCallId, argumentsDelta); - - @override - String toString() => - 'ToolCallDelta($toolCallId, ${argumentsDelta.length} chars)'; -} - -/// Signals that tool call [toolCallId] has received all its arguments. -/// -/// The processor strictly parses the accumulated JSON: on success the call -/// advances to `ToolCallState.inputAvailable`; on failure it is marked errored. -final class ToolCallReady extends AiStreamEvent { - /// Creates a tool-call-ready event. - const ToolCallReady({required this.toolCallId}); - - /// Reconstructs a [ToolCallReady] from [json]. - factory ToolCallReady.fromJson(Map json) => - ToolCallReady(toolCallId: json['toolCallId']! as String); - - /// The call whose arguments are now complete. - final String toolCallId; - - @override - Map toJson() => - {'type': 'tool-call-ready', 'toolCallId': toolCallId}; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is ToolCallReady && other.toolCallId == toolCallId); - - @override - int get hashCode => toolCallId.hashCode; - - @override - String toString() => 'ToolCallReady($toolCallId)'; -} - -/// Delivers the output of tool call [toolCallId] into message [messageId]. -final class ToolResultReceived extends AiStreamEvent { - /// Creates a tool-result event. - const ToolResultReceived({ - required this.messageId, - required this.toolCallId, - required this.result, - this.isError = false, - }); - - /// Reconstructs a [ToolResultReceived] from [json]. - factory ToolResultReceived.fromJson(Map json) => - ToolResultReceived( - messageId: json['messageId']! as String, - toolCallId: json['toolCallId']! as String, - result: json['result'], - isError: json['isError'] as bool? ?? false, - ); - - /// The message the result attaches to. - final String messageId; - - /// The call this result answers. - final String toolCallId; - - /// The tool's output (any JSON-encodable value, or `null`). - final Object? result; - - /// Whether [result] is an error payload. - final bool isError; - - @override - Map toJson() => { - 'type': 'tool-result', - 'messageId': messageId, - 'toolCallId': toolCallId, - 'result': result, - 'isError': isError, - }; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is ToolResultReceived && - other.messageId == messageId && - other.toolCallId == toolCallId && - other.isError == isError && - deepEquals(other.result, result)); - - @override - int get hashCode => - Object.hash(messageId, toolCallId, isError, deepHash(result)); - - @override - String toString() => 'ToolResultReceived($toolCallId, isError: $isError)'; -} - -/// Appends a fully-formed [part] (a file, source, or data payload) to message -/// [messageId]. -final class PartReceived extends AiStreamEvent { - /// Creates a part-received event. - const PartReceived({required this.messageId, required this.part}); - - /// Reconstructs a [PartReceived] from [json]. - factory PartReceived.fromJson(Map json) => PartReceived( - messageId: json['messageId']! as String, - part: AiPart.fromJson((json['part']! as Map).cast()), - ); - - /// The message receiving the part. - final String messageId; - - /// The complete part to append. - final AiPart part; - - @override - Map toJson() => - {'type': 'part-received', 'messageId': messageId, 'part': part.toJson()}; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is PartReceived && - other.messageId == messageId && - other.part == part); - - @override - int get hashCode => Object.hash(messageId, part); - - @override - String toString() => 'PartReceived($messageId, $part)'; -} - -/// Marks message [messageId] complete, carrying the [reason] generation ended. -final class MessageFinished extends AiStreamEvent { - /// Creates a message-finished event. - const MessageFinished({ - required this.messageId, - required this.reason, - this.usage, - }); - - /// Reconstructs a [MessageFinished] from [json]. - factory MessageFinished.fromJson(Map json) { - final usage = json['usage']; - return MessageFinished( - messageId: json['messageId']! as String, - reason: FinishReason.fromJson(json['reason']! as String), - usage: usage == null - ? null - : AiUsage.fromJson((usage as Map).cast()), - ); - } - - /// The message that finished. - final String messageId; - - /// Why generation stopped. - final FinishReason reason; - - /// Token usage for the turn, if the provider reported it. - final AiUsage? usage; - - @override - Map toJson() => { - 'type': 'message-finished', - 'messageId': messageId, - 'reason': reason.toJson(), - if (usage != null) 'usage': usage!.toJson(), - }; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is MessageFinished && - other.messageId == messageId && - other.reason == reason && - other.usage == usage); - - @override - int get hashCode => Object.hash(messageId, reason, usage); - - @override - String toString() => 'MessageFinished($messageId, ${reason.name})'; -} - -/// Reports an error during generation. -/// -/// When [messageId] is set, the processor marks that message errored; a scoped -/// [toolCallId] additionally flags the offending tool call. A `null` -/// [messageId] denotes a stream-level failure not tied to one message. -final class StreamErrorEvent extends AiStreamEvent { - /// Creates an error event. - const StreamErrorEvent({ - required this.error, - this.messageId, - this.toolCallId, - }); - - /// Reconstructs a [StreamErrorEvent] from [json]. - /// - /// The original error object is not recoverable from JSON; its string form is - /// restored as the [error]. - factory StreamErrorEvent.fromJson(Map json) => - StreamErrorEvent( - error: json['error']! as String, - messageId: json['messageId'] as String?, - toolCallId: json['toolCallId'] as String?, - ); - - /// The error that occurred. - final Object error; - - /// The affected message, if the failure is scoped to one. - final String? messageId; - - /// The affected tool call, if the failure is scoped to one. - final String? toolCallId; - - @override - Map toJson() => { - 'type': 'error', - 'error': error.toString(), - if (messageId != null) 'messageId': messageId, - if (toolCallId != null) 'toolCallId': toolCallId, - }; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is StreamErrorEvent && - other.error.toString() == error.toString() && - other.messageId == messageId && - other.toolCallId == toolCallId); - - @override - int get hashCode => Object.hash(error.toString(), messageId, toolCallId); - - @override - String toString() => 'StreamErrorEvent($error, messageId: $messageId)'; -} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/streaming/json_accumulator.dart b/packages/flutter_ai/flutter_ai_core/lib/src/streaming/json_accumulator.dart deleted file mode 100644 index e480da9..0000000 --- a/packages/flutter_ai/flutter_ai_core/lib/src/streaming/json_accumulator.dart +++ /dev/null @@ -1,261 +0,0 @@ -import 'dart:convert'; - -/// Accumulates a JSON document that arrives in fragments and parses it -/// tolerantly while still incomplete. -/// -/// Tool-call arguments stream from the model as a sequence of partial JSON -/// strings. A naive `jsonDecode` of the buffer throws until the very last -/// fragment lands, which makes live rendering impossible. [JsonAccumulator] -/// instead repairs the partial buffer — closing open strings and containers and -/// dropping any trailing incomplete token — so callers can show a best-effort -/// view at every step, then validate strictly once the document is complete. -/// -/// The repair is conservative: it never throws and never invents data. When a -/// trailing value cannot be completed safely it is dropped rather than guessed, -/// so [parsePartial] only ever returns a prefix of the eventual document. -class JsonAccumulator { - final StringBuffer _buffer = StringBuffer(); - Object? _lastPartial; - - /// Appends a fragment to the buffer. - void add(String fragment) => _buffer.write(fragment); - - /// Clears the buffer and cached partial result. - void reset() { - _buffer.clear(); - _lastPartial = null; - } - - /// The raw accumulated text. - String get raw => _buffer.toString(); - - /// Whether nothing has been accumulated yet. - bool get isEmpty => _buffer.isEmpty; - - /// Strictly parses the buffer, returning `null` if it is not yet valid JSON. - /// - /// Never throws — a parse failure simply yields `null`. - Object? tryParseComplete() { - final source = raw; - if (source.trim().isEmpty) return null; - try { - return jsonDecode(source); - } on FormatException { - return null; - } - } - - /// Returns a best-effort decode of the (possibly partial) buffer. - /// - /// If the buffer is already valid JSON it is returned as-is. Otherwise the - /// buffer is repaired and decoded; if even the repair cannot be parsed, the - /// most recent successful partial is returned (or `null` if there is none). - Object? parsePartial() { - final source = raw; - if (source.trim().isEmpty) return _lastPartial; - - final strict = tryParseComplete(); - if (strict != null) { - _lastPartial = strict; - return strict; - } - - final repaired = _repair(source); - if (repaired != null) { - try { - final value = jsonDecode(repaired); - _lastPartial = value; - return value; - } on FormatException { - // Fall through to the cached partial. - } - } - return _lastPartial; - } -} - -const int _quote = 0x22; // " -const int _backslash = 0x5c; // \ -const int _colon = 0x3a; // : -const int _comma = 0x2c; // , -const int _openBrace = 0x7b; // { -const int _closeBrace = 0x7d; // } -const int _openBracket = 0x5b; // [ -const int _closeBracket = 0x5d; // ] -const int _space = 0x20; -const int _tab = 0x09; -const int _newline = 0x0a; -const int _return = 0x0d; - -bool _isWhitespace(int c) => - c == _space || c == _tab || c == _newline || c == _return; - -// Parser states for the repair scanner. -const int _expectValue = 0; // start of value (array elem, after ':', after '[') -const int _afterValue = 1; // a complete value just ended (a safe cut point) -const int _expectKey = 2; // start of object member (a key string, or '}') -const int _expectColon = 3; // a key just ended, ':' must follow - -/// Completes a truncated JSON [source] into a valid JSON string, or returns -/// `null` if no safe completion exists. -/// -/// Walks the document tracking the open-container stack and a small state -/// machine. It remembers the latest position at which the document could be -/// legally closed (a "safe cut") together with the container stack there, then -/// truncates to that point and appends the matching closers. Anything after the -/// last safe cut — an unterminated string, a dangling `"key":`, a half-written -/// number — is discarded. -String? _repair(String source) { - final stack = []; // _openBrace / _openBracket, outermost first - var state = _expectValue; - var safeLen = -1; - var safeStack = const []; - - void markSafe(int length) { - safeLen = length; - safeStack = List.of(stack); - } - - var i = 0; - final length = source.length; - scan: - while (i < length) { - final c = source.codeUnitAt(i); - if (_isWhitespace(c)) { - i++; - continue; - } - - switch (state) { - case _expectKey: - if (c == _closeBrace) { - stack.removeLast(); - state = _afterValue; - i++; - markSafe(i); - } else if (c == _quote) { - final end = _scanString(source, i); - if (end == -1) break scan; // incomplete key - i = end; - state = _expectColon; - } else { - break scan; - } - - case _expectColon: - if (c == _colon) { - state = _expectValue; - i++; - } else { - break scan; - } - - case _expectValue: - if (c == _openBrace) { - stack.add(_openBrace); - state = _expectKey; - i++; - markSafe(i); // an empty object can be closed - } else if (c == _openBracket) { - stack.add(_openBracket); - state = _expectValue; - i++; - markSafe(i); // an empty array can be closed - } else if (c == _closeBracket && - stack.isNotEmpty && - stack.last == _openBracket) { - stack.removeLast(); // empty array: "[]" - state = _afterValue; - i++; - markSafe(i); - } else if (c == _quote) { - final end = _scanString(source, i); - if (end == -1) break scan; // incomplete value string - i = end; - state = _afterValue; - markSafe(i); - } else { - // A number or keyword. It only counts as complete once a structural - // delimiter or whitespace terminates it — otherwise a buffer like - // `1234` might be a truncated prefix of `123456`, a *different* - // scalar, which would violate the prefix contract. An unterminated - // trailing literal is therefore excluded from the safe cut, exactly - // as an unterminated string is. - final end = _scanLiteral(source, i); - if (end == length) break scan; // literal runs to the buffer end - i = end; - state = _afterValue; - markSafe(i); - } - - case _afterValue: - if (c == _comma) { - state = (stack.isNotEmpty && stack.last == _openBrace) - ? _expectKey - : _expectValue; - i++; - } else if (c == _closeBrace && - stack.isNotEmpty && - stack.last == _openBrace) { - stack.removeLast(); - i++; - markSafe(i); - } else if (c == _closeBracket && - stack.isNotEmpty && - stack.last == _openBracket) { - stack.removeLast(); - i++; - markSafe(i); - } else { - break scan; - } - } - } - - if (safeLen < 0) return null; - final buffer = StringBuffer(source.substring(0, safeLen)); - for (var k = safeStack.length - 1; k >= 0; k--) { - buffer.writeCharCode( - safeStack[k] == _openBrace ? _closeBrace : _closeBracket, - ); - } - return buffer.toString(); -} - -/// Returns the index just past the closing quote of the string starting at -/// [start], or `-1` if the string is unterminated. -int _scanString(String source, int start) { - var i = start + 1; // skip the opening quote - final length = source.length; - while (i < length) { - final c = source.codeUnitAt(i); - if (c == _backslash) { - i += 2; // skip the escaped character - continue; - } - if (c == _quote) return i + 1; - i++; - } - return -1; -} - -/// Returns the index just past a literal (number, `true`, `false`, `null`) -/// starting at [start]. -/// -/// Scanning ends at the first structural delimiter or whitespace, or at the end -/// of [source] if the literal is the final token. -int _scanLiteral(String source, int start) { - var i = start; - final length = source.length; - while (i < length) { - final c = source.codeUnitAt(i); - if (_isWhitespace(c) || - c == _comma || - c == _closeBrace || - c == _closeBracket) { - return i; - } - i++; - } - return length; -} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/streaming/message_processor.dart b/packages/flutter_ai/flutter_ai_core/lib/src/streaming/message_processor.dart deleted file mode 100644 index ff982b3..0000000 --- a/packages/flutter_ai/flutter_ai_core/lib/src/streaming/message_processor.dart +++ /dev/null @@ -1,357 +0,0 @@ -import 'package:flutter_ai_core/src/models/ai_conversation.dart'; -import 'package:flutter_ai_core/src/models/ai_message.dart'; -import 'package:flutter_ai_core/src/models/ai_part.dart'; -import 'package:flutter_ai_core/src/models/ai_role.dart'; -import 'package:flutter_ai_core/src/models/finish_reason.dart'; -import 'package:flutter_ai_core/src/models/tool_call_state.dart'; -import 'package:flutter_ai_core/src/streaming/ai_stream_event.dart'; -import 'package:flutter_ai_core/src/streaming/json_accumulator.dart'; -import 'package:flutter_ai_core/src/streaming/mutation_result.dart'; - -/// Folds a stream of [AiStreamEvent]s into evolving [AiConversation] state. -/// -/// The processor is a pure, synchronous, Flutter-free reducer: each [apply] -/// call returns the new conversation plus the ids of the messages that changed, -/// so a host can rebuild only those nodes. It does **no** scheduling itself — -/// batching updates to the frame boundary is the consumer's job, which keeps the -/// reducer testable and the package UI-agnostic. -/// -/// Tool-call arguments are accumulated per call and parsed tolerantly while -/// streaming (see [JsonAccumulator]); a [ToolCallReady] event triggers a strict -/// re-parse. Malformed arguments do not throw — the offending call is marked -/// [ToolCallState.error] and an error [ToolResultPart] is appended, so the rest -/// of the stream proceeds unaffected. -class MessageProcessor { - /// Creates a processor seeded with an optional starting [conversation]. - MessageProcessor({AiConversation? conversation}) - : _conversation = conversation ?? const AiConversation.empty('default'); - - AiConversation _conversation; - final Map _argAccumulators = {}; - final Map _toolCallToMessage = {}; - - /// The current conversation state. - AiConversation get conversation => _conversation; - - /// Resets the processor to [conversation], discarding streaming scratch state. - void reset(AiConversation conversation) { - _conversation = conversation; - _argAccumulators.clear(); - _toolCallToMessage.clear(); - } - - /// Applies [event] and returns the resulting [MutationResult]. - MutationResult apply(AiStreamEvent event) { - switch (event) { - case MessageStarted(:final messageId, :final role): - if (_conversation.messageById(messageId) == null) { - _conversation = _conversation.append( - AiMessage( - id: messageId, - role: role, - status: AiMessageStatus.streaming, - ), - ); - } - return _changed(messageId); - - case TextDelta(:final messageId, :final delta): - _mutate(messageId, (m) => _appendText(m, delta)); - return _changed(messageId); - - case ReasoningDelta(:final messageId, :final delta, :final signature): - _mutate(messageId, (m) => _appendReasoning(m, delta, signature)); - return _changed(messageId); - - case ToolCallStarted( - :final messageId, - :final toolCallId, - :final toolName - ): - _toolCallToMessage[toolCallId] = messageId; - _argAccumulators[toolCallId] = JsonAccumulator(); - _mutate( - messageId, - (m) => m.copyWith( - parts: [ - ...m.parts, - ToolCallPart(toolCallId: toolCallId, toolName: toolName), - ], - status: AiMessageStatus.streaming, - ), - ); - return _changed(messageId); - - case ToolCallDelta(:final toolCallId, :final argumentsDelta): - final messageId = _toolCallToMessage[toolCallId]; - final accumulator = _argAccumulators[toolCallId]; - if (messageId == null || accumulator == null) return _none(); - accumulator.add(argumentsDelta); - final partial = accumulator.parsePartial(); - _updateToolCall( - messageId, - toolCallId, - (p) => p.copyWith( - // Keep the last good partial args when this fragment isn't yet - // parseable, rather than clobbering to {} and flickering the UI. - args: partial is Map ? partial.cast() : p.args, - state: ToolCallState.inputStreaming, - ), - ); - return _changed(messageId); - - case ToolCallReady(:final toolCallId): - final messageId = _toolCallToMessage[toolCallId]; - final accumulator = _argAccumulators[toolCallId]; - if (messageId == null || accumulator == null) return _none(); - final parsed = accumulator.tryParseComplete(); - if (parsed is Map) { - _updateToolCall( - messageId, - toolCallId, - (p) => p.copyWith( - args: parsed.cast(), - state: ToolCallState.inputAvailable, - ), - ); - } else if (accumulator.raw.trim().isEmpty) { - // No arguments were streamed — a legitimate zero-argument tool call - // (e.g. `get_current_time`). Treat as empty args, not an error. - _updateToolCall( - messageId, - toolCallId, - (p) => p.copyWith( - args: const {}, - state: ToolCallState.inputAvailable, - ), - ); - } else { - // Malformed arguments: halt this call without crashing the stream. - _updateToolCall( - messageId, - toolCallId, - (p) => p.copyWith(state: ToolCallState.error), - ); - _mutate( - messageId, - (m) => m.copyWith( - parts: [ - ...m.parts, - ToolResultPart( - toolCallId: toolCallId, - result: 'Invalid tool arguments: ${accumulator.raw}', - isError: true, - ), - ], - ), - ); - } - return _changed(messageId); - - case ToolResultReceived( - :final messageId, - :final toolCallId, - :final result, - :final isError, - ): - // The call lives in the assistant message it was started on, which is - // usually *not* the message carrying the result (e.g. a separate - // tool-role message). Advance the call's state in its owning message. - // After a reset()/rehydration the in-memory map is empty, so fall back - // to scanning the conversation for the message that actually holds the - // matching ToolCallPart before using the result's own message id. - _updateToolCall( - _toolCallToMessage[toolCallId] ?? - _messageIdForToolCall(toolCallId) ?? - messageId, - toolCallId, - (p) => p.copyWith( - state: - isError ? ToolCallState.error : ToolCallState.outputAvailable, - ), - ); - _mutate( - messageId, - (m) => m.copyWith( - parts: [ - ...m.parts, - ToolResultPart( - toolCallId: toolCallId, - result: result, - isError: isError, - ), - ], - ), - ); - return _changed(messageId); - - case PartReceived(:final messageId, :final part): - _mutate( - messageId, - (m) => m.copyWith( - parts: [...m.parts, part], - status: AiMessageStatus.streaming, - ), - ); - return _changed(messageId); - - case MessageFinished(:final messageId, :final reason, :final usage): - _mutate( - messageId, - (m) => m.copyWith( - // Freeze any streaming buffer into a plain, detached part so the - // settled transcript message never pins a live StringBuffer. - parts: _freezeBuffers(m.parts), - status: reason == FinishReason.error - ? AiMessageStatus.error - : AiMessageStatus.complete, - finishReason: reason, - usage: usage, - ), - ); - return _changed(messageId); - - case StreamErrorEvent(:final messageId, :final toolCallId): - // A tool-scoped error fails only that call; generation continues, so - // don't mark the whole message errored (matches UseChatController). - if (toolCallId != null) { - final callMessageId = _toolCallToMessage[toolCallId]; - if (callMessageId == null) return _none(); - _updateToolCall( - callMessageId, - toolCallId, - (p) => p.copyWith(state: ToolCallState.error), - ); - return _changed(callMessageId); - } - if (messageId == null) return _none(); - _mutate( - messageId, - (m) => m.copyWith( - status: AiMessageStatus.error, - finishReason: FinishReason.error, - ), - ); - return _changed(messageId); - } - } - - /// Ensures a message with [messageId] exists, applies [transform], and stores - /// the result. A missing message is created as a streaming [roleIfAbsent] - /// message, so a content event that arrives without a [MessageStarted] still - /// works. - void _mutate( - String messageId, - AiMessage Function(AiMessage message) transform, { - AiRole roleIfAbsent = AiRole.assistant, - }) { - final existing = _conversation.messageById(messageId) ?? - AiMessage( - id: messageId, - role: roleIfAbsent, - status: AiMessageStatus.streaming, - ); - _conversation = _conversation.replace(transform(existing)); - } - - /// Finds the id of the message whose parts contain a [ToolCallPart] with - /// [toolCallId], or `null` if no such message exists. Used to recover the - /// call→message mapping that lives only in memory when results arrive after a - /// [reset] or against a seeded conversation. - String? _messageIdForToolCall(String toolCallId) { - for (final message in _conversation.messages) { - for (final part in message.parts) { - if (part is ToolCallPart && part.toolCallId == toolCallId) { - return message.id; - } - } - } - return null; - } - - void _updateToolCall( - String messageId, - String toolCallId, - ToolCallPart Function(ToolCallPart part) transform, - ) { - _mutate(messageId, (m) { - final parts = [...m.parts]; - final index = parts.indexWhere( - (p) => p is ToolCallPart && p.toolCallId == toolCallId, - ); - if (index == -1) return m; - parts[index] = transform(parts[index] as ToolCallPart); - return m.copyWith(parts: parts); - }); - } - - // Text and reasoning deltas accumulate into a per-part [StringBuffer] rather - // than `last.text + delta`, which would reallocate the whole accumulated - // string on every token (quadratic on long answers). The buffer is appended - // to in place — O(delta) — and the resulting String is materialized lazily, - // only when a consumer reads `TextPart.text`/`ReasoningPart.text`. A buffered - // part already at the tail carries its buffer, so we keep writing to it; a - // plain part (e.g. rehydrated from a stored String) seeds a fresh buffer with - // its current text on the first delta. A non-text part at the tail forces a - // new buffer, so buffers never merge across a part boundary. - - AiMessage _appendText(AiMessage message, String delta) { - final parts = [...message.parts]; - final last = parts.isEmpty ? null : parts.last; - if (last is TextPart) { - final buffer = last.buffer ?? (StringBuffer()..write(last.text)); - buffer.write(delta); - parts[parts.length - 1] = TextPart.buffered(buffer); - } else { - parts.add(TextPart.buffered(StringBuffer()..write(delta))); - } - return message.copyWith(parts: parts, status: AiMessageStatus.streaming); - } - - AiMessage _appendReasoning(AiMessage message, String delta, [String? sig]) { - final parts = [...message.parts]; - final last = parts.isEmpty ? null : parts.last; - if (last is ReasoningPart) { - final buffer = last.buffer ?? (StringBuffer()..write(last.text)); - buffer.write(delta); - parts[parts.length - 1] = ReasoningPart.buffered( - buffer, - signature: sig ?? last.signature, - ); - } else { - parts.add( - ReasoningPart.buffered(StringBuffer()..write(delta), signature: sig)); - } - return message.copyWith(parts: parts, status: AiMessageStatus.streaming); - } - - /// Materializes any still-buffered [TextPart]/[ReasoningPart] into plain, - /// detached parts. Called when a message settles so the stored transcript - /// holds ordinary value objects rather than references to a live buffer. - List _freezeBuffers(List parts) { - var changed = false; - final frozen = []; - for (final part in parts) { - if (part is TextPart && part.buffer != null) { - frozen.add(TextPart(part.text)); - changed = true; - } else if (part is ReasoningPart && part.buffer != null) { - frozen.add(ReasoningPart(part.text, signature: part.signature)); - changed = true; - } else { - frozen.add(part); - } - } - return changed ? frozen : parts; - } - - MutationResult _changed(String messageId) => MutationResult( - conversation: _conversation, - changedMessageIds: {messageId}, - ); - - MutationResult _none() => MutationResult( - conversation: _conversation, - changedMessageIds: const {}, - ); -} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/streaming/mutation_result.dart b/packages/flutter_ai/flutter_ai_core/lib/src/streaming/mutation_result.dart deleted file mode 100644 index cf85719..0000000 --- a/packages/flutter_ai/flutter_ai_core/lib/src/streaming/mutation_result.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:flutter_ai_core/src/models/ai_conversation.dart'; - -/// The outcome of applying one stream event to a `MessageProcessor`. -/// -/// Carries the updated [conversation] and the set of message ids that changed. -/// A UI binds the latter to rebuild only the affected messages — never the whole -/// transcript — which is what keeps streaming at frame rate. -final class MutationResult { - /// Creates a mutation result. - const MutationResult({ - required this.conversation, - required this.changedMessageIds, - }); - - /// The conversation after the event was applied. - final AiConversation conversation; - - /// The ids of messages whose content changed. Empty when the event was a - /// no-op (for example, an event referencing an unknown id). - final Set changedMessageIds; - - /// Whether the event changed any message. - bool get hasChanges => changedMessageIds.isNotEmpty; - - @override - String toString() => 'MutationResult(changed: $changedMessageIds, ' - 'messages: ${conversation.messages.length})'; -} diff --git a/packages/flutter_ai/flutter_ai_core/lib/src/tools/json_schema_validator.dart b/packages/flutter_ai/flutter_ai_core/lib/src/tools/json_schema_validator.dart deleted file mode 100644 index 4fd48e7..0000000 --- a/packages/flutter_ai/flutter_ai_core/lib/src/tools/json_schema_validator.dart +++ /dev/null @@ -1,190 +0,0 @@ -/// A tiny, dependency-free validator for the subset of JSON Schema that LLM -/// tool/function declarations actually use. -/// -/// This is intentionally *not* a full JSON Schema implementation. It covers the -/// keywords providers emit for tool parameters — `type`, `properties`, -/// `required`, `items`, `enum`, `additionalProperties: false`, and the common -/// numeric/string/array bounds — which is enough to catch the malformed -/// arguments a model occasionally produces and to feed an actionable error back -/// so it can correct itself. -/// -/// [validateJsonSchema] returns a list of human-readable violation messages; -/// an empty list means the value satisfies the schema. Unknown keywords are -/// ignored (treated as "no constraint") rather than rejected, so a richer -/// server-side schema never produces false negatives here. -library; - -/// Validates [value] against [schema], returning a list of violation messages -/// (empty when valid). [path] names the root in messages (defaults to `args`). -List validateJsonSchema( - Object? value, - Map schema, { - String path = 'args', -}) { - final errors = []; - _validate(value, schema, path, errors); - return errors; -} - -void _validate( - Object? value, - Map schema, - String path, - List errors, -) { - // An empty schema imposes no constraints. - if (schema.isEmpty) return; - - final type = schema['type']; - if (type != null && !_typeMatches(value, type)) { - errors.add('$path: expected type $type but got ${_typeName(value)}'); - // A type mismatch makes deeper checks meaningless. - return; - } - - final enumValues = schema['enum']; - if (enumValues is List && !enumValues.any((e) => _deepEq(e, value))) { - errors.add('$path: must be one of $enumValues'); - } - - switch (value) { - case final num n: - _validateNumber(n, schema, path, errors); - case final String s: - _validateString(s, schema, path, errors); - case final List list: - _validateArray(list, schema, path, errors); - case final Map map: - _validateObject(map.cast(), schema, path, errors); - } -} - -void _validateNumber( - num n, - Map schema, - String path, - List errors, -) { - final min = schema['minimum']; - if (min is num && n < min) errors.add('$path: must be >= $min'); - final max = schema['maximum']; - if (max is num && n > max) errors.add('$path: must be <= $max'); -} - -void _validateString( - String s, - Map schema, - String path, - List errors, -) { - final minLen = schema['minLength']; - if (minLen is int && s.length < minLen) { - errors.add('$path: must be at least $minLen characters'); - } - final maxLen = schema['maxLength']; - if (maxLen is int && s.length > maxLen) { - errors.add('$path: must be at most $maxLen characters'); - } -} - -void _validateArray( - List list, - Map schema, - String path, - List errors, -) { - final minItems = schema['minItems']; - if (minItems is int && list.length < minItems) { - errors.add('$path: must have at least $minItems items'); - } - final maxItems = schema['maxItems']; - if (maxItems is int && list.length > maxItems) { - errors.add('$path: must have at most $maxItems items'); - } - final items = schema['items']; - if (items is Map) { - for (var i = 0; i < list.length; i++) { - _validate(list[i], items, '$path[$i]', errors); - } - } -} - -void _validateObject( - Map map, - Map schema, - String path, - List errors, -) { - final required = schema['required']; - if (required is List) { - for (final key in required) { - if (key is String && !map.containsKey(key)) { - errors.add('$path: missing required property "$key"'); - } - } - } - - final properties = schema['properties']; - if (properties is Map) { - properties.forEach((key, propSchema) { - if (propSchema is Map && map.containsKey(key)) { - _validate(map[key], propSchema, '$path.$key', errors); - } - }); - } - - // additionalProperties: false rejects keys not named in `properties`. - if (schema['additionalProperties'] == false && properties is Map) { - final allowed = properties.keys.toSet(); - for (final key in map.keys) { - if (!allowed.contains(key)) { - errors.add('$path: unexpected property "$key"'); - } - } - } -} - -bool _typeMatches(Object? value, Object? type) { - // JSON Schema allows a union of types as a list. - if (type is List) return type.any((t) => _typeMatches(value, t)); - return switch (type) { - 'object' => value is Map, - 'array' => value is List, - 'string' => value is String, - 'integer' => value is int, - 'number' => value is num, - 'boolean' => value is bool, - 'null' => value == null, - _ => true, // unknown type keyword: don't constrain - }; -} - -String _typeName(Object? value) => switch (value) { - null => 'null', - Map() => 'object', - List() => 'array', - String() => 'string', - int() => 'integer', - num() => 'number', - bool() => 'boolean', - _ => value.runtimeType.toString(), - }; - -bool _deepEq(Object? a, Object? b) { - if (identical(a, b)) return true; - if (a is List && b is List) { - if (a.length != b.length) return false; - for (var i = 0; i < a.length; i++) { - if (!_deepEq(a[i], b[i])) return false; - } - return true; - } - if (a is Map && b is Map) { - if (a.length != b.length) return false; - for (final key in a.keys) { - if (!b.containsKey(key) || !_deepEq(a[key], b[key])) return false; - } - return true; - } - return a == b; -} diff --git a/packages/flutter_ai/flutter_ai_core/pubspec.yaml b/packages/flutter_ai/flutter_ai_core/pubspec.yaml deleted file mode 100644 index 2a635d5..0000000 --- a/packages/flutter_ai/flutter_ai_core/pubspec.yaml +++ /dev/null @@ -1,34 +0,0 @@ -name: flutter_ai_core -description: "Dependency-free Dart foundation for AI chat: message models, a streaming MessageProcessor, and the provider and renderer contracts the flutter_ai family builds on." -version: 0.1.14 -homepage: https://github.com/ananmouaz/flutter_ai -repository: https://github.com/ananmouaz/flutter_ai/tree/main/packages/flutter_ai_core -issue_tracker: https://github.com/ananmouaz/flutter_ai/issues -topics: - - ai - - llm - - chat - - streaming - - flutter - -environment: - sdk: ^3.6.0 - -platforms: - android: - ios: - linux: - macos: - web: - windows: - -# Part of the flutter_ai workspace; dependencies resolve from the workspace root. -resolution: workspace - -# Intentionally no runtime dependencies. flutter_ai_core relies solely on -# dart:core and dart:convert so downstream apps never face version conflicts -# with build_runner, codegen, or a UI framework. - -dev_dependencies: - lints: ^5.0.0 - test: ^1.25.0 diff --git a/packages/flutter_ai/flutter_ai_core/test/ai_capabilities_test.dart b/packages/flutter_ai/flutter_ai_core/test/ai_capabilities_test.dart deleted file mode 100644 index 9d86c2f..0000000 --- a/packages/flutter_ai/flutter_ai_core/test/ai_capabilities_test.dart +++ /dev/null @@ -1,139 +0,0 @@ -import 'package:flutter_ai_core/flutter_ai_core.dart'; -import 'package:test/test.dart'; - -/// A fake provider that replays a fixed list of [AiStreamEvent]s, so the -/// structured-output helpers can be exercised without any network. -class _FakeProvider implements LlmProvider { - _FakeProvider(this.events); - - final List events; - AiRequestOptions? lastOptions; - - @override - Stream send( - AiConversation conversation, { - List? tools, - AiRequestOptions? options, - }) async* { - lastOptions = options; - for (final event in events) { - yield event; - } - } -} - -/// Splits [text] into individual character TextDeltas, simulating streaming. -List _streamText(String text, {String id = 'm1'}) => [ - MessageStarted(messageId: id, role: AiRole.assistant), - for (final char in text.split('')) TextDelta(messageId: id, delta: char), - const MessageFinished(messageId: 'm1', reason: FinishReason.stop), - ]; - -void main() { - group('AiEmbedding', () { - test('value equality over values and index', () { - expect( - const AiEmbedding([1, 2, 3], index: 0), - const AiEmbedding([1, 2, 3], index: 0), - ); - expect( - const AiEmbedding([1, 2, 3], index: 0).hashCode, - const AiEmbedding([1, 2, 3], index: 0).hashCode, - ); - expect( - const AiEmbedding([1, 2, 3], index: 0), - isNot(const AiEmbedding([1, 2, 4], index: 0)), - ); - expect( - const AiEmbedding([1, 2, 3], index: 0), - isNot(const AiEmbedding([1, 2, 3], index: 1)), - ); - }); - - test('toString reports dimensions and index', () { - expect( - const AiEmbedding([1, 2, 3], index: 2).toString(), - 'AiEmbedding(3 dims, index: 2)', - ); - }); - }); - - group('generateObject', () { - const format = AiResponseFormat( - schema: { - 'type': 'object', - 'properties': { - 'name': {'type': 'string'}, - 'age': {'type': 'integer'}, - }, - }, - ); - - test('decodes the streamed JSON text into a Map', () async { - final provider = _FakeProvider(_streamText('{"name":"Ada","age":36}')); - - final object = await provider.generateObject( - const AiConversation.empty('c'), - format: format, - ); - - expect(object, {'name': 'Ada', 'age': 36}); - }); - - test('sets responseFormat on the merged options', () async { - final provider = _FakeProvider(_streamText('{}')); - - await provider.generateObject( - const AiConversation.empty('c'), - format: format, - options: const AiRequestOptions(model: 'gpt-test', temperature: 0.2), - ); - - expect(provider.lastOptions?.responseFormat, format); - // Pre-existing fields are preserved when merging. - expect(provider.lastOptions?.model, 'gpt-test'); - expect(provider.lastOptions?.temperature, 0.2); - }); - - test('throws FormatException with the raw text on a parse failure', - () async { - final provider = _FakeProvider(_streamText('not json')); - - await expectLater( - provider.generateObject( - const AiConversation.empty('c'), - format: format, - ), - throwsA( - isA().having( - (e) => e.source, - 'source', - 'not json', - ), - ), - ); - }); - }); - - group('streamObject', () { - const format = AiResponseFormat(schema: {'type': 'object'}); - - test('yields growing prefixes ending in the complete object', () async { - final provider = _FakeProvider(_streamText('{"a":1,"b":2}')); - - final frames = await provider - .streamObject(const AiConversation.empty('c'), format: format) - .toList(); - - // The final frame is the complete object. - expect(frames.last, {'a': 1, 'b': 2}); - // Every frame is a (growing) prefix: each is a submap of the next. - for (var i = 0; i < frames.length - 1; i++) { - for (final entry in frames[i].entries) { - expect(frames[i + 1][entry.key], entry.value); - } - expect(frames[i].length, lessThanOrEqualTo(frames[i + 1].length)); - } - }); - }); -} diff --git a/packages/flutter_ai/flutter_ai_core/test/ai_stream_event_test.dart b/packages/flutter_ai/flutter_ai_core/test/ai_stream_event_test.dart deleted file mode 100644 index c5f251b..0000000 --- a/packages/flutter_ai/flutter_ai_core/test/ai_stream_event_test.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:flutter_ai_core/flutter_ai_core.dart'; -import 'package:test/test.dart'; - -void main() { - group('AiStreamEvent JSON round-trips', () { - final events = [ - const MessageStarted(messageId: 'm1', role: AiRole.assistant), - const TextDelta(messageId: 'm1', delta: 'hello'), - const ReasoningDelta(messageId: 'm1', delta: 'because'), - const ToolCallStarted( - messageId: 'm1', - toolCallId: 'c1', - toolName: 'search', - ), - const ToolCallDelta(toolCallId: 'c1', argumentsDelta: '{"q":'), - const ToolCallReady(toolCallId: 'c1'), - const ToolResultReceived( - messageId: 'm1', - toolCallId: 'c1', - result: {'hits': 3}, - ), - const PartReceived( - messageId: 'm1', - part: DataPart(dataType: 'card', data: {'k': 'v'}), - ), - const MessageFinished(messageId: 'm1', reason: FinishReason.stop), - const StreamErrorEvent(error: 'boom', messageId: 'm1', toolCallId: 'c1'), - ]; - - for (final event in events) { - test('${event.runtimeType}', () { - final decoded = AiStreamEvent.fromJson(event.toJson()); - expect(decoded, event); - expect(decoded.runtimeType, event.runtimeType); - }); - } - - test('rejects an unknown event type', () { - expect( - () => AiStreamEvent.fromJson({'type': 'unknown'}), - throwsFormatException, - ); - }); - - test('error event restores the message form of the error', () { - const event = StreamErrorEvent(error: 'boom'); - final decoded = AiStreamEvent.fromJson(event.toJson()); - expect(decoded, event); - }); - }); -} diff --git a/packages/flutter_ai/flutter_ai_core/test/json_accumulator_test.dart b/packages/flutter_ai/flutter_ai_core/test/json_accumulator_test.dart deleted file mode 100644 index fd54e4f..0000000 --- a/packages/flutter_ai/flutter_ai_core/test/json_accumulator_test.dart +++ /dev/null @@ -1,134 +0,0 @@ -import 'package:flutter_ai_core/flutter_ai_core.dart'; -import 'package:test/test.dart'; - -void main() { - group('JsonAccumulator.tryParseComplete', () { - test('returns null for an empty buffer', () { - expect(JsonAccumulator().tryParseComplete(), isNull); - }); - - test('returns null while the JSON is incomplete', () { - final acc = JsonAccumulator()..add('{"city":"Lon'); - expect(acc.tryParseComplete(), isNull); - }); - - test('decodes a complete document', () { - final acc = JsonAccumulator()..add('{"city":"London","days":3}'); - expect(acc.tryParseComplete(), {'city': 'London', 'days': 3}); - }); - - test('reassembles fragments added across calls', () { - final acc = JsonAccumulator() - ..add('{"ci') - ..add('ty":"Lon') - ..add('don"}'); - expect(acc.tryParseComplete(), {'city': 'London'}); - }); - }); - - group('JsonAccumulator.parsePartial', () { - test('returns null before anything is added', () { - expect(JsonAccumulator().parsePartial(), isNull); - }); - - test('closes an object missing its final brace', () { - final acc = JsonAccumulator()..add('{"city":"London"'); - expect(acc.parsePartial(), {'city': 'London'}); - }); - - test('keeps complete members and drops a dangling key', () { - final acc = JsonAccumulator()..add('{"a":1,"b":'); - expect(acc.parsePartial(), {'a': 1}); - }); - - test('drops an unterminated trailing numeric literal', () { - // `2` is not yet delimited, so it might be a prefix of `25` — surfacing - // it would violate the prefix contract. It is dropped until terminated. - final acc = JsonAccumulator()..add('{"a":1,"b":2'); - expect(acc.parsePartial(), {'a': 1}); - }); - - test('keeps a numeric value once a delimiter terminates it', () { - final acc = JsonAccumulator()..add('{"n": 1234'); - // No delimiter yet: the literal is treated as incomplete. - expect(acc.parsePartial(), {}); - // A comma terminates it: now it is safe to surface. - acc.add(','); - expect(acc.parsePartial(), {'n': 1234}); - }); - - test('keeps a numeric value terminated by a closing brace', () { - final acc = JsonAccumulator()..add('{"n": 1234}'); - expect(acc.parsePartial(), {'n': 1234}); - }); - - test('drops an unterminated trailing keyword literal', () { - // `tru` could complete to `true`; an undelimited keyword is incomplete. - final acc = JsonAccumulator()..add('{"ok":tru'); - expect(acc.parsePartial(), {}); - final acc2 = JsonAccumulator()..add('{"ok":true'); - // Still no delimiter after `true`, so it stays incomplete until one lands. - expect(acc2.parsePartial(), {}); - acc2.add('}'); - expect(acc2.parsePartial(), {'ok': true}); - }); - - test('drops a partially streamed string value', () { - final acc = JsonAccumulator()..add('{"city":"Lon'); - expect(acc.parsePartial(), {}); - }); - - test('closes a partial array dropping its undelimited last element', () { - // `3` is not yet delimited, so it is excluded until a delimiter lands. - final acc = JsonAccumulator()..add('[1,2,3'); - expect(acc.parsePartial(), [1, 2]); - acc.add(']'); - expect(acc.parsePartial(), [1, 2, 3]); - }); - - test('handles nested objects', () { - // `1` is undelimited, so the inner member is dropped until terminated. - final acc = JsonAccumulator()..add('{"a":{"b":1'); - expect(acc.parsePartial(), {'a': {}}); - acc.add('}'); - expect(acc.parsePartial(), { - 'a': {'b': 1}, - }); - }); - - test('handles an array of objects with a trailing partial element', () { - final acc = JsonAccumulator()..add('[{"x":1},{"y":'); - expect(acc.parsePartial(), [ - {'x': 1}, - {}, - ]); - }); - - test('respects escaped quotes inside strings', () { - final acc = JsonAccumulator()..add(r'{"msg":"he said \"hi\""'); - expect(acc.parsePartial(), {'msg': 'he said "hi"'}); - }); - - test('returns the already-valid document unchanged', () { - final acc = JsonAccumulator()..add('{"done":true}'); - expect(acc.parsePartial(), {'done': true}); - }); - - test('falls back to the last good partial when a fragment regresses', () { - final acc = JsonAccumulator()..add('{"a":1,"b":2,'); - expect(acc.parsePartial(), {'a': 1, 'b': 2}); - // A lone open quote cannot be repaired to anything new; the previous - // partial is retained rather than regressing to {}. - acc.add('"c":"'); - expect(acc.parsePartial(), {'a': 1, 'b': 2}); - }); - - test('reset clears buffer and cached partial', () { - final acc = JsonAccumulator()..add('{"a":1}'); - expect(acc.parsePartial(), {'a': 1}); - acc.reset(); - expect(acc.isEmpty, isTrue); - expect(acc.parsePartial(), isNull); - }); - }); -} diff --git a/packages/flutter_ai/flutter_ai_core/test/json_schema_validator_test.dart b/packages/flutter_ai/flutter_ai_core/test/json_schema_validator_test.dart deleted file mode 100644 index 40a4198..0000000 --- a/packages/flutter_ai/flutter_ai_core/test/json_schema_validator_test.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'package:flutter_ai_core/flutter_ai_core.dart'; -import 'package:test/test.dart'; - -void main() { - group('validateJsonSchema', () { - const objectSchema = { - 'type': 'object', - 'properties': { - 'city': {'type': 'string'}, - 'days': {'type': 'integer', 'minimum': 1, 'maximum': 14}, - 'unit': { - 'type': 'string', - 'enum': ['c', 'f'], - }, - }, - 'required': ['city'], - 'additionalProperties': false, - }; - - test('accepts a valid object', () { - expect( - validateJsonSchema( - {'city': 'Lisbon', 'days': 3, 'unit': 'c'}, objectSchema), - isEmpty, - ); - }); - - test('an empty schema imposes no constraints', () { - expect(validateJsonSchema({'anything': true}, const {}), isEmpty); - }); - - test('reports a missing required property', () { - final errors = validateJsonSchema({'days': 2}, objectSchema); - expect(errors, hasLength(1)); - expect(errors.single, contains('missing required property "city"')); - }); - - test('reports a type mismatch with a path', () { - final errors = validateJsonSchema({'city': 123}, objectSchema); - expect(errors, contains(contains('args.city: expected type string'))); - }); - - test('reports an out-of-range number', () { - final errors = - validateJsonSchema({'city': 'x', 'days': 99}, objectSchema); - expect(errors, contains(contains('args.days: must be <= 14'))); - }); - - test('reports an enum violation', () { - final errors = - validateJsonSchema({'city': 'x', 'unit': 'k'}, objectSchema); - expect(errors, contains(contains('args.unit: must be one of'))); - }); - - test('rejects unexpected properties when additionalProperties is false', - () { - final errors = - validateJsonSchema({'city': 'x', 'extra': 1}, objectSchema); - expect(errors, contains(contains('unexpected property "extra"'))); - }); - - test('validates array items and bounds', () { - const arraySchema = { - 'type': 'array', - 'minItems': 1, - 'items': {'type': 'string'}, - }; - expect(validateJsonSchema(['a', 'b'], arraySchema), isEmpty); - expect(validateJsonSchema(const [], arraySchema), - contains(contains('at least 1 items'))); - expect( - validateJsonSchema(['a', 2], arraySchema), - contains(contains('args[1]: expected type string')), - ); - }); - - test('integer vs number: a double is not an integer', () { - expect( - validateJsonSchema(1.5, const {'type': 'integer'}), - isNotEmpty, - ); - expect(validateJsonSchema(1.5, const {'type': 'number'}), isEmpty); - }); - - test('accepts a union type list', () { - const schema = { - 'type': ['string', 'null'] - }; - expect(validateJsonSchema(null, schema), isEmpty); - expect(validateJsonSchema('x', schema), isEmpty); - expect(validateJsonSchema(5, schema), isNotEmpty); - }); - - test('string length bounds', () { - const schema = {'type': 'string', 'minLength': 2, 'maxLength': 4}; - expect(validateJsonSchema('ab', schema), isEmpty); - expect(validateJsonSchema('a', schema), - contains(contains('at least 2 characters'))); - expect(validateJsonSchema('abcde', schema), - contains(contains('at most 4 characters'))); - }); - }); -} diff --git a/packages/flutter_ai/flutter_ai_core/test/message_processor_perf_test.dart b/packages/flutter_ai/flutter_ai_core/test/message_processor_perf_test.dart deleted file mode 100644 index 07c7d65..0000000 --- a/packages/flutter_ai/flutter_ai_core/test/message_processor_perf_test.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:flutter_ai_core/flutter_ai_core.dart'; -import 'package:test/test.dart'; - -void main() { - group('MessageProcessor perf', () { - test('accumulates 20000 single-char deltas in linear time', () { - // Regression guard against O(n^2) text accumulation. The processor keeps - // a StringBuffer per text part and appends in place (O(delta) per token), - // so 20000 single-char deltas finish in milliseconds. A regression to - // `last.text + delta` would re-copy the whole accumulated string on every - // token — ~20000^2 / 2 ≈ 200M char-copies — taking many seconds. - // - // The 2-second bound is deliberately GENEROUS: it sits far above the - // real (linear, sub-10ms) runtime yet far below a quadratic blow-up, so - // it distinguishes the two without flaking on slow shared CI runners. - const deltaCount = 20000; - - final processor = MessageProcessor(); - processor.apply( - const MessageStarted(messageId: 'm1', role: AiRole.assistant), - ); - - final stopwatch = Stopwatch()..start(); - for (var i = 0; i < deltaCount; i++) { - processor.apply(const TextDelta(messageId: 'm1', delta: 'x')); - } - processor.apply( - const MessageFinished(messageId: 'm1', reason: FinishReason.stop), - ); - stopwatch.stop(); - - expect( - stopwatch.elapsed, - lessThan(const Duration(seconds: 2)), - reason: 'linear accumulation finishes in ms; a quadratic regression ' - 'would take many seconds (took ${stopwatch.elapsedMilliseconds}ms)', - ); - - // The accumulated text must be exactly the concatenation of every delta. - final message = processor.conversation.messageById('m1')!; - expect(message.text.length, deltaCount); - expect(message.status, AiMessageStatus.complete); - }); - }); -} diff --git a/packages/flutter_ai/flutter_ai_core/test/message_processor_test.dart b/packages/flutter_ai/flutter_ai_core/test/message_processor_test.dart deleted file mode 100644 index 8e5f9d2..0000000 --- a/packages/flutter_ai/flutter_ai_core/test/message_processor_test.dart +++ /dev/null @@ -1,396 +0,0 @@ -import 'package:flutter_ai_core/flutter_ai_core.dart'; -import 'package:test/test.dart'; - -void main() { - group('MessageProcessor text streaming', () { - test('concatenates text deltas into one part', () { - final processor = MessageProcessor(); - processor.apply( - const MessageStarted(messageId: 'm1', role: AiRole.assistant), - ); - processor.apply(const TextDelta(messageId: 'm1', delta: 'Hel')); - final result = processor.apply( - const TextDelta(messageId: 'm1', delta: 'lo'), - ); - - final message = result.conversation.messageById('m1')!; - expect(message.parts, const [TextPart('Hello')]); - expect(message.status, AiMessageStatus.streaming); - expect(result.changedMessageIds, {'m1'}); - }); - - test('auto-creates an assistant message without a start event', () { - final processor = MessageProcessor(); - processor.apply(const TextDelta(messageId: 'm1', delta: 'hi')); - final message = processor.conversation.messageById('m1')!; - expect(message.role, AiRole.assistant); - expect(message.text, 'hi'); - }); - - test('finishing sets status and finishReason', () { - final processor = MessageProcessor(); - processor.apply(const TextDelta(messageId: 'm1', delta: 'done')); - processor.apply( - const MessageFinished(messageId: 'm1', reason: FinishReason.stop), - ); - final message = processor.conversation.messageById('m1')!; - expect(message.status, AiMessageStatus.complete); - expect(message.finishReason, FinishReason.stop); - }); - - test('a returned snapshot is frozen — later deltas do not mutate it', () { - final processor = MessageProcessor(); - processor.apply( - const MessageStarted(messageId: 'm1', role: AiRole.assistant), - ); - final before = - processor.apply(const TextDelta(messageId: 'm1', delta: 'Hello')); - final beforeMessage = before.conversation.messageById('m1')!; - // Capture the text of the earlier snapshot. - expect(beforeMessage.text, 'Hello'); - - final after = - processor.apply(const TextDelta(messageId: 'm1', delta: ' world')); - - // The earlier snapshot must NOT observe the later append (no retroactive - // mutation through the shared buffer). - expect(beforeMessage.text, 'Hello'); - expect(after.conversation.messageById('m1')!.text, 'Hello world'); - }); - - test('consecutive streaming snapshots compare unequal (value semantics)', - () { - final processor = MessageProcessor(); - processor.apply( - const MessageStarted(messageId: 'm1', role: AiRole.assistant), - ); - final first = processor - .apply(const TextDelta(messageId: 'm1', delta: 'Hel')) - .conversation; - final second = processor - .apply(const TextDelta(messageId: 'm1', delta: 'lo')) - .conversation; - - // A consumer that dedupes by equality (Bloc/Riverpod/distinct) must see a - // change between the two streamed snapshots. - expect(first == second, isFalse); - expect(first.messageById('m1') == second.messageById('m1'), isFalse); - }); - - test('finishing freezes the buffer into a plain, detached TextPart', () { - final processor = MessageProcessor(); - processor.apply(const TextDelta(messageId: 'm1', delta: 'done')); - processor.apply( - const MessageFinished(messageId: 'm1', reason: FinishReason.stop), - ); - final part = processor.conversation.messageById('m1')!.parts.single; - expect(part, isA().having((p) => p.buffer, 'buffer', isNull)); - expect((part as TextPart).text, 'done'); - }); - - test('reasoning deltas accumulate in a ReasoningPart', () { - final processor = MessageProcessor(); - processor.apply(const ReasoningDelta(messageId: 'm1', delta: 'be')); - processor.apply(const ReasoningDelta(messageId: 'm1', delta: 'cause')); - final part = processor.conversation.messageById('m1')!.parts.single; - expect(part, const ReasoningPart('because')); - }); - - test('thousands of small deltas materialize to the exact concatenation', - () { - final processor = MessageProcessor(); - processor.apply( - const MessageStarted(messageId: 'm1', role: AiRole.assistant), - ); - final expected = StringBuffer(); - for (var i = 0; i < 5000; i++) { - final token = 'tok$i '; - expected.write(token); - processor.apply(TextDelta(messageId: 'm1', delta: token)); - } - processor.apply( - const MessageFinished(messageId: 'm1', reason: FinishReason.stop), - ); - - final message = processor.conversation.messageById('m1')!; - expect(message.parts, [TextPart(expected.toString())]); - expect(message.text, expected.toString()); - expect(message.status, AiMessageStatus.complete); - }); - - test('a text part rehydrated from a String keeps accumulating correctly', - () { - const seed = AiConversation( - id: 'c1', - messages: [ - AiMessage( - id: 'm1', - role: AiRole.assistant, - parts: [TextPart('Hello')], - status: AiMessageStatus.streaming, - ), - ], - ); - final processor = MessageProcessor(conversation: seed); - processor.apply(const TextDelta(messageId: 'm1', delta: ', world')); - expect(processor.conversation.messageById('m1')!.text, 'Hello, world'); - }); - - test('text after a tool call lands in a separate part, never merged', () { - final processor = MessageProcessor(); - processor.apply(const TextDelta(messageId: 'm1', delta: 'before ')); - processor.apply( - const ToolCallStarted( - messageId: 'm1', - toolCallId: 'c1', - toolName: 'noop', - ), - ); - processor.apply(const TextDelta(messageId: 'm1', delta: 'after')); - - final parts = processor.conversation.messageById('m1')!.parts; - expect( - parts.whereType().map((p) => p.text), ['before ', 'after']); - }); - }); - - group('MessageProcessor tool calls', () { - test('streams arguments then validates on ready', () { - final processor = MessageProcessor(); - processor.apply( - const ToolCallStarted( - messageId: 'm1', - toolCallId: 'c1', - toolName: 'get_weather', - ), - ); - processor.apply( - const ToolCallDelta(toolCallId: 'c1', argumentsDelta: '{"city":"Lon'), - ); - - var call = _firstToolCall(processor); - expect(call.state, ToolCallState.inputStreaming); - - processor.apply( - const ToolCallDelta(toolCallId: 'c1', argumentsDelta: 'don"}'), - ); - processor.apply(const ToolCallReady(toolCallId: 'c1')); - - call = _firstToolCall(processor); - expect(call.state, ToolCallState.inputAvailable); - expect(call.args, {'city': 'London'}); - }); - - test('appends a result and advances the call state', () { - final processor = MessageProcessor(); - processor.apply( - const ToolCallStarted( - messageId: 'm1', - toolCallId: 'c1', - toolName: 'get_weather', - ), - ); - processor.apply( - const ToolResultReceived( - messageId: 'm1', - toolCallId: 'c1', - result: {'tempC': 21}, - ), - ); - - final message = processor.conversation.messageById('m1')!; - expect(_firstToolCall(processor).state, ToolCallState.outputAvailable); - final resultPart = message.parts.whereType().single; - expect(resultPart.result, {'tempC': 21}); - expect(resultPart.isError, isFalse); - }); - - test('malformed arguments mark the call errored without throwing', () { - final processor = MessageProcessor(); - processor.apply( - const ToolCallStarted( - messageId: 'm1', - toolCallId: 'c1', - toolName: 'broken', - ), - ); - processor.apply( - const ToolCallDelta(toolCallId: 'c1', argumentsDelta: '{not json'), - ); - - expect( - () => processor.apply(const ToolCallReady(toolCallId: 'c1')), - returnsNormally, - ); - - final message = processor.conversation.messageById('m1')!; - expect(_firstToolCall(processor).state, ToolCallState.error); - final errorResult = message.parts.whereType().single; - expect(errorResult.isError, isTrue); - }); - - test('a delta for an unknown call is a no-op', () { - final processor = MessageProcessor(); - final result = processor.apply( - const ToolCallDelta(toolCallId: 'ghost', argumentsDelta: '{}'), - ); - expect(result.hasChanges, isFalse); - expect(result.conversation.messages, isEmpty); - }); - }); - - group('MessageProcessor errors and lifecycle', () { - test('scoped stream error marks the message errored', () { - final processor = MessageProcessor(); - processor.apply(const TextDelta(messageId: 'm1', delta: 'partial')); - processor.apply( - const StreamErrorEvent(error: 'boom', messageId: 'm1'), - ); - expect( - processor.conversation.messageById('m1')!.status, - AiMessageStatus.error, - ); - }); - - test('an unscoped stream error changes nothing', () { - final processor = MessageProcessor(); - final result = - processor.apply(const StreamErrorEvent(error: 'transport down')); - expect(result.hasChanges, isFalse); - }); - - test('reset restores a seed conversation and clears scratch state', () { - final processor = MessageProcessor(); - processor.apply(const TextDelta(messageId: 'm1', delta: 'hi')); - processor.reset(const AiConversation.empty('fresh')); - expect(processor.conversation.id, 'fresh'); - expect(processor.conversation.messages, isEmpty); - }); - - test('seeds from an existing conversation', () { - const seed = AiConversation( - id: 'c1', - messages: [ - AiMessage(id: 'm1', role: AiRole.user, parts: [TextPart('q')]), - ], - ); - final processor = MessageProcessor(conversation: seed); - processor.apply(const TextDelta(messageId: 'm2', delta: 'a')); - expect(processor.conversation.messages.map((m) => m.id), ['m1', 'm2']); - }); - }); - - group('MessageProcessor tool fixes', () { - ToolCallPart callOf(MutationResult r, String mid, String cid) => - r.conversation - .messageById(mid)! - .parts - .whereType() - .firstWhere((p) => p.toolCallId == cid); - - test('a zero-argument tool call becomes inputAvailable, not error', () { - final processor = MessageProcessor(); - processor.apply( - const MessageStarted(messageId: 'm1', role: AiRole.assistant), - ); - processor.apply( - const ToolCallStarted( - messageId: 'm1', - toolCallId: 'c1', - toolName: 'refresh', - ), - ); - final r = processor.apply(const ToolCallReady(toolCallId: 'c1')); - final call = callOf(r, 'm1', 'c1'); - expect(call.state, ToolCallState.inputAvailable); - expect(call.args, isEmpty); - expect( - r.conversation.messageById('m1')!.parts.whereType(), - isEmpty, - ); - }); - - test('a result in a separate message advances the call to outputAvailable', - () { - final processor = MessageProcessor(); - processor.apply( - const MessageStarted(messageId: 'a1', role: AiRole.assistant), - ); - processor.apply( - const ToolCallStarted( - messageId: 'a1', - toolCallId: 'c1', - toolName: 'get_weather', - ), - ); - processor.apply(const ToolCallReady(toolCallId: 'c1')); - // Result arrives in a separate tool-role message (as addToolResults does). - final r = processor.apply( - const ToolResultReceived( - messageId: 't1', - toolCallId: 'c1', - result: {'tempC': 18}, - ), - ); - expect(callOf(r, 'a1', 'c1').state, ToolCallState.outputAvailable); - }); - - test('result advances a call seeded from a rehydrated conversation', () { - // Seed a conversation that already holds an assistant message with a tool - // call (e.g. loaded from persistence), so the in-memory call→message map - // is empty. A result must still find the owning message by scanning. - const seed = AiConversation( - id: 'c1', - messages: [ - AiMessage( - id: 'a1', - role: AiRole.assistant, - parts: [ - ToolCallPart( - toolCallId: 'c1', - toolName: 'get_weather', - state: ToolCallState.inputAvailable, - ), - ], - ), - ], - ); - final processor = MessageProcessor(conversation: seed); - final r = processor.apply( - const ToolResultReceived( - messageId: 't1', - toolCallId: 'c1', - result: {'tempC': 18}, - ), - ); - expect(callOf(r, 'a1', 'c1').state, ToolCallState.outputAvailable); - }); - - test('a tool-scoped error marks only the call, not the whole message', () { - final processor = MessageProcessor(); - processor.apply( - const MessageStarted(messageId: 'a1', role: AiRole.assistant), - ); - processor.apply( - const ToolCallStarted( - messageId: 'a1', - toolCallId: 'c1', - toolName: 'get_weather', - ), - ); - final r = processor.apply( - const StreamErrorEvent(error: 'tool failed', toolCallId: 'c1'), - ); - final message = r.conversation.messageById('a1')!; - expect(callOf(r, 'a1', 'c1').state, ToolCallState.error); - expect(message.status, AiMessageStatus.streaming); // message not killed - }); - }); -} - -/// The first tool call across the processor's conversation. -ToolCallPart _firstToolCall(MessageProcessor processor) => - processor.conversation.messages - .expand((m) => m.parts) - .whereType() - .first; diff --git a/packages/flutter_ai/flutter_ai_core/test/models_test.dart b/packages/flutter_ai/flutter_ai_core/test/models_test.dart deleted file mode 100644 index 40a8240..0000000 --- a/packages/flutter_ai/flutter_ai_core/test/models_test.dart +++ /dev/null @@ -1,187 +0,0 @@ -import 'dart:typed_data'; - -import 'package:flutter_ai_core/flutter_ai_core.dart'; -import 'package:test/test.dart'; - -void main() { - group('enum JSON', () { - test('AiRole round-trips and rejects unknown values', () { - for (final role in AiRole.values) { - expect(AiRole.fromJson(role.toJson()), role); - } - expect(() => AiRole.fromJson('nope'), throwsFormatException); - }); - - test('FinishReason round-trips with hyphenated wire names', () { - expect(FinishReason.toolCalls.toJson(), 'tool-calls'); - expect(FinishReason.fromJson('tool-calls'), FinishReason.toolCalls); - expect(() => FinishReason.fromJson('x'), throwsFormatException); - }); - - test('ToolCallState round-trips', () { - for (final state in ToolCallState.values) { - expect(ToolCallState.fromJson(state.toJson()), state); - } - }); - }); - - group('AiPart', () { - test('TextPart round-trips and compares by value', () { - const part = TextPart('hello'); - expect(AiPart.fromJson(part.toJson()), part); - expect(part, const TextPart('hello')); - expect(part.copyWith(text: 'hi'), const TextPart('hi')); - }); - - test('ToolCallPart preserves args with deep equality', () { - const part = ToolCallPart( - toolCallId: 'c1', - toolName: 'search', - args: { - 'query': 'flutter', - 'filters': ['recent', 'open'], - }, - state: ToolCallState.inputAvailable, - ); - final decoded = AiPart.fromJson(part.toJson()); - expect(decoded, part); - expect(decoded.hashCode, part.hashCode); - }); - - test('FilePart round-trips inline bytes via base64', () { - final part = FilePart( - mediaType: 'image/png', - bytes: Uint8List.fromList([1, 2, 3, 250]), - name: 'pixel.png', - ); - final decoded = AiPart.fromJson(part.toJson()) as FilePart; - expect(decoded.bytes, part.bytes); - expect(decoded, part); - }); - - test('FilePart round-trips a url', () { - final part = FilePart( - mediaType: 'application/pdf', - url: Uri.parse('https://example.com/a.pdf'), - ); - expect(AiPart.fromJson(part.toJson()), part); - }); - - test('SourcePart and DataPart round-trip', () { - final source = SourcePart(url: Uri.parse('https://x.test'), title: 'X'); - expect(AiPart.fromJson(source.toJson()), source); - - const data = DataPart(dataType: 'weather_card', data: {'tempC': 21}); - expect(AiPart.fromJson(data.toJson()), data); - }); - - test('fromJson rejects an unknown type', () { - expect( - () => AiPart.fromJson({'type': 'mystery'}), - throwsFormatException, - ); - }); - }); - - group('AiMessage', () { - test('text getter concatenates only TextParts', () { - const message = AiMessage( - id: 'm1', - role: AiRole.assistant, - parts: [ - TextPart('Hello '), - ReasoningPart('thinking'), - TextPart('world'), - ], - ); - expect(message.text, 'Hello world'); - }); - - test('round-trips including finishReason and createdAt', () { - final message = AiMessage( - id: 'm1', - role: AiRole.assistant, - parts: const [TextPart('hi')], - status: AiMessageStatus.complete, - finishReason: FinishReason.stop, - createdAt: DateTime.utc(2026, 6, 26, 12), - ); - expect(AiMessage.fromJson(message.toJson()), message); - }); - - test('text convenience constructor builds a single TextPart', () { - final message = AiMessage.text( - id: 'm1', - role: AiRole.user, - text: 'hey', - ); - expect(message.parts, const [TextPart('hey')]); - }); - - test('copyWith replaces only provided fields', () { - const message = AiMessage(id: 'm1', role: AiRole.user); - final updated = message.copyWith(status: AiMessageStatus.streaming); - expect(updated.id, 'm1'); - expect(updated.role, AiRole.user); - expect(updated.status, AiMessageStatus.streaming); - }); - }); - - group('AiConversation', () { - const m1 = AiMessage(id: 'm1', role: AiRole.user, parts: [TextPart('hi')]); - const m2 = AiMessage(id: 'm2', role: AiRole.assistant); - - test('append and messageById', () { - const convo = AiConversation.empty('c1'); - final next = convo.append(m1); - expect(next.messages, [m1]); - expect(next.messageById('m1'), m1); - expect(next.messageById('absent'), isNull); - expect(next.lastMessage, m1); - }); - - test('replace upserts by id', () { - const convo = AiConversation(id: 'c1', messages: [m1, m2]); - final edited = m1.copyWith(parts: const [TextPart('edited')]); - final next = convo.replace(edited); - expect(next.messages.length, 2); - expect(next.messageById('m1')!.text, 'edited'); - - const m3 = AiMessage(id: 'm3', role: AiRole.user); - expect(convo.replace(m3).messages.last, m3); - }); - - test('round-trips through JSON', () { - const convo = AiConversation(id: 'c1', messages: [m1, m2]); - expect(AiConversation.fromJson(convo.toJson()), convo); - }); - }); - - group('ToolDefinition', () { - test('round-trips with a JSON schema', () { - const tool = ToolDefinition( - name: 'get_weather', - description: 'Get weather for a city', - parametersSchema: { - 'type': 'object', - 'properties': { - 'city': {'type': 'string'}, - }, - }, - ); - expect(ToolDefinition.fromJson(tool.toJson()), tool); - }); - }); - - group('AiRequestOptions', () { - test('copyWith and value equality', () { - const options = AiRequestOptions(model: 'gpt-4o', temperature: 0.7); - expect(options.copyWith(model: 'gpt-4o-mini').model, 'gpt-4o-mini'); - expect(options.copyWith(model: 'gpt-4o-mini').temperature, 0.7); - expect( - const AiRequestOptions(model: 'gpt-4o', temperature: 0.7), - options, - ); - }); - }); -} diff --git a/packages/flutter_ai/flutter_ai_core/test/usage_test.dart b/packages/flutter_ai/flutter_ai_core/test/usage_test.dart deleted file mode 100644 index 698eb67..0000000 --- a/packages/flutter_ai/flutter_ai_core/test/usage_test.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'package:flutter_ai_core/flutter_ai_core.dart'; -import 'package:test/test.dart'; - -void main() { - group('AiUsage', () { - test('round-trips through JSON, omitting null fields', () { - const usage = AiUsage( - inputTokens: 100, - outputTokens: 50, - cachedInputTokens: 20, - cacheCreationTokens: 15, - totalTokens: 150, - ); - final json = usage.toJson(); - expect(json.containsKey('reasoningTokens'), isFalse); - expect(json['cacheCreationTokens'], 15); - expect(AiUsage.fromJson(json), usage); - }); - - test('omits cacheCreationTokens from JSON when null', () { - const usage = AiUsage(inputTokens: 10, outputTokens: 5); - expect(usage.toJson().containsKey('cacheCreationTokens'), isFalse); - }); - - test('resolvedTotal derives from input + output when total is absent', () { - const usage = AiUsage(inputTokens: 30, outputTokens: 12); - expect(usage.resolvedTotal, 42); - expect(const AiUsage().resolvedTotal, isNull); - }); - - test('operator + sums each field', () { - const a = AiUsage( - inputTokens: 10, - outputTokens: 5, - cacheCreationTokens: 4, - ); - const b = AiUsage( - inputTokens: 3, - outputTokens: 7, - cacheCreationTokens: 6, - totalTokens: 10, - ); - final sum = a + b; - expect(sum.inputTokens, 13); - expect(sum.outputTokens, 12); - expect(sum.cacheCreationTokens, 10); - expect(sum.totalTokens, 10); // null + 10 - }); - - test('equality distinguishes cacheCreationTokens', () { - const a = AiUsage(inputTokens: 10, cacheCreationTokens: 4); - const b = AiUsage(inputTokens: 10, cacheCreationTokens: 5); - const c = AiUsage(inputTokens: 10, cacheCreationTokens: 4); - expect(a, isNot(b)); - expect(a, c); - expect(a.hashCode, c.hashCode); - }); - - test('estimateCost bills cached input at the discounted rate', () { - const usage = AiUsage( - inputTokens: 1000, - cachedInputTokens: 400, - outputTokens: 500, - ); - // 600 uncached @ $3/M + 400 cached @ $0.3/M + 500 out @ $15/M - final cost = usage.estimateCost( - inputPer1M: 3, - outputPer1M: 15, - cachedInputPer1M: 0.3, - ); - expect(cost, closeTo(0.0018 + 0.00012 + 0.0075, 1e-9)); - }); - - test('estimateCost bills cache writes at an explicit write rate', () { - const usage = AiUsage( - inputTokens: 1000, - cachedInputTokens: 200, - cacheCreationTokens: 300, - outputTokens: 500, - ); - // 500 uncached @ $3/M + 200 read @ $0.3/M + 300 write @ $3.75/M - // + 500 out @ $15/M - final cost = usage.estimateCost( - inputPer1M: 3, - outputPer1M: 15, - cachedInputPer1M: 0.3, - cacheWritePer1M: 3.75, - ); - expect( - cost, - closeTo(0.0015 + 0.00006 + 0.001125 + 0.0075, 1e-9), - ); - }); - - test('estimateCost defaults cache-write rate to 1.25x input', () { - const usage = AiUsage( - inputTokens: 1000, - cacheCreationTokens: 400, - ); - // 600 uncached @ $3/M + 400 write @ (1.25 * $3)/M = $3.75/M - final cost = usage.estimateCost(inputPer1M: 3, outputPer1M: 15); - expect(cost, closeTo(0.0018 + 0.0015, 1e-9)); - }); - - test('estimateCost does not double-count cache writes at input rate', () { - const withWrite = AiUsage(inputTokens: 1000, cacheCreationTokens: 1000); - // All 1000 are cache writes -> billed only at the 1.25x write rate. - final cost = withWrite.estimateCost(inputPer1M: 3, outputPer1M: 15); - expect(cost, closeTo(1000 * 3.75 / 1e6, 1e-9)); - }); - - test('estimateCost returns null with no token counts', () { - expect( - const AiUsage().estimateCost(inputPer1M: 1, outputPer1M: 1), - isNull, - ); - }); - }); - - test('MessageFinished carries usage through JSON', () { - const event = MessageFinished( - messageId: 'a1', - reason: FinishReason.stop, - usage: AiUsage(inputTokens: 5, outputTokens: 9), - ); - final restored = AiStreamEvent.fromJson(event.toJson()) as MessageFinished; - expect(restored.usage?.inputTokens, 5); - expect(restored.usage?.outputTokens, 9); - }); -} diff --git a/packages/flutter_ai/flutter_ai_elements/CHANGELOG.md b/packages/flutter_ai/flutter_ai_elements/CHANGELOG.md deleted file mode 100644 index e2d970b..0000000 --- a/packages/flutter_ai/flutter_ai_elements/CHANGELOG.md +++ /dev/null @@ -1,215 +0,0 @@ -# Changelog - -## 0.2.0 - -Extensibility release — driven by dogfooding a full Gemini-clone app on the -packages. All changes are additive; requires `flutter_ai_client ^0.3.0`. - -- Add `AiThemeExtension.chipColor` (resolved via `effectiveChipColor`) so - **bubble-less** themes (a transparent `assistantBubbleColor`) keep visible - suggestion chips, the selected `AiConversationList` row, and the - scroll-to-latest button instead of having them vanish. (#135) -- `AiModelSelector`: add `labelStyle`, `labelBuilder`, `showBorder`, and - `padding` so the trigger chip can be brand-styled (e.g. a larger two-tone - title) while keeping the package's picker sheet. (#143) -- `AiConversationList`: add `header`, `footer`, and a per-thread - `trailingBuilder` so a real sidebar can have section headers, an account - footer, and custom per-thread affordances (pin/overflow). (#144) -- `AiMessageActions`: add `order` and `trailing` (with the new - `AiMessageActionKind` enum) to reorder actions and push some — e.g. read-aloud - — to the far side. (#145) -- `AiEmptyState`: add `titleStyle`, `subtitleStyle`, and a `background` slot for - a gradient/hero greeting. (#141) -- `AiPromptInput`: add `textController` so voice dictation / quick-replies can - populate the composer for review instead of dictate-and-send. (#138) -- Add `AiLiveController` + `AiVoiceEngine`: a drop-in - listen → send → speak → re-listen state machine that maps a `UseChatController` - and a pluggable audio engine onto `AiLiveSession`, so live-voice plumbing no - longer lives entirely in the app. (#139) -- `AiLiveSession`: add a `backgroundColor` knob for apps that want a non-black - live surface. (#146) -- Docs: document the `share_plus` recipe on `AiMessageActions.onShare` (the - package ships no share implementation to stay plugin-free). (#142) - -## 0.1.16 - -- Fix: `AiChat` auto-scroll no longer fights mouse-wheel, trackpad, or keyboard - scrolling during streaming. The top-pin now releases on any upward - user-initiated scroll (`UserScrollNotification`), not only touch drags, so - scrolling up to re-read while the response streams works on desktop/web. -- Fix: `AiComposer`'s main button is now Send (not Live) whenever there are - staged attachments, so tapping it with an attachment-only draft sends the - attachment instead of launching full-screen voice mode. - -## 0.1.15 - -- Fix: raise the `flutter_ai_core` (`^0.1.11`) and `flutter_ai_client` - (`^0.2.0`) lower bounds so dependency downgrades can't resolve sibling - versions the widgets can't compile against. -- Docs: shortened the pubspec `description` into pub.dev's 60–180 character - window. - -## 0.1.14 - -- Widen the `flutter_ai_client` constraint to `>=0.1.0 <0.3.0` so it resolves - with client 0.2.0 (which adds the tool-call cancellation signal). No API - changes here. - -## 0.1.13 - -- Docs: refreshed the README listing with a hero image, screenshot gallery, - and badges (consistent across the package family). No code changes. - -## 0.1.12 - -UX polish bundle: - -- Skeleton shimmer that crossfades into the first streamed token, and a - streaming→Markdown crossfade when a turn finishes (both reduced-motion aware). -- Reading-width column: new `AiThemeExtension.maxContentWidth` (default 720) - centers long answers on wide screens; set to `double.infinity` to disable. -- `AiEmptyState` gains a brand `glyph` and tappable `suggestions`. -- Light haptics on turn completion, confirmation, and chip taps (opt-out via - `enableHaptics`; no-op on web/desktop). -- Markdown: strikethrough, horizontal rules, and GFM task-list checkboxes; - link color is now themeable (`AiThemeExtension.linkColor`). -- Source chips: numeric index badge, hover state, and an opt-in favicon - (`AiSources.showFavicons`, default off — fetching discloses cited hosts to a - third-party service). -- `AiConfirmation.tone` (`neutral`/`caution`/`danger`) restyles the confirm - button; `danger` uses the theme error color. -- New `AiOrb` widget and a themeable live-session orb (`AiThemeExtension.orbColor`). - -## 0.1.11 - -- Reduce-motion: `AiLoader` and `AiShimmer` now hold a static state (and stop - their controllers) when the platform "reduce motion" setting is on, completing - the accessibility pass across the animated widgets. - -## 0.1.10 - -- `AiAnimatedResponse` shows a blinking caret at the streaming edge (the - "being written" cue); it holds steady under reduce-motion. - -## 0.1.9 - -- Focus / hover / keyboard on the primary controls (desktop & web): the - composer's attach/mic and send buttons and the confirmation Allow/Deny buttons - use Material ink + focus traversal + Enter/Space instead of bare gesture - detectors. The send/stop/live button now morphs (AnimatedSwitcher) and Stop - reads as a distinct error-toned affordance. - -## 0.1.8 - -- Semantic theme tokens: `AiThemeExtension` gains `errorColor`, `successColor`, - `warningColor`, `codeBackgroundColor`, and `codeForegroundColor` (light + dark - defaults). Previously-hardcoded error/success/warning colors and the code - block's dark palette now read from the theme, so the family is fully - rebrandable. - -## 0.1.7 - -- `AiChatView`: a batteries-included drop-in (transcript + composer + layout + - safe area) so a working chat is a single widget in your `Scaffold` body. - -## 0.1.6 - -- Declare supported platforms (Android/iOS/web/macOS/Windows/Linux) for the - pub.dev listing; fix a stale library-doc reference (`AiChat`, not the removed - `AiConversation` widget name). - -## 0.1.5 - -- Performance: `AiConversationView` memoizes bubbles by message identity, so - only the changing message rebuilds while streaming. -- `AiAnimatedResponse` honors reduce-motion (renders plain text) and isolates - its reveal in a `RepaintBoundary`. -- `AiLocalizationsScope`: override UI strings with one widget, no delegate - wiring. Remaining hardcoded strings (reasoning, Allow/Deny, loader/shimmer/ - avatar a11y labels) are now localized. - -## 0.1.4 - -- Internationalization: `AiLocalizations` (+ `AiLocalizationsDelegate`) holds the - widgets' user-facing strings (defaults English). Every previously-hardcoded - tooltip/label/action now reads from it, so apps can translate the UI by - providing a delegate. `AiConversationList.newChatLabel` now defaults to the - localized value. - -## 0.1.3 - -- `AiConversationList`: a ChatGPT-style conversation sidebar (New chat + a list - of `ChatThread`s with select/delete) to pair with a `ChatThreadStore`. - -## 0.1.2 - -- Generative UI: `AiWidgetRegistry` (a `dataType`→widget allowlist) and - `AiDataView` render `DataPart`s the model emits as your own widgets — no - reflection, unknown types fall back. The demo wires its chain-of-thought / task - / confirmation cards through it. - -## 0.1.1 - -- `AiChat` now anchors the message you just sent to the **top** of the viewport - (ChatGPT-style) and **holds it there** while the answer streams in below, - reserving just enough trailing space and releasing it as the answer grows. - A drag releases the pin; a floating "scroll to latest" button appears whenever - the conversation is scrolled above the bottom. -- New `AiAnimatedResponse`: a **blur fade-in** reveal (the Apple-Intelligence / - Siri look) so streamed answers appear smoothly — each newly revealed word - arrives blurred and faded, then sharpens into place over `fadeDuration` - (`blurSigma` controls the starting blur). Text is paced at a readable - `charsPerSecond` (default 120) and accelerates to drain a backlog within - `catchUpWindow` so it never trails far behind a fast stream. Only the few - words at the leading edge animate at once, so the cost stays bounded. The - in-flight text renders as plain prose and settles into full Markdown once - complete. `MarkdownTextRenderer` uses it automatically while streaming. -- `AiMessageActions` is restyled with compact, evenly spaced icon buttons - (ChatGPT-style) and gains optional `onSpeak`/`onGood`/`onBad`/`onShare` - actions. -- `AiSources` collapses past `maxVisible` chips (default 6) behind a "+N more" - toggle, so grounded answers that return dozens of sources no longer flood the - bubble. -- Pluggable syntax highlighting: `AiCodeBlock`, `AiResponse`, and - `MarkdownTextRenderer` accept an optional `CodeHighlighter` that turns code + - language into styled spans. The package ships no grammar engine (stays - dependency-free); supply one from the app. Defaults to plain monospace. -- Fixed the Markdown block parser hanging (and exhausting memory) when handed a - partial stream that ended mid-construct, e.g. a lone `#` before its heading - text arrived — the parser now always makes forward progress. - -## 0.1.0 - -Initial release. - -- `AiThemeExtension` — a `ThemeExtension` of design tokens (bubble colors, - shapes, ambient shadow, spacing, typography, motion, haptics) with `copyWith`, - `lerp`, `of(context)`, and a mobile-first `fallback()`. -- Presentational widgets: `AiMessageBubble` (renders every `AiPart` type; - streaming-safe semantics), `AiConversationView`, `AiComposer` (Send↔Stop swap, - haptics), `AiLoader`. -- Controller-bound widgets: `AiChat` (live transcript with auto-scroll and a - thinking loader) and `AiPromptInput`. -- `AiResponse` — a dependency-free Markdown renderer (headings, bold/italic, - inline + fenced code, lists, blockquotes, links); `MarkdownTextRenderer` wraps - it and is now the **default** `AiTextRenderer`. `PlainTextRenderer` remains. -- `AiChainOfThought` (stepwise timeline), `AiTask` (agent checklist), - `AiInlineCitation` (numbered badge), `AiBranch` (version navigation), - `AiImage` (loading/error/tap-to-zoom). -- Input upgrades: `AiComposer` gains an attach button, a model-selector slot, a - voice button, and removable attachment previews; `AiPromptInput` stages - attachments and switches models via the controller. `AiModelSelector`, - `AiConfirmation` (approve/deny), `AiContextMeter` (token usage), and - `AiShimmer` (loading skeleton). -- `AiLiveSession` — a full-screen, engine-agnostic Live voice surface (animated - orb reacting to amplitude + status, live transcript, mute/keyboard/end). UI - only; drive it from a realtime audio engine. -- Performance & a11y hardening: `AiResponse` parses Markdown and builds gesture - recognizers once per text change (not every frame) — important on the - streaming hot path; `AiLiveSession` animates only the orb (60fps no longer - rebuilds the conversation); message bubbles no longer subscribe to window - size in the common bounded case; list items carry stable keys; the composer - measures with the ambient text scale + direction; disclosure widgets expose - button/expanded semantics; modal sheets scroll; high-traffic layout uses - directional insets/alignment for RTL. -- Re-exports `flutter_ai_client` (and `flutter_ai_core`). diff --git a/packages/flutter_ai/flutter_ai_elements/LICENSE b/packages/flutter_ai/flutter_ai_elements/LICENSE deleted file mode 100644 index 56023ee..0000000 --- a/packages/flutter_ai/flutter_ai_elements/LICENSE +++ /dev/null @@ -1,29 +0,0 @@ -BSD 3-Clause License - -Copyright (c) 2026, The flutter_ai authors. -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/flutter_ai/flutter_ai_elements/README.md b/packages/flutter_ai/flutter_ai_elements/README.md deleted file mode 100644 index ed03441..0000000 --- a/packages/flutter_ai/flutter_ai_elements/README.md +++ /dev/null @@ -1,168 +0,0 @@ -

flutter_ai_elements

- -

The batteries-included AI chat UI kit for Flutter — drop in a polished, streaming chat in one widget, or compose 30+ themeable pieces yourself.

- -

- flutter_ai_elements: a streaming answer with chain-of-thought and a generative-UI task card -

- -

- flutter_ai_elements on pub.dev - pub points - License: BSD-3-Clause -

- -

- Family: flutter_ai · - core · client · - openai · anthropic · gemini · - tools · mcp · voice
- Recipes · Migrating from the Vercel AI SDK -

- ---- - -## Gallery - - - - - - - - - - - - -
- Streaming response
- Streaming response
- AiChat · AiResponse · AiLoader -
- Generative UI task card
- Generative UI
- AiMessageBubble (custom DataPart renderers) -
- Tool calls
- Tool calls
- AiToolGroup · AiReasoning -
- Source citations
- Citations
- AiSources · AiInlineCitation -
- Theming
- Theming
- AiThemeExtension tokens -
- Dark mode
- Dark mode
- One theme extension, light & dark -
- -Composable, themeable Flutter UI for AI chat — the UI layer of the -[`flutter_ai`](../../README.md) family. - -It adopts the Vercel AI Elements component vocabulary but renders through a -**mobile-first `AiThemeExtension`**, built from base Flutter widgets. No -`shadcn_flutter` / `forui` dependency, no hardcoded Material or Cupertino look — -restyle everything via theme tokens. - -## Widgets - -**Presentational** (plain data + callbacks; reusable, testable): -- `AiMessageBubble` — renders one message's parts (text, reasoning, tool calls, - results, files, sources, data), role-aware, with streaming-safe semantics. -- `AiConversationView` — a scrolling list of bubbles, optional thinking loader. -- `AiComposer` — the **presentational** input: a leading attach (`+`) button and - a main button that is Live while empty, Send once you type, and Stop while - streaming; emits haptics. Use this only if you're wiring callbacks yourself. -- `AiLoader` — a pulsing three-dot thinking indicator. - -**Controller-bound** (wire to a `UseChatController` — what you usually want): -- `AiChatView` — the batteries-included one-widget chat: transcript + composer + - layout. The fastest way to drop in a full chat. -- `AiChat` — live transcript with auto-scroll and a thinking loader. -- `AiPromptInput` — the drop-in composer: wraps `AiComposer` and wires it to - `sendText` / `stop`. Prefer this over `AiComposer`. - -## Quick start - -```dart -import 'package:flutter/material.dart'; -import 'package:flutter_ai_elements/flutter_ai_elements.dart'; - -class ChatScreen extends StatelessWidget { - const ChatScreen({super.key, required this.controller}); - final UseChatController controller; // from flutter_ai_client - - @override - Widget build(BuildContext context) => Scaffold( - // Batteries-included: transcript + composer + layout in one widget. - body: AiChatView(controller: controller), - ); -} -``` - -Need a custom layout between the transcript and composer? Compose the pieces -yourself instead: - -```dart -Scaffold( - body: Column( - children: [ - Expanded(child: AiChat(controller: controller)), - AiPromptInput(controller: controller), - ], - ), -); -``` - -## Theming - -Register an `AiThemeExtension` (or override the default) on your `ThemeData`: - -```dart -MaterialApp( - theme: ThemeData( - extensions: [ - AiThemeExtension.fallback().copyWith( - userBubbleColor: const Color(0xFF7C3AED), - bubbleRadius: const BorderRadius.all(Radius.circular(28)), - enableHaptics: true, - ), - ], - ), - home: const ChatScreen(...), -); -``` - -Widgets read tokens via `AiThemeExtension.of(context)`, falling back to the -mobile-first default when none is registered. All visual constants live behind -this one extension, so a future `flutter_ai_design_system` can extract them -without breaking the API. - -## Rich text - -Markdown renders **by default** — headings, lists, bold/italic, links, and fenced -code blocks — via `MarkdownTextRenderer`, so streamed answers format themselves -out of the box. Text flows through an injectable `AiTextRenderer` -(`TextRenderer`), so you can swap in `PlainTextRenderer` for raw text, or -your own renderer for LaTeX or custom syntax highlighting: - -```dart -// Markdown is the default — this line is optional. -AiChat(controller: controller, textRenderer: const MarkdownTextRenderer()); - -// Opt out to plain text, or bring your own. -AiChat(controller: controller, textRenderer: const PlainTextRenderer()); -``` - -## Status - -Published on pub.dev (see the CHANGELOG); depends on the sibling `flutter_ai` -packages. -See [`example/`](example/) for a full app. - -_If `flutter_ai` saves you time, you can [buy me a coffee ☕](https://ko-fi.com/ananmouaz)._ diff --git a/packages/flutter_ai/flutter_ai_elements/analysis_options.yaml b/packages/flutter_ai/flutter_ai_elements/analysis_options.yaml deleted file mode 100644 index bddaa31..0000000 --- a/packages/flutter_ai/flutter_ai_elements/analysis_options.yaml +++ /dev/null @@ -1,2 +0,0 @@ -# Inherits the workspace-wide strict configuration. -include: ../../analysis_options.yaml diff --git a/packages/flutter_ai/flutter_ai_elements/example/flutter_ai_elements_example.dart b/packages/flutter_ai/flutter_ai_elements/example/flutter_ai_elements_example.dart deleted file mode 100644 index 5e648d9..0000000 --- a/packages/flutter_ai/flutter_ai_elements/example/flutter_ai_elements_example.dart +++ /dev/null @@ -1,73 +0,0 @@ -// A complete chat screen built from flutter_ai_elements, driven by a fake -// provider that echoes the prompt back word by word. -// -// Run inside a Flutter app target; this file shows the wiring. -import 'package:flutter/material.dart'; -import 'package:flutter_ai_elements/flutter_ai_elements.dart'; - -void main() => runApp(const _ExampleApp()); - -/// Echoes the user's last message, streamed word by word. -class _EchoProvider implements LlmProvider { - @override - Stream send( - AiConversation conversation, { - List? tools, - AiRequestOptions? options, - }) async* { - const id = 'assistant'; - final prompt = conversation.lastMessage?.text ?? ''; - yield const MessageStarted(messageId: id, role: AiRole.assistant); - for (final word in prompt.split(' ')) { - await Future.delayed(const Duration(milliseconds: 80)); - yield TextDelta(messageId: id, delta: '$word '); - } - yield const MessageFinished(messageId: id, reason: FinishReason.stop); - } -} - -class _ExampleApp extends StatefulWidget { - const _ExampleApp(); - - @override - State<_ExampleApp> createState() => _ExampleAppState(); -} - -class _ExampleAppState extends State<_ExampleApp> { - late final UseChatController _controller = - UseChatController(provider: _EchoProvider()); - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return MaterialApp( - theme: ThemeData( - useMaterial3: true, - extensions: [ - // A bespoke mobile skin layered on the mobile-first default. - AiThemeExtension.fallback().copyWith( - userBubbleColor: const Color(0xFF7C3AED), - bubbleRadius: const BorderRadius.all(Radius.circular(24)), - ), - ], - ), - home: Scaffold( - appBar: AppBar(title: const Text('flutter_ai_elements')), - body: SafeArea( - child: Column( - children: [ - Expanded(child: AiChat(controller: _controller)), - const Divider(height: 1), - AiPromptInput(controller: _controller), - ], - ), - ), - ), - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/flutter_ai_elements.dart b/packages/flutter_ai/flutter_ai_elements/lib/flutter_ai_elements.dart deleted file mode 100644 index fdc9169..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/flutter_ai_elements.dart +++ /dev/null @@ -1,56 +0,0 @@ -/// Composable, themeable Flutter UI for AI chat. -/// -/// Adopts the Vercel AI Elements component vocabulary while rendering through a -/// mobile-first `AiThemeExtension` — no shadcn or forui dependency. Built from -/// base Flutter widgets so any design system can restyle it via theme tokens. -/// -/// ### Presentational vs. bound widgets -/// -/// - **Presentational** (`AiMessageBubble`, `AiConversationView`, `AiComposer`, -/// `AiLoader`) take plain data and callbacks; reusable and easy to test. -/// - **Bound** (`AiChat`, `AiPromptInput`) wire those to a -/// `UseChatController` from `flutter_ai_client` for a drop-in chat surface. -/// -/// Re-exports `flutter_ai_client` (and transitively `flutter_ai_core`) so a -/// single import provides the controller, models, and UI. -library; - -export 'package:flutter_ai_client/flutter_ai_client.dart'; - -export 'src/generative_ui/ai_widget_registry.dart'; -export 'src/l10n/ai_localizations.dart'; -export 'src/rendering/ai_text_renderer.dart'; -export 'src/theme/ai_theme_extension.dart'; -export 'src/widgets/ai_animated_response.dart'; -export 'src/widgets/ai_attachment.dart'; -export 'src/widgets/ai_avatar.dart'; -export 'src/widgets/ai_branch.dart'; -export 'src/widgets/ai_chain_of_thought.dart'; -export 'src/widgets/ai_chat.dart'; -export 'src/widgets/ai_chat_view.dart'; -export 'src/widgets/ai_code_block.dart'; -export 'src/widgets/ai_composer.dart'; -export 'src/widgets/ai_confirmation.dart'; -export 'src/widgets/ai_context_meter.dart'; -export 'src/widgets/ai_conversation_list.dart'; -export 'src/widgets/ai_conversation_view.dart'; -export 'src/widgets/ai_empty_state.dart'; -export 'src/widgets/ai_error_banner.dart'; -export 'src/widgets/ai_image.dart'; -export 'src/widgets/ai_inline_citation.dart'; -export 'src/widgets/ai_live_controller.dart'; -export 'src/widgets/ai_live_session.dart'; -export 'src/widgets/ai_loader.dart'; -export 'src/widgets/ai_message_actions.dart'; -export 'src/widgets/ai_message_bubble.dart'; -export 'src/widgets/ai_model_selector.dart'; -export 'src/widgets/ai_orb.dart'; -export 'src/widgets/ai_prompt_input.dart'; -export 'src/widgets/ai_reasoning.dart'; -export 'src/widgets/ai_response.dart'; -export 'src/widgets/ai_shimmer.dart'; -export 'src/widgets/ai_sources.dart'; -export 'src/widgets/ai_suggestions.dart'; -export 'src/widgets/ai_task.dart'; -export 'src/widgets/ai_tool_group.dart'; -export 'src/widgets/ai_tool_invocation.dart'; diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/generative_ui/ai_widget_registry.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/generative_ui/ai_widget_registry.dart deleted file mode 100644 index 056ea7f..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/generative_ui/ai_widget_registry.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_ai_core/flutter_ai_core.dart'; - -/// Builds a widget for a [DataPart]'s payload. -typedef AiDataWidgetBuilder = Widget Function( - BuildContext context, - Map data, -); - -/// A name→widget allowlist for **generative UI**: the model emits a [DataPart] -/// with a `dataType` discriminator and a JSON payload, and the registry maps it -/// to a Flutter widget. -/// -/// This is a deliberate allowlist — only registered `dataType`s render (no -/// reflection, no arbitrary instantiation), so a model can't conjure UI you -/// didn't sanction. Unknown types fall back (see [AiDataView]). -class AiWidgetRegistry { - /// Creates a registry, optionally seeded with [builders]. - AiWidgetRegistry([Map? builders]) - : _builders = {...?builders}; - - final Map _builders; - - /// Registers [builder] for [dataType], replacing any previous entry. Returns - /// the registry for chaining. - AiWidgetRegistry register(String dataType, AiDataWidgetBuilder builder) { - _builders[dataType] = builder; - return this; - } - - /// Whether a builder is registered for [dataType]. - bool contains(String dataType) => _builders.containsKey(dataType); - - /// The `dataType`s with a registered builder. - Iterable get types => _builders.keys; - - /// Builds the widget for [part], or `null` if its `dataType` is not - /// registered. - Widget? build(BuildContext context, DataPart part) => - _builders[part.dataType]?.call(context, part.data); -} - -/// Renders a [DataPart] via a [registry], showing [fallback] (or nothing) when -/// the part's `dataType` is not registered. -class AiDataView extends StatelessWidget { - /// Creates a view for [part]. - const AiDataView({ - super.key, - required this.part, - required this.registry, - this.fallback, - }); - - /// The structured part to render. - final DataPart part; - - /// The allowlist of `dataType`→widget builders. - final AiWidgetRegistry registry; - - /// Shown when [part]'s `dataType` is not registered. Defaults to an empty box. - final Widget? fallback; - - @override - Widget build(BuildContext context) => - registry.build(context, part) ?? fallback ?? const SizedBox.shrink(); -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/l10n/ai_localizations.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/l10n/ai_localizations.dart deleted file mode 100644 index a7da073..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/l10n/ai_localizations.dart +++ /dev/null @@ -1,201 +0,0 @@ -import 'package:flutter/widgets.dart'; - -/// The user-facing strings used by `flutter_ai_elements` widgets. -/// -/// Defaults are English. To translate, provide an [AiLocalizations] (or a -/// per-locale [AiLocalizationsDelegate]) through `MaterialApp.localizationsDelegates`: -/// -/// ```dart -/// MaterialApp( -/// localizationsDelegates: const [ -/// AiLocalizationsDelegate(AiLocalizations(copy: 'Copier', send: 'Envoyer')), -/// ...GlobalMaterialLocalizations.delegates, -/// ], -/// ); -/// ``` -/// -/// Widgets read these via [AiLocalizations.of], which falls back to the English -/// defaults when none is provided. -@immutable -class AiLocalizations { - /// Creates a set of strings (English by default). - const AiLocalizations({ - this.copy = 'Copy', - this.regenerate = 'Regenerate', - this.edit = 'Edit', - this.readAloud = 'Read aloud', - this.share = 'Share', - this.goodResponse = 'Good response', - this.badResponse = 'Bad response', - this.stop = 'Stop', - this.send = 'Send', - this.live = 'Live', - this.attach = 'Attach', - this.dictate = 'Dictate', - this.delete = 'Delete', - this.dismiss = 'Dismiss', - this.retry = 'Retry', - this.close = 'Close', - this.newChat = 'New chat', - this.previousVersion = 'Previous version', - this.nextVersion = 'Next version', - this.scrollToLatest = 'Scroll to latest', - this.messageHint = 'Message', - this.reasoning = 'Reasoning', - this.chainOfThought = 'Chain of thought', - this.allow = 'Allow', - this.deny = 'Deny', - this.thinking = 'Assistant is thinking', - this.loading = 'Loading', - this.you = 'You', - this.assistant = 'Assistant', - }); - - /// Copy-to-clipboard action. - final String copy; - - /// Regenerate-response action. - final String regenerate; - - /// Edit-message action. - final String edit; - - /// Read-aloud (TTS) action. - final String readAloud; - - /// Share action. - final String share; - - /// Thumbs-up action. - final String goodResponse; - - /// Thumbs-down action. - final String badResponse; - - /// Stop-generation action. - final String stop; - - /// Send-message action. - final String send; - - /// Start-live-voice action. - final String live; - - /// Attach-file action. - final String attach; - - /// Start-dictation (mic) action. - final String dictate; - - /// Delete action. - final String delete; - - /// Dismiss action (e.g. error banner). - final String dismiss; - - /// Retry action. - final String retry; - - /// Close action (e.g. full-screen image). - final String close; - - /// New-conversation action. - final String newChat; - - /// Previous-branch navigation label. - final String previousVersion; - - /// Next-branch navigation label. - final String nextVersion; - - /// Scroll-to-latest button label. - final String scrollToLatest; - - /// Composer placeholder text. - final String messageHint; - - /// Read-aloud / collapsible reasoning section title. - final String reasoning; - - /// Chain-of-thought section title. - final String chainOfThought; - - /// Approve action on a confirmation card. - final String allow; - - /// Deny action on a confirmation card. - final String deny; - - /// Accessibility label while the assistant is generating. - final String thinking; - - /// Accessibility label for a loading placeholder. - final String loading; - - /// Avatar accessibility label for the user. - final String you; - - /// Avatar accessibility label for the assistant. - final String assistant; - - /// The nearest [AiLocalizations]. Resolution order: an [AiLocalizationsScope] - /// in the tree (the simplest way to override — no delegate wiring), then a - /// `Localizations` delegate, then the English defaults. - static AiLocalizations of(BuildContext context) => - context - .dependOnInheritedWidgetOfExactType() - ?.strings ?? - Localizations.of(context, AiLocalizations) ?? - const AiLocalizations(); - - /// A delegate serving the English defaults. Wrap your own - /// [AiLocalizations] with [AiLocalizationsDelegate] to translate. Prefer - /// [AiLocalizationsScope] unless you switch strings by locale. - static const LocalizationsDelegate delegate = - AiLocalizationsDelegate(); -} - -/// Overrides the [AiLocalizations] for the widgets below it — the simplest way -/// to translate or customize labels, with no `localizationsDelegates` wiring: -/// -/// ```dart -/// AiLocalizationsScope( -/// strings: const AiLocalizations(send: 'Envoyer', copy: 'Copier'), -/// child: myChat, -/// ); -/// ``` -class AiLocalizationsScope extends InheritedWidget { - /// Provides [strings] to descendants. - const AiLocalizationsScope({ - super.key, - required this.strings, - required super.child, - }); - - /// The strings descendants read via [AiLocalizations.of]. - final AiLocalizations strings; - - @override - bool updateShouldNotify(AiLocalizationsScope oldWidget) => - oldWidget.strings != strings; -} - -/// Serves a fixed [AiLocalizations] instance. Provide a translated instance to -/// localize, or implement your own delegate to switch by locale. -class AiLocalizationsDelegate extends LocalizationsDelegate { - /// Creates a delegate serving [strings] (English defaults if omitted). - const AiLocalizationsDelegate([this.strings = const AiLocalizations()]); - - /// The strings this delegate serves. - final AiLocalizations strings; - - @override - bool isSupported(Locale locale) => true; - - @override - Future load(Locale locale) async => strings; - - @override - bool shouldReload(AiLocalizationsDelegate old) => - !identical(old.strings, strings); -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/rendering/ai_text_renderer.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/rendering/ai_text_renderer.dart deleted file mode 100644 index ee24fe1..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/rendering/ai_text_renderer.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_ai_core/flutter_ai_core.dart'; - -/// A `TextRenderer` that produces a Flutter [Widget] — the rendering seam used -/// throughout the UI. -/// -/// The widgets default to `MarkdownTextRenderer` (Markdown, incl. fenced code -/// blocks). Inject [PlainTextRenderer] for raw text, or a custom implementation -/// (for example a LaTeX renderer) wherever a renderer is accepted. -typedef AiTextRenderer = TextRenderer; - -/// A renderer that emits a plain [Text] widget (opt in; the widgets default to -/// `MarkdownTextRenderer`). -/// -/// It intentionally sets no color or size so the surrounding `DefaultTextStyle` -/// (driven by the active theme and message role) governs appearance. Use it when -/// you want raw, unformatted text instead of the default Markdown rendering. -class PlainTextRenderer implements AiTextRenderer { - /// Creates a plain-text renderer. - const PlainTextRenderer(); - - @override - Widget render(String text, {required bool isStreaming}) => Text(text); -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/theme/ai_theme_extension.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/theme/ai_theme_extension.dart deleted file mode 100644 index 846a99b..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/theme/ai_theme_extension.dart +++ /dev/null @@ -1,358 +0,0 @@ -import 'dart:ui' show lerpDouble; - -import 'package:flutter/material.dart'; - -/// How an assistant message is laid out. -enum AiMessageStyle { - /// Full-width text on the page, with no container — the modern AI-assistant - /// look (ChatGPT / Claude / Gemini). The default. - plain, - - /// Wrapped in a filled bubble, like a messaging app. - bubble, -} - -/// The design tokens that style every `flutter_ai_elements` widget. -/// -/// Registered as a Flutter [ThemeExtension], so the components adopt any host -/// design system without being hardcoded to Material or Cupertino. Read it with -/// [AiThemeExtension.of]; override individual tokens via [copyWith]; or replace -/// it wholesale in `ThemeData.extensions`. -/// -/// [AiThemeExtension.fallback] supplies a clean, modern default modeled on -/// current AI assistants: a near-monochrome palette, a **bubble-less** assistant -/// ([AiMessageStyle.plain]), a quiet user bubble, no shadows, and a solid -/// [accentColor] for actions. Re-theme it to anything, or swap message rendering -/// entirely via `AiChat`'s `messageBuilder`. -/// -/// All visual constants for the package live behind this one extension, so a -/// future `flutter_ai_design_system` can lift them out without an API break. -@immutable -class AiThemeExtension extends ThemeExtension { - /// Creates a theme extension. Prefer [AiThemeExtension.fallback] and - /// [copyWith] for most cases. - const AiThemeExtension({ - required this.assistantMessageStyle, - required this.userBubbleColor, - required this.assistantBubbleColor, - this.chipColor, - required this.userTextColor, - required this.assistantTextColor, - required this.accentColor, - required this.onAccentColor, - required this.borderColor, - required this.errorColor, - required this.successColor, - required this.warningColor, - required this.codeBackgroundColor, - required this.codeForegroundColor, - required this.linkColor, - required this.bubbleRadius, - required this.bubbleShadow, - required this.bubblePadding, - required this.messageSpacing, - required this.maxBubbleWidthFraction, - required this.maxContentWidth, - required this.composerPadding, - required this.textStyle, - required this.codeStyle, - required this.loaderColor, - required this.orbColor, - required this.motionDuration, - required this.motionCurve, - required this.enableHaptics, - }); - - /// The modern, near-monochrome default (light). - factory AiThemeExtension.fallback() => const AiThemeExtension( - assistantMessageStyle: AiMessageStyle.plain, - userBubbleColor: Color(0xFFF4F4F4), - assistantBubbleColor: Color(0xFFF7F7F8), - userTextColor: Color(0xFF0D0D0D), - assistantTextColor: Color(0xFF0D0D0D), - accentColor: Color(0xFF0D0D0D), - onAccentColor: Color(0xFFFFFFFF), - borderColor: Color(0xFFE5E5E5), - errorColor: Color(0xFFDC2626), - successColor: Color(0xFF16A34A), - warningColor: Color(0xFFF59E0B), - codeBackgroundColor: Color(0xFF1E1E1E), - codeForegroundColor: Color(0xFFE6E6E6), - linkColor: Color(0xFF2563EB), - bubbleRadius: BorderRadius.all(Radius.circular(22)), - bubbleShadow: [], - bubblePadding: EdgeInsets.symmetric(horizontal: 16, vertical: 11), - messageSpacing: 18, - maxBubbleWidthFraction: 0.80, - maxContentWidth: 720, - composerPadding: EdgeInsets.fromLTRB(14, 8, 14, 12), - textStyle: TextStyle(fontSize: 16.5, height: 1.5), - codeStyle: - TextStyle(fontFamily: 'monospace', fontSize: 14, height: 1.45), - loaderColor: Color(0xFF8E8EA0), - orbColor: Color(0xFF2F7BE6), - motionDuration: Duration(milliseconds: 240), - motionCurve: Curves.easeOutCubic, - enableHaptics: true, - ); - - /// A dark counterpart to [AiThemeExtension.fallback]. Pair it with a dark - /// `ThemeData` so ambient text/icon colors are light. - factory AiThemeExtension.dark() => const AiThemeExtension( - assistantMessageStyle: AiMessageStyle.plain, - userBubbleColor: Color(0xFF2F2F33), - assistantBubbleColor: Color(0xFF202024), - userTextColor: Color(0xFFECECEC), - assistantTextColor: Color(0xFFECECEC), - accentColor: Color(0xFFFFFFFF), - onAccentColor: Color(0xFF0D0D0D), - borderColor: Color(0xFF3A3A40), - errorColor: Color(0xFFF87171), - successColor: Color(0xFF4ADE80), - warningColor: Color(0xFFFBBF24), - codeBackgroundColor: Color(0xFF1E1E1E), - codeForegroundColor: Color(0xFFE6E6E6), - linkColor: Color(0xFF60A5FA), - bubbleRadius: BorderRadius.all(Radius.circular(22)), - bubbleShadow: [], - bubblePadding: EdgeInsets.symmetric(horizontal: 16, vertical: 11), - messageSpacing: 18, - maxBubbleWidthFraction: 0.80, - maxContentWidth: 720, - composerPadding: EdgeInsets.fromLTRB(14, 8, 14, 12), - textStyle: TextStyle(fontSize: 16.5, height: 1.5), - codeStyle: - TextStyle(fontFamily: 'monospace', fontSize: 14, height: 1.45), - loaderColor: Color(0xFF8E8EA0), - orbColor: Color(0xFF2F7BE6), - motionDuration: Duration(milliseconds: 240), - motionCurve: Curves.easeOutCubic, - enableHaptics: true, - ); - - /// How assistant messages are laid out (plain full-width vs. bubble). - final AiMessageStyle assistantMessageStyle; - - /// Background of a user's message bubble. - final Color userBubbleColor; - - /// Background of an assistant bubble (used when [assistantMessageStyle] is - /// [AiMessageStyle.bubble]) and of the composer field. - final Color assistantBubbleColor; - - /// Fill for small standalone surfaces — suggestion/starter chips, the selected - /// conversation-list row, and the scroll-to-latest button. - /// - /// These reuse [assistantBubbleColor] when this is null. Set it explicitly for - /// **bubble-less** themes (a transparent [assistantBubbleColor], e.g. a - /// Gemini-style plain assistant) so those chips/rows don't visually vanish. - final Color? chipColor; - - /// The resolved chip/selection surface: [chipColor] if set, otherwise - /// [assistantBubbleColor]. Widgets should read this rather than - /// [assistantBubbleColor] directly. - Color get effectiveChipColor => chipColor ?? assistantBubbleColor; - - /// Text color inside a user bubble. - final Color userTextColor; - - /// Text color for assistant content. - final Color assistantTextColor; - - /// Solid accent for primary actions (the send button, etc.). - final Color accentColor; - - /// Foreground drawn on top of [accentColor]. - final Color onAccentColor; - - /// Hairline/border color for fields, cards, and dividers. - final Color borderColor; - - /// Error/destructive accent (error banner, failed tool, danger states). - final Color errorColor; - - /// Success/positive accent (completed tasks, succeeded tools). - final Color successColor; - - /// Warning/caution accent (e.g. context meter approaching the limit). - final Color warningColor; - - /// Background of a fenced code block. - final Color codeBackgroundColor; - - /// Default foreground (text) color inside a fenced code block. - final Color codeForegroundColor; - - /// Color of inline links in rendered Markdown. - final Color linkColor; - - /// Corner radius of bubbles and the composer field. - final BorderRadius bubbleRadius; - - /// Shadow cast by bubbles. Empty by default (flat). - final List bubbleShadow; - - /// Inner padding of a message bubble. - final EdgeInsets bubblePadding; - - /// Vertical gap between consecutive messages. - final double messageSpacing; - - /// Maximum width of a *bubble* as a fraction of available width (`0`–`1`). - /// Plain assistant messages always span the full width. - final double maxBubbleWidthFraction; - - /// Default reading-width the conversation column is centered at on wide - /// screens, so prose doesn't run edge-to-edge (like ChatGPT/Claude on - /// desktop). Set to [double.infinity] for full-width. A `maxContentWidth` - /// passed directly to a widget overrides this. - final double maxContentWidth; - - /// Padding around the composer. - final EdgeInsets composerPadding; - - /// Base text style for message prose. - final TextStyle textStyle; - - /// Text style for code spans and blocks. - final TextStyle codeStyle; - - /// Color of the thinking/typing loader. - final Color loaderColor; - - /// Base color of the live-voice `AiOrb` / `AiLiveSession` orb. - final Color orbColor; - - /// Duration for entrance and state-change animations. - final Duration motionDuration; - - /// Curve for entrance and state-change animations. - final Curve motionCurve; - - /// Whether widgets emit haptic feedback on key interactions. - final bool enableHaptics; - - /// Returns the extension from [context], or [AiThemeExtension.fallback] if no - /// theme provides one. - static AiThemeExtension of(BuildContext context) => - Theme.of(context).extension() ?? - AiThemeExtension.fallback(); - - @override - AiThemeExtension copyWith({ - AiMessageStyle? assistantMessageStyle, - Color? userBubbleColor, - Color? assistantBubbleColor, - Color? chipColor, - Color? userTextColor, - Color? assistantTextColor, - Color? accentColor, - Color? onAccentColor, - Color? borderColor, - Color? errorColor, - Color? successColor, - Color? warningColor, - Color? codeBackgroundColor, - Color? codeForegroundColor, - Color? linkColor, - BorderRadius? bubbleRadius, - List? bubbleShadow, - EdgeInsets? bubblePadding, - double? messageSpacing, - double? maxBubbleWidthFraction, - double? maxContentWidth, - EdgeInsets? composerPadding, - TextStyle? textStyle, - TextStyle? codeStyle, - Color? loaderColor, - Color? orbColor, - Duration? motionDuration, - Curve? motionCurve, - bool? enableHaptics, - }) => - AiThemeExtension( - assistantMessageStyle: - assistantMessageStyle ?? this.assistantMessageStyle, - userBubbleColor: userBubbleColor ?? this.userBubbleColor, - assistantBubbleColor: assistantBubbleColor ?? this.assistantBubbleColor, - chipColor: chipColor ?? this.chipColor, - userTextColor: userTextColor ?? this.userTextColor, - assistantTextColor: assistantTextColor ?? this.assistantTextColor, - accentColor: accentColor ?? this.accentColor, - onAccentColor: onAccentColor ?? this.onAccentColor, - borderColor: borderColor ?? this.borderColor, - errorColor: errorColor ?? this.errorColor, - successColor: successColor ?? this.successColor, - warningColor: warningColor ?? this.warningColor, - codeBackgroundColor: codeBackgroundColor ?? this.codeBackgroundColor, - codeForegroundColor: codeForegroundColor ?? this.codeForegroundColor, - linkColor: linkColor ?? this.linkColor, - bubbleRadius: bubbleRadius ?? this.bubbleRadius, - bubbleShadow: bubbleShadow ?? this.bubbleShadow, - bubblePadding: bubblePadding ?? this.bubblePadding, - messageSpacing: messageSpacing ?? this.messageSpacing, - maxBubbleWidthFraction: - maxBubbleWidthFraction ?? this.maxBubbleWidthFraction, - maxContentWidth: maxContentWidth ?? this.maxContentWidth, - composerPadding: composerPadding ?? this.composerPadding, - textStyle: textStyle ?? this.textStyle, - codeStyle: codeStyle ?? this.codeStyle, - loaderColor: loaderColor ?? this.loaderColor, - orbColor: orbColor ?? this.orbColor, - motionDuration: motionDuration ?? this.motionDuration, - motionCurve: motionCurve ?? this.motionCurve, - enableHaptics: enableHaptics ?? this.enableHaptics, - ); - - @override - AiThemeExtension lerp(covariant AiThemeExtension? other, double t) { - if (other == null) return this; - return AiThemeExtension( - assistantMessageStyle: - t < 0.5 ? assistantMessageStyle : other.assistantMessageStyle, - userBubbleColor: Color.lerp(userBubbleColor, other.userBubbleColor, t)!, - assistantBubbleColor: - Color.lerp(assistantBubbleColor, other.assistantBubbleColor, t)!, - chipColor: Color.lerp(chipColor, other.chipColor, t), - userTextColor: Color.lerp(userTextColor, other.userTextColor, t)!, - assistantTextColor: - Color.lerp(assistantTextColor, other.assistantTextColor, t)!, - accentColor: Color.lerp(accentColor, other.accentColor, t)!, - onAccentColor: Color.lerp(onAccentColor, other.onAccentColor, t)!, - borderColor: Color.lerp(borderColor, other.borderColor, t)!, - errorColor: Color.lerp(errorColor, other.errorColor, t)!, - successColor: Color.lerp(successColor, other.successColor, t)!, - warningColor: Color.lerp(warningColor, other.warningColor, t)!, - codeBackgroundColor: - Color.lerp(codeBackgroundColor, other.codeBackgroundColor, t)!, - codeForegroundColor: - Color.lerp(codeForegroundColor, other.codeForegroundColor, t)!, - linkColor: Color.lerp(linkColor, other.linkColor, t)!, - bubbleRadius: BorderRadius.lerp(bubbleRadius, other.bubbleRadius, t)!, - bubbleShadow: BoxShadow.lerpList(bubbleShadow, other.bubbleShadow, t) ?? - bubbleShadow, - bubblePadding: EdgeInsets.lerp(bubblePadding, other.bubblePadding, t)!, - messageSpacing: lerpDouble(messageSpacing, other.messageSpacing, t)!, - maxBubbleWidthFraction: lerpDouble( - maxBubbleWidthFraction, - other.maxBubbleWidthFraction, - t, - )!, - // Guard against a non-finite reading width (e.g. double.infinity) which - // would lerp to NaN; snap discretely instead. - maxContentWidth: - (maxContentWidth.isFinite && other.maxContentWidth.isFinite) - ? lerpDouble(maxContentWidth, other.maxContentWidth, t)! - : (t < 0.5 ? maxContentWidth : other.maxContentWidth), - composerPadding: - EdgeInsets.lerp(composerPadding, other.composerPadding, t)!, - textStyle: TextStyle.lerp(textStyle, other.textStyle, t)!, - codeStyle: TextStyle.lerp(codeStyle, other.codeStyle, t)!, - loaderColor: Color.lerp(loaderColor, other.loaderColor, t)!, - orbColor: Color.lerp(orbColor, other.orbColor, t)!, - motionDuration: t < 0.5 ? motionDuration : other.motionDuration, - motionCurve: t < 0.5 ? motionCurve : other.motionCurve, - enableHaptics: t < 0.5 ? enableHaptics : other.enableHaptics, - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_animated_response.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_animated_response.dart deleted file mode 100644 index 39e25cc..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_animated_response.dart +++ /dev/null @@ -1,327 +0,0 @@ -import 'dart:async'; -import 'dart:math' as math; -import 'dart:ui' as ui; - -import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; - -/// Reveals a streamed answer with a trailing **blur fade-in** — the -/// Apple-Intelligence / Siri look — instead of a hard typewriter edge. -/// -/// Text appears progressively (paced like [charsPerSecond], accelerating to -/// drain a backlog within [catchUpWindow] so it never trails far behind a fast -/// stream), and each newly revealed word arrives blurred and semi-transparent, -/// then sharpens and fades into place over [fadeDuration]. Only the few words -/// at the leading edge animate at once, so the cost stays bounded no matter how -/// long the answer is. -/// -/// This renders the in-flight text as **plain prose** (no Markdown formatting) -/// — inline blur can only be applied to whole inline boxes, not to spans inside -/// a laid-out paragraph. Use it for the *streaming* message only; completed -/// messages should render with the full Markdown widget so headings, lists, -/// code, and links come back. -class AiAnimatedResponse extends StatefulWidget { - /// Creates an animated Markdown response. - const AiAnimatedResponse({ - super.key, - required this.text, - this.onLinkTap, - this.charsPerSecond = 120, - this.catchUpWindow = const Duration(seconds: 1), - this.fadeDuration = const Duration(milliseconds: 340), - this.blurSigma = 5, - }); - - /// The (growing) source text to reveal. - final String text; - - /// Reserved for API compatibility with the completed renderer. Links are not - /// tappable during the animated phase (the in-flight text is plain prose); - /// they become active once the message settles into the Markdown renderer. - final void Function(Uri url)? onLinkTap; - - /// The baseline (readable) reveal speed used while the typewriter is keeping - /// up with the stream feeding it. Tuned to a comfortable reading pace. - final double charsPerSecond; - - /// When the reveal falls behind the stream, it accelerates so the remaining - /// backlog drains within this window — keeping the pace readable on slow - /// streams while never trailing far behind a fast one. - final Duration catchUpWindow; - - /// How long each freshly revealed word takes to sharpen from blurred and - /// faded to crisp and opaque. - final Duration fadeDuration; - - /// The blur applied to a word the moment it appears, in logical pixels. It - /// eases to zero over [fadeDuration]. - final double blurSigma; - - @override - State createState() => _AiAnimatedResponseState(); -} - -class _AiAnimatedResponseState extends State - with SingleTickerProviderStateMixin { - /// At most this many trailing words animate at once, bounding the number of - /// blur/opacity layers regardless of how fast the stream bursts. - static const _maxAnimating = 6; - - late final Ticker _ticker; - - /// `[start, end)` char ranges of every non-whitespace run in the text. - List> _words = const []; - - /// Word start offset -> the ticker time at which it became fully revealed. - final Map _births = {}; - - int _shown = 0; // characters revealed so far - int _settledCursor = 0; // index into [_words] whose births are recorded - Duration _last = Duration.zero; - Duration _elapsed = Duration.zero; - - @visibleForTesting - int get shownChars => _shown; - - @override - void initState() { - super.initState(); - _retokenize(); - _ticker = createTicker(_tick); - if (widget.text.isNotEmpty) unawaited(_ticker.start()); - } - - void _retokenize() { - final s = widget.text; - final words = >[]; - var i = 0; - while (i < s.length) { - if (_isSpace(s.codeUnitAt(i))) { - i++; - continue; - } - final start = i; - while (i < s.length && !_isSpace(s.codeUnitAt(i))) { - i++; - } - words.add([start, i]); - } - _words = words; - } - - static bool _isSpace(int c) => - c == 0x20 || c == 0x0A || c == 0x09 || c == 0x0D; - - @override - void didUpdateWidget(AiAnimatedResponse oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.text == widget.text) return; - // If the text was replaced (e.g. a regenerate) rather than appended to, - // restart the reveal from the beginning. - final appended = widget.text.startsWith(oldWidget.text.substring( - 0, - math.min(oldWidget.text.length, widget.text.length), - )); - _retokenize(); - if (!appended) { - _shown = 0; - _settledCursor = 0; - _births.clear(); - } - if (!_settled() && !_ticker.isActive) { - _last = Duration.zero; - unawaited(_ticker.start()); - } - } - - /// True once everything is revealed and the last word has finished sharpening. - bool _settled() { - if (_shown < widget.text.length) return false; - if (_words.isEmpty) return true; - final birth = _births[_words.last[0]]; - if (birth == null) return false; - return (_elapsed - birth) >= widget.fadeDuration; - } - - void _tick(Duration elapsed) { - final dt = _last == Duration.zero - ? 0.0 - : (elapsed - _last).inMicroseconds / Duration.microsecondsPerSecond; - _last = elapsed; - _elapsed = elapsed; - - final target = widget.text.length; - // Reveal at the readable baseline while caught up, but accelerate to drain - // a large backlog within [catchUpWindow] so the typewriter never trails far - // behind the stream once the answer has fully arrived. - final window = - widget.catchUpWindow.inMicroseconds / Duration.microsecondsPerSecond; - final backlogRate = - window > 0 ? (target - _shown) / window : double.infinity; - final rate = math.max(widget.charsPerSecond, backlogRate); - final step = math.max(1, (rate * dt).round()); - _shown = _shown + step < target ? _shown + step : target; - - // Stamp the birth time of every word that just became fully revealed. - while ( - _settledCursor < _words.length && _words[_settledCursor][1] <= _shown) { - _births.putIfAbsent(_words[_settledCursor][0], () => elapsed); - _settledCursor++; - } - - if (_settled()) { - _ticker.stop(); - _last = Duration.zero; - } - setState(() {}); - } - - @override - void dispose() { - _ticker.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = AiThemeExtension.of(context); - final base = DefaultTextStyle.of(context).style.merge(theme.textStyle); - final text = widget.text; - - // Respect the platform "reduce motion" setting: skip the blur/typewriter - // and show the text as-is (an accessibility requirement, WCAG 2.3.3). - if (MediaQuery.maybeDisableAnimationsOf(context) ?? false) { - return Text(text, style: base); - } - - final visible = math.min(_shown, text.length); - - // Index of the last word that is fully revealed; only the trailing - // [_maxAnimating] of these (plus any partially revealed word) animate. - var lastFull = -1; - for (var i = 0; i < _words.length; i++) { - if (_words[i][1] <= visible) { - lastFull = i; - } else { - break; - } - } - final animateFrom = lastFull - _maxAnimating + 1; - - final spans = []; - final settled = StringBuffer(); - var cursor = 0; - for (var i = 0; i < _words.length; i++) { - final start = _words[i][0]; - final end = _words[i][1]; - if (start >= visible) break; - if (start > cursor) settled.write(text.substring(cursor, start)); - final shownEnd = math.min(end, visible); - final partial = end > visible; - - double t; // 0 = just born (blurred), 1 = settled (crisp) - if (partial) { - t = 0; - } else { - final birth = _births[start]; - t = birth == null - ? 1 - : ((_elapsed - birth).inMicroseconds / - widget.fadeDuration.inMicroseconds) - .clamp(0.0, 1.0); - } - - final recent = i >= animateFrom; - if (t >= 1.0 || (!partial && !recent)) { - settled.write(text.substring(start, shownEnd)); - } else { - if (settled.isNotEmpty) { - spans.add(TextSpan(text: settled.toString(), style: base)); - settled.clear(); - } - final eased = Curves.easeOut.transform(t); - spans.add(WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - child: Opacity( - opacity: 0.25 + 0.75 * eased, - child: ImageFiltered( - imageFilter: ui.ImageFilter.blur( - sigmaX: widget.blurSigma * (1 - eased), - sigmaY: widget.blurSigma * (1 - eased), - tileMode: TileMode.decal, - ), - child: Text(text.substring(start, shownEnd), style: base), - ), - ), - )); - } - cursor = shownEnd; - } - if (cursor < visible) settled.write(text.substring(cursor, visible)); - if (settled.isNotEmpty) { - spans.add(TextSpan(text: settled.toString(), style: base)); - } - - // A blinking caret at the leading edge — the "being written right now" cue. - spans.add(WidgetSpan( - alignment: PlaceholderAlignment.middle, - child: _Caret( - key: const ValueKey('ai-caret'), - color: base.color ?? const Color(0xFF000000), - base: base, - ), - )); - - // Isolate the per-frame repaint of the animating reveal from the rest of - // the message/list so the blur layers don't dirty their neighbors. - return RepaintBoundary( - child: Text.rich(TextSpan(children: spans, style: base)), - ); - } -} - -/// A thin blinking text caret. Holds steady (no blink) under reduce-motion. -class _Caret extends StatefulWidget { - const _Caret({super.key, required this.color, required this.base}); - - final Color color; - final TextStyle base; - - @override - State<_Caret> createState() => _CaretState(); -} - -class _CaretState extends State<_Caret> with SingleTickerProviderStateMixin { - late final AnimationController _blink = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1100), - )..repeat(); - - @override - void dispose() { - _blink.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final height = (widget.base.fontSize ?? 16) * (widget.base.height ?? 1.2); - final bar = Padding( - padding: const EdgeInsetsDirectional.only(start: 1), - child: Container(width: 2, height: height * 0.78, color: widget.color), - ); - if (MediaQuery.maybeDisableAnimationsOf(context) ?? false) return bar; - return FadeTransition( - // Square wave-ish blink: mostly on, brief off. - opacity: _blink.drive( - TweenSequence([ - TweenSequenceItem(tween: ConstantTween(1), weight: 55), - TweenSequenceItem(tween: ConstantTween(0), weight: 45), - ]), - ), - child: bar, - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_attachment.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_attachment.dart deleted file mode 100644 index 2a556f4..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_attachment.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_ai_core/flutter_ai_core.dart'; - -/// A compact preview of a [FilePart] attachment. -/// -/// Images (with inline bytes or a URL) render as a rounded thumbnail; everything -/// else renders as a labeled file chip. Document text extraction is out of scope -/// — that belongs to a backend, off the UI thread. -class AiAttachment extends StatelessWidget { - /// Creates an attachment preview for [file]. - const AiAttachment({ - super.key, - required this.file, - this.maxImageHeight = 200, - }); - - /// The file to preview. - final FilePart file; - - /// Maximum height for image previews. - final double maxImageHeight; - - bool get _isImage => file.mediaType.startsWith('image/'); - - @override - Widget build(BuildContext context) { - if (_isImage) { - final image = _buildImage(); - if (image != null) { - return Semantics( - image: true, - label: file.name ?? 'Image attachment', - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: ConstrainedBox( - constraints: BoxConstraints(maxHeight: maxImageHeight), - child: image, - ), - ), - ); - } - } - return _FileChip(label: file.name ?? file.mediaType); - } - - Widget? _buildImage() { - final bytes = file.bytes; - if (bytes != null) { - return Image.memory(bytes, fit: BoxFit.cover, errorBuilder: _onError); - } - final url = file.url; - if (url != null) { - return Image.network( - url.toString(), - fit: BoxFit.cover, - errorBuilder: _onError, - ); - } - return null; - } - - Widget _onError(BuildContext context, Object error, StackTrace? stack) => - _FileChip(label: file.name ?? file.mediaType); -} - -class _FileChip extends StatelessWidget { - const _FileChip({required this.label}); - - final String label; - - @override - Widget build(BuildContext context) { - final color = DefaultTextStyle.of(context).style.color; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: (color ?? const Color(0xFF000000)).withValues(alpha: 0.18), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.insert_drive_file_outlined, size: 16, color: color), - const SizedBox(width: 6), - Flexible( - child: Text( - label, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: color), - ), - ), - ], - ), - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_avatar.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_avatar.dart deleted file mode 100644 index dbbea6d..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_avatar.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_ai_core/flutter_ai_core.dart'; -import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; - -/// A small circular avatar identifying a message's author. -/// -/// Colors derive from the active [AiThemeExtension]; override the icon per role. -class AiAvatar extends StatelessWidget { - /// Creates an avatar for [role]. - const AiAvatar({ - super.key, - required this.role, - this.size = 32, - this.userIcon = Icons.person_outline, - this.assistantIcon = Icons.auto_awesome, - }); - - /// The author whose avatar to show. - final AiRole role; - - /// Diameter of the avatar. - final double size; - - /// Icon for user/system messages. - final IconData userIcon; - - /// Icon for assistant/tool messages. - final IconData assistantIcon; - - @override - Widget build(BuildContext context) { - final theme = AiThemeExtension.of(context); - final isUser = role == AiRole.user || role == AiRole.system; - final background = - isUser ? theme.userBubbleColor : theme.assistantBubbleColor; - final foreground = isUser ? theme.userTextColor : theme.assistantTextColor; - return Container( - width: size, - height: size, - decoration: BoxDecoration(color: background, shape: BoxShape.circle), - alignment: Alignment.center, - child: Icon( - isUser ? userIcon : assistantIcon, - size: size * 0.56, - color: foreground, - semanticLabel: isUser - ? AiLocalizations.of(context).you - : AiLocalizations.of(context).assistant, - ), - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_branch.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_branch.dart deleted file mode 100644 index 50b948d..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_branch.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; - -/// A compact "‹ 2/3 ›" control for navigating between alternate versions of a -/// message (e.g. successive regenerations). -/// -/// Purely presentational: it reports navigation via [onPrevious]/[onNext] and -/// shows [index] of [total] (both 1-based for display; pass a 0-based [index]). -class AiBranch extends StatelessWidget { - /// Creates a branch navigator. - const AiBranch({ - super.key, - required this.index, - required this.total, - this.onPrevious, - this.onNext, - }); - - /// The 0-based index of the current version. - final int index; - - /// The total number of versions. - final int total; - - /// Called to go to the previous version. Disabled at the first. - final VoidCallback? onPrevious; - - /// Called to go to the next version. Disabled at the last. - final VoidCallback? onNext; - - @override - Widget build(BuildContext context) { - if (total <= 1) return const SizedBox.shrink(); - final theme = AiThemeExtension.of(context); - final l = AiLocalizations.of(context); - final color = DefaultTextStyle.of(context).style.color?.withValues( - alpha: 0.7, - ); - final canPrev = index > 0 && onPrevious != null; - final canNext = index < total - 1 && onNext != null; - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - _Arrow( - icon: Icons.chevron_left, - label: l.previousVersion, - color: color, - onTap: canPrev ? onPrevious : null, - ), - Text( - '${index + 1}/$total', - style: theme.codeStyle.copyWith(fontSize: 12, color: color), - ), - _Arrow( - icon: Icons.chevron_right, - label: l.nextVersion, - color: color, - onTap: canNext ? onNext : null, - ), - ], - ); - } -} - -class _Arrow extends StatelessWidget { - const _Arrow({ - required this.icon, - required this.label, - required this.color, - required this.onTap, - }); - - final IconData icon; - final String label; - final Color? color; - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - return Semantics( - button: true, - enabled: onTap != null, - label: label, - child: GestureDetector( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.all(4), - child: Icon( - icon, - size: 18, - color: onTap == null ? color?.withValues(alpha: 0.3) : color, - ), - ), - ), - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_chain_of_thought.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_chain_of_thought.dart deleted file mode 100644 index 4dae830..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_chain_of_thought.dart +++ /dev/null @@ -1,190 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; - -/// One step in an [AiChainOfThought]. -@immutable -class AiThoughtStep { - /// Creates a step with a [label] and optional [detail]. - const AiThoughtStep({ - required this.label, - this.detail, - this.isActive = false, - }); - - /// The step's headline. - final String label; - - /// Optional supporting detail shown beneath the label. - final String? detail; - - /// Whether this step is the one currently in progress. - final bool isActive; -} - -/// A collapsible, vertical timeline of reasoning steps. -/// -/// Richer than `AiReasoning` (which shows free-form text): use this when the -/// model exposes discrete steps (search → read → synthesize). -class AiChainOfThought extends StatefulWidget { - /// Creates a chain-of-thought timeline from [steps]. - const AiChainOfThought({ - super.key, - required this.steps, - this.title = 'Chain of thought', - this.initiallyExpanded = false, - }); - - /// The ordered steps. - final List steps; - - /// The disclosure label. - final String title; - - /// Whether the timeline starts expanded. - final bool initiallyExpanded; - - @override - State createState() => _AiChainOfThoughtState(); -} - -class _AiChainOfThoughtState extends State { - late bool _expanded = widget.initiallyExpanded; - - @override - Widget build(BuildContext context) { - final theme = AiThemeExtension.of(context); - final color = DefaultTextStyle.of(context).style.color; - final subdued = color?.withValues(alpha: 0.6); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Semantics( - button: true, - expanded: _expanded, - child: InkWell( - onTap: () => setState(() => _expanded = !_expanded), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.account_tree_outlined, size: 16, color: subdued), - const SizedBox(width: 6), - Text( - widget.title, - style: TextStyle( - color: subdued, - fontWeight: FontWeight.w600, - fontSize: 13, - ), - ), - Icon( - _expanded ? Icons.expand_less : Icons.expand_more, - size: 18, - color: subdued, - ), - ], - ), - ), - ), - AnimatedSize( - duration: theme.motionDuration, - curve: theme.motionCurve, - alignment: Alignment.topCenter, - child: _expanded - ? Padding( - padding: const EdgeInsets.only(top: 8, left: 2), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (var i = 0; i < widget.steps.length; i++) - _StepRow( - step: widget.steps[i], - isLast: i == widget.steps.length - 1, - theme: theme, - textColor: color, - subdued: subdued, - ), - ], - ), - ) - : const SizedBox(width: double.infinity), - ), - ], - ); - } -} - -class _StepRow extends StatelessWidget { - const _StepRow({ - required this.step, - required this.isLast, - required this.theme, - required this.textColor, - required this.subdued, - }); - - final AiThoughtStep step; - final bool isLast; - final AiThemeExtension theme; - final Color? textColor; - final Color? subdued; - - @override - Widget build(BuildContext context) { - final dotColor = step.isActive ? theme.accentColor : subdued; - return IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - children: [ - Container( - width: 9, - height: 9, - margin: const EdgeInsets.only(top: 4), - decoration: - BoxDecoration(color: dotColor, shape: BoxShape.circle), - ), - if (!isLast) - Expanded( - child: Container(width: 1.5, color: theme.borderColor), - ), - ], - ), - const SizedBox(width: 10), - Expanded( - child: Padding( - padding: EdgeInsets.only(bottom: isLast ? 0 : 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - step.label, - style: theme.textStyle.copyWith( - color: textColor, - fontSize: 14.5, - fontWeight: - step.isActive ? FontWeight.w600 : FontWeight.w400, - ), - ), - if (step.detail != null) - Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - step.detail!, - style: theme.textStyle.copyWith( - color: subdued, - fontSize: 13, - ), - ), - ), - ], - ), - ), - ), - ], - ), - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_chat.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_chat.dart deleted file mode 100644 index e65b0ae..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_chat.dart +++ /dev/null @@ -1,364 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter_ai_client/flutter_ai_client.dart'; -import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; -import 'package:flutter_ai_elements/src/rendering/ai_text_renderer.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; -import 'package:flutter_ai_elements/src/widgets/ai_conversation_view.dart'; -import 'package:flutter_ai_elements/src/widgets/ai_haptics.dart'; -import 'package:flutter_ai_elements/src/widgets/ai_response.dart'; - -/// A live, drop-in chat transcript bound to a [UseChatController]. -/// -/// Rebuilds as the controller's transcript changes and shows a thinking loader -/// while awaiting the first token. -/// -/// When you send a message, the chat anchors that message to the **top** of the -/// viewport (ChatGPT-style) and lets the answer stream into the space below it, -/// reserving just enough trailing space and releasing it as the answer grows. -/// -/// Named `AiChat` rather than `AiConversation` to avoid colliding with the -/// `AiConversation` data model from `flutter_ai_core`. -class AiChat extends StatefulWidget { - /// Creates a chat transcript bound to [controller]. - const AiChat({ - super.key, - required this.controller, - this.textRenderer = const MarkdownTextRenderer(), - this.messageBuilder, - this.padding = const EdgeInsets.all(16), - this.autoScroll = true, - this.emptyState, - this.loadingBuilder, - this.maxContentWidth, - }); - - /// The chat controller to observe. - final UseChatController controller; - - /// Shown in place of the list while the conversation is empty and idle. - final Widget? emptyState; - - /// Renderer for message text. - final AiTextRenderer textRenderer; - - /// Optional override for how each message is built. - final Widget Function(BuildContext context, AiMessage message)? - messageBuilder; - - /// Padding around the list. - final EdgeInsets padding; - - /// Whether to auto-scroll to the newest message when already near the bottom. - final bool autoScroll; - - /// Builds the thinking indicator (defaults to `AiLoader`). - final WidgetBuilder? loadingBuilder; - - /// On wide screens, centers the conversation at this width. When `null`, - /// falls back to [AiThemeExtension.maxContentWidth]; pass [double.infinity] - /// for full-width. - final double? maxContentWidth; - - @override - State createState() => _AiChatState(); -} - -class _AiChatState extends State { - final ScrollController _scrollController = ScrollController(); - final GlobalKey _anchorKey = GlobalKey(); - - /// Id of the user message currently pinned to the top of the viewport. - String? _anchorId; - - /// Empty space reserved after the last item so the anchor can reach the top. - double _trailingSpace = 0; - - /// Whether we're actively holding the anchor at the top. Released when the - /// user scrolls manually, re-armed on the next sent message. - bool _pinned = false; - - /// Whether to show the floating "scroll to latest" button. - bool _showJump = false; - int _lastCount = 0; - - /// The controller status at the previous change, to detect turn completion. - ChatStatus? _lastStatus; - - /// Bounds the per-change settle retries (waiting for the anchor to lay out). - int _settleAttempts = 0; - - /// Coalesces overlapping settle callbacks into one per frame. - bool _settleScheduled = false; - - @override - void initState() { - super.initState(); - _lastCount = widget.controller.messages.length; - _lastStatus = widget.controller.status; - widget.controller.addListener(_onChange); - } - - @override - void didUpdateWidget(AiChat oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.controller != widget.controller) { - oldWidget.controller.removeListener(_onChange); - widget.controller.addListener(_onChange); - } - } - - @override - void dispose() { - widget.controller.removeListener(_onChange); - _scrollController.dispose(); - super.dispose(); - } - - void _onChange() { - // A light tap when a turn finishes (busy → idle) — independent of scrolling. - final status = widget.controller.status; - if (_lastStatus != null && - _lastStatus!.isBusy && - !status.isBusy && - mounted) { - aiLightHaptic(AiThemeExtension.of(context)); - } - _lastStatus = status; - - if (!widget.autoScroll) return; - final messages = widget.controller.messages; - final count = messages.length; - final newMessage = count > _lastCount; - _lastCount = count; - - if (messages.isEmpty) { - if (_anchorId != null || _trailingSpace != 0) { - setState(() { - _anchorId = null; - _trailingSpace = 0; - _pinned = false; - _showJump = false; - }); - } - return; - } - - if (newMessage) { - // Pin the latest user turn to the top for the whole turn. Start with no - // reserved space so the freshly-appended anchor (the last item) is within - // the lazy list's build area; _settle() then reserves what's needed and - // holds the anchor at the top as the answer streams in. - final lastUser = _lastUserId(messages); - if (lastUser != null) { - setState(() { - _anchorId = lastUser; - _trailingSpace = 0; - _pinned = true; - }); - } - } - // Re-assert the anchor every change while pinned so it *persists* at the top - // as the answer streams (not just on the first frame). - _settle(); - } - - static String? _lastUserId(List messages) { - for (var i = messages.length - 1; i >= 0; i--) { - if (messages[i].role == AiRole.user) return messages[i].id; - } - return null; - } - - /// Re-asserts the top-pin: reserves just enough trailing space for the - /// anchored message to reach the top, then holds it there. Retries across a - /// few frames while the anchor (or viewport) finishes laying out. - void _settle() { - _settleAttempts = 0; - _scheduleSettle(); - } - - void _scheduleSettle() { - if (_settleScheduled) return; - _settleScheduled = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - _settleScheduled = false; - _doSettle(); - }); - } - - void _doSettle() { - if (!mounted || !_scrollController.hasClients) return; - final pos = _scrollController.position; - if (!pos.haveDimensions) { - if (_settleAttempts++ < 12) _scheduleSettle(); - return; - } - if (!_pinned || _anchorId == null) { - _updateJump(); - return; - } - - final box = _anchorKey.currentContext?.findRenderObject(); - if (box is! RenderBox || !box.attached) { - // The just-appended anchor isn't built yet. Nudge toward the end (it's the - // last real item, so this builds it) and retry — NEVER leave it bottom- - // pinned, which is what produced "shows previous messages". - if (_settleAttempts++ < 12) { - _scrollController.jumpTo(pos.maxScrollExtent); - _scheduleSettle(); - } - return; - } - - final viewport = pos.viewportDimension; - // Offset that puts the anchor at the very top — independent of the trailing - // spacer (which is below the anchor), so it's stable across frames. - final reveal = - RenderAbstractViewport.of(box).getOffsetToReveal(box, 0).offset; - // Body height excluding the current spacer, computed from this frame's - // consistent (max, trailing) pair — avoids the off-by-one feedback that made - // the reservation oscillate and the anchor land short of the top. - final body = pos.maxScrollExtent + viewport - _trailingSpace; - final contentBelow = body - reveal; - final desired = (viewport - contentBelow).clamp(0.0, viewport); - - if ((desired - _trailingSpace).abs() > 0.5) { - // Set the reservation and pin on the next frame, once it has laid out — - // don't jump using the stale (pre-relayout) extents. - setState(() => _trailingSpace = desired); - if (_settleAttempts++ < 12) _scheduleSettle(); - return; - } - // Reservation is correct for this layout: pin the anchor to the top. - _scrollController.jumpTo(reveal.clamp(0.0, pos.maxScrollExtent)); - _updateJump(); - } - - /// Shows the jump button whenever there's content below the fold. - void _updateJump() { - if (!_scrollController.hasClients) return; - final pos = _scrollController.position; - final show = pos.maxScrollExtent - pos.pixels > 240; - if (show != _showJump) setState(() => _showJump = show); - } - - // A user-initiated scroll away from the bottom releases the top-pin so we stop - // fighting the user, and frees the reserved space so they can't scroll into - // empty space below the last message. - // - // Touch drags surface as a ScrollStartNotification with dragDetails; mouse - // wheel, trackpad, and keyboard scrolling surface only as a - // UserScrollNotification (programmatic jumpTo never emits one, so this won't - // self-trigger). Releasing on an upward user scroll covers all input types. - bool _onScrollNotification(ScrollNotification n) { - final userDrag = n is ScrollStartNotification && n.dragDetails != null; - final scrolledUp = - n is UserScrollNotification && n.direction == ScrollDirection.forward; - if (_pinned && (userDrag || scrolledUp)) { - _pinned = false; - if (_trailingSpace != 0) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && !_pinned && _trailingSpace != 0) { - setState(() => _trailingSpace = 0); - } - }); - } - } - _updateJump(); - return false; - } - - void _jumpToLatest() { - _pinned = false; - if (_scrollController.hasClients) { - unawaited( - _scrollController.animateTo( - _scrollController.position.maxScrollExtent, - duration: const Duration(milliseconds: 250), - curve: Curves.easeOut, - ), - ); - } - } - - @override - Widget build(BuildContext context) { - return ListenableBuilder( - listenable: widget.controller, - builder: (context, _) { - if (widget.emptyState != null && - widget.controller.messages.isEmpty && - !widget.controller.status.isBusy) { - return widget.emptyState!; - } - final view = AiConversationView( - messages: widget.controller.messages, - scrollController: _scrollController, - textRenderer: widget.textRenderer, - messageBuilder: widget.messageBuilder, - loadingBuilder: widget.loadingBuilder, - maxContentWidth: widget.maxContentWidth, - padding: widget.padding, - // Show the loader only while awaiting the first streamed token. - showLoader: widget.controller.status == ChatStatus.submitted, - trailingSpace: widget.autoScroll ? _trailingSpace : 0, - anchorKey: _anchorKey, - anchorId: _anchorId, - ); - return NotificationListener( - onNotification: _onScrollNotification, - child: Stack( - children: [ - view, - if (_showJump) - PositionedDirectional( - bottom: 8, - start: 0, - end: 0, - child: Center(child: _JumpButton(onTap: _jumpToLatest)), - ), - ], - ), - ); - }, - ); - } -} - -/// A small circular "scroll to latest" affordance, shown when the conversation -/// has scrolled above the bottom. -class _JumpButton extends StatelessWidget { - const _JumpButton({required this.onTap}); - - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final theme = AiThemeExtension.of(context); - return Semantics( - button: true, - label: AiLocalizations.of(context).scrollToLatest, - child: Material( - color: theme.effectiveChipColor, - shape: const CircleBorder(), - elevation: 2, - shadowColor: Colors.black.withValues(alpha: 0.2), - child: InkWell( - customBorder: const CircleBorder(), - onTap: onTap, - child: Padding( - padding: const EdgeInsets.all(8), - child: Icon( - Icons.arrow_downward_rounded, - size: 20, - color: theme.assistantTextColor, - ), - ), - ), - ), - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_chat_view.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_chat_view.dart deleted file mode 100644 index a7045db..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_chat_view.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_ai_client/flutter_ai_client.dart'; -import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; -import 'package:flutter_ai_elements/src/rendering/ai_text_renderer.dart'; -import 'package:flutter_ai_elements/src/widgets/ai_chat.dart'; -import 'package:flutter_ai_elements/src/widgets/ai_prompt_input.dart'; -import 'package:flutter_ai_elements/src/widgets/ai_response.dart' - show MarkdownTextRenderer; - -/// A batteries-included chat surface: the [AiChat] transcript above an -/// [AiPromptInput], laid out and safe-area-aware. Drop it straight into a -/// `Scaffold` body — the fastest path from `pub add` to a working chat: -/// -/// ```dart -/// Scaffold(body: AiChatView(controller: controller)); -/// ``` -/// -/// Everything is overridable; reach for [AiChat] + [AiPromptInput] directly -/// only when you need a custom layout between them. -class AiChatView extends StatelessWidget { - /// Creates a chat surface bound to [controller]. - const AiChatView({ - super.key, - required this.controller, - this.textRenderer = const MarkdownTextRenderer(), - this.emptyState, - this.hintText, - this.maxContentWidth, - this.onPickAttachment, - this.onVoice, - this.onLive, - this.messageBuilder, - }); - - /// The chat controller to drive the transcript and input. - final UseChatController controller; - - /// Renderer for message text. Defaults to [MarkdownTextRenderer]. - final AiTextRenderer textRenderer; - - /// Shown when the conversation is empty. - final Widget? emptyState; - - /// Composer placeholder. Defaults to the localized "Message". - final String? hintText; - - /// On wide screens, centers the transcript at this width (like ChatGPT). - final double? maxContentWidth; - - /// Stages attachments to send with the next message. Hidden when null. - final Future> Function()? onPickAttachment; - - /// Voice-dictation entry point. Hidden when null. - final VoidCallback? onVoice; - - /// Live-voice entry point. Hidden when null. - final VoidCallback? onLive; - - /// Optional override for how each message is built. - final Widget Function(BuildContext context, AiMessage message)? - messageBuilder; - - @override - Widget build(BuildContext context) { - return SafeArea( - child: Column( - children: [ - Expanded( - child: AiChat( - controller: controller, - textRenderer: textRenderer, - emptyState: emptyState, - maxContentWidth: maxContentWidth, - messageBuilder: messageBuilder, - ), - ), - AiPromptInput( - controller: controller, - hintText: hintText ?? AiLocalizations.of(context).messageHint, - onPickAttachment: onPickAttachment, - onVoice: onVoice, - onLive: onLive, - ), - ], - ), - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_code_block.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_code_block.dart deleted file mode 100644 index 9b59c03..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_code_block.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; - -/// Turns [code] in [language] into styled spans for syntax highlighting, using -/// [base] as the baseline text style (family/size/default color). -/// -/// Return `null` to fall back to unhighlighted monospace. This package ships no -/// grammar engine to stay dependency-free; supply one from the app (e.g. wrap -/// the `highlight` package) and pass it to [AiCodeBlock] / `AiResponse`. -typedef CodeHighlighter = List? Function( - String code, - String? language, - TextStyle base, -); - -/// A monospace code block with a header showing the language and a copy button. -/// -/// A useful building block for a custom `AiTextRenderer` that wants to present -/// fenced code distinctly from prose. Pass a [highlighter] to colorize the -/// source; without one it renders plain monospace. -class AiCodeBlock extends StatelessWidget { - /// Creates a code block for [code]. - const AiCodeBlock({ - super.key, - required this.code, - this.language, - this.highlighter, - }); - - /// The source code to display. - final String code; - - /// An optional language label (for example `dart`). - final String? language; - - /// Optional syntax highlighter. When `null`, code renders as plain monospace. - final CodeHighlighter? highlighter; - - @override - Widget build(BuildContext context) { - final theme = AiThemeExtension.of(context); - final l = AiLocalizations.of(context); - final background = theme.codeBackgroundColor; - final foreground = theme.codeForegroundColor; - return Container( - decoration: BoxDecoration( - color: background, - borderRadius: BorderRadius.circular(12), - ), - clipBehavior: Clip.antiAlias, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - const SizedBox(width: 12), - Expanded( - child: Text( - language ?? 'code', - style: const TextStyle( - color: Color(0xFF9CA3AF), - fontSize: 12, - ), - ), - ), - IconButton( - icon: - const Icon(Icons.copy, size: 16, color: Color(0xFF9CA3AF)), - tooltip: l.copy, - onPressed: () => unawaited( - Clipboard.setData(ClipboardData(text: code)), - ), - ), - ], - ), - Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), - child: SizedBox( - width: double.infinity, - child: _buildCode(theme.codeStyle.copyWith(color: foreground)), - ), - ), - ], - ), - ); - } - - Widget _buildCode(TextStyle base) { - final spans = highlighter?.call(code, language, base); - if (spans == null) return SelectableText(code, style: base); - return SelectableText.rich(TextSpan(style: base, children: spans)); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_composer.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_composer.dart deleted file mode 100644 index 68305dc..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_composer.dart +++ /dev/null @@ -1,518 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_ai_core/flutter_ai_core.dart'; -import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; - -/// A modern message composer: a rounded input with a leading attach (`+`) -/// button beside the field, and a trailing pair — a secondary mic and a main -/// button that is **Live** (voice) while the field is empty and swaps to -/// **Send** once you type (hiding the mic), or **Stop** while streaming. -/// -/// The model selector is intentionally *not* here — modern apps put it in the -/// app bar. Everything is opt-in via the callbacks. -class AiComposer extends StatefulWidget { - /// Creates a composer. - const AiComposer({ - super.key, - required this.onSend, - this.onStop, - this.isBusy = false, - this.hintText = 'Message', - this.controller, - this.enabled = true, - this.onAttach, - this.onVoice, - this.onLive, - this.attachments = const [], - this.onRemoveAttachment, - }); - - /// Called with the trimmed text when the user submits. - final ValueChanged onSend; - - /// Called when the user taps Stop while [isBusy]. - final VoidCallback? onStop; - - /// Whether a response is in flight; the main button shows Stop. - final bool isBusy; - - /// Placeholder text. - final String hintText; - - /// Optional external text controller. - final TextEditingController? controller; - - /// Whether the input accepts text. - final bool enabled; - - /// Shows a leading attach (`+`) button when non-null. - final VoidCallback? onAttach; - - /// Shows a secondary mic button (voice dictation) while the field is empty. - final VoidCallback? onVoice; - - /// When non-null, the main button is a **Live** voice button while the field - /// is empty (it becomes Send once the user types). - final VoidCallback? onLive; - - /// Staged attachments shown as removable previews above the field. - final List attachments; - - /// Removes a staged attachment. If `null`, previews aren't removable. - final void Function(FilePart attachment)? onRemoveAttachment; - - @override - State createState() => _AiComposerState(); -} - -class _AiComposerState extends State { - TextEditingController? _internalController; - - // Keeps the field (and its focus) alive when the layout reparents from the - // single-row form to the stacked, full-width form. - final GlobalKey _fieldKey = GlobalKey(); - - TextEditingController get _controller => - widget.controller ?? (_internalController ??= TextEditingController()); - - @override - void didUpdateWidget(AiComposer oldWidget) { - super.didUpdateWidget(oldWidget); - // If a parent starts supplying its own controller, drop the internal one we - // lazily created so it doesn't leak (and we stop driving a stale field). - if (widget.controller != null && _internalController != null) { - _internalController!.dispose(); - _internalController = null; - } - } - - @override - void dispose() { - _internalController?.dispose(); - super.dispose(); - } - - void _handleSend() { - final text = _controller.text.trim(); - if (text.isEmpty && widget.attachments.isEmpty) return; - if (AiThemeExtension.of(context).enableHaptics) { - unawaited(HapticFeedback.lightImpact()); - } - widget.onSend(text); - _controller.clear(); - } - - void _handleStop() { - if (AiThemeExtension.of(context).enableHaptics) { - unawaited(HapticFeedback.mediumImpact()); - } - widget.onStop?.call(); - } - - // Whether [text] needs more than one line at the *single-row* field width. - // Measured against that fixed width (not the current layout's) so the decision - // doesn't flip-flop once the buttons drop below. - bool _isMultiline( - String text, - double innerWidth, - bool hasText, - AiThemeExtension theme, - ) { - if (text.isEmpty) return false; - if (text.contains('\n')) return true; - const iconBox = 40.0; // _ToolIcon tap target - const mainBtn = 38.0; // main circular button - final attachW = widget.onAttach != null ? iconBox : 0.0; - final micW = !hasText && widget.onVoice != null ? iconBox : 0.0; - final trailingW = micW + 2 + mainBtn; - final fieldLeftPad = widget.onAttach == null ? 10.0 : 2.0; - final textWidth = innerWidth - attachW - trailingW - fieldLeftPad - 6; - if (textWidth <= 0) return false; - final painter = TextPainter( - text: TextSpan( - text: text, - style: theme.textStyle.copyWith(color: theme.assistantTextColor), - ), - textDirection: Directionality.of(context), - textScaler: MediaQuery.textScalerOf(context), - maxLines: 1, - )..layout(maxWidth: textWidth); - return painter.didExceedMaxLines; - } - - @override - Widget build(BuildContext context) { - final theme = AiThemeExtension.of(context); - final l = AiLocalizations.of(context); - final subdued = theme.assistantTextColor.withValues(alpha: 0.6); - - return Padding( - padding: theme.composerPadding, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (widget.attachments.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (final file in widget.attachments) - Padding( - padding: const EdgeInsetsDirectional.only(end: 8), - child: _AttachmentPreview( - file: file, - theme: theme, - onRemove: widget.onRemoveAttachment == null - ? null - : () => widget.onRemoveAttachment!(file), - ), - ), - ], - ), - ), - ), - Container( - decoration: BoxDecoration( - color: theme.assistantBubbleColor, - borderRadius: BorderRadius.circular(26), - border: Border.all(color: theme.borderColor), - ), - padding: const EdgeInsets.fromLTRB(6, 4, 6, 4), - child: ValueListenableBuilder( - valueListenable: _controller, - builder: (context, value, _) { - final hasText = value.text.trim().isNotEmpty; - final field = TextField( - key: _fieldKey, - controller: _controller, - enabled: widget.enabled, - minLines: 1, - maxLines: 6, - cursorColor: theme.accentColor, - style: theme.textStyle.copyWith( - color: theme.assistantTextColor, - ), - textInputAction: TextInputAction.send, - onSubmitted: widget.enabled ? (_) => _handleSend() : null, - decoration: InputDecoration( - hintText: widget.hintText, - hintStyle: theme.textStyle.copyWith( - color: theme.assistantTextColor.withValues(alpha: 0.45), - ), - border: InputBorder.none, - isDense: true, - contentPadding: const EdgeInsets.symmetric(vertical: 10), - ), - ); - final attach = widget.onAttach == null - ? null - : _ToolIcon( - icon: Icons.add, - color: subdued, - tooltip: l.attach, - onTap: widget.enabled ? widget.onAttach : null, - ); - final trailing = _trailing(theme, hasText, subdued, l); - - return LayoutBuilder( - builder: (context, constraints) { - // When the text needs more than one line, give it the full - // width and drop the buttons to a row beneath it. - if (_isMultiline( - value.text, - constraints.maxWidth, - hasText, - theme, - )) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(10, 0, 8, 2), - child: field, - ), - Row( - children: [ - if (attach != null) attach, - const Spacer(), - trailing, - ], - ), - ], - ); - } - // Single-line inline layout: vertically center the icons - // with the field (multi-line goes to the stacked layout). - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (attach != null) attach, - Expanded( - child: Padding( - padding: EdgeInsetsDirectional.only( - start: widget.onAttach == null ? 10 : 2, - ), - child: field, - ), - ), - trailing, - ], - ); - }, - ); - }, - ), - ), - ], - ), - ); - } - - Widget _trailing( - AiThemeExtension theme, - bool hasText, - Color subdued, - AiLocalizations l, - ) { - final showStop = widget.isBusy && widget.onStop != null; - // Staged attachments are sendable even with no text (_handleSend allows an - // attachment-only send), so the main button must be Send — not Live — then. - final hasSendable = hasText || widget.attachments.isNotEmpty; - final liveWhenEmpty = !hasSendable && !showStop && widget.onLive != null; - - final IconData mainIcon; - final VoidCallback? mainTap; - if (showStop) { - mainIcon = Icons.stop_rounded; - mainTap = _handleStop; - } else if (hasSendable) { - mainIcon = Icons.arrow_upward_rounded; - mainTap = _handleSend; - } else if (liveWhenEmpty) { - mainIcon = Icons.graphic_eq; - mainTap = widget.onLive; - } else { - mainIcon = Icons.arrow_upward_rounded; - mainTap = _handleSend; - } - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Secondary mic, only while empty (and not streaming). - if (!hasText && !showStop && widget.onVoice != null) - _ToolIcon( - icon: Icons.mic_none_rounded, - color: subdued, - tooltip: l.dictate, - onTap: widget.enabled ? widget.onVoice : null, - ), - const SizedBox(width: 2), - _MainButton( - // Stop reads as a distinct (error-toned) affordance, not the same - // accent as Send/Live. - color: showStop ? theme.errorColor : theme.accentColor, - iconColor: theme.onAccentColor, - icon: mainIcon, - tooltip: showStop - ? l.stop - : hasSendable - ? l.send - : liveWhenEmpty - ? l.live - : l.send, - onPressed: widget.enabled ? mainTap : null, - ), - ], - ); - } -} - -class _ToolIcon extends StatelessWidget { - const _ToolIcon({ - required this.icon, - required this.color, - required this.tooltip, - required this.onTap, - }); - - final IconData icon; - final Color color; - final String tooltip; - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - return Semantics( - button: true, - label: tooltip, - child: Tooltip( - message: tooltip, - // InkResponse gives focus traversal, keyboard (Enter/Space), hover, and - // a ripple — none of which a bare GestureDetector provides. - child: InkResponse( - onTap: onTap, - radius: 22, - customBorder: const CircleBorder(), - child: Padding( - padding: const EdgeInsets.all(8), - child: Icon(icon, size: 24, color: color), - ), - ), - ), - ); - } -} - -class _AttachmentPreview extends StatelessWidget { - const _AttachmentPreview({ - required this.file, - required this.theme, - this.onRemove, - }); - - final FilePart file; - final AiThemeExtension theme; - final VoidCallback? onRemove; - - @override - Widget build(BuildContext context) { - final isImage = file.mediaType.startsWith('image/'); - Widget content; - if (isImage && (file.bytes != null || file.url != null)) { - content = ClipRRect( - borderRadius: BorderRadius.circular(10), - child: SizedBox( - width: 52, - height: 52, - child: file.bytes != null - ? Image.memory(file.bytes!, fit: BoxFit.cover) - : Image.network(file.url!.toString(), fit: BoxFit.cover), - ), - ); - } else { - content = Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all(color: theme.borderColor), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.insert_drive_file_outlined, size: 16), - const SizedBox(width: 6), - Text( - file.name ?? file.mediaType, - style: theme.textStyle.copyWith(fontSize: 13), - ), - ], - ), - ); - } - - if (onRemove == null) return content; - return Stack( - clipBehavior: Clip.none, - children: [ - content, - PositionedDirectional( - top: -6, - end: -6, - child: GestureDetector( - onTap: onRemove, - child: Container( - decoration: BoxDecoration( - color: theme.accentColor, - shape: BoxShape.circle, - border: Border.all(color: theme.onAccentColor, width: 1.5), - ), - padding: const EdgeInsets.all(2), - child: Icon(Icons.close, size: 12, color: theme.onAccentColor), - ), - ), - ), - ], - ); - } -} - -/// The circular main action button with a press scale instead of a ripple. -class _MainButton extends StatefulWidget { - const _MainButton({ - required this.color, - required this.iconColor, - required this.icon, - required this.tooltip, - required this.onPressed, - }); - - final Color color; - final Color iconColor; - final IconData icon; - final String tooltip; - final VoidCallback? onPressed; - - @override - State<_MainButton> createState() => _MainButtonState(); -} - -class _MainButtonState extends State<_MainButton> { - bool _pressed = false; - - @override - Widget build(BuildContext context) { - final enabled = widget.onPressed != null; - return Semantics( - button: true, - label: widget.tooltip, - child: Tooltip( - message: widget.tooltip, - child: AnimatedScale( - scale: _pressed ? 0.9 : 1, - duration: const Duration(milliseconds: 100), - child: Material( - color: enabled ? widget.color : widget.color.withValues(alpha: 0.4), - shape: const CircleBorder(), - clipBehavior: Clip.antiAlias, - child: InkWell( - customBorder: const CircleBorder(), - onTap: widget.onPressed, - onTapDown: - enabled ? (_) => setState(() => _pressed = true) : null, - onTapCancel: - enabled ? () => setState(() => _pressed = false) : null, - onHighlightChanged: - enabled ? (h) => setState(() => _pressed = h) : null, - child: SizedBox( - width: 38, - height: 38, - // Morph between Send / Stop / Live rather than hard-swapping. - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - transitionBuilder: (child, anim) => ScaleTransition( - scale: anim, - child: FadeTransition(opacity: anim, child: child), - ), - child: Icon( - widget.icon, - key: ValueKey(widget.icon), - color: widget.iconColor, - size: 20, - ), - ), - ), - ), - ), - ), - ), - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_confirmation.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_confirmation.dart deleted file mode 100644 index aca8a65..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_confirmation.dart +++ /dev/null @@ -1,198 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; -import 'package:flutter_ai_elements/src/widgets/ai_haptics.dart'; - -/// The visual weight of an [AiConfirmation], which restyles its confirm button. -enum AiConfirmationTone { - /// The default look — a neutral accent-colored confirm button. - neutral, - - /// A cautionary action — the confirm button uses the theme's warning color. - caution, - - /// A destructive action — the confirm button uses the theme's error color. - danger, -} - -/// An approve/deny card for actions an agent wants to take (running a tool, -/// sending an email, making a purchase) — the human-in-the-loop gate. -class AiConfirmation extends StatelessWidget { - /// Creates a confirmation card. - const AiConfirmation({ - super.key, - required this.title, - this.description, - this.confirmLabel, - this.denyLabel, - this.onConfirm, - this.onDeny, - this.icon = Icons.shield_outlined, - this.tone = AiConfirmationTone.neutral, - }); - - /// The action being confirmed. - final String title; - - /// Optional supporting detail. - final String? description; - - /// Label for the confirm button. Defaults to the localized "Allow". - final String? confirmLabel; - - /// Label for the deny button. Defaults to the localized "Deny". - final String? denyLabel; - - /// Called when the user approves. - final VoidCallback? onConfirm; - - /// Called when the user denies. - final VoidCallback? onDeny; - - /// Leading icon. - final IconData icon; - - /// The action's weight, which restyles the confirm button. Defaults to - /// [AiConfirmationTone.neutral] (the original accent look). - final AiConfirmationTone tone; - - @override - Widget build(BuildContext context) { - final theme = AiThemeExtension.of(context); - final l = AiLocalizations.of(context); - final color = DefaultTextStyle.of(context).style.color; - // The confirm button's fill follows the tone; neutral keeps the accent. - final confirmColor = switch (tone) { - AiConfirmationTone.neutral => theme.accentColor, - AiConfirmationTone.caution => theme.warningColor, - AiConfirmationTone.danger => theme.errorColor, - }; - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - border: Border.all(color: theme.borderColor), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Icon(icon, size: 18, color: color), - const SizedBox(width: 8), - Expanded( - child: Text( - title, - style: theme.textStyle.copyWith( - color: color, - fontSize: 15, - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ), - if (description != null) ...[ - const SizedBox(height: 6), - Text( - description!, - style: theme.textStyle.copyWith( - color: color?.withValues(alpha: 0.65), - fontSize: 14, - ), - ), - ], - const SizedBox(height: 14), - Row( - children: [ - Expanded( - child: _Button( - label: denyLabel ?? l.deny, - onTap: onDeny == null - ? null - : () { - aiLightHaptic(theme); - onDeny!(); - }, - filled: false, - fillColor: confirmColor, - theme: theme, - ), - ), - const SizedBox(width: 10), - Expanded( - child: _Button( - label: confirmLabel ?? l.allow, - onTap: onConfirm == null - ? null - : () { - aiLightHaptic(theme); - onConfirm!(); - }, - filled: true, - fillColor: confirmColor, - theme: theme, - ), - ), - ], - ), - ], - ), - ); - } -} - -class _Button extends StatelessWidget { - const _Button({ - required this.label, - required this.onTap, - required this.filled, - required this.fillColor, - required this.theme, - }); - - final String label; - final VoidCallback? onTap; - final bool filled; - final Color fillColor; - final AiThemeExtension theme; - - @override - Widget build(BuildContext context) { - final radius = BorderRadius.circular(12); - return Semantics( - button: true, - enabled: onTap != null, - label: label, - // A confirmation gate must be keyboard- and focus-reachable (desktop/web) - // — Material + InkWell give focus traversal, Enter/Space, hover, ripple. - child: Material( - color: filled ? fillColor : Colors.transparent, - shape: RoundedRectangleBorder( - borderRadius: radius, - side: filled ? BorderSide.none : BorderSide(color: theme.borderColor), - ), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - borderRadius: radius, - child: Container( - height: 40, - alignment: Alignment.center, - child: Text( - label, - style: theme.textStyle.copyWith( - fontSize: 14, - fontWeight: FontWeight.w600, - color: filled - ? theme.onAccentColor - : DefaultTextStyle.of(context).style.color, - ), - ), - ), - ), - ), - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_context_meter.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_context_meter.dart deleted file mode 100644 index af21eef..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_context_meter.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; - -/// A compact context-window usage meter: a label, a `used / total` token -/// readout, and a thin progress bar that turns amber/red as it fills. -class AiContextMeter extends StatelessWidget { - /// Creates a usage meter. - const AiContextMeter({ - super.key, - required this.usedTokens, - required this.totalTokens, - this.label = 'Context', - }); - - /// Tokens used so far. - final int usedTokens; - - /// The context-window size. - final int totalTokens; - - /// Leading label. - final String label; - - double get _fraction => - totalTokens <= 0 ? 0 : (usedTokens / totalTokens).clamp(0, 1); - - @override - Widget build(BuildContext context) { - final theme = AiThemeExtension.of(context); - final color = DefaultTextStyle.of(context).style.color; - final fraction = _fraction; - final barColor = fraction > 0.9 - ? theme.errorColor - : fraction > 0.7 - ? theme.warningColor - : theme.accentColor; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Text( - label, - style: theme.textStyle.copyWith( - fontSize: 12, - fontWeight: FontWeight.w600, - color: color?.withValues(alpha: 0.6), - ), - ), - const Spacer(), - Text( - '${_fmt(usedTokens)} / ${_fmt(totalTokens)}', - style: theme.codeStyle.copyWith( - fontSize: 12, - color: color?.withValues(alpha: 0.6), - ), - ), - ], - ), - const SizedBox(height: 6), - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Stack( - children: [ - Container(height: 6, color: theme.borderColor), - FractionallySizedBox( - widthFactor: fraction, - child: Container(height: 6, color: barColor), - ), - ], - ), - ), - ], - ); - } - - static String _fmt(int n) { - if (n >= 1000000) return '${(n / 1000000).toStringAsFixed(1)}M'; - if (n >= 1000) return '${(n / 1000).toStringAsFixed(1)}k'; - return '$n'; - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_conversation_list.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_conversation_list.dart deleted file mode 100644 index 9fa383c..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_conversation_list.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_ai_client/flutter_ai_client.dart'; -import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; - -/// A ChatGPT-style conversation list / sidebar: a "New chat" action above a -/// scrollable list of [ChatThread]s, with select and (optional) delete. -/// -/// Presentational — drive it from a [ChatThreadStore]: pass [threads], -/// [selectedId], and wire [onSelect] / [onNew] / [onDelete] to your store and -/// controller. -class AiConversationList extends StatelessWidget { - /// Creates a conversation list. - const AiConversationList({ - super.key, - required this.threads, - this.selectedId, - this.onSelect, - this.onNew, - this.onDelete, - this.newChatLabel, - this.header, - this.footer, - this.trailingBuilder, - }); - - /// The threads to show, in display order (typically newest first). - final List threads; - - /// The id of the currently open thread, highlighted in the list. - final String? selectedId; - - /// Called when a thread is tapped. - final void Function(ChatThread thread)? onSelect; - - /// Called when the "New chat" action is tapped. Hidden when null. - final VoidCallback? onNew; - - /// Called when a thread's delete affordance is tapped. Hidden when null. - final void Function(ChatThread thread)? onDelete; - - /// Label for the new-chat action. Defaults to the localized "New chat". - final String? newChatLabel; - - /// Optional content pinned above the new-chat action and thread list — e.g. a - /// brand wordmark, a close button, or fixed nav entries (Images/Library/…). - final Widget? header; - - /// Optional content pinned below the thread list — e.g. an account footer - /// (avatar · name · settings). - final Widget? footer; - - /// Per-thread trailing widget (e.g. a pin glyph + overflow menu). When - /// provided it replaces the default delete affordance, so wire delete/pin - /// yourself. Return null for no trailing on a given thread. - final Widget? Function(BuildContext context, ChatThread thread)? - trailingBuilder; - - @override - Widget build(BuildContext context) { - final theme = AiThemeExtension.of(context); - final l = AiLocalizations.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (header != null) header!, - if (onNew != null) - Padding( - padding: const EdgeInsets.all(8), - child: OutlinedButton.icon( - onPressed: onNew, - icon: const Icon(Icons.add, size: 18), - label: Align( - alignment: Alignment.centerLeft, - child: Text(newChatLabel ?? l.newChat), - ), - ), - ), - Expanded( - child: ListView.builder( - itemCount: threads.length, - itemBuilder: (context, i) { - final thread = threads[i]; - final selected = thread.id == selectedId; - return Material( - color: selected ? theme.effectiveChipColor : Colors.transparent, - borderRadius: BorderRadius.circular(10), - clipBehavior: Clip.antiAlias, - child: ListTile( - dense: true, - selected: selected, - title: Text( - thread.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - onTap: onSelect == null ? null : () => onSelect!(thread), - trailing: trailingBuilder != null - ? trailingBuilder!(context, thread) - : onDelete == null - ? null - : IconButton( - icon: const Icon(Icons.delete_outline, size: 18), - tooltip: l.delete, - visualDensity: VisualDensity.compact, - onPressed: () => onDelete!(thread), - ), - ), - ); - }, - ), - ), - if (footer != null) footer!, - ], - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_conversation_view.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_conversation_view.dart deleted file mode 100644 index 3514a0e..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_conversation_view.dart +++ /dev/null @@ -1,165 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_ai_core/flutter_ai_core.dart'; -import 'package:flutter_ai_elements/src/rendering/ai_text_renderer.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; -import 'package:flutter_ai_elements/src/widgets/ai_loader.dart'; -import 'package:flutter_ai_elements/src/widgets/ai_message_bubble.dart'; -import 'package:flutter_ai_elements/src/widgets/ai_response.dart'; - -/// A scrolling list of message bubbles. -/// -/// Presentational: it renders the [messages] it is given and reports nothing -/// back. The controller-bound `AiConversation` wraps it with live updates and -/// auto-scroll. -class AiConversationView extends StatefulWidget { - /// Creates a conversation view. - const AiConversationView({ - super.key, - required this.messages, - this.scrollController, - this.textRenderer = const MarkdownTextRenderer(), - this.messageBuilder, - this.showLoader = false, - this.loadingBuilder, - this.padding = const EdgeInsets.all(16), - this.maxContentWidth, - this.trailingSpace = 0, - this.anchorKey, - this.anchorId, - }); - - /// The messages to display, oldest first. - final List messages; - - /// Optional scroll controller, supplied by a parent that manages scrolling. - final ScrollController? scrollController; - - /// Renderer for message text. Defaults to [MarkdownTextRenderer]. - final AiTextRenderer textRenderer; - - /// Optional override for how each message is built. - final Widget Function(BuildContext context, AiMessage message)? - messageBuilder; - - /// Whether to append a thinking indicator after the last message. - final bool showLoader; - - /// Builds the thinking indicator shown when [showLoader] is true. Defaults to - /// an `AiLoader`; pass one returning `AiShimmer` for a skeleton instead. - final WidgetBuilder? loadingBuilder; - - /// Padding around the list. - final EdgeInsets padding; - - /// On wide screens, centers the conversation at this width (like ChatGPT on - /// tablet/desktop). When `null`, falls back to - /// [AiThemeExtension.maxContentWidth]. Pass [double.infinity] for full-width. - final double? maxContentWidth; - - /// Extra empty space reserved after the last item. Used by `AiChat` to let the - /// newest turn scroll to the top of the viewport (ChatGPT-style anchoring). - final double trailingSpace; - - /// When set, the message whose [AiMessage.id] equals [anchorId] is wrapped in - /// a [KeyedSubtree] keyed by this, so a parent can scroll it into view. - final GlobalKey? anchorKey; - - /// The id of the message to attach [anchorKey] to. - final Object? anchorId; - - @override - State createState() => _AiConversationViewState(); -} - -class _AiConversationViewState extends State { - // Memoize the built bubble per message identity. While streaming, only the - // changed message gets a new AiMessage instance, so unchanged bubbles return - // the *same* widget instance and Flutter skips their rebuild entirely. - final Map _cachedMessage = {}; - final Map _cachedBubble = {}; - - void _clearCache() { - _cachedMessage.clear(); - _cachedBubble.clear(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _clearCache(); // theme/inherited changed — bubbles may need restyling - } - - @override - void didUpdateWidget(AiConversationView oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.textRenderer != widget.textRenderer || - oldWidget.messageBuilder != widget.messageBuilder) { - _clearCache(); - } - } - - Widget _bubbleFor(BuildContext context, AiMessage message) { - // Custom builders aren't memoized (they may capture changing state). - if (widget.messageBuilder != null) { - return widget.messageBuilder!(context, message); - } - if (identical(_cachedMessage[message.id], message)) { - return _cachedBubble[message.id]!; - } - final bubble = AiMessageBubble( - key: ValueKey(message.id), - message: message, - textRenderer: widget.textRenderer, - ); - _cachedMessage[message.id] = message; - _cachedBubble[message.id] = bubble; - return bubble; - } - - @override - Widget build(BuildContext context) { - final messages = widget.messages; - final showLoader = widget.showLoader; - final hasSpacer = widget.trailingSpace > 0; - final loaderIndex = showLoader ? messages.length : -1; - final spacerIndex = hasSpacer ? messages.length + (showLoader ? 1 : 0) : -1; - final itemCount = - messages.length + (showLoader ? 1 : 0) + (hasSpacer ? 1 : 0); - final list = ListView.builder( - controller: widget.scrollController, - padding: widget.padding, - itemCount: itemCount, - itemBuilder: (context, index) { - if (index == spacerIndex) { - return SizedBox(height: widget.trailingSpace); - } - if (index == loaderIndex) { - return Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: widget.loadingBuilder?.call(context) ?? const AiLoader(), - ), - ); - } - final message = messages[index]; - final bubble = _bubbleFor(context, message); - if (widget.anchorKey != null && widget.anchorId == message.id) { - return KeyedSubtree(key: widget.anchorKey, child: bubble); - } - return bubble; - }, - ); - // A width passed to the widget wins; otherwise fall back to the theme's - // reading-width default. `double.infinity` means full-width (no column). - final width = - widget.maxContentWidth ?? AiThemeExtension.of(context).maxContentWidth; - if (!width.isFinite) return list; - return Center( - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: width), - child: list, - ), - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_empty_state.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_empty_state.dart deleted file mode 100644 index 0f749cc..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_empty_state.dart +++ /dev/null @@ -1,154 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; -import 'package:flutter_ai_elements/src/widgets/ai_haptics.dart'; - -/// A centered placeholder shown when a conversation has no messages yet. -/// -/// Beyond a title/subtitle it can show a brand [glyph] (or a default [icon]) -/// and a set of tappable [suggestions] that seed the first turn via -/// [onSuggestionTap] — the conversation-starter pattern from modern assistants. -/// Fully themed via [AiThemeExtension]. -class AiEmptyState extends StatelessWidget { - /// Creates an empty state. - const AiEmptyState({ - super.key, - this.title = 'Start the conversation', - this.subtitle, - this.icon = Icons.chat_bubble_outline, - this.glyph, - this.suggestions = const [], - this.onSuggestionTap, - this.titleStyle, - this.subtitleStyle, - this.background, - }); - - /// The primary headline. - final String title; - - /// Optional supporting line beneath the title. - final String? subtitle; - - /// The icon shown above the title when [glyph] is null. - final IconData icon; - - /// An optional brand widget shown in place of [icon] (e.g. a logo). - final Widget? glyph; - - /// Conversation-starter prompts shown as tappable chips. Empty hides them. - final List suggestions; - - /// Called with the chosen suggestion's text. Required for the chips to be - /// interactive; without it the chips render but don't respond. - final ValueChanged? onSuggestionTap; - - /// Overrides the title style, merged over the themed default. Use a shader - /// `foreground` here for a gradient "hero" greeting. - final TextStyle? titleStyle; - - /// Overrides the subtitle style, merged over the themed default. - final TextStyle? subtitleStyle; - - /// Optional widget painted behind the content (e.g. an ambient gradient), for - /// a branded hero empty state. Sized to fill the available space. - final Widget? background; - - @override - Widget build(BuildContext context) { - final theme = AiThemeExtension.of(context); - final color = DefaultTextStyle.of(context).style.color; - final muted = color?.withValues(alpha: 0.6); - final content = Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - glyph ?? Icon(icon, size: 48, color: muted), - const SizedBox(height: 12), - Text( - title, - textAlign: TextAlign.center, - style: theme.textStyle - .copyWith( - color: color, - fontSize: 18, - fontWeight: FontWeight.w600, - ) - .merge(titleStyle), - ), - if (subtitle != null) ...[ - const SizedBox(height: 4), - Text( - subtitle!, - textAlign: TextAlign.center, - style: - theme.textStyle.copyWith(color: muted).merge(subtitleStyle), - ), - ], - if (suggestions.isNotEmpty) ...[ - const SizedBox(height: 20), - Wrap( - alignment: WrapAlignment.center, - spacing: 8, - runSpacing: 8, - children: [ - for (final s in suggestions) - _SuggestionChip( - label: s, - theme: theme, - onTap: onSuggestionTap == null - ? null - : () { - aiLightHaptic(theme); - onSuggestionTap!(s); - }, - ), - ], - ), - ], - ], - ), - ), - ); - if (background == null) return content; - return Stack( - fit: StackFit.expand, - children: [background!, content], - ); - } -} - -class _SuggestionChip extends StatelessWidget { - const _SuggestionChip({ - required this.label, - required this.theme, - required this.onTap, - }); - - final String label; - final AiThemeExtension theme; - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - return Material( - color: theme.effectiveChipColor, - borderRadius: BorderRadius.circular(20), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), - child: Text( - label, - style: theme.textStyle.copyWith( - color: theme.assistantTextColor, - fontSize: 14, - ), - ), - ), - ), - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_error_banner.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_error_banner.dart deleted file mode 100644 index c8daa98..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_error_banner.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; - -/// An inline banner surfacing an error, with optional retry and dismiss. -/// -/// Pair it with a controller's `error`/`status` to show failures without a -/// modal interruption. -class AiErrorBanner extends StatelessWidget { - /// Creates an error banner displaying [message]. - const AiErrorBanner({ - super.key, - required this.message, - this.onRetry, - this.onDismiss, - }); - - /// The error text to display. - final String message; - - /// Called when the user taps Retry. Hidden if `null`. - final VoidCallback? onRetry; - - /// Called when the user dismisses the banner. Hidden if `null`. - final VoidCallback? onDismiss; - - @override - Widget build(BuildContext context) { - final errorColor = AiThemeExtension.of(context).errorColor; - final l = AiLocalizations.of(context); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: errorColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: errorColor.withValues(alpha: 0.4)), - ), - child: Row( - children: [ - Icon(Icons.error_outline, size: 18, color: errorColor), - const SizedBox(width: 8), - Expanded( - child: Text( - message, - style: TextStyle(color: errorColor), - ), - ), - if (onRetry != null) - TextButton(onPressed: onRetry, child: Text(l.retry)), - if (onDismiss != null) - IconButton( - icon: const Icon(Icons.close, size: 18), - color: errorColor, - onPressed: onDismiss, - tooltip: l.dismiss, - ), - ], - ), - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_haptics.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_haptics.dart deleted file mode 100644 index 474b4d6..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_haptics.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; - -/// Fires a light haptic tap for a key interaction (turn completion, a -/// confirmation choice, a chip tap), gated on [AiThemeExtension.enableHaptics]. -/// -/// No-op on the web and on desktop platforms, where the `HapticFeedback` -/// channel isn't backed by a tactile actuator — guarded by -/// [defaultTargetPlatform] so a host doesn't get spurious platform-channel -/// chatter. -void aiLightHaptic(AiThemeExtension theme) { - if (!theme.enableHaptics || kIsWeb) return; - // An allowlist `if` rather than an exhaustive switch: the OHOS Flutter fork - // adds TargetPlatform.ohos, so an exhaustive switch can't compile on both - // it and upstream Flutter at once. - final platform = defaultTargetPlatform; - if (platform == TargetPlatform.iOS || platform == TargetPlatform.android) { - unawaited(HapticFeedback.lightImpact()); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_image.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_image.dart deleted file mode 100644 index 23827e0..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_image.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; - -import 'package:flutter/material.dart'; -import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; - -/// Displays an AI-generated (or attached) image with rounded corners, a loading -/// placeholder, an error fallback, and tap-to-zoom into a full-screen, -/// pinch-zoomable viewer. -/// -/// Provide inline [bytes] or a remote [url]. -class AiImage extends StatelessWidget { - /// Creates an image from inline [bytes] or a remote [url]. - const AiImage({ - super.key, - this.bytes, - this.url, - this.aspectRatio = 1, - this.enableZoom = true, - }) : assert(bytes != null || url != null, 'Provide bytes or url'); - - /// Inline image bytes. - final Uint8List? bytes; - - /// Remote image location. - final Uri? url; - - /// Aspect ratio of the inline preview. - final double aspectRatio; - - /// Whether tapping opens a full-screen zoomable viewer. - final bool enableZoom; - - @override - Widget build(BuildContext context) { - final theme = AiThemeExtension.of(context); - final image = _image(fit: BoxFit.cover); - - return Semantics( - image: true, - button: enableZoom, - label: enableZoom ? 'Image, double tap to zoom' : 'Image', - child: GestureDetector( - onTap: enableZoom ? () => _openViewer(context) : null, - child: ClipRRect( - borderRadius: BorderRadius.circular(14), - child: AspectRatio( - aspectRatio: aspectRatio, - child: DecoratedBox( - decoration: BoxDecoration(color: theme.assistantBubbleColor), - child: image, - ), - ), - ), - ), - ); - } - - Image _image({required BoxFit fit}) { - if (bytes != null) { - return Image.memory(bytes!, fit: fit, errorBuilder: _error); - } - return Image.network( - url!.toString(), - fit: fit, - errorBuilder: _error, - loadingBuilder: (context, child, progress) { - if (progress == null) return child; - return const Center( - child: SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ); - }, - ); - } - - Widget _error(BuildContext context, Object error, StackTrace? stack) => - const Center(child: Icon(Icons.broken_image_outlined, size: 32)); - - void _openViewer(BuildContext context) { - unawaited( - Navigator.of(context).push( - PageRouteBuilder( - opaque: false, - barrierColor: Colors.black, - pageBuilder: (context, _, __) => - _FullScreenImage(child: _image(fit: BoxFit.contain)), - ), - ), - ); - } -} - -class _FullScreenImage extends StatelessWidget { - const _FullScreenImage({required this.child}); - - final Widget child; - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.transparent, - body: Stack( - children: [ - Positioned.fill( - child: InteractiveViewer( - minScale: 1, - maxScale: 5, - child: Center(child: child), - ), - ), - Positioned( - top: MediaQuery.paddingOf(context).top + 8, - right: 8, - child: IconButton( - icon: const Icon(Icons.close, color: Colors.white), - tooltip: AiLocalizations.of(context).close, - onPressed: () => Navigator.of(context).pop(), - ), - ), - ], - ), - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_inline_citation.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_inline_citation.dart deleted file mode 100644 index d5f7384..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_inline_citation.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; - -/// A small numbered citation badge (e.g. `1`) shown inline with text or after a -/// claim, tappable to open or reveal the source. -/// -/// Compose it into rich text with a `WidgetSpan`, or place it in a row of -/// citations. -class AiInlineCitation extends StatelessWidget { - /// Creates a citation badge for [number]. - const AiInlineCitation({super.key, required this.number, this.onTap}); - - /// The 1-based citation index. - final int number; - - /// Called when the badge is tapped. - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - final theme = AiThemeExtension.of(context); - final color = DefaultTextStyle.of(context).style.color; - // Sizes to its content. (Note: a Container with `alignment` set expands to - // fill bounded parents — so this badge intentionally has none.) - return Semantics( - button: onTap != null, - label: 'Citation $number', - child: GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), - decoration: BoxDecoration( - color: theme.assistantBubbleColor, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: theme.borderColor), - ), - child: Text( - '$number', - style: theme.codeStyle.copyWith( - fontSize: 11, - height: 1.3, - color: color?.withValues(alpha: 0.75), - ), - ), - ), - ), - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_live_controller.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_live_controller.dart deleted file mode 100644 index d7cb0a6..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_live_controller.dart +++ /dev/null @@ -1,203 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter_ai_client/flutter_ai_client.dart'; -import 'package:flutter_ai_elements/src/widgets/ai_live_session.dart'; - -/// The audio side of a live voice session: speech-to-text in, text-to-speech -/// out. Implement it over your engine of choice (`speech_to_text` + -/// `flutter_tts`, a realtime API, …); [AiLiveController] drives the rest. -/// -/// The package ships no implementation (it has no platform plugins) — a typical -/// `speech_to_text` + `flutter_tts` adapter is ~30 lines. -abstract interface class AiVoiceEngine { - /// Starts a single listen turn. Report interim transcripts through [onPartial] - /// (for live display) and optional normalized mic level (`0`–`1`) through - /// [onLevel]. Call [onDone] with the settled text when the turn ends (silence - /// timeout or error included); pass an empty string if nothing was recognized. - Future startListening({ - required void Function(String text) onPartial, - required void Function(String finalText) onDone, - void Function(double level)? onLevel, - }); - - /// Stops an in-progress listen turn. - Future stopListening(); - - /// Speaks [text], calling [onDone] when playback finishes or is interrupted. - Future speak(String text, {required void Function() onDone}); - - /// Stops any in-progress speech. - Future stopSpeaking(); - - /// Releases engine resources. - Future dispose(); -} - -/// Drives a live-voice loop — **listen → send → speak → re-listen** — by mapping -/// an [AiVoiceEngine] onto a [UseChatController], and exposes the -/// [AiLiveSession] props ([status], [amplitude], [transcript], [muted]) as a -/// [ChangeNotifier]. -/// -/// This removes the hand-rolled voice state machine from the app: build the UI -/// with `AnimatedBuilder(animation: liveController, ...)` feeding an -/// `AiLiveSession`, and call [start] / [toggleMute] / [stop]. -/// -/// ```dart -/// final live = AiLiveController(controller: chat, engine: MyVoiceEngine()); -/// live.start(); -/// // AiLiveSession(status: live.status, amplitude: live.amplitude, -/// // transcript: live.transcript, muted: live.muted, -/// // onMute: live.toggleMute, onEnd: live.stop) -/// ``` -class AiLiveController extends ChangeNotifier { - /// Creates a live controller over [controller] and [engine]. - AiLiveController({required this.controller, required this.engine}); - - /// The chat controller the voice loop sends to and reads replies from. - final UseChatController controller; - - /// The audio engine (STT + TTS). - final AiVoiceEngine engine; - - AiLiveStatus _status = AiLiveStatus.connecting; - - /// The current phase, for [AiLiveSession.status]. - AiLiveStatus get status => _status; - - double _amplitude = 0; - - /// The latest normalized mic level (`0`–`1`), for [AiLiveSession.amplitude]. - double get amplitude => _amplitude; - - String? _transcript; - - /// The in-progress transcript, for [AiLiveSession.transcript]. - String? get transcript => _transcript; - - bool _muted = false; - - /// Whether the mic is muted, for [AiLiveSession.muted]. - bool get muted => _muted; - - bool _running = false; - // Bumped on every stop()/dispose() so late engine callbacks from a torn-down - // turn are ignored instead of resurrecting the loop. - int _generation = 0; - - void _set({AiLiveStatus? status, double? amplitude, String? transcript}) { - if (_disposed) return; - if (status != null) _status = status; - if (amplitude != null) _amplitude = amplitude; - if (transcript != null) _transcript = transcript; - notifyListeners(); - } - - /// Starts the session and begins listening. - void start() { - if (_running || _disposed) return; - _running = true; - _listen(); - } - - void _listen() { - if (!_running || _muted || _disposed) return; - final gen = _generation; - _set(status: AiLiveStatus.listening, transcript: ''); - unawaited(engine.startListening( - onPartial: (text) { - if (gen != _generation) return; - _set(transcript: text); - }, - onLevel: (level) { - if (gen != _generation) return; - _set(amplitude: level.clamp(0, 1).toDouble()); - }, - onDone: (finalText) { - if (gen != _generation) return; - unawaited(_onHeard(finalText.trim())); - }, - )); - } - - Future _onHeard(String text) async { - if (!_running || _disposed) return; - // Nothing recognized — just listen again. - if (text.isEmpty) { - _listen(); - return; - } - _set(status: AiLiveStatus.thinking, amplitude: 0); - final gen = _generation; - try { - await controller.sendText(text); - } catch (_) { - // Surface nothing audibly; drop back to listening. - if (gen == _generation && _running) _listen(); - return; - } - if (gen != _generation || !_running || _disposed) return; - final reply = controller.conversation.lastMessage; - final replyText = reply?.role == AiRole.assistant ? reply?.text ?? '' : ''; - if (replyText.trim().isEmpty) { - _listen(); - return; - } - _speak(replyText); - } - - void _speak(String text) { - if (!_running || _disposed) return; - final gen = _generation; - _set(status: AiLiveStatus.speaking, transcript: null); - unawaited(engine.speak( - text, - onDone: () { - if (gen != _generation || !_running || _disposed) return; - _listen(); - }, - )); - } - - /// Toggles the mic. Muting stops listening/speaking; unmuting re-listens. - void toggleMute() { - if (_disposed) return; - _muted = !_muted; - if (_muted) { - _generation++; // ignore any in-flight engine callbacks - unawaited(engine.stopListening()); - unawaited(engine.stopSpeaking()); - _set(status: AiLiveStatus.listening, amplitude: 0); - } else if (_running) { - _listen(); - } else { - notifyListeners(); - } - } - - /// Ends the session and releases the engine. - void stop() { - if (!_running) { - _set(status: AiLiveStatus.ended); - return; - } - _running = false; - _generation++; - unawaited(engine.stopListening()); - unawaited(engine.stopSpeaking()); - _set(status: AiLiveStatus.ended, amplitude: 0); - } - - bool _disposed = false; - - @override - void dispose() { - _disposed = true; - _running = false; - _generation++; - unawaited(engine.stopListening()); - unawaited(engine.stopSpeaking()); - unawaited(engine.dispose()); - super.dispose(); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_live_session.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_live_session.dart deleted file mode 100644 index d4a9afa..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_live_session.dart +++ /dev/null @@ -1,409 +0,0 @@ -import 'dart:async'; -import 'dart:math' as math; -import 'dart:ui' show lerpDouble; - -import 'package:flutter/material.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; - -/// The phase of a live voice session. -enum AiLiveStatus { - /// Establishing the session. - connecting, - - /// Listening to the user. - listening, - - /// Processing. - thinking, - - /// The assistant is speaking. - speaking, - - /// The session has ended. - ended, -} - -/// A full-screen, engine-agnostic **Live voice** surface, modelled on modern -/// assistant voice modes: a luminous sky-orb that opens centered, then *drops -/// and shrinks* to dock above the controls while the [conversation] fades in -/// behind it so you can read along. The orb's interior is an animated, -/// cloud-lit sky that breathes and reacts to audio [amplitude]. -/// -/// Purely presentational: drive [status] and [amplitude] from your audio engine -/// (realtime STT/TTS) and handle the control callbacks. It paints its own dark, -/// immersive background and fills its parent — wrap it in a `Scaffold` for -/// full-screen use. -class AiLiveSession extends StatefulWidget { - /// Creates a live session surface. - const AiLiveSession({ - super.key, - this.status = AiLiveStatus.listening, - this.amplitude = 0, - this.transcript, - this.conversation, - this.muted = false, - this.onMute, - this.onKeyboard, - this.onEnd, - this.backgroundColor = const Color(0xFF000000), - }); - - /// The current phase. - final AiLiveStatus status; - - /// Normalized audio level (`0`–`1`) driving the orb's reaction. - final double amplitude; - - /// Live transcript text shown briefly under the centered orb (before docking). - final String? transcript; - - /// The scrolling conversation to reveal behind the docked orb. When non-null, - /// the orb drops and shrinks shortly after opening to make room for it. - final Widget? conversation; - - /// Whether the mic is muted. - final bool muted; - - /// Toggles mute. Hidden if `null`. - final VoidCallback? onMute; - - /// Switches back to the text composer. Hidden if `null`. - final VoidCallback? onKeyboard; - - /// Ends the session. Hidden if `null`. - final VoidCallback? onEnd; - - /// The immersive backdrop color. Defaults to black. The orb and overlay text - /// are tuned for a dark surface — pass a light color only if you also theme - /// the content accordingly. - final Color backgroundColor; - - @override - State createState() => _AiLiveSessionState(); -} - -class _AiLiveSessionState extends State - with TickerProviderStateMixin { - // Gentle pulse. - late final AnimationController _breathe = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 2600), - )..repeat(); - - // Opening pop (fade + scale-in). - late final AnimationController _intro = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 520), - ); - - // Centered → docked (drop + shrink) with the conversation revealed. - late final AnimationController _dock = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 900), - ); - - Timer? _dockTimer; - - @override - void initState() { - super.initState(); - unawaited(_intro.forward()); - // The orb opens centered, then drops and shrinks to dock above the controls. - _dockTimer = Timer(const Duration(milliseconds: 600), () { - if (mounted) unawaited(_dock.forward()); - }); - } - - @override - void dispose() { - _dockTimer?.cancel(); - _breathe.dispose(); - _intro.dispose(); - _dock.dispose(); - super.dispose(); - } - - String get _label => switch (widget.status) { - AiLiveStatus.connecting => 'Connecting…', - AiLiveStatus.listening => 'Listening', - AiLiveStatus.thinking => 'Thinking…', - AiLiveStatus.speaking => 'Speaking', - AiLiveStatus.ended => '', - }; - - @override - Widget build(BuildContext context) { - final theme = AiThemeExtension.of(context); - final active = widget.status == AiLiveStatus.speaking || - widget.status == AiLiveStatus.listening; - - // Immersive dark surface (voice mode is a focused, dark experience). - return ColoredBox( - color: widget.backgroundColor, - child: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - final w = constraints.maxWidth; - final h = constraints.maxHeight; - - // Static subtree — does NOT depend on the animation, so it is built - // once and handed to the AnimatedBuilder via `child:` instead of - // rebuilding at 60fps with the orb. - final controls = Positioned( - left: 0, - right: 0, - bottom: 28, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (widget.onKeyboard != null) - _CircleButton( - icon: Icons.keyboard_outlined, - label: 'Keyboard', - onTap: widget.onKeyboard, - ), - if (widget.onMute != null) - _CircleButton( - icon: - widget.muted ? Icons.mic_off : Icons.mic_none_rounded, - label: widget.muted ? 'Unmute' : 'Mute', - onTap: widget.onMute, - ), - if (widget.onEnd != null) - _CircleButton( - icon: Icons.close, - label: 'End', - onTap: widget.onEnd, - filled: true, - ), - ], - ), - ); - - return AnimatedBuilder( - animation: Listenable.merge([_breathe, _intro, _dock]), - // The conversation/control subtree is the static child; only the - // orb and the animated opacities/positions are rebuilt per frame. - child: controls, - builder: (context, child) { - final intro = Curves.easeOut.transform(_intro.value); - final dock = Curves.easeOutCubic.transform(_dock.value); - final breathe = - 0.5 - 0.5 * math.cos(2 * math.pi * _breathe.value); - final amp = (widget.muted ? 0.0 : widget.amplitude).clamp(0, 1); - final react = (active ? amp : amp * 0.3).toDouble(); - - // Big and centered while listening; a small ball once docked. - final base = lerpDouble(240, 92, dock)!; - final orb = base * - (1 + 0.04 * breathe + 0.16 * react) * - (0.86 + 0.14 * intro); - final centerY = lerpDouble(h * 0.44, h * 0.70, dock)!; - final top = centerY - orb / 2; - - return Stack( - children: [ - // Readable area above the docked orb: the conversation if - // given, otherwise the live transcript. Fades in as it docks. - if (widget.conversation != null || - widget.transcript != null) - Positioned( - top: 52, - left: 0, - right: 0, - bottom: h - top + 12, - child: Opacity( - opacity: dock, - child: widget.conversation ?? - _TranscriptText(text: widget.transcript!), - ), - ), - // Status label near the top. - Positioned( - top: 14, - left: 0, - right: 0, - child: Opacity( - opacity: intro * (1 - dock), - child: Text( - _label, - textAlign: TextAlign.center, - style: theme.textStyle.copyWith( - fontSize: 15, - fontWeight: FontWeight.w600, - color: Colors.white.withValues(alpha: 0.85), - ), - ), - ), - ), - // The sky orb. - Positioned( - left: (w - orb) / 2, - top: top, - width: orb, - height: orb, - child: Opacity( - opacity: intro, - child: RepaintBoundary( - child: _Orb( - breathe: breathe, - react: react, - color: theme.orbColor, - ), - ), - ), - ), - // Live transcript under the centered orb (pre-dock only). - if (widget.transcript != null) - Positioned( - left: 28, - right: 28, - top: top + orb + 28, - child: Opacity( - opacity: (intro * (1 - dock * 1.6)).clamp(0, 1), - child: Text( - widget.transcript!, - textAlign: TextAlign.center, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: theme.textStyle.copyWith( - fontSize: 18, - height: 1.4, - color: Colors.white, - ), - ), - ), - ), - // Controls (static child, not rebuilt per frame). - child!, - ], - ); - }, - ); - }, - ), - ), - ); - } -} - -/// A luminous nebula sphere: deep space lit by slowly drifting clouds of violet, -/// blue, cyan and magenta, with a bright galactic core, scattered twinkling -/// stars, an outer glow, and rim-shading for depth. -/// A simple, calm sky-blue sphere (ChatGPT-style) — a soft radial gradient with -/// a light top-left highlight and an audio-reactive outer glow. -class _Orb extends StatelessWidget { - const _Orb({ - required this.breathe, - required this.react, - required this.color, - }); - - /// Breathing value (`0`–`1`). - final double breathe; - - /// Audio reaction (`0`–`1`). - final double react; - - /// Base orb color (themed via [AiThemeExtension.orbColor]). - final Color color; - - @override - Widget build(BuildContext context) { - // Derive the radial stops from the themed base so any color reads well. - final highlight = Color.lerp(color, Colors.white, 0.85)!; - final light = Color.lerp(color, Colors.white, 0.45)!; - final deep = Color.lerp(color, Colors.black, 0.30)!; - return DecoratedBox( - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( - center: const Alignment(-0.35, -0.45), - radius: 1.15, - colors: [highlight, light, color, deep], - stops: const [0.0, 0.4, 0.75, 1.0], - ), - boxShadow: [ - BoxShadow( - color: - color.withValues(alpha: 0.30 + 0.28 * react + 0.08 * breathe), - blurRadius: 40 + 36 * react, - spreadRadius: 2 + 6 * react, - ), - ], - ), - ); - } -} - -/// The live transcript shown above the docked orb when there's no conversation -/// to display — bottom-aligned so the latest words sit just over the orb. -class _TranscriptText extends StatelessWidget { - const _TranscriptText({required this.text}); - - final String text; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 28), - child: Align( - alignment: Alignment.bottomCenter, - child: SingleChildScrollView( - reverse: true, - child: Text( - text, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 19, - height: 1.4, - color: Colors.white, - ), - ), - ), - ), - ); - } -} - -class _CircleButton extends StatelessWidget { - const _CircleButton({ - required this.icon, - required this.label, - required this.onTap, - this.filled = false, - }); - - final IconData icon; - final String label; - final VoidCallback? onTap; - final bool filled; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Semantics( - button: true, - label: label, - child: GestureDetector( - onTap: onTap, - child: Container( - width: 60, - height: 60, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: - filled ? Colors.white : Colors.white.withValues(alpha: 0.14), - ), - child: Icon( - icon, - size: 26, - color: filled ? Colors.black : Colors.white, - ), - ), - ), - ), - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_loader.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_loader.dart deleted file mode 100644 index 7d356ad..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_loader.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/widgets.dart'; -import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; - -/// A three-dot "thinking" indicator shown while the assistant is preparing a -/// response. -/// -/// The dots pulse in sequence using the theme's loader color and motion timing. -class AiLoader extends StatefulWidget { - /// Creates a loader. - const AiLoader({super.key, this.dotSize = 8, this.dotSpacing = 4}); - - /// Diameter of each dot. - final double dotSize; - - /// Horizontal gap between dots. - final double dotSpacing; - - @override - State createState() => _AiLoaderState(); -} - -class _AiLoaderState extends State - with SingleTickerProviderStateMixin { - late final AnimationController _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1100), - ); - bool _reduceMotion = false; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _reduceMotion = MediaQuery.maybeDisableAnimationsOf(context) ?? false; - if (_reduceMotion) { - _controller.stop(); - } else if (!_controller.isAnimating) { - unawaited(_controller.repeat()); - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = AiThemeExtension.of(context); - Row dots(double Function(int) opacity) => Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (var i = 0; i < 3; i++) - Padding( - padding: EdgeInsets.only( - right: i == 2 ? 0 : widget.dotSpacing, - ), - child: _dot(theme.loaderColor, opacity(i)), - ), - ], - ); - return Semantics( - label: AiLocalizations.of(context).thinking, - child: _reduceMotion - // Static dots — no pulse under reduce-motion (WCAG 2.3.3). - ? dots((_) => 0.6) - : AnimatedBuilder( - animation: _controller, - builder: (context, _) => dots(_opacityForDot), - ), - ); - } - - // Each dot is a third of a cycle out of phase with the previous one. - double _opacityForDot(int index) { - final phase = (_controller.value + index / 3) % 1.0; - // Triangle wave: 0 -> 1 -> 0 across the cycle. - final wave = phase < 0.5 ? phase * 2 : (1 - phase) * 2; - return 0.3 + 0.7 * wave; - } - - Widget _dot(Color color, double opacity) => Container( - width: widget.dotSize, - height: widget.dotSize, - decoration: BoxDecoration( - color: color.withValues(alpha: opacity), - shape: BoxShape.circle, - ), - ); -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_message_actions.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_message_actions.dart deleted file mode 100644 index 413dedc..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_message_actions.dart +++ /dev/null @@ -1,235 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_ai_core/flutter_ai_core.dart'; -import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; - -/// The per-message actions, used to control ordering via -/// [AiMessageActions.order] and [AiMessageActions.trailing]. -enum AiMessageActionKind { - /// Copy the message text. - copy, - - /// Read the message aloud. - speak, - - /// Thumbs-up feedback. - good, - - /// Thumbs-down feedback. - bad, - - /// Share the message. - share, - - /// Regenerate the response. - regenerate, - - /// Edit the message. - edit, -} - -/// A compact row of per-message actions: copy, and optionally regenerate and -/// edit. -/// -/// Copy defaults to placing the message's text on the clipboard; override it via -/// [onCopy]. On mobile, prefer presenting these via [showAiMessageActions] from -/// a long-press rather than always-visible buttons. -/// -/// [order] controls the sequence; actions listed in [trailing] are pushed to the -/// far (end) side after a spacer — e.g. Gemini keeps 👍👎↻⧉⋮ on the left and -/// read-aloud on the right. Only actions with a non-null callback render (copy -/// always renders). -class AiMessageActions extends StatelessWidget { - /// Creates an actions row for [message]. - const AiMessageActions({ - super.key, - required this.message, - this.onCopy, - this.onSpeak, - this.onGood, - this.onBad, - this.onShare, - this.onRegenerate, - this.onEdit, - this.iconSize = 18, - this.order = const [ - AiMessageActionKind.copy, - AiMessageActionKind.speak, - AiMessageActionKind.good, - AiMessageActionKind.bad, - AiMessageActionKind.share, - AiMessageActionKind.regenerate, - AiMessageActionKind.edit, - ], - this.trailing = const {}, - }); - - /// The message these actions apply to. - final AiMessage message; - - /// Overrides the default copy-to-clipboard behavior. - final VoidCallback? onCopy; - - /// Shows a read-aloud action when non-null. - final VoidCallback? onSpeak; - - /// Shows a thumbs-up action when non-null. - final VoidCallback? onGood; - - /// Shows a thumbs-down action when non-null. - final VoidCallback? onBad; - - /// Shows a share action when non-null. - /// - /// The package ships no share implementation (it has no platform plugins); - /// wire your own, e.g. with `share_plus`: - /// `onShare: () => Share.share(message.text)`. - final VoidCallback? onShare; - - /// Shows a Regenerate action when non-null. - final VoidCallback? onRegenerate; - - /// Shows an Edit action when non-null. - final VoidCallback? onEdit; - - /// Size of the action icons. - final double iconSize; - - /// The order actions are rendered in. - final List order; - - /// Actions pushed to the far (end) side, after a spacer. When non-empty the - /// row expands to fill its width so the split is visible. - final Set trailing; - - void _copy() { - if (onCopy != null) { - onCopy!(); - } else { - unawaited(Clipboard.setData(ClipboardData(text: message.text))); - } - } - - @override - Widget build(BuildContext context) { - final l = AiLocalizations.of(context); - final color = DefaultTextStyle.of(context).style.color?.withValues( - alpha: 0.6, - ); - // Compact, evenly spaced icon buttons (ChatGPT-style): a uniform 36px target - // with tight, equal padding rather than the default ~48px IconButton gaps. - Widget button(IconData icon, String tooltip, VoidCallback onPressed) { - return IconButton( - icon: Icon(icon, size: iconSize), - color: color, - tooltip: tooltip, - visualDensity: VisualDensity.compact, - padding: const EdgeInsets.all(6), - constraints: const BoxConstraints(minWidth: 36, minHeight: 36), - style: const ButtonStyle( - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - onPressed: onPressed, - ); - } - - // Resolve each kind to a button, or null when its callback is absent (copy - // always renders, defaulting to clipboard). - Widget? forKind(AiMessageActionKind kind) => switch (kind) { - AiMessageActionKind.copy => button(Icons.copy_rounded, l.copy, _copy), - AiMessageActionKind.speak => onSpeak == null - ? null - : button(Icons.volume_up_outlined, l.readAloud, onSpeak!), - AiMessageActionKind.good => onGood == null - ? null - : button(Icons.thumb_up_outlined, l.goodResponse, onGood!), - AiMessageActionKind.bad => onBad == null - ? null - : button(Icons.thumb_down_outlined, l.badResponse, onBad!), - AiMessageActionKind.share => onShare == null - ? null - : button(Icons.ios_share_rounded, l.share, onShare!), - AiMessageActionKind.regenerate => onRegenerate == null - ? null - : button(Icons.refresh_rounded, l.regenerate, onRegenerate!), - AiMessageActionKind.edit => onEdit == null - ? null - : button(Icons.edit_outlined, l.edit, onEdit!), - }; - - final leading = []; - final tail = []; - for (final kind in order) { - final w = forKind(kind); - if (w == null) continue; - (trailing.contains(kind) ? tail : leading).add(w); - } - - if (tail.isEmpty) { - return Row(mainAxisSize: MainAxisSize.min, children: leading); - } - return Row(children: [...leading, const Spacer(), ...tail]); - } -} - -/// Presents the per-message actions in a native bottom sheet — the idiomatic -/// mobile pattern, triggered from a long-press on a message. -Future showAiMessageActions( - BuildContext context, { - required AiMessage message, - VoidCallback? onCopy, - VoidCallback? onRegenerate, - VoidCallback? onEdit, -}) { - final l = AiLocalizations.of(context); - return showModalBottomSheet( - context: context, - showDragHandle: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - builder: (sheetContext) => SafeArea( - // Scrollable so the actions never overflow in landscape / small heights. - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.copy), - title: Text(l.copy), - onTap: () { - if (onCopy != null) { - onCopy(); - } else { - unawaited( - Clipboard.setData(ClipboardData(text: message.text))); - } - Navigator.of(sheetContext).pop(); - }, - ), - if (onRegenerate != null) - ListTile( - leading: const Icon(Icons.refresh), - title: Text(l.regenerate), - onTap: () { - onRegenerate(); - Navigator.of(sheetContext).pop(); - }, - ), - if (onEdit != null) - ListTile( - leading: const Icon(Icons.edit_outlined), - title: Text(l.edit), - onTap: () { - onEdit(); - Navigator.of(sheetContext).pop(); - }, - ), - ], - ), - ), - ), - ); -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_message_bubble.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_message_bubble.dart deleted file mode 100644 index 71c62b7..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_message_bubble.dart +++ /dev/null @@ -1,258 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_ai_core/flutter_ai_core.dart'; -import 'package:flutter_ai_elements/src/rendering/ai_text_renderer.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; -import 'package:flutter_ai_elements/src/widgets/ai_attachment.dart'; -import 'package:flutter_ai_elements/src/widgets/ai_reasoning.dart'; -import 'package:flutter_ai_elements/src/widgets/ai_response.dart'; -import 'package:flutter_ai_elements/src/widgets/ai_shimmer.dart'; -import 'package:flutter_ai_elements/src/widgets/ai_tool_invocation.dart'; - -/// A single chat bubble that renders one [AiMessage]'s parts. -/// -/// Purely presentational — it takes data, not a controller — so it is trivially -/// testable and reusable. Styling comes entirely from [AiThemeExtension]. -/// -/// Each part type gets an appropriate widget: prose via the [textRenderer], -/// reasoning via `AiReasoning`, tool calls via `AiToolInvocation` (paired with -/// their results), files via `AiAttachment`, and sources as link chips. -/// -/// ### Accessibility while streaming -/// -/// Rapidly updating text floods screen readers. While [AiMessage.status] is -/// [AiMessageStatus.streaming] the bubble is wrapped in [ExcludeSemantics]; -/// once the message completes it becomes a live region so assistive technology -/// announces the finished answer exactly once. -class AiMessageBubble extends StatelessWidget { - /// Creates a message bubble. - const AiMessageBubble({ - super.key, - required this.message, - this.textRenderer = const MarkdownTextRenderer(), - }); - - /// The message to render. - final AiMessage message; - - /// How text and reasoning parts are rendered. Defaults to - /// [MarkdownTextRenderer]. - final AiTextRenderer textRenderer; - - @override - Widget build(BuildContext context) { - final theme = AiThemeExtension.of(context); - final isUser = message.role == AiRole.user; - final isStreaming = message.status == AiMessageStatus.streaming; - // The user is always bubbled; the assistant follows the theme (plain by - // default — full-width, like a modern AI assistant). - final bubbled = - isUser || theme.assistantMessageStyle == AiMessageStyle.bubble; - - final content = DefaultTextStyle.merge( - style: theme.textStyle.copyWith( - color: isUser ? theme.userTextColor : theme.assistantTextColor, - ), - child: _content(context, isStreaming), - ); - - final Widget body; - if (bubbled) { - // Size the bubble relative to its container (so it stays correct inside a - // centered, max-width column on tablets/desktop), not the whole screen. - body = LayoutBuilder( - builder: (context, constraints) { - // Only fall back to the window width when the incoming constraints are - // unbounded; in the common bounded case never subscribe to media size. - final available = constraints.maxWidth.isFinite - ? constraints.maxWidth - : MediaQuery.sizeOf(context).width; - return Align( - alignment: isUser - ? AlignmentDirectional.centerEnd - : AlignmentDirectional.centerStart, - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: available * theme.maxBubbleWidthFraction, - ), - child: Container( - decoration: BoxDecoration( - color: isUser - ? theme.userBubbleColor - : theme.assistantBubbleColor, - borderRadius: theme.bubbleRadius, - boxShadow: theme.bubbleShadow, - ), - padding: theme.bubblePadding, - child: content, - ), - ), - ); - }, - ); - } else { - // Plain assistant: full-width, no container. - body = SizedBox(width: double.infinity, child: content); - } - - return Padding( - padding: EdgeInsets.only(bottom: theme.messageSpacing), - child: isStreaming - ? ExcludeSemantics(child: body) - : Semantics(liveRegion: true, child: body), - ); - } - - Widget _content(BuildContext context, bool isStreaming) { - // Pair tool results with their calls so each renders inside one card. - final results = { - for (final part in message.parts) - if (part is ToolResultPart) part.toolCallId: part, - }; - - final children = []; - for (final part in message.parts) { - switch (part) { - case TextPart(:final text): - children.add( - _CrossfadeText( - text: text, - isStreaming: isStreaming, - renderer: textRenderer, - ), - ); - case ReasoningPart(:final text): - children.add(AiReasoning(text: text)); - case ToolCallPart(): - children.add( - AiToolInvocation(call: part, result: results[part.toolCallId]), - ); - case ToolResultPart(): - // Rendered within its AiToolInvocation; skip the standalone part. - break; - case FilePart(): - children.add(AiAttachment(file: part)); - case SourcePart(:final url, :final title): - children.add(_SourceChip(url: url, title: title)); - case DataPart(:final dataType): - children.add(_DataChip(label: dataType)); - } - } - - if (children.isEmpty) return const SizedBox.shrink(); - if (children.length == 1) return children.first; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - for (var i = 0; i < children.length; i++) ...[ - if (i > 0) const SizedBox(height: 8), - children[i], - ], - ], - ); - } -} - -/// Crossfades from the streaming text view to the final rendered Markdown when -/// a message finishes streaming, avoiding a hard pop. Under reduce-motion it -/// swaps instantly (WCAG 2.3.3). -class _CrossfadeText extends StatelessWidget { - const _CrossfadeText({ - required this.text, - required this.isStreaming, - required this.renderer, - }); - - final String text; - final bool isStreaming; - final AiTextRenderer renderer; - - @override - Widget build(BuildContext context) { - final theme = AiThemeExtension.of(context); - final reduceMotion = MediaQuery.maybeDisableAnimationsOf(context) ?? false; - // Awaiting the first token: an assistant turn that is streaming but has no - // text yet. Show a skeleton shimmer that crossfades into the streamed text - // once the first delta lands. - final awaiting = isStreaming && text.isEmpty; - // Three phases the switcher crossfades between: shimmer → streaming → final. - final phase = awaiting ? 0 : (isStreaming ? 1 : 2); - final Widget rendered = awaiting - ? const AiShimmer() - : renderer.render(text, isStreaming: isStreaming); - final child = KeyedSubtree( - key: ValueKey(phase), - child: rendered, - ); - if (reduceMotion) return child; - return AnimatedSwitcher( - duration: theme.motionDuration, - switchInCurve: theme.motionCurve, - switchOutCurve: theme.motionCurve, - // Cross-fade in place; size to the incoming child so the answer doesn't - // jump when it settles into Markdown. - layoutBuilder: (currentChild, previousChildren) => Stack( - alignment: AlignmentDirectional.topStart, - children: [ - ...previousChildren, - if (currentChild != null) currentChild, - ], - ), - child: child, - ); - } -} - -class _SourceChip extends StatelessWidget { - const _SourceChip({required this.url, this.title}); - - final Uri url; - final String? title; - - @override - Widget build(BuildContext context) { - final color = DefaultTextStyle.of(context).style.color; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.link, size: 16, color: color), - const SizedBox(width: 6), - Flexible( - child: Text( - title ?? url.toString(), - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: color, - decoration: TextDecoration.underline, - ), - ), - ), - ], - ); - } -} - -class _DataChip extends StatelessWidget { - const _DataChip({required this.label}); - - final String label; - - @override - Widget build(BuildContext context) { - final color = DefaultTextStyle.of(context).style.color; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.widgets_outlined, size: 16, color: color), - const SizedBox(width: 6), - Flexible( - child: Text( - label, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: color), - ), - ), - ], - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_model_selector.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_model_selector.dart deleted file mode 100644 index 034edcc..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_model_selector.dart +++ /dev/null @@ -1,153 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; - -/// A selectable model option. -@immutable -class AiModelOption { - /// Creates a model option. - const AiModelOption({ - required this.id, - required this.label, - this.description, - }); - - /// The stable identifier passed to the provider. - final String id; - - /// The display name. - final String label; - - /// An optional one-line description shown in the picker. - final String? description; -} - -/// A compact "model ▾" chip that opens a bottom sheet to switch models. -/// -/// Wire [onSelected] to `UseChatController.setOptions` (or your own state) to -/// change the active model. -class AiModelSelector extends StatelessWidget { - /// Creates a model selector. - const AiModelSelector({ - super.key, - required this.models, - required this.selectedId, - required this.onSelected, - this.labelStyle, - this.labelBuilder, - this.showBorder = true, - this.padding = const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - }); - - /// The available models. - final List models; - - /// The id of the currently selected model. - final String selectedId; - - /// Called with the chosen model id. - final ValueChanged onSelected; - - /// Style for the trigger's label text. Merged over the themed default (which - /// is `theme.textStyle` at size 13). Ignored when [labelBuilder] is set. - final TextStyle? labelStyle; - - /// Fully replaces the trigger's label+chevron with a custom widget (e.g. a - /// larger two-tone brand title). The chevron is *not* added automatically — - /// include your own. The picker sheet is still opened on tap. - final Widget Function(BuildContext context, AiModelOption selected)? - labelBuilder; - - /// Whether to draw the rounded border around the trigger. Turn off for a - /// borderless brand title. - final bool showBorder; - - /// Padding inside the trigger. - final EdgeInsets padding; - - AiModelOption? get _selected { - if (models.isEmpty) return null; - return models.firstWhere( - (m) => m.id == selectedId, - orElse: () => models.first, - ); - } - - @override - Widget build(BuildContext context) { - final theme = AiThemeExtension.of(context); - // Nothing to select yet (e.g. models still loading) — render nothing. - final selected = _selected; - if (selected == null) return const SizedBox.shrink(); - final color = DefaultTextStyle.of(context).style.color; - return Semantics( - button: true, - label: 'Select model, ${selected.label}', - child: GestureDetector( - onTap: () => unawaited(_open(context)), - child: Container( - padding: padding, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - border: showBorder ? Border.all(color: theme.borderColor) : null, - ), - child: labelBuilder != null - ? labelBuilder!(context, selected) - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - selected.label, - style: theme.textStyle - .copyWith(fontSize: 13, color: color) - .merge(labelStyle), - ), - const SizedBox(width: 2), - Icon(Icons.expand_more, size: 16, color: color), - ], - ), - ), - ), - ); - } - - Future _open(BuildContext context) { - return showModalBottomSheet( - context: context, - showDragHandle: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - // Bounded + scrollable so a long model list (or landscape) doesn't - // overflow the sheet. - isScrollControlled: true, - builder: (sheetContext) => SafeArea( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final model in models) - ListTile( - title: Text(model.label), - subtitle: model.description == null - ? null - : Text(model.description!), - trailing: model.id == selectedId - ? Icon( - Icons.check, - color: AiThemeExtension.of(sheetContext).successColor, - ) - : null, - onTap: () { - onSelected(model.id); - Navigator.of(sheetContext).pop(); - }, - ), - ], - ), - ), - ), - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_orb.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_orb.dart deleted file mode 100644 index aef3956..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_orb.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'dart:async'; -import 'dart:math' as math; - -import 'package:flutter/material.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; - -/// A small, calm voice/loading **orb** — a luminous sphere that gently breathes -/// and reacts to audio [amplitude]. The compact counterpart to the full-screen -/// orb in `AiLiveSession`, usable inline (e.g. in a composer or status row). -/// -/// Colors derive from [AiThemeExtension.orbColor] and the size from [size]; -/// both are fully themeable. Under reduce-motion the breathing stops and a -/// static sphere is shown (WCAG 2.3.3). -class AiOrb extends StatefulWidget { - /// Creates an orb of diameter [size]. - const AiOrb({super.key, this.size = 64, this.amplitude = 0}); - - /// Diameter of the orb in logical pixels. - final double size; - - /// Normalized audio level (`0`–`1`) the orb reacts to. `0` is calm. - final double amplitude; - - @override - State createState() => _AiOrbState(); -} - -class _AiOrbState extends State with SingleTickerProviderStateMixin { - late final AnimationController _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 2600), - ); - bool _reduceMotion = false; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _reduceMotion = MediaQuery.maybeDisableAnimationsOf(context) ?? false; - if (_reduceMotion) { - _controller.stop(); - } else if (!_controller.isAnimating) { - unawaited(_controller.repeat()); - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = AiThemeExtension.of(context); - final react = widget.amplitude.clamp(0.0, 1.0); - if (_reduceMotion) { - return _sphere(theme.orbColor, breathe: 0, react: react); - } - return AnimatedBuilder( - animation: _controller, - builder: (context, _) { - final breathe = 0.5 - 0.5 * math.cos(2 * math.pi * _controller.value); - return _sphere(theme.orbColor, breathe: breathe, react: react); - }, - ); - } - - Widget _sphere(Color base, {required double breathe, required double react}) { - final light = Color.lerp(base, Colors.white, 0.7)!; - final dark = Color.lerp(base, Colors.black, 0.35)!; - final d = widget.size * (1 + 0.04 * breathe + 0.16 * react); - return SizedBox( - width: widget.size, - height: widget.size, - child: Center( - child: Container( - width: d, - height: d, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( - center: const Alignment(-0.35, -0.45), - radius: 1.15, - colors: [light, base, dark], - stops: const [0.0, 0.65, 1.0], - ), - boxShadow: [ - BoxShadow( - color: base.withValues( - alpha: 0.30 + 0.28 * react + 0.08 * breathe, - ), - blurRadius: widget.size * (0.4 + 0.4 * react), - spreadRadius: widget.size * 0.03 * (1 + react), - ), - ], - ), - ), - ), - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_prompt_input.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_prompt_input.dart deleted file mode 100644 index 62f0561..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_prompt_input.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/widgets.dart'; -import 'package:flutter_ai_client/flutter_ai_client.dart'; -import 'package:flutter_ai_elements/src/widgets/ai_composer.dart'; - -/// A composer bound to a [UseChatController]. -/// -/// Stages attachments (via [onPickAttachment]) to send with the next message, -/// offers voice dictation ([onVoice]) and a Live entry point ([onLive]). The -/// model selector lives in the app bar, not here. -/// -/// Pass a [textController] to read or write the field's text from the host — -/// e.g. so [onVoice] dictation can *insert* the recognized text for review -/// (`textController.text = recognized`) instead of dictate-and-send. -class AiPromptInput extends StatefulWidget { - /// Creates a prompt input bound to [controller]. - const AiPromptInput({ - super.key, - required this.controller, - this.hintText = 'Message', - this.onPickAttachment, - this.onVoice, - this.onLive, - this.textController, - }); - - /// The chat controller to drive. - final UseChatController controller; - - /// Placeholder text for the empty input. - final String hintText; - - /// Host-provided picker; when non-null an attach (+) button is shown. - final Future> Function()? onPickAttachment; - - /// Voice dictation; when non-null a mic button shows while the field is empty. - /// Combine with [textController] to write recognized speech into the field. - final VoidCallback? onVoice; - - /// Live voice mode; when non-null the main button is Live while the field is - /// empty (and Send once the user types). - final VoidCallback? onLive; - - /// Optional external controller for the text field. Own its lifecycle (create - /// and dispose it in the host). Lets dictation/quick-replies set the text. - final TextEditingController? textController; - - @override - State createState() => _AiPromptInputState(); -} - -class _AiPromptInputState extends State { - final List _attachments = []; - - void _send(String text) { - final staged = List.of(_attachments); - setState(_attachments.clear); - unawaited(widget.controller.sendText(text, attachments: staged)); - } - - Future _pick() async { - final picked = await widget.onPickAttachment!(); - if (picked.isNotEmpty && mounted) { - setState(() => _attachments.addAll(picked)); - } - } - - @override - Widget build(BuildContext context) { - return ListenableBuilder( - listenable: widget.controller, - builder: (context, _) => AiComposer( - controller: widget.textController, - hintText: widget.hintText, - isBusy: widget.controller.status.isBusy, - onStop: widget.controller.stop, - onSend: _send, - onAttach: - widget.onPickAttachment == null ? null : () => unawaited(_pick()), - onVoice: widget.onVoice, - onLive: widget.onLive, - attachments: _attachments, - onRemoveAttachment: (f) => setState(() => _attachments.remove(f)), - ), - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_reasoning.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_reasoning.dart deleted file mode 100644 index 3b6742c..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_reasoning.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; - -/// A collapsible disclosure for the model's reasoning ("chain of thought"). -/// -/// Kept out of the main answer flow and collapsed by default so reasoning is -/// available without dominating the bubble. -class AiReasoning extends StatefulWidget { - /// Creates a reasoning disclosure for [text]. - const AiReasoning({ - super.key, - required this.text, - this.initiallyExpanded = false, - }); - - /// The reasoning content. - final String text; - - /// Whether the disclosure starts expanded. - final bool initiallyExpanded; - - @override - State createState() => _AiReasoningState(); -} - -class _AiReasoningState extends State { - late bool _expanded = widget.initiallyExpanded; - - @override - Widget build(BuildContext context) { - final theme = AiThemeExtension.of(context); - final color = DefaultTextStyle.of(context).style.color; - final subdued = color?.withValues(alpha: 0.6); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Semantics( - button: true, - expanded: _expanded, - child: InkWell( - onTap: () => setState(() => _expanded = !_expanded), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.psychology_outlined, size: 16, color: subdued), - const SizedBox(width: 6), - Text( - AiLocalizations.of(context).reasoning, - style: TextStyle( - color: subdued, - fontWeight: FontWeight.w600, - fontSize: 13, - ), - ), - Icon( - _expanded ? Icons.expand_less : Icons.expand_more, - size: 18, - color: subdued, - ), - ], - ), - ), - ), - AnimatedSize( - duration: theme.motionDuration, - curve: theme.motionCurve, - alignment: Alignment.topCenter, - child: _expanded - ? Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - widget.text, - style: theme.textStyle.copyWith( - color: subdued, - fontSize: 14.5, - height: 1.45, - ), - ), - ) - : const SizedBox(width: double.infinity), - ), - ], - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_response.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_response.dart deleted file mode 100644 index 2320827..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_response.dart +++ /dev/null @@ -1,662 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_ai_elements/src/rendering/ai_text_renderer.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; -import 'package:flutter_ai_elements/src/widgets/ai_animated_response.dart'; -import 'package:flutter_ai_elements/src/widgets/ai_code_block.dart'; - -/// Renders a useful subset of Markdown — headings, bold/italic, inline code, -/// fenced code blocks, ordered/unordered lists, blockquotes, and links — with -/// **no external dependency**. -/// -/// This is the content renderer for assistant answers. Inline links are styled -/// always and become tappable when [onLinkTap] is provided. -class AiResponse extends StatefulWidget { - /// Creates a Markdown response from [text]. - const AiResponse({ - super.key, - required this.text, - this.onLinkTap, - this.codeHighlighter, - }); - - /// The Markdown source to render. - final String text; - - /// Called when a link is tapped. If `null`, links render but aren't tappable. - final void Function(Uri url)? onLinkTap; - - /// Optional syntax highlighter for fenced code blocks. When `null`, code - /// renders as plain monospace. - final CodeHighlighter? codeHighlighter; - - @override - State createState() => _AiResponseState(); -} - -class _AiResponseState extends State { - // Heading font sizes by level; hoisted so we don't rebuild the map per block. - static const Map _headingSizes = {1: 24.0, 2: 20.0, 3: 17.0}; - - // Matches an alphanumeric char; used to skip intraword `_` emphasis. - static final RegExp _intraword = RegExp(r'[A-Za-z0-9]'); - - final List _recognizers = []; - - // The parsed/built content, computed once per unique (text, onLinkTap) — never - // in build(). Recognizers are created here and disposed when text changes. - List<_Block>? _blocks; - - // The theme/base style the cached widget was built against. If the inherited - // style changes we re-resolve in build() without re-parsing the Markdown. - AiThemeExtension? _builtTheme; - TextStyle? _builtBase; - Widget? _built; - - void _disposeRecognizers() { - for (final r in _recognizers) { - r.dispose(); - } - _recognizers.clear(); - } - - // Parses the Markdown source once and caches the block list. Recognizers from - // the previous parse are disposed first. Does NOT build widgets (those depend - // on the inherited theme, resolved lazily in build()). - void _parse() { - _disposeRecognizers(); - _blocks = _parseBlocks(widget.text); - // Invalidate the built widget so it's rebuilt against the current theme. - _built = null; - _builtTheme = null; - _builtBase = null; - } - - @override - void initState() { - super.initState(); - _parse(); - } - - @override - void didUpdateWidget(AiResponse oldWidget) { - super.didUpdateWidget(oldWidget); - // Re-parse (and rebuild recognizers) only when the inputs that affect them - // change — never every frame. - if (oldWidget.text != widget.text || - oldWidget.onLinkTap != widget.onLinkTap || - oldWidget.codeHighlighter != widget.codeHighlighter) { - _parse(); - } - } - - @override - void dispose() { - _disposeRecognizers(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = AiThemeExtension.of(context); - final base = DefaultTextStyle.of(context).style.merge(theme.textStyle); - - // Return the cached widget unless the inherited theme/base style changed. - if (_built != null && theme == _builtTheme && base == _builtBase) { - return _built!; - } - - final blocks = _blocks!; - final built = Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - for (var i = 0; i < blocks.length; i++) ...[ - if (i > 0) const SizedBox(height: 8), - _buildBlock(blocks[i], theme, base), - ], - ], - ); - _built = built; - _builtTheme = theme; - _builtBase = base; - return built; - } - - Widget _buildBlock(_Block block, AiThemeExtension theme, TextStyle base) { - switch (block.type) { - case _BlockType.heading: - final style = base.copyWith( - fontSize: _headingSizes[block.level] ?? 16, - fontWeight: FontWeight.w700, - height: 1.3, - ); - return Text.rich(TextSpan(children: _inline(block.text, style, theme))); - case _BlockType.code: - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: AiCodeBlock( - code: block.text, - language: block.language, - highlighter: widget.codeHighlighter, - ), - ); - case _BlockType.bullet: - case _BlockType.ordered: - final isTask = block.checks.isNotEmpty; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (var i = 0; i < block.items.length; i++) - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 24, - child: isTask - ? Padding( - padding: const EdgeInsets.only(top: 2), - child: Icon( - block.checks[i] - ? Icons.check_box_rounded - : Icons.check_box_outline_blank_rounded, - size: 16, - color: block.checks[i] - ? theme.successColor - : theme.borderColor, - ), - ) - : Text( - block.type == _BlockType.ordered - ? '${i + 1}.' - : '•', - style: base, - ), - ), - Expanded( - child: Text.rich( - TextSpan( - children: _inline(block.items[i], base, theme), - ), - ), - ), - ], - ), - ), - ], - ); - case _BlockType.rule: - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Divider(height: 1, thickness: 1, color: theme.borderColor), - ); - case _BlockType.quote: - return Container( - padding: const EdgeInsets.only(left: 12), - decoration: BoxDecoration( - border: Border( - left: BorderSide(color: theme.borderColor, width: 3), - ), - ), - child: Text.rich( - TextSpan( - children: _inline( - block.text, - base.copyWith(color: base.color?.withValues(alpha: 0.7)), - theme, - ), - ), - ), - ); - case _BlockType.table: - return _buildTable(block.rows, theme, base); - case _BlockType.paragraph: - return Text.rich(TextSpan(children: _inline(block.text, base, theme))); - } - } - - Widget _buildTable( - List> rows, - AiThemeExtension theme, - TextStyle base, - ) { - if (rows.isEmpty) return const SizedBox.shrink(); - final cols = rows.first.length; - final headerStyle = base.copyWith(fontWeight: FontWeight.w700); - // Horizontal scroll keeps wide tables from overflowing the bubble. - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Container( - decoration: BoxDecoration( - border: Border.all(color: theme.borderColor), - borderRadius: BorderRadius.circular(10), - ), - child: Table( - defaultColumnWidth: const IntrinsicColumnWidth(), - defaultVerticalAlignment: TableCellVerticalAlignment.middle, - border: TableBorder.symmetric( - inside: BorderSide(color: theme.borderColor), - ), - children: [ - for (var r = 0; r < rows.length; r++) - TableRow( - decoration: BoxDecoration( - color: r == 0 ? theme.assistantBubbleColor : null, - ), - children: [ - for (var c = 0; c < cols; c++) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - child: Text.rich( - TextSpan( - children: _inline( - c < rows[r].length ? rows[r][c] : '', - r == 0 ? headerStyle : base, - theme, - ), - ), - ), - ), - ], - ), - ], - ), - ), - ), - ); - } - - // Inline parsing: **bold**, *italic*/_italic_, `code`, [text](url). - List _inline( - String text, - TextStyle base, - AiThemeExtension theme, - ) { - final spans = []; - final buffer = StringBuffer(); - var i = 0; - - void flush() { - if (buffer.isNotEmpty) { - spans.add(TextSpan(text: buffer.toString(), style: base)); - buffer.clear(); - } - } - - while (i < text.length) { - if (text.startsWith('**', i)) { - final end = text.indexOf('**', i + 2); - if (end != -1) { - flush(); - spans.addAll( - _inline( - text.substring(i + 2, end), - base.copyWith(fontWeight: FontWeight.w700), - theme, - ), - ); - i = end + 2; - continue; - } - } - if (text.startsWith('~~', i)) { - final end = text.indexOf('~~', i + 2); - if (end != -1) { - flush(); - spans.addAll( - _inline( - text.substring(i + 2, end), - base.copyWith( - decoration: TextDecoration.lineThrough, - decorationColor: base.color, - ), - theme, - ), - ); - i = end + 2; - continue; - } - } - final char = text[i]; - if (char == '`') { - final end = text.indexOf('`', i + 1); - if (end != -1) { - flush(); - spans.add( - TextSpan( - text: text.substring(i + 1, end), - style: theme.codeStyle.copyWith(color: base.color), - ), - ); - i = end + 1; - continue; - } - } - if (char == '[') { - final close = text.indexOf(']', i + 1); - if (close != -1 && close + 1 < text.length && text[close + 1] == '(') { - final urlEnd = text.indexOf(')', close + 2); - if (urlEnd != -1) { - flush(); - spans.add( - _linkSpan( - text.substring(i + 1, close), - text.substring(close + 2, urlEnd), - base, - theme, - ), - ); - i = urlEnd + 1; - continue; - } - } - } - if (char == '*' || char == '_') { - final end = text.indexOf(char, i + 1); - // Avoid false emphasis on prose: require non-space right after the - // opening marker (so "2 * 3" isn't italic), and for `_` skip intraword - // use (so identifiers like `snake_case` aren't italicized). - final prev = i > 0 ? text[i - 1] : ' '; - final intraword = char == '_' && _intraword.hasMatch(prev); - if (!intraword && end > i + 1 && text[i + 1] != ' ') { - flush(); - spans.addAll( - _inline( - text.substring(i + 1, end), - base.copyWith(fontStyle: FontStyle.italic), - theme, - ), - ); - i = end + 1; - continue; - } - } - buffer.write(char); - i++; - } - flush(); - return spans; - } - - InlineSpan _linkSpan( - String label, - String url, - TextStyle base, - AiThemeExtension theme, - ) { - final style = base.copyWith( - color: theme.linkColor, - decoration: TextDecoration.underline, - ); - final onTap = widget.onLinkTap; - if (onTap == null) return TextSpan(text: label, style: style); - final recognizer = TapGestureRecognizer() - ..onTap = () => onTap(Uri.parse(url)); - _recognizers.add(recognizer); - return TextSpan(text: label, style: style, recognizer: recognizer); - } -} - -/// An [AiTextRenderer] that renders Markdown via [AiResponse]. The default -/// renderer for assistant content. -class MarkdownTextRenderer implements AiTextRenderer { - /// Creates a Markdown renderer. - const MarkdownTextRenderer({this.onLinkTap, this.codeHighlighter}); - - /// Forwarded to [AiResponse.onLinkTap]. - final void Function(Uri url)? onLinkTap; - - /// Forwarded to [AiResponse.codeHighlighter] for the completed message. - final CodeHighlighter? codeHighlighter; - - @override - Widget render(String text, {required bool isStreaming}) => isStreaming - ? AiAnimatedResponse(text: text, onLinkTap: onLinkTap) - : AiResponse( - text: text, - onLinkTap: onLinkTap, - codeHighlighter: codeHighlighter, - ); -} - -enum _BlockType { - paragraph, - heading, - code, - bullet, - ordered, - quote, - table, - rule -} - -class _Block { - _Block.paragraph(this.text) - : type = _BlockType.paragraph, - level = 0, - language = null, - items = const [], - checks = const [], - rows = const []; - _Block.heading(this.level, this.text) - : type = _BlockType.heading, - language = null, - items = const [], - checks = const [], - rows = const []; - _Block.code(this.text, this.language) - : type = _BlockType.code, - level = 0, - items = const [], - checks = const [], - rows = const []; - _Block.quote(this.text) - : type = _BlockType.quote, - level = 0, - language = null, - items = const [], - checks = const [], - rows = const []; - _Block.list(this.type, this.items, {this.checks = const []}) - : level = 0, - language = null, - text = '', - rows = const []; - _Block.rule() - : type = _BlockType.rule, - level = 0, - language = null, - text = '', - items = const [], - checks = const [], - rows = const []; - _Block.table(this.rows) - : type = _BlockType.table, - level = 0, - language = null, - text = '', - items = const [], - checks = const []; - - final _BlockType type; - final String text; - final int level; - final String? language; - final List items; - - /// For task lists: per-item checkbox state (`true`/`false`), or empty for a - /// plain bullet/ordered list. Parallel to [items]. - final List checks; - - /// Table cells, first row being the header. Empty for non-tables. - final List> rows; -} - -List<_Block> _parseBlocks(String source) { - final lines = source.replaceAll('\r\n', '\n').split('\n'); - final blocks = <_Block>[]; - var i = 0; - - while (i < lines.length) { - final line = lines[i]; - final trimmed = line.trim(); - - if (trimmed.isEmpty) { - i++; - continue; - } - - // Fenced code block. - if (trimmed.startsWith('```')) { - final language = trimmed.substring(3).trim(); - final codeLines = []; - i++; - while (i < lines.length && !lines[i].trim().startsWith('```')) { - codeLines.add(lines[i]); - i++; - } - if (i < lines.length) i++; // skip closing fence - blocks.add( - _Block.code(codeLines.join('\n'), language.isEmpty ? null : language), - ); - continue; - } - - // Horizontal rule: three or more -, * or _ (optionally spaced), alone. - if (RegExp(r'^(?:-\s*){3,}$|^(?:\*\s*){3,}$|^(?:_\s*){3,}$') - .hasMatch(trimmed)) { - blocks.add(_Block.rule()); - i++; - continue; - } - - // Heading. - final heading = RegExp(r'^(#{1,6})\s+(.*)$').firstMatch(trimmed); - if (heading != null) { - blocks.add(_Block.heading(heading.group(1)!.length, heading.group(2)!)); - i++; - continue; - } - - // GFM table: a header row, a `---|---` separator, then body rows. - if (_isTableHeaderAt(lines, i)) { - final rows = >[_splitTableRow(trimmed)]; - i += 2; // header + separator - while (i < lines.length && - lines[i].trim().isNotEmpty && - lines[i].contains('|')) { - rows.add(_splitTableRow(lines[i].trim())); - i++; - } - blocks.add(_Block.table(rows)); - continue; - } - - // Blockquote (consecutive > lines). - if (trimmed.startsWith('>')) { - final quoteLines = []; - while (i < lines.length && lines[i].trim().startsWith('>')) { - quoteLines.add(lines[i].trim().replaceFirst(RegExp(r'^>\s?'), '')); - i++; - } - blocks.add(_Block.quote(quoteLines.join(' '))); - continue; - } - - // Task list (GFM checkboxes): `- [ ] todo` / `- [x] done`. - final task = RegExp(r'^[-*+]\s+\[([ xX])\]\s+'); - if (task.hasMatch(trimmed)) { - final items = []; - final checks = []; - while (i < lines.length && task.hasMatch(lines[i].trim())) { - final t = lines[i].trim(); - final m = task.firstMatch(t)!; - checks.add(m.group(1) != ' '); - items.add(t.substring(m.end)); - i++; - } - blocks.add(_Block.list(_BlockType.bullet, items, checks: checks)); - continue; - } - - // Unordered list. - if (RegExp(r'^[-*+]\s+').hasMatch(trimmed)) { - final items = []; - while (i < lines.length && - RegExp(r'^[-*+]\s+').hasMatch(lines[i].trim()) && - !task.hasMatch(lines[i].trim())) { - items.add(lines[i].trim().replaceFirst(RegExp(r'^[-*+]\s+'), '')); - i++; - } - blocks.add(_Block.list(_BlockType.bullet, items)); - continue; - } - - // Ordered list. - if (RegExp(r'^\d+\.\s+').hasMatch(trimmed)) { - final items = []; - while ( - i < lines.length && RegExp(r'^\d+\.\s+').hasMatch(lines[i].trim())) { - items.add(lines[i].trim().replaceFirst(RegExp(r'^\d+\.\s+'), '')); - i++; - } - blocks.add(_Block.list(_BlockType.ordered, items)); - continue; - } - - // Paragraph (consecutive non-blank, non-special lines). - // - // The first line here was already rejected by every block detector above, - // so it is genuinely paragraph text — always consume it. Only *subsequent* - // lines may break the paragraph. Gating the break on a non-empty paragraph - // guarantees `i` advances every outer iteration, so a partial stream that - // ends mid-construct (e.g. a lone `#` before its space arrives) can never - // spin this loop forever. - final paragraph = []; - while (i < lines.length && lines[i].trim().isNotEmpty) { - final t = lines[i].trim(); - if (paragraph.isNotEmpty && - (t.startsWith('```') || - RegExp(r'^#{1,6}\s+').hasMatch(t) || - t.startsWith('>') || - _isTableHeaderAt(lines, i) || - RegExp(r'^(?:-\s*){3,}$|^(?:\*\s*){3,}$|^(?:_\s*){3,}$') - .hasMatch(t) || - RegExp(r'^[-*+]\s+').hasMatch(t) || - RegExp(r'^\d+\.\s+').hasMatch(t))) { - break; - } - paragraph.add(t); - i++; - } - if (paragraph.isNotEmpty) blocks.add(_Block.paragraph(paragraph.join(' '))); - } - - return blocks; -} - -/// True if line [i] is a table header (contains a pipe) followed by a -/// `---|:--:` separator row. -bool _isTableHeaderAt(List lines, int i) { - if (i + 1 >= lines.length) return false; - if (!lines[i].contains('|')) return false; - final sep = lines[i + 1].trim(); - return sep.contains('-') && - sep.contains('|') && - RegExp(r'^[\s|:-]+$').hasMatch(sep); -} - -/// Splits a `| a | b |` row into trimmed cells, dropping the outer pipes. -List _splitTableRow(String line) { - var s = line.trim(); - if (s.startsWith('|')) s = s.substring(1); - if (s.endsWith('|')) s = s.substring(0, s.length - 1); - return s.split('|').map((c) => c.trim()).toList(); -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_shimmer.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_shimmer.dart deleted file mode 100644 index d1f2a51..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_shimmer.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_ai_elements/src/l10n/ai_localizations.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; - -/// An animated shimmer placeholder for pending content — a row of grey bars -/// with a highlight sweeping across them. -class AiShimmer extends StatefulWidget { - /// Creates a shimmer with [lines] placeholder bars. - const AiShimmer({super.key, this.lines = 3, this.spacing = 10}); - - /// Number of placeholder bars. - final int lines; - - /// Vertical gap between bars. - final double spacing; - - @override - State createState() => _AiShimmerState(); -} - -class _AiShimmerState extends State - with SingleTickerProviderStateMixin { - late final AnimationController _controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 1300), - ); - bool _reduceMotion = false; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _reduceMotion = MediaQuery.maybeDisableAnimationsOf(context) ?? false; - if (_reduceMotion) { - _controller.stop(); - } else if (!_controller.isAnimating) { - unawaited(_controller.repeat()); - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = AiThemeExtension.of(context); - final base = theme.borderColor; - // A clearly lighter sweep that works in both light and dark themes. - final highlight = Color.lerp(base, Colors.white, 0.5)!; - - final bars = Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - for (var i = 0; i < widget.lines; i++) ...[ - if (i > 0) SizedBox(height: widget.spacing), - FractionallySizedBox( - widthFactor: i == widget.lines - 1 ? 0.55 : 1, - child: Container( - height: 12, - decoration: BoxDecoration( - color: base, - borderRadius: BorderRadius.circular(6), - ), - ), - ), - ], - ], - ); - - return Semantics( - label: AiLocalizations.of(context).loading, - // Static grey bars (no sweep) under reduce-motion (WCAG 2.3.3). - child: _reduceMotion - ? bars - : AnimatedBuilder( - animation: _controller, - builder: (context, child) { - // Travel the highlight fully across (off-left → off-right) so - // the loop is seamless — it's off-screen at both ends. - final c = -1.5 + 3.0 * _controller.value; - return ShaderMask( - blendMode: BlendMode.srcATop, - shaderCallback: (rect) => LinearGradient( - begin: Alignment(c - 0.7, 0), - end: Alignment(c + 0.7, 0), - colors: [base, highlight, base], - stops: const [0.0, 0.5, 1.0], - ).createShader(rect), - child: child, - ); - }, - child: bars, - ), - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_sources.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_sources.dart deleted file mode 100644 index bf2843d..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_sources.dart +++ /dev/null @@ -1,229 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_ai_core/flutter_ai_core.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; -import 'package:flutter_ai_elements/src/widgets/ai_haptics.dart'; - -/// A wrapped list of citation chips built from [SourcePart]s. -/// -/// Render it beneath an answer to show where the model's information came from. -/// Tapping a chip invokes [onTap] (wire it to a URL launcher). -/// -/// Grounded answers can return dozens of sources, so by default only the first -/// [maxVisible] chips are shown with a "+N more" toggle; tapping it reveals the -/// rest. Set [maxVisible] to `null` to always show every source. -class AiSources extends StatefulWidget { - /// Creates a sources strip. - const AiSources({ - super.key, - required this.sources, - this.onTap, - this.maxVisible = 6, - this.showFavicons = false, - }); - - /// The citations to display. - final List sources; - - /// Called with the tapped source. - final void Function(SourcePart source)? onTap; - - /// How many chips to show before collapsing the rest behind a "+N more" - /// toggle. `null` shows all sources. - final int? maxVisible; - - /// Whether to fetch and show a per-source favicon. - /// - /// Off by default: favicons are fetched from a third-party service - /// (Google's favicon endpoint), which makes a network request per host and - /// discloses the cited hosts to that service. Enable it only when that - /// trade-off is acceptable; chips always fall back to a link glyph offline. - final bool showFavicons; - - @override - State createState() => _AiSourcesState(); -} - -class _AiSourcesState extends State { - bool _expanded = false; - - @override - Widget build(BuildContext context) { - final sources = widget.sources; - if (sources.isEmpty) return const SizedBox.shrink(); - final theme = AiThemeExtension.of(context); - - final cap = widget.maxVisible; - final collapsible = cap != null && sources.length > cap; - final visible = - (collapsible && !_expanded) ? sources.take(cap).toList() : sources; - final hiddenCount = collapsible ? sources.length - cap : 0; - - return Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (var i = 0; i < visible.length; i++) - _SourceChip( - index: i + 1, - label: visible[i].title ?? visible[i].url.host, - url: widget.showFavicons ? visible[i].url : null, - theme: theme, - onTap: widget.onTap == null - ? null - : () { - aiLightHaptic(theme); - widget.onTap!(visible[i]); - }, - ), - if (collapsible) - _SourceChip( - label: _expanded ? 'Show less' : '+$hiddenCount more', - icon: _expanded - ? Icons.expand_less_rounded - : Icons.expand_more_rounded, - theme: theme, - onTap: () => setState(() => _expanded = !_expanded), - ), - ], - ); - } -} - -class _SourceChip extends StatefulWidget { - const _SourceChip({ - required this.label, - required this.theme, - required this.onTap, - this.index, - this.url, - this.icon = Icons.link, - }); - - final String label; - final AiThemeExtension theme; - final VoidCallback? onTap; - - /// 1-based citation index, shown as a leading badge. Null for the toggle. - final int? index; - - /// The source URL, used to fetch a favicon. Null falls back to [icon]. - final Uri? url; - final IconData icon; - - @override - State<_SourceChip> createState() => _SourceChipState(); -} - -class _SourceChipState extends State<_SourceChip> { - bool _hovered = false; - - @override - Widget build(BuildContext context) { - final theme = widget.theme; - final fg = theme.assistantTextColor; - return Material( - // Subtle hover lift: blend toward the border color on pointer-over. - color: _hovered - ? Color.lerp(theme.assistantBubbleColor, theme.borderColor, 0.5) - : theme.assistantBubbleColor, - borderRadius: BorderRadius.circular(16), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: widget.onTap, - onHover: (h) => setState(() => _hovered = h), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (widget.index != null) ...[ - _IndexBadge(index: widget.index!, theme: theme), - const SizedBox(width: 6), - ], - if (widget.url != null) - _Favicon(url: widget.url!, fallback: widget.icon, color: fg) - else - Icon(widget.icon, size: 14, color: fg), - const SizedBox(width: 6), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 200), - child: Text( - widget.label, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: fg, fontSize: 13), - ), - ), - ], - ), - ), - ), - ); - } -} - -/// A small numeric badge for a citation's index. -class _IndexBadge extends StatelessWidget { - const _IndexBadge({required this.index, required this.theme}); - - final int index; - final AiThemeExtension theme; - - @override - Widget build(BuildContext context) { - return Container( - constraints: const BoxConstraints(minWidth: 16), - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: theme.accentColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(5), - ), - child: Text( - '$index', - textAlign: TextAlign.center, - style: theme.codeStyle.copyWith( - fontSize: 11, - height: 1.3, - color: theme.assistantTextColor, - ), - ), - ); - } -} - -/// Best-effort favicon for [url]'s host, degrading to [fallback] on any error -/// (offline, blocked, unknown host) so the chip always renders something. -class _Favicon extends StatelessWidget { - const _Favicon({ - required this.url, - required this.fallback, - required this.color, - }); - - final Uri url; - final IconData fallback; - final Color color; - - @override - Widget build(BuildContext context) { - final host = url.host; - final icon = Icon(fallback, size: 14, color: color); - if (host.isEmpty) return icon; - final src = Uri.https('www.google.com', '/s2/favicons', { - 'domain': host, - 'sz': '32', - }); - return ClipRRect( - borderRadius: BorderRadius.circular(3), - child: Image.network( - src.toString(), - width: 14, - height: 14, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => icon, - // Avoid a flash of broken layout while loading: keep the fallback until - // the first frame is available. - frameBuilder: (_, child, frame, ___) => frame == null ? icon : child, - ), - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_suggestions.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_suggestions.dart deleted file mode 100644 index 580bf6a..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_suggestions.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; -import 'package:flutter_ai_elements/src/widgets/ai_haptics.dart'; - -/// A horizontally scrolling row of tappable suggested prompts. -/// -/// Useful as a conversation starter or for follow-up suggestions; tapping a chip -/// invokes [onSelected] with its text. -class AiSuggestions extends StatelessWidget { - /// Creates a suggestions strip. - const AiSuggestions({ - super.key, - required this.suggestions, - required this.onSelected, - this.padding = const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - }); - - /// The prompt texts to offer. - final List suggestions; - - /// Called with the chosen suggestion. - final ValueChanged onSelected; - - /// Padding around the strip. - final EdgeInsets padding; - - @override - Widget build(BuildContext context) { - final theme = AiThemeExtension.of(context); - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: padding, - child: Row( - children: [ - for (var i = 0; i < suggestions.length; i++) ...[ - if (i > 0) const SizedBox(width: 8), - _Chip( - label: suggestions[i], - theme: theme, - onTap: () { - aiLightHaptic(theme); - onSelected(suggestions[i]); - }, - ), - ], - ], - ), - ); - } -} - -class _Chip extends StatelessWidget { - const _Chip({required this.label, required this.theme, required this.onTap}); - - final String label; - final AiThemeExtension theme; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - return Material( - color: theme.effectiveChipColor, - borderRadius: BorderRadius.circular(20), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - child: Text( - label, - style: TextStyle(color: theme.assistantTextColor), - ), - ), - ), - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_task.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_task.dart deleted file mode 100644 index bc4d444..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_task.dart +++ /dev/null @@ -1,184 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; - -/// The state of an [AiTaskItem]. -enum AiTaskStatus { - /// Not started yet. - pending, - - /// Currently running. - active, - - /// Finished successfully. - complete, - - /// Failed. - error, -} - -/// One line item within an [AiTask]. -@immutable -class AiTaskItem { - /// Creates a task item. - const AiTaskItem({required this.label, this.status = AiTaskStatus.pending}); - - /// The item text (a step, a file name, …). - final String label; - - /// The item's status, which selects its leading icon. - final AiTaskStatus status; -} - -/// A collapsible "task" card showing a titled checklist the agent works -/// through — each item with a pending/active/complete/error indicator. -class AiTask extends StatefulWidget { - /// Creates a task card. - const AiTask({ - super.key, - required this.title, - required this.items, - this.initiallyExpanded = true, - }); - - /// The task headline. - final String title; - - /// The checklist items. - final List items; - - /// Whether the card starts expanded. - final bool initiallyExpanded; - - @override - State createState() => _AiTaskState(); -} - -class _AiTaskState extends State { - late bool _expanded = widget.initiallyExpanded; - - @override - Widget build(BuildContext context) { - final theme = AiThemeExtension.of(context); - final color = DefaultTextStyle.of(context).style.color; - final done = widget.items.where((i) => i.status == AiTaskStatus.complete); - - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: theme.borderColor), - ), - clipBehavior: Clip.antiAlias, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Semantics( - button: true, - expanded: _expanded, - child: InkWell( - onTap: () => setState(() => _expanded = !_expanded), - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - children: [ - Icon(Icons.checklist_rtl, size: 16, color: color), - const SizedBox(width: 8), - Expanded( - child: Text( - widget.title, - style: theme.textStyle.copyWith( - color: color, - fontSize: 14.5, - fontWeight: FontWeight.w600, - ), - overflow: TextOverflow.ellipsis, - ), - ), - Text( - '${done.length}/${widget.items.length}', - style: theme.codeStyle.copyWith( - color: color?.withValues(alpha: 0.6), - fontSize: 12, - ), - ), - Icon( - _expanded ? Icons.expand_less : Icons.expand_more, - size: 18, - color: color?.withValues(alpha: 0.6), - ), - ], - ), - ), - ), - ), - AnimatedSize( - duration: theme.motionDuration, - curve: theme.motionCurve, - alignment: Alignment.topCenter, - child: _expanded - ? Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final item in widget.items) - _ItemRow(item: item, theme: theme, textColor: color), - ], - ), - ) - : const SizedBox(width: double.infinity), - ), - ], - ), - ); - } -} - -class _ItemRow extends StatelessWidget { - const _ItemRow({ - required this.item, - required this.theme, - required this.textColor, - }); - - final AiTaskItem item; - final AiThemeExtension theme; - final Color? textColor; - - @override - Widget build(BuildContext context) { - final (icon, color) = switch (item.status) { - AiTaskStatus.complete => ( - Icons.check_circle, - theme.successColor, - ), - AiTaskStatus.active => (Icons.adjust, theme.accentColor), - AiTaskStatus.error => (Icons.error, theme.errorColor), - AiTaskStatus.pending => ( - Icons.radio_button_unchecked, - textColor?.withValues(alpha: 0.4) ?? const Color(0xFF999999), - ), - }; - final faded = item.status == AiTaskStatus.pending; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(icon, size: 16, color: color), - const SizedBox(width: 8), - Expanded( - child: Text( - item.label, - style: theme.textStyle.copyWith( - color: faded ? textColor?.withValues(alpha: 0.6) : textColor, - fontSize: 14, - ), - ), - ), - ], - ), - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_tool_group.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_tool_group.dart deleted file mode 100644 index 1ad21fc..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_tool_group.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_ai_core/flutter_ai_core.dart'; -import 'package:flutter_ai_elements/src/widgets/ai_tool_invocation.dart'; - -/// A vertically stacked list of [AiToolInvocation] cards — the recommended way -/// to present parallel tool calls. -/// -/// Each call is paired with its result (by `toolCallId`) from [results], so the -/// user can inspect every action independently. -class AiToolGroup extends StatelessWidget { - /// Creates a tool group for [calls], pairing each with its result in - /// [results] (keyed by `toolCallId`). - const AiToolGroup({ - super.key, - required this.calls, - this.results = const {}, - this.spacing = 8, - }); - - /// The tool calls to display, in order. - final List calls; - - /// Results keyed by `toolCallId`. - final Map results; - - /// Vertical gap between cards. - final double spacing; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - for (var i = 0; i < calls.length; i++) ...[ - if (i > 0) SizedBox(height: spacing), - AiToolInvocation( - call: calls[i], - result: results[calls[i].toolCallId], - ), - ], - ], - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_tool_invocation.dart b/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_tool_invocation.dart deleted file mode 100644 index ce4357e..0000000 --- a/packages/flutter_ai/flutter_ai_elements/lib/src/widgets/ai_tool_invocation.dart +++ /dev/null @@ -1,190 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter_ai_core/flutter_ai_core.dart'; -import 'package:flutter_ai_elements/src/theme/ai_theme_extension.dart'; - -/// A collapsible card showing a single tool call: its name, lifecycle state, -/// arguments, and (once available) result. -/// -/// Stacking several of these vertically is the intended way to present parallel -/// tool calls — see `AiToolGroup`. -class AiToolInvocation extends StatefulWidget { - /// Creates a tool-invocation card for [call] with an optional [result]. - const AiToolInvocation({ - super.key, - required this.call, - this.result, - this.initiallyExpanded = false, - }); - - /// The tool call to display. - final ToolCallPart call; - - /// The matching result, if it has arrived. - final ToolResultPart? result; - - /// Whether the card starts expanded. - final bool initiallyExpanded; - - @override - State createState() => _AiToolInvocationState(); -} - -class _AiToolInvocationState extends State { - late bool _expanded = widget.initiallyExpanded; - - @override - Widget build(BuildContext context) { - final theme = AiThemeExtension.of(context); - final baseColor = DefaultTextStyle.of(context).style.color; - final (icon, iconColor) = _statusVisual(context); - - return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: (baseColor ?? const Color(0xFF000000)).withValues(alpha: 0.18), - ), - ), - clipBehavior: Clip.antiAlias, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Semantics( - button: true, - expanded: _expanded, - child: InkWell( - onTap: () => setState(() => _expanded = !_expanded), - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - children: [ - Icon(icon, size: 16, color: iconColor), - const SizedBox(width: 8), - Expanded( - child: Text( - widget.call.toolName, - style: theme.codeStyle.copyWith(color: baseColor), - overflow: TextOverflow.ellipsis, - ), - ), - Text( - _stateLabel(widget.call.state), - style: theme.codeStyle.copyWith( - color: baseColor?.withValues(alpha: 0.6), - fontSize: 12, - ), - ), - Icon( - _expanded ? Icons.expand_less : Icons.expand_more, - size: 18, - color: baseColor?.withValues(alpha: 0.6), - ), - ], - ), - ), - ), - ), - AnimatedSize( - duration: theme.motionDuration, - curve: theme.motionCurve, - alignment: Alignment.topCenter, - child: _expanded - ? Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _Section( - label: 'Arguments', - body: _pretty(widget.call.args), - style: theme.codeStyle.copyWith(color: baseColor), - ), - if (widget.result != null) ...[ - const SizedBox(height: 8), - _Section( - label: widget.result!.isError ? 'Error' : 'Result', - body: _pretty(widget.result!.result), - style: theme.codeStyle.copyWith(color: baseColor), - ), - ], - ], - ), - ) - : const SizedBox(width: double.infinity), - ), - ], - ), - ); - } - - (IconData, Color) _statusVisual(BuildContext context) { - final theme = AiThemeExtension.of(context); - final base = - DefaultTextStyle.of(context).style.color ?? const Color(0xFF000000); - return switch (_effectiveState()) { - ToolCallState.error => (Icons.error_outline, theme.errorColor), - ToolCallState.outputAvailable => ( - Icons.check_circle_outline, - theme.successColor, - ), - _ => (Icons.build_outlined, base), - }; - } - - // A result marked error overrides the call's own state for display. - ToolCallState _effectiveState() { - if (widget.result?.isError ?? false) return ToolCallState.error; - return widget.call.state; - } - - static String _stateLabel(ToolCallState state) => switch (state) { - ToolCallState.inputStreaming => 'preparing…', - ToolCallState.inputAvailable => 'ready', - ToolCallState.executing => 'running…', - ToolCallState.outputAvailable => 'done', - ToolCallState.error => 'error', - }; - - static String _pretty(Object? value) { - try { - return const JsonEncoder.withIndent(' ').convert(value); - } on Object { - return '$value'; - } - } -} - -class _Section extends StatelessWidget { - const _Section({ - required this.label, - required this.body, - required this.style, - }); - - final String label; - final String body; - final TextStyle style; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: style.copyWith( - fontSize: 12, - fontWeight: FontWeight.w600, - color: style.color?.withValues(alpha: 0.6), - ), - ), - const SizedBox(height: 2), - Text(body, style: style), - ], - ); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/pubspec.yaml b/packages/flutter_ai/flutter_ai_elements/pubspec.yaml deleted file mode 100644 index 0188e23..0000000 --- a/packages/flutter_ai/flutter_ai_elements/pubspec.yaml +++ /dev/null @@ -1,52 +0,0 @@ -name: flutter_ai_elements -description: "Composable, themeable Flutter UI for AI chat: conversation view, message bubbles, a streaming-aware composer, and a loader, styled through a mobile-first theme extension." -version: 0.2.0 -homepage: https://github.com/ananmouaz/flutter_ai -repository: https://github.com/ananmouaz/flutter_ai/tree/main/packages/flutter_ai_elements -issue_tracker: https://github.com/ananmouaz/flutter_ai/issues - -topics: - - ai - - llm - - chat - - streaming - - widgets - -# Rendered on the pub.dev listing (up to 5). -screenshots: - - description: "A streamed assistant message with Markdown formatting." - path: screenshots/element_message_assistant.png - - description: "Collapsible chain-of-thought reasoning disclosure." - path: screenshots/element_reasoning.png - - description: "A tool invocation card with arguments and result." - path: screenshots/element_tool_invocation.png - - description: "Syntax-highlighted code block with a copy action." - path: screenshots/element_code_block.png - - description: "Grounded answer with source citations." - path: screenshots/element_sources.png - -environment: - sdk: ^3.6.0 - flutter: ">=3.27.0" - -# Pure Flutter widgets (no platform channels) — supported everywhere Flutter is. -platforms: - android: - ios: - linux: - macos: - web: - windows: - -resolution: workspace - -dependencies: - flutter: - sdk: flutter - flutter_ai_client: ^0.3.0 - flutter_ai_core: ^0.1.11 - -dev_dependencies: - flutter_test: - sdk: flutter - lints: ^5.0.0 diff --git a/packages/flutter_ai/flutter_ai_elements/screenshots/element_code_block.png b/packages/flutter_ai/flutter_ai_elements/screenshots/element_code_block.png deleted file mode 100644 index d819c45c874be935b60feb9dfd55a516dab92125..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7158 zcmb_hbx_pbyIxWnmhM=P5L`N>W63WlAkrXRONgYjAR(zDor2PhG^-*YUD93BNO#`D zy)$>_-aGUA<2Uy+J1o1O*mKVNzR&Z#&v~o)SeX!y77qe}5UQ#mv>^~QJMe!t4mSA9 z!gW#)-Z0(dRdsN{;fG@t0Y0O-X)DV^%KGUyAP~B2RfL?5clzI1Umcy%rF(n3^fZhI zA2IJ~F-B6t3aNUPQ)x_pG*jkTpLI9CS>^6i6G9oho<%-*FhQ{lZF3p`^(1u&%DINEjJoWp2+0UbwqE z)?QfmRoXdfX+cTJ$o5C|o-fX!d{@h2BRY8;9Ub{RxAoQ2LX^ko{SeGvw`o&CxTORyF zgv!C=OiB9#0S(4MKkauBjLNH>(((7)pFK+`jADa*kaV+RvaM$QACW3DSPNl?35ecP z<{}(uWvG#9wi4cbkI`zCJmjF&Y+WAeXPn4qF&HYV}FRLw)@en z{X-njEP1Y~K=$l(jk4>$#)yudQ6m@P2!~GGkKE_T2m9T|T)A%Hi&Y&F&s`PdP96>7 z0~Kz5Z#q8HJUk}wlR9<^bJtT?j*ZRu6eH@A=#AR5C?nC^!E@zO-Q|>kftLQVnlEX^ z@oc_>8l!-HAepo67`H9rqocz+Ic(I^xBEw0<0lnV^|*-DlKPw$ugel^Xi zm!$mSr3QgG8Bbeis)*gh*LIQS%Ok9`DT(cvm}GE*gYX+})ADqX^eUylm;%Wy{%nyg zu9gLK04csu$- zHdqxw)M<8)+OBLNj{=y{-rrRZ??ycr*Q~q_V?&>NV`C!To`WQipzJl>#_n!Buud#V zM!Ait!n|D(_n!L$zB>c~VyT6P$?~y z5);F|`Cp3s)8y{z>8Vt1ii(EY{)(p2ud>25HZiHGZE9#(+zq^y$a|9I?9KpLSXl}6 zzdSJ?FEe51iiP`;_WWtT%9Tn!S(eS7US{v&P?Bv$` ztN^?OHR}xy%>;2m!DR2k~5GpD-5ZO*j}^75!=(!mI1+PrAjfE8Pi7 zb)u}S7;0Zg%gV|MN=n*lkmG|5Znos)varYm!PSX{Ew*l@Ozp>~<97#@}e-B_VFfi!s?hZCA4XdtpLp3JC zO(KJWAXit10}~By98+~J^Xn&5Q&aNrq~*@{tah`F!XO=7ug+Y(eSIx|1ffBKMvL`w z|4D*RGvGU>Z$bJw=89ffSsCN(qN__u$!i+D`~8-YhKA;j%|W0CbCrqdrTwTm zo@BPgGDz$lk3Wxc%HX%eM#sQ#-5lY9{K!#^f5MkXdPP*$bvoE-FE<^`}y zcPR>%qTNzxp0lW^h}N-MyYP+Lm*D(-+uQ*vNN{lQ-p%DM2E=n`X7aN@M1d>MTnKiM z%eQmvnwpwqF;^JG>`&)=PHt`mPfrm@wsIUJBUT#i~d`T`C&s;l# zgCjpYtXfG!gD^ckopuyECV^Y;XGh09I#K7gP<*OX9c-v@EE{@bULN-3-n z!{YLC$ZV5W)MaUDDH;R}bxB0y#pJ{UwNCh|m_lnWbyrsxwSW~)nMqUF*RMjQ#8{RE zqnkd{Ma9KKfX+fpdE>h@pFSP_+^AP-7`n7%5lthQr>dZU81epqV>favURqL8@p45i zdq?+C%RB5R6B^0^;fE3DV>jn62yCo_Y4p>$sMXdl()HxGx*% zp-?C!==$n{kRMgnJtP2No4j@{x<7I)ow=Bq zFhw2@n+w)@1(5_ z0WO8X;Y8IoeLs)4#?c^8WUjw@ZwF)HgGaH>(R{K;T3LP8Pb_1JxmRZopNOFqE@}~p zqkp8mb(W`HJi7D_kXn*@DK}O$n2A&O(x$72N2uBtc~#Z7TGk_DV@v%R66{XX*O+oa zb#uHzoDN^ZZW;a*o6*V>OKSS|7c5Xo|%q=Zh z=fdow0Vx0M>mz&~aQg;SeF74>gpO9L| z$>2wBs|#1>he}C`2twaKVPYyWq4F%TUUXF%5$ao&OiRqyN1=SJDeFUR|EHsQ2)!61 zWAph4(8?91MrI+qWnNT+PFNXm?Sneyi(`su5U3OfDe2h8COgLF(J~gx zU(s@ghN)*94;~cF@dGL|?4TA8$$kVfl?9LtU^E3?-Ndux?<{MFV!jhT8F-=S0N3p* zqRYSE7`lX8EVaFz_~tKRY-}9u%*~&DEZ|uxtEZ=@o*{nx>HGFw1|-2v_}o_2GcL1y zHo49&FUO~M*4Dy7z@x}ckd8C;TIt9s60qc&5AI%jM@I-*Sy_~&&?S~!5NIUYZL7DY z>v(f=ax|Yk3kB76hcw@BZzRkX&Z!*$eR{7X=+R29_KD7aUC(KaIj%Ss*DEtFtRV@5 zgL>H8+Z$|nB4~S-DvXSFj}+E=`f$I(z=E9bPR<2V2SPi3G%4$DasW`+4Gj(Jb0`T-(cx+E@oNB!*Y?C~kV#l4CnrEYya6Ip zTU-0x$B&rQ6}o2b?m{qD*7vEYk)53?ohvWNDJaYj{&dxQ{-tehZmzH&X2GW3|t*gX3H?4c{Ir@cMY1-)TyS$EcP7fR_j$4^0CDE}}{59NU4+h?tmm zvL}*gHHI-if6Di!KO)mBH^m|_F0bY%Vw5bXQ$T}+6Ei6(Dq^u~q<$97282XHK@keB z1 z_(1&xB_&aX5zqvKw5YuL{ad@V3arE1-@kujuTf5Z4-5=h`@66(xRC6oriRyyLRBKi z;WJHM!~pzNR8@zXdUcNQ1By+kZ!P8kM~VzsN5HAd(`Q#DNuz0x7S_- z4C3UBjEqZq`@j4q^o@)Z;7P4RLs~PdOG`i%!Lb8@;_1(niWhVYj{B|?a)0vZcpk!nrfZkXEi4V;ulai9&EeDX%^-WEd zZ4pov6%`VhuR>cM=s=!fxR;GjQirJz`|ivBvj`$8qt$PyDAl=>Fd%x&h?i3)q zQRC*U*LAHjf@_IxfuX6Vq+`x|=#Xpu8Dg&vKA-Jg+Lmk$=MzJig@kmPH_IBfa(z#C*zrL_%>*nJWdhp~e=-^g zPaa80NlkryyAM8$_hbBbg}HHsbzi-D1jHb}*N(B@(U3CWr_h)fjrcWjQHs9qU635& z7^wuqOXwE?smJ%Qs_Sp#T~ul5!?E(`#3}0RMERj%VL6r^u^JC}MMdkndUB*D z;E;OX)3PCJseUkjB)tFA4rR^erbBgDwR9?Rc-Xhcx;i>SuM(UT(j~lz%D-JaIN6>I zx#NauCnqNjA6pf3a~5=TbWmCxVOlR<@QpZl?BA!S6F}BMmd9f>^4Hmkw9U>kEFesjkgjMb055E8Y|LzIn1Jp( z-jZ?99{W5krSRTgQq11zkI4$S!2LT_(+c#+XAik8pbgfiYLK~891IZd zu7TX%c4WJMpM{?v3Iyy~hs@3Efa{CJjr^>!hMPseRCi4q!0Q2?wX2Fltp^DOMMd&< zcANmyB7oKgC9Y#@0Qz8sa3TgipCh|JtE)dpM=4TLQVcsd6Vxy2R zseK8_BvPUUh7UR-W*#1OV}Glz5BCkJ2#D#$g233p*r=Pu&qLh(B?iBi0mt%OGngaNq&OT-I2?#hHJ{_?M2#~(>3;@V14SrIf z7P2ErjrLbu=i&IJOl_LW^P|KUYp5)%{W-uWGM zEm)DWv$LNxIR^ns3wtUw$|Ss0cGX$-ZPt+oTNcHe<~Cc7ysGXiUByRlu8{m zcHjp)`}!2!-M<=4o4dFW1Jm`-+FF`NX9w^)L|x{{!@YMnaL{8Jr6QxKcxh20IhB}l zcQ%A8a4oRsiThj~Y8vMh_-R28F*i5I$;CDFO7HR0d0!+F={kCG&t9B>wzjtRPIJl0 zp@F7}uxwc_nE6L{x7ymkfaY>XT*p`YVLtPpm?)|o!1??A61&WBcv(xh0i$qUX2tjR ziA1^W$6VOsMdTfQAAFibdXMbT97N!8P zmIsa6m$ZXUZ@!2xJMmAXTL8X*P9_~YE=?Fo2|Re7muR;vxlYO;A$#JUzNB`6j&@BnApl{Mc+FnWPgz%PM`ic&H8 z?Y$lnY=%N>FhAhhfs6s-6|@_+S^i_c`&eLAVqKU*o0-^{K|nd_178|J$x#?IR?@k+ zyL)FLLk-eDqhD-}76Sx@-{}T`HU?{K{J@~a@49~{`?j~Y`CR6jvGuUnVGbK?qG=`; z7BB|~hYJ5oPXGyqjgP=HSqp=lr)wVqeez;&L5|0us_j32oKuz1)ou*!k6#PeBEbh; zsS$>8QAPyLJ40C{qo%!Gr7A3^Bh(7r2S`cK42e}$S3lYig}(m?C!MQz#o*Q}YXdd& zaF!efQ(Z`XgFvvu&V_LwkugA}Vgd7MnC5k1O!f8kjVO{rRWlz|4g5i4AR#f^gn=EH z0dV}WPL<3C<&ON%Z(Ihm<=AGt9#vMde@iC%I-Yob(0CM4megFUf diff --git a/packages/flutter_ai/flutter_ai_elements/screenshots/element_message_assistant.png b/packages/flutter_ai/flutter_ai_elements/screenshots/element_message_assistant.png deleted file mode 100644 index 301ea8428584b831108db8a2a00123f7d3dca576..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19230 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 DpejPB diff --git a/packages/flutter_ai/flutter_ai_elements/screenshots/element_reasoning.png b/packages/flutter_ai/flutter_ai_elements/screenshots/element_reasoning.png deleted file mode 100644 index 3afa2b4d399a399e85a89585615a0cb7bd49f2cf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9727 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 RUf@9sV61PVSEb_^^Iz>QgggKM diff --git a/packages/flutter_ai/flutter_ai_elements/screenshots/element_tool_invocation.png b/packages/flutter_ai/flutter_ai_elements/screenshots/element_tool_invocation.png deleted file mode 100644 index 26922f63dafdcd93f8e521db65d881276794142d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16093 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 TCaJ;m=@3aVxwl0kAHMt#IUMy} diff --git a/packages/flutter_ai/flutter_ai_elements/test/ai_chat_scroll_test.dart b/packages/flutter_ai/flutter_ai_elements/test/ai_chat_scroll_test.dart deleted file mode 100644 index ad166ab..0000000 --- a/packages/flutter_ai/flutter_ai_elements/test/ai_chat_scroll_test.dart +++ /dev/null @@ -1,112 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_ai_elements/flutter_ai_elements.dart'; -import 'package:flutter_test/flutter_test.dart'; - -/// A provider that streams a multi-line assistant reply so the chat has more -/// content than fits the viewport — the case where top-pinning matters. -class _StreamingProvider implements LlmProvider { - @override - Stream send( - AiConversation conversation, { - List? tools, - AiRequestOptions? options, - }) async* { - // A short reply: shorter than the viewport, so the chat must RESERVE - // trailing space for the anchored question to reach the top. - yield const MessageStarted(messageId: 'a-new', role: AiRole.assistant); - yield const TextDelta(messageId: 'a-new', delta: 'Short streamed answer.'); - yield const MessageFinished(messageId: 'a-new', reason: FinishReason.stop); - } -} - -Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); - -void main() { - group('AiChat top-pin scroll', () { - testWidgets('anchors the just-sent user message to the viewport top', - (tester) async { - // Fix the viewport to a small phone-ish size so prior content overflows - // and the anchor genuinely has to scroll to the top. - tester.view.physicalSize = const Size(400, 800); - tester.view.devicePixelRatio = 1.0; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - // A backlog of prior turns, each tall enough that the transcript is far - // taller than the 800px viewport. - final longText = List.filled( - 16, - 'This is a fairly long prior line of the conversation.', - ).join(' '); - final initial = AiConversation( - id: 'c', - messages: [ - for (var i = 0; i < 4; i++) ...[ - AiMessage( - id: 'u$i', - role: AiRole.user, - parts: [TextPart('Question $i')], - ), - AiMessage( - id: 'a$i', - role: AiRole.assistant, - parts: [TextPart(longText)], - status: AiMessageStatus.complete, - ), - ], - ], - ); - final controller = - UseChatController(provider: _StreamingProvider(), initial: initial); - addTearDown(controller.dispose); - - await tester.pumpWidget(_wrap(AiChat(controller: controller))); - await _settle(tester); - - // Send a new user turn; the assistant reply streams in below it. - await controller.sendText('the new question'); - await _settle(tester); - - final question = find.text('the new question'); - expect(question, findsOneWidget); - - // The anchored user message must be pinned at/near the top of the AiChat - // viewport (ChatGPT-style), NOT mid-screen with prior answers above it. - final chatTop = tester.getTopLeft(find.byType(AiChat)).dy; - final qTop = tester.getTopLeft(question).dy; - expect( - qTop - chatTop, - lessThan(80), - reason: 'newly-sent question should pin to the top, was ' - '${qTop - chatTop}px below the chat top', - ); - - // Trailing space must be reserved beneath the last item so the anchor can - // reach the top even though the streamed answer is shorter than the - // viewport. The reservation lives on AiConversationView.trailingSpace. - final view = tester.widget( - find.byType(AiConversationView), - ); - expect( - view.trailingSpace, - greaterThan(0), - reason: 'trailing space must be reserved so the anchor can reach the ' - 'top, was ${view.trailingSpace}', - ); - // The view must have an anchor wired up (the just-sent user message), - // matching the id of the controller's last user message. - final lastUserId = - controller.messages.lastWhere((m) => m.role == AiRole.user).id; - expect(view.anchorId, lastUserId); - }); - }); -} - -/// Pumps a bounded number of frames so [AiChat]'s post-frame `_settle()` retry -/// loop can run. We can't `pumpAndSettle` — the streaming caret blinks forever, -/// so the tree never reaches a steady state. -Future _settle(WidgetTester tester) async { - for (var i = 0; i < 20; i++) { - await tester.pump(const Duration(milliseconds: 16)); - } -} diff --git a/packages/flutter_ai/flutter_ai_elements/test/dogfood_apis_test.dart b/packages/flutter_ai/flutter_ai_elements/test/dogfood_apis_test.dart deleted file mode 100644 index c75f20e..0000000 --- a/packages/flutter_ai/flutter_ai_elements/test/dogfood_apis_test.dart +++ /dev/null @@ -1,153 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_ai_elements/flutter_ai_elements.dart'; -import 'package:flutter_test/flutter_test.dart'; - -Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); - -/// Echoes a fixed assistant reply. -class _EchoProvider implements LlmProvider { - @override - Stream send( - AiConversation conversation, { - List? tools, - AiRequestOptions? options, - }) async* { - yield const MessageStarted(messageId: 'a1', role: AiRole.assistant); - yield const TextDelta(messageId: 'a1', delta: 'Echo reply'); - yield const MessageFinished(messageId: 'a1', reason: FinishReason.stop); - } -} - -/// A test double for [AiVoiceEngine] whose turns are advanced by the test. -class _FakeEngine implements AiVoiceEngine { - int listenCalls = 0; - int speakCalls = 0; - String? lastSpoken; - void Function(String finalText)? _onListenDone; - void Function()? _onSpeakDone; - - void finishListening(String text) => _onListenDone?.call(text); - void finishSpeaking() => _onSpeakDone?.call(); - - @override - Future startListening({ - required void Function(String text) onPartial, - required void Function(String finalText) onDone, - void Function(double level)? onLevel, - }) async { - listenCalls++; - _onListenDone = onDone; - } - - @override - Future speak(String text, {required void Function() onDone}) async { - speakCalls++; - lastSpoken = text; - _onSpeakDone = onDone; - } - - @override - Future stopListening() async {} - @override - Future stopSpeaking() async {} - @override - Future dispose() async {} -} - -void main() { - group('AiMessageActions ordering', () { - const message = AiMessage( - id: 'a1', - role: AiRole.assistant, - parts: [TextPart('hi')], - ); - - testWidgets('no trailing → compact row, no spacer', (tester) async { - await tester.pumpWidget(_wrap( - AiMessageActions(message: message, onSpeak: () {}, onRegenerate: () {}), - )); - expect(find.byType(Spacer), findsNothing); - }); - - testWidgets('trailing set pushes an action to the far side via a spacer', - (tester) async { - await tester.pumpWidget(_wrap( - AiMessageActions( - message: message, - onSpeak: () {}, - onGood: () {}, - trailing: const {AiMessageActionKind.speak}, - ), - )); - expect(find.byType(Spacer), findsOneWidget); - }); - }); - - group('AiModelSelector theming', () { - testWidgets('labelBuilder replaces the trigger label', (tester) async { - await tester.pumpWidget(_wrap( - AiModelSelector( - models: const [AiModelOption(id: 'pro', label: 'Pro')], - selectedId: 'pro', - onSelected: (_) {}, - showBorder: false, - labelBuilder: (context, selected) => Text('Gemini ${selected.label}'), - ), - )); - expect(find.text('Gemini Pro'), findsOneWidget); - }); - }); - - group('AiLiveController', () { - test('runs listen → send → speak → re-listen', () async { - final controller = UseChatController( - provider: _EchoProvider(), - scheduler: (cb) => cb(), - ); - addTearDown(controller.dispose); - final engine = _FakeEngine(); - final live = AiLiveController(controller: controller, engine: engine); - addTearDown(live.dispose); - - live.start(); - expect(engine.listenCalls, 1); - expect(live.status, AiLiveStatus.listening); - - // User finishes speaking → controller sends → assistant echoes. - engine.finishListening('hello there'); - await Future.delayed(const Duration(milliseconds: 20)); - - expect(controller.messages.first.text, 'hello there'); - expect(live.status, AiLiveStatus.speaking); - expect(engine.speakCalls, 1); - expect(engine.lastSpoken, 'Echo reply'); - - // TTS finishes → back to listening. - engine.finishSpeaking(); - expect(live.status, AiLiveStatus.listening); - expect(engine.listenCalls, 2); - - live.stop(); - expect(live.status, AiLiveStatus.ended); - }); - - test('empty transcript re-listens without sending', () async { - final controller = UseChatController( - provider: _EchoProvider(), - scheduler: (cb) => cb(), - ); - addTearDown(controller.dispose); - final engine = _FakeEngine(); - final live = AiLiveController(controller: controller, engine: engine); - addTearDown(live.dispose); - - live.start(); - engine.finishListening(' '); - await Future.delayed(const Duration(milliseconds: 20)); - - expect(controller.messages, isEmpty); - expect(engine.speakCalls, 0); - expect(engine.listenCalls, 2); // listened again - }); - }); -} diff --git a/packages/flutter_ai/flutter_ai_elements/test/widgets_test.dart b/packages/flutter_ai/flutter_ai_elements/test/widgets_test.dart deleted file mode 100644 index 7348bc2..0000000 --- a/packages/flutter_ai/flutter_ai_elements/test/widgets_test.dart +++ /dev/null @@ -1,1019 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_ai_elements/flutter_ai_elements.dart'; -import 'package:flutter_test/flutter_test.dart'; - -Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); - -class _EchoProvider implements LlmProvider { - @override - Stream send( - AiConversation conversation, { - List? tools, - AiRequestOptions? options, - }) async* { - yield const MessageStarted(messageId: 'a1', role: AiRole.assistant); - yield const TextDelta(messageId: 'a1', delta: 'Echo'); - yield const MessageFinished(messageId: 'a1', reason: FinishReason.stop); - } -} - -void main() { - group('AiWidgetRegistry (generative UI)', () { - testWidgets('renders a registered dataType and falls back otherwise', - (tester) async { - final registry = AiWidgetRegistry() - ..register( - 'weather', - (context, data) => Text('It is ${data['temp']}°'), - ); - - await tester.pumpWidget( - _wrap( - AiDataView( - part: const DataPart(dataType: 'weather', data: {'temp': 21}), - registry: registry, - ), - ), - ); - expect(find.text('It is 21°'), findsOneWidget); - - // Unregistered type → fallback. - await tester.pumpWidget( - _wrap( - AiDataView( - part: const DataPart(dataType: 'unknown', data: {}), - registry: registry, - fallback: const Text('unsupported'), - ), - ), - ); - expect(find.text('unsupported'), findsOneWidget); - }); - }); - - group('AiLocalizations', () { - test('delegate serves provided strings and reloads on change', () async { - const custom = AiLocalizations(copy: 'Copier', send: 'Envoyer'); - const delegate = AiLocalizationsDelegate(custom); - expect(delegate.isSupported(const Locale('fr')), isTrue); - final loaded = await delegate.load(const Locale('fr')); - expect(loaded.copy, 'Copier'); - expect(loaded.send, 'Envoyer'); - expect(delegate.shouldReload(const AiLocalizationsDelegate()), isTrue); - }); - - testWidgets('widgets read overridden strings from the tree', - (tester) async { - await tester.pumpWidget( - MaterialApp( - localizationsDelegates: const [ - AiLocalizationsDelegate(AiLocalizations(retry: 'Réessayer')), - DefaultMaterialLocalizations.delegate, - DefaultWidgetsLocalizations.delegate, - ], - home: Scaffold( - body: AiErrorBanner(message: 'boom', onRetry: () {}), - ), - ), - ); - await tester.pumpAndSettle(); - expect(find.text('Réessayer'), findsOneWidget); - }); - - testWidgets('AiLocalizationsScope overrides strings without a delegate', - (tester) async { - await tester.pumpWidget( - _wrap( - const AiLocalizationsScope( - strings: AiLocalizations(allow: 'Autoriser', deny: 'Refuser'), - child: AiConfirmation(title: 'Proceed?'), - ), - ), - ); - expect(find.text('Autoriser'), findsOneWidget); - expect(find.text('Refuser'), findsOneWidget); - }); - }); - - group('AiChatView', () { - testWidgets('composes transcript + input from a controller', - (tester) async { - final controller = UseChatController(provider: _EchoProvider()); - addTearDown(controller.dispose); - await tester.pumpWidget(_wrap(AiChatView(controller: controller))); - expect(find.byType(AiChat), findsOneWidget); - expect(find.byType(AiPromptInput), findsOneWidget); - }); - }); - - group('AiConversationList', () { - testWidgets('lists threads and fires select/new/delete', (tester) async { - ChatThread? selected; - var created = 0; - ChatThread? deleted; - await tester.pumpWidget( - _wrap( - AiConversationList( - threads: const [ - ChatThread(id: '1', title: 'Lisbon trip'), - ChatThread(id: '2', title: 'Dinner recipe'), - ], - selectedId: '1', - onSelect: (t) => selected = t, - onNew: () => created++, - onDelete: (t) => deleted = t, - ), - ), - ); - - expect(find.text('Lisbon trip'), findsOneWidget); - expect(find.text('Dinner recipe'), findsOneWidget); - - await tester.tap(find.text('New chat')); - expect(created, 1); - - await tester.tap(find.text('Dinner recipe')); - expect(selected?.id, '2'); - - await tester.tap(find.byIcon(Icons.delete_outline).first); - expect(deleted?.id, '1'); - }); - }); - - group('AiThemeExtension', () { - test('of returns the fallback when none is registered', () { - final fallback = AiThemeExtension.fallback(); - expect(fallback.enableHaptics, isTrue); - expect(fallback.maxBubbleWidthFraction, closeTo(0.80, 0.001)); - expect(fallback.maxContentWidth, closeTo(720, 0.001)); - }); - - test('lerp snaps an infinite reading width instead of producing NaN', () { - final a = AiThemeExtension.fallback(); - final b = a.copyWith(maxContentWidth: double.infinity); - final lerped = a.lerp(b, 0.7); - expect(lerped.maxContentWidth, double.infinity); // snaps to b past 0.5 - expect(lerped.maxContentWidth.isNaN, isFalse); - }); - - test('copyWith overrides only the given token', () { - final base = AiThemeExtension.fallback(); - final edited = base.copyWith(enableHaptics: false); - expect(edited.enableHaptics, isFalse); - expect(edited.userBubbleColor, base.userBubbleColor); - }); - - test('lerp interpolates continuous tokens and snaps discrete ones', () { - final a = AiThemeExtension.fallback(); - final b = a.copyWith(messageSpacing: 30, enableHaptics: false); - final lerped = a.lerp(b, 0.4); - expect(lerped.messageSpacing, closeTo(22.8, 0.001)); // 18 + 0.4 * 12 - expect(lerped.enableHaptics, isTrue); // snaps to `a` while t < 0.5 - }); - }); - - group('AiMessageBubble', () { - testWidgets('renders text and right-aligns the user', (tester) async { - await tester.pumpWidget( - _wrap( - const AiMessageBubble( - message: AiMessage( - id: 'm1', - role: AiRole.user, - parts: [TextPart('Hello there')], - ), - ), - ), - ); - expect(find.text('Hello there'), findsOneWidget); - final align = tester.widget(find.byType(Align).first); - // Directional so it mirrors correctly under RTL (end == right in LTR). - expect(align.alignment, AlignmentDirectional.centerEnd); - }); - - testWidgets('excludes semantics while streaming', (tester) async { - await tester.pumpWidget( - _wrap( - const AiMessageBubble( - message: AiMessage( - id: 'm1', - role: AiRole.assistant, - parts: [TextPart('partial')], - status: AiMessageStatus.streaming, - ), - ), - ), - ); - expect(find.byType(ExcludeSemantics), findsAtLeastNWidgets(1)); - }); - - testWidgets('renders a tool call line', (tester) async { - await tester.pumpWidget( - _wrap( - const AiMessageBubble( - message: AiMessage( - id: 'm1', - role: AiRole.assistant, - parts: [ - ToolCallPart( - toolCallId: 'c1', - toolName: 'get_weather', - state: ToolCallState.outputAvailable, - ), - ], - ), - ), - ), - ); - expect(find.textContaining('get_weather'), findsOneWidget); - }); - }); - - group('AiComposer', () { - testWidgets('sends trimmed text and clears the field', (tester) async { - String? sent; - await tester.pumpWidget( - _wrap(AiComposer(onSend: (t) => sent = t)), - ); - await tester.enterText(find.byType(TextField), ' hi '); - await tester.tap(find.byIcon(Icons.arrow_upward_rounded)); - await tester.pump(); - expect(sent, 'hi'); - expect(find.text(' hi '), findsNothing); - }); - - testWidgets('shows Stop while busy and calls onStop', (tester) async { - var stopped = false; - await tester.pumpWidget( - _wrap( - AiComposer( - onSend: (_) {}, - onStop: () => stopped = true, - isBusy: true, - ), - ), - ); - expect(find.byIcon(Icons.stop_rounded), findsOneWidget); - await tester.tap(find.byIcon(Icons.stop_rounded)); - expect(stopped, isTrue); - }); - }); - - group('AiChat (controller-bound)', () { - testWidgets('renders the streamed conversation', (tester) async { - final controller = UseChatController(provider: _EchoProvider()); - addTearDown(controller.dispose); - - await tester.pumpWidget(_wrap(AiChat(controller: controller))); - await controller.sendText('hi'); - await tester.pumpAndSettle(); - - expect(find.text('hi'), findsOneWidget); - expect(find.text('Echo'), findsOneWidget); - }); - - testWidgets('pins the just-sent question to the top of the viewport', - (tester) async { - tester.view.physicalSize = const Size(400, 800); - tester.view.devicePixelRatio = 1.0; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - final longText = List.filled( - 16, - 'This is a fairly long prior line of the conversation.', - ).join(' '); - final initial = AiConversation( - id: 'c', - messages: [ - for (var i = 0; i < 4; i++) ...[ - AiMessage( - id: 'u$i', - role: AiRole.user, - parts: [TextPart('Question $i')], - ), - AiMessage( - id: 'a$i', - role: AiRole.assistant, - parts: [TextPart(longText)], - status: AiMessageStatus.complete, - ), - ], - ], - ); - final controller = - UseChatController(provider: _EchoProvider(), initial: initial); - addTearDown(controller.dispose); - - await tester.pumpWidget(_wrap(AiChat(controller: controller))); - await tester.pumpAndSettle(); - await controller.sendText('the new question'); - await tester.pumpAndSettle(); - - // The question must sit near the top of the chat (pinned), not mid-screen - // showing previous answers above it. - final chatTop = tester.getTopLeft(find.byType(AiChat)).dy; - final qTop = tester.getTopLeft(find.text('the new question')).dy; - expect(qTop - chatTop, lessThan(80), - reason: - 'question should be pinned to the top, was ${qTop - chatTop}px ' - 'below the top'); - }); - }); - - group('AiToolInvocation', () { - const call = ToolCallPart( - toolCallId: 'c1', - toolName: 'get_weather', - args: {'city': 'London'}, - state: ToolCallState.outputAvailable, - ); - - testWidgets('shows the tool name and is collapsed by default', - (tester) async { - await tester.pumpWidget(_wrap(const AiToolInvocation(call: call))); - expect(find.text('get_weather'), findsOneWidget); - expect(find.text('Arguments'), findsNothing); - }); - - testWidgets('reveals arguments and result when expanded', (tester) async { - await tester.pumpWidget( - _wrap( - const AiToolInvocation( - call: call, - result: ToolResultPart(toolCallId: 'c1', result: {'tempC': 21}), - initiallyExpanded: true, - ), - ), - ); - expect(find.text('Arguments'), findsOneWidget); - expect(find.text('Result'), findsOneWidget); - expect(find.textContaining('London'), findsOneWidget); - }); - - testWidgets('expands on tap', (tester) async { - await tester.pumpWidget(_wrap(const AiToolInvocation(call: call))); - await tester.tap(find.text('get_weather')); - await tester.pumpAndSettle(); - expect(find.text('Arguments'), findsOneWidget); - }); - }); - - group('AiReasoning', () { - testWidgets('hides text until expanded', (tester) async { - await tester.pumpWidget( - _wrap(const AiReasoning(text: 'step by step')), - ); - expect(find.text('Reasoning'), findsOneWidget); - expect(find.text('step by step'), findsNothing); - - await tester.tap(find.text('Reasoning')); - await tester.pumpAndSettle(); - expect(find.text('step by step'), findsOneWidget); - }); - }); - - group('AiAttachment', () { - testWidgets('renders a file chip for non-images', (tester) async { - await tester.pumpWidget( - _wrap( - const AiAttachment( - file: FilePart(mediaType: 'application/pdf', name: 'report.pdf'), - ), - ), - ); - expect(find.text('report.pdf'), findsOneWidget); - }); - }); - - group('AiToolGroup', () { - testWidgets('stacks one card per call', (tester) async { - await tester.pumpWidget( - _wrap( - const AiToolGroup( - calls: [ - ToolCallPart(toolCallId: 'c1', toolName: 'alpha'), - ToolCallPart(toolCallId: 'c2', toolName: 'beta'), - ], - ), - ), - ); - expect(find.byType(AiToolInvocation), findsNWidgets(2)); - expect(find.text('alpha'), findsOneWidget); - expect(find.text('beta'), findsOneWidget); - }); - }); - - group('expanded elements', () { - testWidgets('AiAvatar shows a role icon', (tester) async { - await tester.pumpWidget(_wrap(const AiAvatar(role: AiRole.assistant))); - expect(find.byIcon(Icons.auto_awesome), findsOneWidget); - }); - - testWidgets('AiEmptyState shows title and subtitle', (tester) async { - await tester.pumpWidget( - _wrap(const AiEmptyState(title: 'Hello', subtitle: 'Ask me anything')), - ); - expect(find.text('Hello'), findsOneWidget); - expect(find.text('Ask me anything'), findsOneWidget); - }); - - testWidgets('AiEmptyState fires the tapped suggestion', (tester) async { - String? chosen; - await tester.pumpWidget( - _wrap( - AiEmptyState( - title: 'Hi', - suggestions: const ['Plan a trip', 'Write code'], - onSuggestionTap: (s) => chosen = s, - ), - ), - ); - expect(find.text('Plan a trip'), findsOneWidget); - await tester.tap(find.text('Write code')); - expect(chosen, 'Write code'); - }); - - testWidgets('AiOrb builds and animates', (tester) async { - await tester.pumpWidget(_wrap(const AiOrb(size: 48))); - expect(find.byType(AiOrb), findsOneWidget); - await tester.pump(const Duration(milliseconds: 100)); - expect(tester.takeException(), isNull); - }); - - testWidgets('AiErrorBanner shows message and fires retry', (tester) async { - var retried = false; - await tester.pumpWidget( - _wrap( - AiErrorBanner(message: 'boom', onRetry: () => retried = true), - ), - ); - expect(find.text('boom'), findsOneWidget); - await tester.tap(find.text('Retry')); - expect(retried, isTrue); - }); - - testWidgets('AiSuggestions reports the chosen suggestion', (tester) async { - String? chosen; - await tester.pumpWidget( - _wrap( - AiSuggestions( - suggestions: const ['Summarize', 'Translate'], - onSelected: (s) => chosen = s, - ), - ), - ); - await tester.tap(find.text('Translate')); - expect(chosen, 'Translate'); - }); - - testWidgets('AiSources renders a chip per source', (tester) async { - await tester.pumpWidget( - _wrap( - AiSources( - sources: [ - SourcePart( - url: Uri.parse('https://flutter.dev'), - title: 'Flutter', - ), - SourcePart(url: Uri.parse('https://dart.dev')), - ], - ), - ), - ); - expect(find.text('Flutter'), findsOneWidget); - expect(find.text('dart.dev'), findsOneWidget); // falls back to host - // Numeric citation indices on each chip. - expect(find.text('1'), findsOneWidget); - expect(find.text('2'), findsOneWidget); - }); - - testWidgets('AiSources collapses past maxVisible and expands on tap', - (tester) async { - final sources = [ - for (var i = 0; i < 10; i++) - SourcePart(url: Uri.parse('https://site$i.example')), - ]; - await tester - .pumpWidget(_wrap(AiSources(sources: sources, maxVisible: 3))); - // Only the first 3 chips show, plus a "+7 more" toggle. - expect(find.text('site0.example'), findsOneWidget); - expect(find.text('site2.example'), findsOneWidget); - expect(find.text('site3.example'), findsNothing); - expect(find.text('+7 more'), findsOneWidget); - - await tester.tap(find.text('+7 more')); - await tester.pumpAndSettle(); - expect(find.text('site9.example'), findsOneWidget); - expect(find.text('Show less'), findsOneWidget); - }); - - testWidgets('AiCodeBlock shows code and a copy button', (tester) async { - await tester.pumpWidget( - _wrap(const AiCodeBlock(code: 'print("hi");', language: 'dart')), - ); - expect(find.text('dart'), findsOneWidget); - expect(find.text('print("hi");'), findsOneWidget); - expect(find.byIcon(Icons.copy), findsOneWidget); - }); - - testWidgets('AiMessageActions fires regenerate', (tester) async { - var regenerated = false; - await tester.pumpWidget( - _wrap( - AiMessageActions( - message: const AiMessage( - id: 'm1', - role: AiRole.assistant, - parts: [TextPart('hi')], - ), - onRegenerate: () => regenerated = true, - ), - ), - ); - await tester.tap(find.byIcon(Icons.refresh_rounded)); - expect(regenerated, isTrue); - }); - - testWidgets('AiAnimatedResponse reveals the full text over time', - (tester) async { - await tester.pumpWidget( - _wrap(const AiAnimatedResponse(text: 'Hello world')), - ); - // Advance the reveal to completion. (Can't pumpAndSettle — the streaming - // caret blinks forever.) The first tick has dt=0, so pump twice. - // Advance frame by frame to reveal + settle. (Can't pumpAndSettle — the - // streaming caret blinks forever.) - for (var i = 0; i < 40; i++) { - await tester.pump(const Duration(milliseconds: 100)); - } - expect(find.textContaining('Hello world'), findsOneWidget); - }); - - testWidgets('AiAnimatedResponse accelerates to drain a large backlog', - (tester) async { - final long = List.filled(200, 'word').join(' '); // ~1000 chars - await tester.pumpWidget( - _wrap(SingleChildScrollView(child: AiAnimatedResponse(text: long))), - ); - int shownChars() => - (tester.state(find.byType(AiAnimatedResponse)) as dynamic).shownChars - as int; - await tester.pump(const Duration(milliseconds: 100)); // baseline tick - await tester.pump(const Duration(milliseconds: 100)); - // The 120 cps floor alone would reveal only ~24 chars in 200ms; the - // catch-up rate drains the large backlog far faster (~100 chars here) so - // the reveal never trails a fast stream by much. - expect(shownChars(), greaterThan(60)); - for (var i = 0; i < 60; i++) { - await tester.pump(const Duration(milliseconds: 100)); // drain fully - } - expect(shownChars(), long.length); - }); - - testWidgets('AiChat shows the empty state when idle and empty', - (tester) async { - final controller = UseChatController(provider: _EchoProvider()); - addTearDown(controller.dispose); - await tester.pumpWidget( - _wrap( - AiChat( - controller: controller, - emptyState: const AiEmptyState(title: 'Nothing yet'), - ), - ), - ); - expect(find.text('Nothing yet'), findsOneWidget); - }); - }); - - group('new components', () { - testWidgets('AiResponse renders markdown blocks', (tester) async { - await tester.pumpWidget( - _wrap( - const SingleChildScrollView( - child: AiResponse( - text: '# Title\n\nHello **world** and `code`.\n\n' - '```dart\nx();\n```\n\n- one\n- two', - ), - ), - ), - ); - expect(find.text('Title'), findsOneWidget); - expect(find.byType(AiCodeBlock), findsOneWidget); - expect(find.text('one'), findsOneWidget); - }); - - testWidgets('AiResponse applies a code highlighter when provided', - (tester) async { - String? seenCode; - String? seenLanguage; - List? highlight(String code, String? language, TextStyle base) { - seenCode = code; - seenLanguage = language; - return [TextSpan(text: code, style: base)]; - } - - await tester.pumpWidget( - _wrap( - SingleChildScrollView( - child: AiResponse( - text: '```dart\nfinal x = 1;\n```', - codeHighlighter: highlight, - ), - ), - ), - ); - - expect(seenCode, 'final x = 1;'); - expect(seenLanguage, 'dart'); - expect(find.byType(AiCodeBlock), findsOneWidget); - }); - - testWidgets('AiResponse renders strikethrough, a rule, and task lists', - (tester) async { - await tester.pumpWidget( - _wrap( - const SingleChildScrollView( - child: AiResponse( - text: 'has ~~struck~~ text\n\n---\n\n' - '- [x] done\n- [ ] todo', - ), - ), - ), - ); - // Strikethrough span present. - final rich = tester.widget(find.byType(RichText).first); - var sawStrike = false; - rich.text.visitChildren((span) { - if (span is TextSpan && - span.style?.decoration == TextDecoration.lineThrough) { - sawStrike = true; - } - return true; - }); - expect(sawStrike, isTrue); - // Horizontal rule renders a Divider. - expect(find.byType(Divider), findsOneWidget); - // Task list: a checked + an unchecked checkbox icon, with labels. - expect(find.byIcon(Icons.check_box_rounded), findsOneWidget); - expect( - find.byIcon(Icons.check_box_outline_blank_rounded), findsOneWidget); - expect(find.text('done'), findsOneWidget); - expect(find.text('todo'), findsOneWidget); - }); - - testWidgets('AiResponse colors links from the theme linkColor', - (tester) async { - await tester.pumpWidget( - MaterialApp( - theme: ThemeData( - extensions: [ - AiThemeExtension.fallback() - .copyWith(linkColor: const Color(0xFF00FF00)), - ], - ), - home: Scaffold( - body: SingleChildScrollView( - child: AiResponse( - text: 'a [link](https://example.com)', - onLinkTap: (_) {}, - ), - ), - ), - ), - ); - final rich = tester.widget(find.byType(RichText).first); - var sawLinkColor = false; - rich.text.visitChildren((span) { - if (span is TextSpan && span.style?.color == const Color(0xFF00FF00)) { - sawLinkColor = true; - } - return true; - }); - expect(sawLinkColor, isTrue); - }); - - testWidgets('AiResponse renders a Markdown table', (tester) async { - await tester.pumpWidget( - _wrap( - const SingleChildScrollView( - child: AiResponse( - text: '| Model | Speed |\n' - '| --- | --- |\n' - '| Flash | Fast |\n' - '| Pro | Slower |', - ), - ), - ), - ); - expect(find.byType(Table), findsOneWidget); - expect(find.text('Model'), findsOneWidget); // header cell - expect(find.text('Flash'), findsOneWidget); // body cell - expect(find.text('Slower'), findsOneWidget); - }); - - testWidgets('AiResponse updates when its text changes (cache refresh)', - (tester) async { - await tester.pumpWidget( - _wrap(const SingleChildScrollView(child: AiResponse(text: 'first'))), - ); - expect(find.text('first'), findsOneWidget); - // Re-pump with new text: the cached tree must be rebuilt (didUpdateWidget). - await tester.pumpWidget( - _wrap(const SingleChildScrollView(child: AiResponse(text: 'second'))), - ); - expect(find.text('first'), findsNothing); - expect(find.text('second'), findsOneWidget); - }); - - testWidgets('AiResponse renders a partial-heading prefix without hanging', - (tester) async { - // A streamed prefix can end on a lone `#` before its space/text arrive. - // The block parser must still make forward progress (no infinite loop / - // OOM) and treat it as text. - await tester.pumpWidget( - _wrap(const SingleChildScrollView(child: AiResponse(text: 'Intro\n#'))), - ); - expect(find.byType(AiResponse), findsOneWidget); - // The completed heading then renders as a heading once it arrives. - await tester.pumpWidget( - _wrap(const SingleChildScrollView( - child: AiResponse(text: 'Intro\n# Title'), - )), - ); - expect(find.text('Title'), findsOneWidget); - }); - - testWidgets('AiResponse does not italicize "2 * 3" or snake_case', - (tester) async { - var taps = 0; - await tester.pumpWidget( - _wrap( - SingleChildScrollView( - child: AiResponse( - text: 'compute 2 * 3 with snake_case and a ' - '[link](https://example.com)', - onLinkTap: (_) => taps++, - ), - ), - ), - ); - final rich = tester.widget(find.byType(RichText).first); - var sawItalic = false; - rich.text.visitChildren((span) { - if (span is TextSpan && span.style?.fontStyle == FontStyle.italic) { - sawItalic = true; - } - return true; - }); - expect(sawItalic, isFalse); - expect(taps, 0); // sanity: link present, callback wired but untapped - }); - - testWidgets('AiChainOfThought reveals steps when expanded', (tester) async { - await tester.pumpWidget( - _wrap( - const AiChainOfThought( - initiallyExpanded: true, - steps: [ - AiThoughtStep(label: 'Search'), - AiThoughtStep(label: 'Synthesize', isActive: true), - ], - ), - ), - ); - expect(find.text('Search'), findsOneWidget); - expect(find.text('Synthesize'), findsOneWidget); - }); - - testWidgets('AiTask shows title, count, and items', (tester) async { - await tester.pumpWidget( - _wrap( - const AiTask( - title: 'Refactor', - items: [ - AiTaskItem(label: 'Read files', status: AiTaskStatus.complete), - AiTaskItem(label: 'Apply edits', status: AiTaskStatus.active), - ], - ), - ), - ); - expect(find.text('Refactor'), findsOneWidget); - expect(find.text('1/2'), findsOneWidget); - expect(find.text('Read files'), findsOneWidget); - }); - - testWidgets('AiInlineCitation shows its number', (tester) async { - await tester.pumpWidget(_wrap(const AiInlineCitation(number: 3))); - expect(find.text('3'), findsOneWidget); - }); - - testWidgets('AiBranch shows position and hides when single', - (tester) async { - await tester.pumpWidget(_wrap(const AiBranch(index: 1, total: 3))); - expect(find.text('2/3'), findsOneWidget); - - await tester.pumpWidget(_wrap(const AiBranch(index: 0, total: 1))); - expect(find.text('1/1'), findsNothing); - }); - - testWidgets('AiImage builds with a url', (tester) async { - await tester.pumpWidget( - _wrap(AiImage(url: Uri.parse('https://example.com/a.png'))), - ); - expect(find.byType(AiImage), findsOneWidget); - }); - }); - - group('input & more', () { - testWidgets( - 'AiComposer with a staged attachment shows Send, not Live, as the ' - 'main button', (tester) async { - await tester.pumpWidget( - _wrap( - AiComposer( - onSend: (_) {}, - onAttach: () {}, - onVoice: () {}, - onLive: () {}, - attachments: const [ - FilePart(mediaType: 'application/pdf', name: 'a.pdf'), - ], - onRemoveAttachment: (_) {}, - ), - ), - ); - expect(find.byIcon(Icons.add), findsOneWidget); - expect(find.byIcon(Icons.mic_none_rounded), findsOneWidget); // secondary - // An attachment is sendable content, so the main button must be Send — - // tapping the prominent button must not launch full-screen voice mode. - expect(find.byIcon(Icons.arrow_upward_rounded), findsOneWidget); - expect(find.byIcon(Icons.graphic_eq), findsNothing); - expect(find.text('a.pdf'), findsOneWidget); // staged attachment preview - }); - - testWidgets('AiComposer shows Live only when truly empty', (tester) async { - await tester.pumpWidget( - _wrap(AiComposer(onSend: (_) {}, onLive: () {})), - ); - // No text and no attachments: Live is the main affordance. - expect(find.byIcon(Icons.graphic_eq), findsOneWidget); - expect(find.byIcon(Icons.arrow_upward_rounded), findsNothing); - }); - - testWidgets('AiComposer swaps Live for Send once typing', (tester) async { - await tester.pumpWidget( - _wrap(AiComposer(onSend: (_) {}, onVoice: () {}, onLive: () {})), - ); - expect(find.byIcon(Icons.graphic_eq), findsOneWidget); - await tester.enterText(find.byType(TextField), 'hello'); - await tester.pumpAndSettle(); // let the main-button icon morph finish - expect(find.byIcon(Icons.arrow_upward_rounded), findsOneWidget); - expect(find.byIcon(Icons.graphic_eq), findsNothing); - expect(find.byIcon(Icons.mic_none_rounded), findsNothing); // mic hidden - }); - - testWidgets('AiModelSelector shows selection and opens a picker', - (tester) async { - String? chosen; - await tester.pumpWidget( - _wrap( - AiModelSelector( - selectedId: 'fast', - onSelected: (id) => chosen = id, - models: const [ - AiModelOption(id: 'fast', label: 'Fast'), - AiModelOption(id: 'smart', label: 'Smart'), - ], - ), - ), - ); - expect(find.text('Fast'), findsOneWidget); - await tester.tap(find.text('Fast')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Smart')); - await tester.pumpAndSettle(); - expect(chosen, 'smart'); - }); - - testWidgets('AiModelSelector exposes a labelled button to a11y', - (tester) async { - final handle = tester.ensureSemantics(); - await tester.pumpWidget( - _wrap( - AiModelSelector( - selectedId: 'fast', - onSelected: (_) {}, - models: const [AiModelOption(id: 'fast', label: 'Fast')], - ), - ), - ); - expect( - tester.getSemantics(find.text('Fast')), - matchesSemantics( - isButton: true, - hasTapAction: true, - label: 'Select model, Fast\nFast', - ), - ); - handle.dispose(); - }); - - testWidgets('AiModelSelector renders nothing (no crash) with no models', - (tester) async { - await tester.pumpWidget( - _wrap( - AiModelSelector( - selectedId: 'x', onSelected: (_) {}, models: const []), - ), - ); - expect(tester.takeException(), isNull); - expect(find.byType(AiModelSelector), findsOneWidget); - }); - - testWidgets('AiConfirmation fires confirm/deny', (tester) async { - var allowed = false; - await tester.pumpWidget( - _wrap( - AiConfirmation( - title: 'Send the email?', - onConfirm: () => allowed = true, - ), - ), - ); - expect(find.text('Send the email?'), findsOneWidget); - await tester.tap(find.text('Allow')); - expect(allowed, isTrue); - }); - - testWidgets('AiConfirmation danger tone fills confirm with errorColor', - (tester) async { - final theme = AiThemeExtension.fallback(); - await tester.pumpWidget( - MaterialApp( - theme: ThemeData(extensions: [theme]), - home: const Scaffold( - body: AiConfirmation( - title: 'Delete everything?', - tone: AiConfirmationTone.danger, - ), - ), - ), - ); - // The filled confirm button's Material uses the theme error color. - final materials = tester - .widgetList(find.byType(Material)) - .where((m) => m.color == theme.errorColor); - expect(materials, isNotEmpty); - }); - - testWidgets('AiContextMeter formats usage', (tester) async { - await tester.pumpWidget( - _wrap(const AiContextMeter(usedTokens: 12345, totalTokens: 128000)), - ); - expect(find.text('12.3k / 128.0k'), findsOneWidget); - }); - - testWidgets('AiShimmer builds', (tester) async { - await tester.pumpWidget(_wrap(const AiShimmer(lines: 2))); - expect(find.byType(AiShimmer), findsOneWidget); - }); - - testWidgets('AiLiveSession shows status and ends', (tester) async { - var ended = false; - await tester.pumpWidget( - _wrap( - SizedBox( - height: 500, - child: AiLiveSession( - onEnd: () => ended = true, - ), - ), - ), - ); - await tester.pump(const Duration(milliseconds: 100)); - expect(find.text('Listening'), findsOneWidget); - await tester.tap(find.byIcon(Icons.close)); - expect(ended, isTrue); - }); - }); - - group('AiConversationView', () { - testWidgets('renders a bubble per message plus a loader', (tester) async { - await tester.pumpWidget( - _wrap( - const AiConversationView( - showLoader: true, - messages: [ - AiMessage(id: 'm1', role: AiRole.user, parts: [TextPart('one')]), - AiMessage(id: 'm2', role: AiRole.user, parts: [TextPart('two')]), - ], - ), - ), - ); - expect(find.byType(AiMessageBubble), findsNWidgets(2)); - expect(find.byType(AiLoader), findsOneWidget); - }); - }); -} diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/CHANGELOG.md b/packages/flutter_ai/flutter_ai_provider_anthropic/CHANGELOG.md deleted file mode 100644 index 29d2819..0000000 --- a/packages/flutter_ai/flutter_ai_provider_anthropic/CHANGELOG.md +++ /dev/null @@ -1,111 +0,0 @@ -# Changelog - -## 0.1.12 - -- Fix: `reasoningEffort` now emits adaptive thinking (`{type: adaptive}`) on - Claude 4.6+ models — including the default `claude-opus-4-8`, which rejects the - legacy `budget_tokens` shape with a 400. Claude 3.7 and 4.0–4.5 continue to use - the budgeted shape. An explicit `thinking` block in `extra` still takes - precedence. -- Fix: a mid-stream `error` event (e.g. `overloaded_error`) is no longer - overwritten by a synthetic successful finish — the message now settles as - errored. -- Fix: setting both `responseFormat` and `reasoningEffort` no longer sends an - invalid forced-tool-choice-plus-thinking request (a guaranteed 400). Thinking - is dropped when structured output is requested. - -## 0.1.11 - -- Map `AiRequestOptions.reasoningEffort` to extended thinking - (`thinking.budget_tokens`): raises `max_tokens` above the budget when needed - and drops `temperature` (the API rejects both together). An explicit - `thinking` block in `extra` takes precedence. Requires `flutter_ai_core` - ^0.1.13. - -## 0.1.10 - -- Fix (Web): the default HTTP client now streams token-by-token on Flutter Web. - `http.Client()` resolves to the XHR-backed `BrowserClient` on the web, which - buffers the entire response body before the stream emits — silently degrading - streaming to all-at-once. The default is now a `fetch`-based client (via a - conditional import) that reads the response `ReadableStream` incrementally. - Native platforms are unchanged. Inject your own `client` to override. - -## 0.1.9 - -- Fix: raise the `flutter_ai_core` lower bound to `^0.1.11` — the parser emits - `AiUsage` (added in core 0.1.3) and later APIs, so the old `^0.1.0` bound let - dependency downgrades resolve a core that couldn't compile. -- Docs: shortened the pubspec `description` into pub.dev's 60–180 character - window. - -## 0.1.8 - -- Docs: refreshed the README listing with a hero image, screenshot gallery, - and badges (consistent across the package family). No code changes. - -## 0.1.7 - -- Cost accuracy: `cache_creation_input_tokens` now map to - `AiUsage.cacheCreationTokens` (billed at the ~1.25x write rate) instead of - being folded into base input and billed wrong. -- Declares supported `platforms:` (all 6). - -## 0.1.6 - -- Throws typed `LlmException`s (auth/rate-limit/server/request) on HTTP errors - instead of a generic `Exception`; retries 408/409 too. - -## 0.1.5 - -- Replays signed `thinking` blocks before `tool_use` in the assistant turn, so - extended thinking + tools no longer 400 on Claude 4.x. -- A mid-stream stall surfaces a message-scoped `StreamErrorEvent` instead of - also finalizing (which masked the timeout). -- Asserts a non-empty `apiKey` with an actionable message. - -## 0.1.4 - -- Prompt caching: when `AiRequestOptions.cachePrompt` is set, marks the system - prompt and the last tool with `cache_control: ephemeral` (caches the stable - prefix for ~90% cheaper repeat input). - -## 0.1.3 - -- Structured output: maps `AiRequestOptions.responseFormat` to a forced tool - whose input is the schema; its streamed input is surfaced as the JSON answer - text and the turn finishes as `stop`. - -## 0.1.2 - -- Reports token usage: accumulates input (incl. cache read/creation) from - `message_start` and output from `message_delta` into `AiUsage` on - `MessageFinished`. - -## 0.1.1 - -- Docs: added a "Buy me a coffee" (Ko-fi) support section to the README. No code - changes. - -## 0.1.0 - -Initial release. - -- `AnthropicProvider` — an `LlmProvider` for the Anthropic Messages API - (`POST /v1/messages`), with an injectable HTTP client, a configurable default - model (`claude-opus-4-8`) and `max_tokens`. -- Maps conversations into the request: system messages fold into the top-level - `system` field, assistant tool calls become `tool_use` blocks, and tool - results become `tool_result` blocks. Streams text, extended thinking, tool - calls, and finish reasons back as `AiStreamEvent`s. -- `AnthropicEventParser` — the SSE-event→event mapping, unit-tested against - recorded events. -- Robustness: configurable connect + idle `timeout` (a stalled stream surfaces a - `StreamErrorEvent` instead of hanging); a wrong-shape event emits a - `StreamErrorEvent` instead of crashing the stream; `close()` only closes a - client it created; retry backoff is now capped and jittered; adjacent - same-role turns are merged so the API's strict alternation isn't violated. -- Re-exports `flutter_ai_core`. - -> The mapping is unit-tested against recorded SSE events; it has not been run -> against the live Anthropic API in this release. diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/LICENSE b/packages/flutter_ai/flutter_ai_provider_anthropic/LICENSE deleted file mode 100644 index 56023ee..0000000 --- a/packages/flutter_ai/flutter_ai_provider_anthropic/LICENSE +++ /dev/null @@ -1,29 +0,0 @@ -BSD 3-Clause License - -Copyright (c) 2026, The flutter_ai authors. -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/README.md b/packages/flutter_ai/flutter_ai_provider_anthropic/README.md deleted file mode 100644 index 9b3062d..0000000 --- a/packages/flutter_ai/flutter_ai_provider_anthropic/README.md +++ /dev/null @@ -1,80 +0,0 @@ -

flutter_ai_provider_anthropic

- -

Anthropic (Claude) provider for flutter_ai — streams the Messages API with extended thinking and tool use, mapped to AiStreamEvents so the rest of the family works against Claude unchanged.

- -

- A streamed answer with reasoning, a tool call, and the final answer -

- -

- flutter_ai_provider_anthropic on pub.dev - pub points - License: BSD-3-Clause -

- -

- Family: flutter_ai · - core · client · elements · - openai · gemini
- Recipes · Migrating from the Vercel AI SDK -

- ---- - -An Anthropic (Claude) [`LlmProvider`](../flutter_ai_core) for the `flutter_ai` -family. It streams the Anthropic **Messages API** and maps each event to -`AiStreamEvent`s, so the controllers and UI in `flutter_ai_client` / -`flutter_ai_elements` work against Claude unchanged. - -- Streams text, **extended thinking**, **tool use**, and finish reasons. -- Maps `flutter_ai` conversations to Anthropic's wire format (system folded into - the top-level `system` field; assistant tool calls → `tool_use`; tool results - → `tool_result`). -- Injectable `http.Client` for testing and custom transport. - -## Usage - -```dart -import 'package:flutter_ai_provider_anthropic/flutter_ai_provider_anthropic.dart'; - -final provider = AnthropicProvider( - apiKey: const String.fromEnvironment('ANTHROPIC_API_KEY'), - // defaultModel: 'claude-opus-4-8', // override per request via AiRequestOptions -); - -await for (final event in provider.send(conversation, tools: tools)) { - // feed into a MessageProcessor / UseChatController -} -``` - -Wire it into a controller: - -```dart -final controller = UseChatController( - provider: AnthropicProvider(apiKey: myKey), - options: const AiRequestOptions(model: 'claude-opus-4-8'), -); -``` - -## Notes - -- **`max_tokens` is required** by the API. Set it via - `AiRequestOptions.maxOutputTokens`, or rely on `AnthropicProvider`'s - `defaultMaxTokens` (4096). -- **Sampling parameters** (`temperature`) are forwarded only when set. Newer - Claude models reject them — leave it unset for those. -- **Extended thinking**: pass it through `AiRequestOptions.extra`, e.g. - `extra: {'thinking': {'type': 'adaptive'}}`. Thinking text streams as - `ReasoningDelta` events (`AiReasoning` in the UI). -- **Images**: user-message image attachments (`FilePart` with an `image/*` - media type) are sent as base64 or URL image blocks. Other document types are - not yet sent. -- **Retry**: transient failures (429/5xx, network) are retried with backoff - honoring `Retry-After` (`maxRetries`, default 2). - -## Status - -The request/response mapping is unit-tested against recorded SSE events. Supply -an API key to use it against the live API. - -_If `flutter_ai` saves you time, you can [buy me a coffee ☕](https://ko-fi.com/ananmouaz)._ diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/analysis_options.yaml b/packages/flutter_ai/flutter_ai_provider_anthropic/analysis_options.yaml deleted file mode 100644 index bddaa31..0000000 --- a/packages/flutter_ai/flutter_ai_provider_anthropic/analysis_options.yaml +++ /dev/null @@ -1,2 +0,0 @@ -# Inherits the workspace-wide strict configuration. -include: ../../analysis_options.yaml diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/example/flutter_ai_provider_anthropic_example.dart b/packages/flutter_ai/flutter_ai_provider_anthropic/example/flutter_ai_provider_anthropic_example.dart deleted file mode 100644 index 46655fb..0000000 --- a/packages/flutter_ai/flutter_ai_provider_anthropic/example/flutter_ai_provider_anthropic_example.dart +++ /dev/null @@ -1,33 +0,0 @@ -// Streams a single completion and prints the assembled reply. -// -// Run with: -// dart run --define=ANTHROPIC_API_KEY=... example/flutter_ai_provider_anthropic_example.dart -import 'package:flutter_ai_provider_anthropic/flutter_ai_provider_anthropic.dart'; - -Future main() async { - const apiKey = String.fromEnvironment('ANTHROPIC_API_KEY'); - if (apiKey.isEmpty) { - print('Set ANTHROPIC_API_KEY via --define to run against the live API.'); - return; - } - - final provider = AnthropicProvider(apiKey: apiKey); - final processor = MessageProcessor(); - - const conversation = AiConversation( - id: 'demo', - messages: [ - AiMessage( - id: 'u1', - role: AiRole.user, - parts: [TextPart('Say hello in one short sentence.')], - ), - ], - ); - - await for (final event in provider.send(conversation)) { - processor.apply(event); - } - print(processor.conversation.lastMessage?.text); - provider.close(); -} diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/lib/flutter_ai_provider_anthropic.dart b/packages/flutter_ai/flutter_ai_provider_anthropic/lib/flutter_ai_provider_anthropic.dart deleted file mode 100644 index eab0aa7..0000000 --- a/packages/flutter_ai/flutter_ai_provider_anthropic/lib/flutter_ai_provider_anthropic.dart +++ /dev/null @@ -1,14 +0,0 @@ -/// Anthropic (Claude) provider for the `flutter_ai` family. -/// -/// `AnthropicProvider` implements `LlmProvider` by streaming the Anthropic -/// Messages API and mapping each event to `AiStreamEvent`s via -/// `AnthropicEventParser`. Supports text, extended thinking, tool use, and -/// finish reasons over an injectable HTTP client. -/// -/// Re-exports `flutter_ai_core`. -library; - -export 'package:flutter_ai_core/flutter_ai_core.dart'; - -export 'src/anthropic_event_parser.dart'; -export 'src/anthropic_provider.dart'; diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/anthropic_event_parser.dart b/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/anthropic_event_parser.dart deleted file mode 100644 index 7225553..0000000 --- a/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/anthropic_event_parser.dart +++ /dev/null @@ -1,199 +0,0 @@ -import 'package:flutter_ai_core/flutter_ai_core.dart'; - -/// Translates Anthropic Messages API streaming events into [AiStreamEvent]s. -/// -/// Stateful across a single response: it tracks the message id, maps each -/// content block's `index` to a streamed tool call's id (later -/// `input_json_delta` fragments arrive carrying only the index), and remembers -/// the final `stop_reason` reported on `message_delta`. Kept separate from -/// transport so it can be unit-tested against recorded SSE events. -/// -/// Recognized event `type`s: `message_start`, `content_block_start`, -/// `content_block_delta` (text / thinking / tool-input), `content_block_stop`, -/// `message_delta`, `message_stop`, and `error`. `ping` and unknown types are -/// ignored. -class AnthropicEventParser { - /// Creates a parser. When [structuredToolName] is set (structured output via a - /// forced tool), that tool's streamed input is surfaced as [TextDelta]s — the - /// JSON answer — rather than as a tool call. - AnthropicEventParser({String? structuredToolName}) - : _structuredToolName = structuredToolName; - - final String? _structuredToolName; - int? _structuredIndex; - String _messageId = 'assistant'; - - /// The id of the assistant message being built (for error finalization). - String get messageId => _messageId; - final Map _toolCallIdByIndex = {}; - String _stopReason = 'end_turn'; - bool _started = false; - bool _finished = false; - int? _inputTokens; - int? _cachedInputTokens; - int? _cacheCreationTokens; - int? _outputTokens; - - /// Emits a terminal [MessageFinished] if the stream ended after starting but - /// without a `message_stop` (e.g. a dropped connection), so the message isn't - /// left streaming forever. Call once after the SSE stream completes. - List finalize() => _started && !_finished - ? [ - MessageFinished( - messageId: _messageId, - reason: _finishReason(), - usage: _buildUsage(), - ), - ] - : const []; - - AiUsage? _buildUsage() { - if (_inputTokens == null && _outputTokens == null) return null; - return AiUsage( - inputTokens: _inputTokens, - outputTokens: _outputTokens, - cachedInputTokens: _cachedInputTokens, - cacheCreationTokens: _cacheCreationTokens, - ); - } - - /// Returns the events implied by one decoded Anthropic stream event. - List parse(Map event) { - switch (event['type']) { - case 'message_start': - _started = true; - final message = (event['message'] as Map?)?.cast(); - final id = message?['id']; - if (id is String && id.isNotEmpty) _messageId = id; - final usage = (message?['usage'] as Map?)?.cast(); - if (usage != null) { - final input = (usage['input_tokens'] as int?) ?? 0; - final cacheRead = (usage['cache_read_input_tokens'] as int?) ?? 0; - final cacheCreate = - (usage['cache_creation_input_tokens'] as int?) ?? 0; - // cache_read and cache_creation are subsets of inputTokens (kept - // folded in here, billed separately in AiUsage.estimateCost). - _inputTokens = input + cacheRead + cacheCreate; - _cachedInputTokens = cacheRead == 0 ? null : cacheRead; - _cacheCreationTokens = cacheCreate == 0 ? null : cacheCreate; - _outputTokens = usage['output_tokens'] as int?; - } - return [MessageStarted(messageId: _messageId, role: AiRole.assistant)]; - - case 'content_block_start': - final index = (event['index'] as num?)?.toInt() ?? 0; - final block = (event['content_block'] as Map?)?.cast(); - if (block?['type'] == 'tool_use') { - final name = block?['name'] as String? ?? ''; - // Structured-output tool: capture its input as the JSON answer text - // rather than exposing it as a tool call. - if (_structuredToolName != null && name == _structuredToolName) { - _structuredIndex = index; - return const []; - } - final id = block?['id'] as String? ?? '$_messageId-tool-$index'; - _toolCallIdByIndex[index] = id; - return [ - ToolCallStarted( - messageId: _messageId, - toolCallId: id, - toolName: name, - ), - ]; - } - return const []; - - case 'content_block_delta': - final index = (event['index'] as num?)?.toInt() ?? 0; - final delta = - (event['delta'] as Map?)?.cast() ?? const {}; - switch (delta['type']) { - case 'text_delta': - final text = delta['text'] as String? ?? ''; - return text.isEmpty - ? const [] - : [TextDelta(messageId: _messageId, delta: text)]; - case 'thinking_delta': - final thinking = delta['thinking'] as String? ?? ''; - return thinking.isEmpty - ? const [] - : [ReasoningDelta(messageId: _messageId, delta: thinking)]; - case 'signature_delta': - // The signed proof of the thinking block; must be replayed verbatim - // on the next turn or the API rejects it. - final sig = delta['signature'] as String? ?? ''; - return sig.isEmpty - ? const [] - : [ - ReasoningDelta( - messageId: _messageId, delta: '', signature: sig) - ]; - case 'input_json_delta': - final partial = delta['partial_json'] as String? ?? ''; - if (index == _structuredIndex) { - return partial.isEmpty - ? const [] - : [TextDelta(messageId: _messageId, delta: partial)]; - } - final id = _toolCallIdByIndex[index]; - return (id == null || partial.isEmpty) - ? const [] - : [ToolCallDelta(toolCallId: id, argumentsDelta: partial)]; - } - return const []; - - case 'content_block_stop': - final index = (event['index'] as num?)?.toInt() ?? 0; - if (index == _structuredIndex) return const []; - final id = _toolCallIdByIndex[index]; - return id == null ? const [] : [ToolCallReady(toolCallId: id)]; - - case 'message_delta': - final delta = (event['delta'] as Map?)?.cast(); - final reason = delta?['stop_reason']; - if (reason is String) _stopReason = reason; - final usage = (event['usage'] as Map?)?.cast(); - final out = usage?['output_tokens'] as int?; - if (out != null) _outputTokens = out; - return const []; - - case 'message_stop': - _finished = true; - return [ - MessageFinished( - messageId: _messageId, - reason: _finishReason(), - usage: _buildUsage(), - ), - ]; - - case 'error': - // Anthropic closes the stream after an error event. Mark the message - // finished so finalize() doesn't paper over it with a synthetic - // MessageFinished(stop) that would overwrite the error status. - _finished = true; - final error = (event['error'] as Map?)?.cast(); - final message = - error?['message'] as String? ?? 'Anthropic stream error'; - return [StreamErrorEvent(error: message, messageId: _messageId)]; - - default: - return const []; // ping and unknown events carry no state for us. - } - } - - // Structured output forces a tool call, so `tool_use` really means "done". - FinishReason _finishReason() => - (_structuredIndex != null && _stopReason == 'tool_use') - ? FinishReason.stop - : _mapFinish(_stopReason); - - static FinishReason _mapFinish(String reason) => switch (reason) { - 'end_turn' => FinishReason.stop, - 'stop_sequence' => FinishReason.stop, - 'max_tokens' => FinishReason.length, - 'tool_use' => FinishReason.toolCalls, - 'refusal' => FinishReason.contentFilter, - _ => FinishReason.stop, - }; -} diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/anthropic_provider.dart b/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/anthropic_provider.dart deleted file mode 100644 index c736f36..0000000 --- a/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/anthropic_provider.dart +++ /dev/null @@ -1,366 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter_ai_core/flutter_ai_core.dart'; -import 'package:flutter_ai_provider_anthropic/src/anthropic_event_parser.dart'; -import 'package:flutter_ai_provider_anthropic/src/default_http_client.dart'; -import 'package:flutter_ai_provider_anthropic/src/http_retry.dart'; -import 'package:http/http.dart' as http; - -/// An `LlmProvider` backed by the Anthropic **Messages API** -/// (`POST /v1/messages`). -/// -/// Streams text, extended-thinking, tool calls, and finish reasons as -/// `AiStreamEvent`s. The HTTP client is injectable for testing and custom -/// transport configuration. -/// -/// Notes: -/// * `max_tokens` is required by the API; [defaultMaxTokens] is used when -/// [AiRequestOptions.maxOutputTokens] is not set. -/// * System messages are folded into the top-level `system` field (Anthropic -/// has no `system` role inside `messages`). -/// * Assistant tool calls and tool results are mapped to Anthropic -/// `tool_use` / `tool_result` content blocks. -/// * `temperature` is forwarded only when set; newer Claude models reject -/// sampling parameters, so leave it unset for those. -/// * Set [AiRequestOptions.reasoningEffort] to enable extended thinking with a -/// mapped `budget_tokens` (max_tokens is raised above the budget when needed, -/// and `temperature` is dropped since the API rejects both together). For -/// full control, pass an explicit `thinking` block via -/// [AiRequestOptions.extra], which takes precedence. -/// -/// > The request/response mapping is unit-tested against recorded SSE events; -/// > supply an API key to use it against the live API. -class AnthropicProvider implements LlmProvider { - /// Creates a provider. - /// - /// [apiKey] authenticates requests (sent as `x-api-key`). [baseUrl] defaults - /// to the public Anthropic v1 endpoint; override it for a proxy or gateway. - /// [client] is injectable (defaults to a streaming-capable client — a - /// fetch-based client on the web, [http.Client] elsewhere). [defaultModel] - /// and [defaultMaxTokens] are used when [AiRequestOptions] omits them. - /// [timeout] bounds both the initial connection and the idle gap between - /// streamed chunks. - AnthropicProvider({ - required this.apiKey, - Uri? baseUrl, - http.Client? client, - this.defaultModel = 'claude-opus-4-8', - this.defaultMaxTokens = 4096, - this.anthropicVersion = '2023-06-01', - this.maxRetries = 2, - this.timeout = const Duration(seconds: 60), - }) : assert( - apiKey.isNotEmpty, - 'AnthropicProvider: apiKey is empty — pass a key or set ' - 'ANTHROPIC_API_KEY via --dart-define.', - ), - _baseUrl = baseUrl ?? Uri.parse('https://api.anthropic.com/v1'), - _ownsClient = client == null, - _client = client ?? createDefaultHttpClient(); - - /// The API key sent as the `x-api-key` header. - final String apiKey; - - /// The default model when options don't specify one. - final String defaultModel; - - /// The `max_tokens` used when options don't specify one (the API requires it). - final int defaultMaxTokens; - - /// The `anthropic-version` header value. - final String anthropicVersion; - - /// How many times to retry the initial connection on a transient failure - /// (network error, 429, or 5xx), with backoff honoring `Retry-After`. - final int maxRetries; - - /// Bounds the initial connection (a connect timeout is retried like a network - /// error) and the idle gap between streamed chunks (a mid-stream stall yields - /// a terminal error instead of hanging forever). - final Duration timeout; - - final Uri _baseUrl; - final http.Client _client; - - /// Whether this provider created [_client] itself (vs. an injected one). - /// [close] only closes a client it owns. - final bool _ownsClient; - - @override - Stream send( - AiConversation conversation, { - List? tools, - AiRequestOptions? options, - }) async* { - final (system, messages) = _buildMessages(conversation); - final responseFormat = options?.responseFormat; - // Anthropic has no response_format; structured output is a forced tool whose - // input is the schema (the parser surfaces its input as the JSON answer). - final toolList = >[ - if (tools != null) ..._buildTools(tools), - if (responseFormat != null) - { - 'name': responseFormat.name, - 'description': 'Respond with the structured result.', - 'input_schema': responseFormat.schema, - }, - ]; - // Prompt caching: mark the stable prefix (system + the last tool, which - // anchors the cached span covering all tools) with `cache_control`. - final cache = options?.cachePrompt ?? false; - const cacheControl = {'type': 'ephemeral'}; - if (cache && toolList.isNotEmpty) { - toolList[toolList.length - 1] = { - ...toolList.last, - 'cache_control': cacheControl, - }; - } - // Extended thinking: enable when reasoningEffort is set (unless the caller - // supplied an explicit `thinking` block via extra). Claude 4.6+ (including - // the default model) uses adaptive thinking and rejects `budget_tokens`; - // Claude 3.7 and 4.0–4.5 take the legacy budgeted shape. The API rejects - // `temperature` alongside thinking. - // - // Structured output forces a tool (`tool_choice: {type: tool}`), which the - // API rejects while thinking is enabled — so thinking is dropped when a - // responseFormat is set (structured output takes precedence). - final model = options?.model ?? defaultModel; - final effort = options?.reasoningEffort; - final thinkingEnabled = effort != null && - responseFormat == null && - !(options?.extra.containsKey('thinking') ?? false); - final useLegacyThinking = thinkingEnabled && _usesBudgetedThinking(model); - final budget = effort?.budgetTokens ?? 0; - var maxTokens = options?.maxOutputTokens ?? defaultMaxTokens; - // Only the legacy budgeted shape needs headroom above budget_tokens; - // adaptive thinking draws from max_tokens directly. - if (useLegacyThinking && maxTokens <= budget) { - maxTokens = budget + defaultMaxTokens; - } - final payload = { - if (options?.extra != null) ...options!.extra, - 'model': model, - 'max_tokens': maxTokens, - if (thinkingEnabled) - 'thinking': useLegacyThinking - ? {'type': 'enabled', 'budget_tokens': budget} - : {'type': 'adaptive'}, - 'stream': true, - 'messages': messages, - if (system != null && system.isNotEmpty) - 'system': cache - ? [ - { - 'type': 'text', - 'text': system, - 'cache_control': cacheControl, - }, - ] - : system, - if (options?.temperature != null && !thinkingEnabled) - 'temperature': options!.temperature, - if (toolList.isNotEmpty) 'tools': toolList, - if (responseFormat != null) - 'tool_choice': {'type': 'tool', 'name': responseFormat.name}, - }; - - final http.StreamedResponse response; - try { - response = await connectWithRetry( - client: _client, - maxRetries: maxRetries, - label: 'Anthropic', - timeout: timeout, - build: () => http.Request('POST', _endpoint()) - ..headers['x-api-key'] = apiKey - ..headers['anthropic-version'] = anthropicVersion - ..headers['content-type'] = 'application/json' - ..body = jsonEncode(payload), - ); - } on Object catch (error) { - yield StreamErrorEvent(error: error); - return; - } - - final parser = - AnthropicEventParser(structuredToolName: responseFormat?.name); - // Idle timeout: a stall longer than [timeout] between chunks aborts the - // `await for` with a TimeoutException instead of hanging forever. - final lines = response.stream - .transform(utf8.decoder) - .transform(const LineSplitter()) - .timeout(timeout); - try { - await for (final line in lines) { - final trimmed = line.trim(); - // Anthropic SSE interleaves `event:` and `data:` lines; the JSON on the - // `data:` line carries its own `type`, so we only need the data lines. - if (!trimmed.startsWith('data:')) continue; - final data = trimmed.substring(5).trim(); - if (data.isEmpty) continue; - try { - final Map chunk; - try { - chunk = (jsonDecode(data) as Map).cast(); - } on FormatException { - continue; // skip malformed keep-alive or partial lines - } - for (final event in parser.parse(chunk)) { - yield event; - } - } on Object catch (error) { - // A valid-JSON-but-wrong-shape chunk must not kill the whole stream; - // surface it as a StreamErrorEvent and skip the bad chunk. - yield StreamErrorEvent(error: error); - continue; - } - } - } on TimeoutException catch (error) { - // A mid-stream stall: mark the in-flight message errored. Don't also - // finalize() — that terminal MessageFinished would mask the timeout. - yield StreamErrorEvent(error: error, messageId: parser.messageId); - return; - } - // Stream ended — emit a terminal event if no `message_stop` arrived. - for (final event in parser.finalize()) { - yield event; - } - } - - /// Closes the underlying HTTP client, but only if this provider created it. - /// When a `client` was injected, `close` is a no-op so a shared client isn't - /// torn out from under its owner. - void close() { - if (_ownsClient) _client.close(); - } - - Uri _endpoint() { - final base = _baseUrl.toString().replaceAll(RegExp(r'/+$'), ''); - return Uri.parse('$base/messages'); - } - - /// Matches models that take the legacy budgeted extended-thinking shape - /// (`{type: 'enabled', budget_tokens: N}`): Claude 3.x and Claude 4.0–4.5. - static final RegExp _budgetedThinkingModel = - RegExp(r'^claude-3|^claude-(?:opus|sonnet|haiku)-4-[0-5](?![0-9])'); - - /// Whether [model] uses the legacy budgeted extended-thinking shape rather - /// than adaptive thinking. - /// - /// Claude 4.6+ (including the default `claude-opus-4-8`) removed - /// `budget_tokens` in favor of adaptive thinking (`{type: 'adaptive'}`); - /// sending the budgeted shape there is a 400. Unknown or newer model ids - /// default to adaptive, matching the current flagship models. - static bool _usesBudgetedThinking(String model) => - _budgetedThinkingModel.hasMatch(model); - - /// Builds the top-level `system` string and the `messages` array. Messages - /// with empty content are dropped (the API rejects them). - (String?, List>) _buildMessages( - AiConversation conversation, - ) { - final systemBuffer = StringBuffer(); - final messages = >[]; - - void addContent(String role, List> content) { - if (content.isEmpty) return; - messages.add({'role': role, 'content': content}); - } - - for (final message in conversation.messages) { - switch (message.role) { - case AiRole.system: - if (message.text.isEmpty) break; - if (systemBuffer.isNotEmpty) systemBuffer.write('\n\n'); - systemBuffer.write(message.text); - case AiRole.user: - addContent('user', [ - if (message.text.isNotEmpty) {'type': 'text', 'text': message.text}, - for (final image in _images(message)) - {'type': 'image', 'source': _imageSource(image)}, - ]); - case AiRole.assistant: - addContent('assistant', [ - // Replay signed thinking blocks first — required by extended - // thinking when the turn also has tool_use, or the API 400s. - for (final reasoning in message.parts.whereType()) - if (reasoning.signature != null) - { - 'type': 'thinking', - 'thinking': reasoning.text, - 'signature': reasoning.signature, - }, - if (message.text.isNotEmpty) {'type': 'text', 'text': message.text}, - for (final call in message.parts.whereType()) - { - 'type': 'tool_use', - 'id': call.toolCallId, - 'name': call.toolName, - 'input': call.args, - }, - ]); - case AiRole.tool: - addContent('user', [ - for (final result in message.parts.whereType()) - { - 'type': 'tool_result', - 'tool_use_id': result.toolCallId, - 'content': result.result is String - ? result.result as String - : jsonEncode(result.result), - if (result.isError) 'is_error': true, - }, - ]); - } - } - - final system = systemBuffer.isEmpty ? null : systemBuffer.toString(); - return (system, _mergeAdjacentRoles(messages)); - } - - /// Anthropic requires strict user/assistant alternation; consecutive entries - /// with the same `role` (e.g. a tool-result `user` turn following a normal - /// `user` turn, or two tool turns in a row) 400 with "roles must alternate". - /// Merge adjacent same-role messages by concatenating their `content` arrays. - static List> _mergeAdjacentRoles( - List> messages, - ) { - final merged = >[]; - for (final message in messages) { - if (merged.isNotEmpty && merged.last['role'] == message['role']) { - final content = [ - ...(merged.last['content']! as List), - ...(message['content']! as List), - ]; - merged.last['content'] = content; - } else { - merged.add({...message}); - } - } - return merged; - } - - static Iterable _images(AiMessage message) => message.parts - .whereType() - .where((f) => f.mediaType.startsWith('image/')); - - /// Anthropic image source: base64 for inline bytes, else a URL source. - static Map _imageSource(FilePart image) => - image.bytes != null - ? { - 'type': 'base64', - 'media_type': image.mediaType, - 'data': base64Encode(image.bytes!), - } - : {'type': 'url', 'url': image.url.toString()}; - - List> _buildTools(List tools) => [ - for (final tool in tools) - { - 'name': tool.name, - 'description': tool.description, - 'input_schema': tool.parametersSchema, - }, - ]; -} diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/default_http_client.dart b/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/default_http_client.dart deleted file mode 100644 index 392ff85..0000000 --- a/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/default_http_client.dart +++ /dev/null @@ -1,4 +0,0 @@ -// Provides `createDefaultHttpClient`, resolved per-platform via conditional -// import so streaming works everywhere. -export 'default_http_client_io.dart' - if (dart.library.js_interop) 'default_http_client_web.dart'; diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/default_http_client_io.dart b/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/default_http_client_io.dart deleted file mode 100644 index bc63899..0000000 --- a/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/default_http_client_io.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:http/http.dart' as http; - -/// The default HTTP client on native platforms: a standard [http.Client], -/// which already delivers a streamed response body chunk-by-chunk. -http.Client createDefaultHttpClient() => http.Client(); diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/default_http_client_web.dart b/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/default_http_client_web.dart deleted file mode 100644 index 19d9654..0000000 --- a/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/default_http_client_web.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:fetch_client/fetch_client.dart'; -import 'package:http/http.dart' as http; - -/// The default HTTP client on the web: a [FetchClient] backed by the streaming -/// Fetch API, so SSE tokens arrive progressively. -/// -/// The `http.Client()` default resolves to `BrowserClient` on the web, which is -/// XHR-backed and buffers the entire response body before the stream emits — -/// silently degrading token-by-token streaming to all-at-once. `FetchClient` -/// reads the response `ReadableStream` incrementally, restoring real streaming. -http.Client createDefaultHttpClient() => FetchClient(mode: RequestMode.cors); diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/http_retry.dart b/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/http_retry.dart deleted file mode 100644 index ee19a49..0000000 --- a/packages/flutter_ai/flutter_ai_provider_anthropic/lib/src/http_retry.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:flutter_ai_core/flutter_ai_core.dart'; -import 'package:http/http.dart' as http; - -/// Sends [build]'s request, retrying transient failures (network errors, -/// connect timeouts, HTTP 429, and 5xx) up to [maxRetries] times with -/// exponential backoff (capped and jittered) that honors a `Retry-After` -/// header. A fresh request is built per attempt. -/// -/// Each `send` attempt is bounded by [timeout]; a connect timeout is treated as -/// a transient failure and retried like a network error. -/// -/// Returns the `200` streamed response. Retries only happen *before* the body -/// is consumed — once a 200 stream starts, the caller owns it. Throws the -/// underlying error on a network failure, or a descriptive [Exception] -/// ("[label] request failed (status): body") on a non-retryable HTTP error; -/// callers surface these as a `StreamErrorEvent`. -Future connectWithRetry({ - required http.Client client, - required http.Request Function() build, - required int maxRetries, - required String label, - required Duration timeout, -}) async { - for (var attempt = 0;; attempt++) { - final http.StreamedResponse response; - try { - response = await client.send(build()).timeout(timeout); - } on Object { - if (attempt < maxRetries) { - await Future.delayed(_backoff(attempt)); - continue; - } - rethrow; - } - - if (response.statusCode == 200) return response; - - if (_isRetryable(response.statusCode) && attempt < maxRetries) { - final wait = - _retryAfter(response.headers['retry-after']) ?? _backoff(attempt); - await response.stream.drain(); - await Future.delayed(wait); - continue; - } - - final body = await response.stream.bytesToString(); - throw llmExceptionFor( - response.statusCode, - '$label: $body', - retryAfter: _retryAfter(response.headers['retry-after']), - ); - } -} - -bool _isRetryable(int code) => - code == 408 || code == 409 || code == 429 || (code >= 500 && code < 600); - -final _random = Random(); - -/// Exponential backoff, capped at 30s, with randomized jitter so retries from -/// many clients don't synchronize. The base doubles per attempt up to the cap, -/// then a random 0–100% jitter of the (capped) base is added on top. -Duration _backoff(int attempt) { - const base = Duration(milliseconds: 400); - const cap = Duration(seconds: 30); - // Guard against overflow on large attempt counts before comparing to the cap. - final shift = attempt.clamp(0, 30); - final scaledMs = base.inMilliseconds * (1 << shift); - final cappedMs = min(cap.inMilliseconds, scaledMs); - final jitterMs = _random.nextInt(cappedMs + 1); - return Duration(milliseconds: cappedMs + jitterMs); -} - -Duration? _retryAfter(String? header) { - final seconds = int.tryParse(header?.trim() ?? ''); - return seconds == null ? null : Duration(seconds: seconds); -} diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/pubspec.yaml b/packages/flutter_ai/flutter_ai_provider_anthropic/pubspec.yaml deleted file mode 100644 index 682a8a4..0000000 --- a/packages/flutter_ai/flutter_ai_provider_anthropic/pubspec.yaml +++ /dev/null @@ -1,35 +0,0 @@ -name: flutter_ai_provider_anthropic -description: "Anthropic (Claude) LlmProvider for flutter_ai: streams the Messages API (text, extended thinking, tool use) as flutter_ai_core AiStreamEvents." -version: 0.1.12 -homepage: https://github.com/ananmouaz/flutter_ai -repository: https://github.com/ananmouaz/flutter_ai/tree/main/packages/flutter_ai_provider_anthropic -issue_tracker: https://github.com/ananmouaz/flutter_ai/issues -topics: - - ai - - llm - - anthropic - - claude - - chatbot - - -environment: - sdk: ^3.6.0 - -platforms: - android: - ios: - linux: - macos: - web: - windows: - -resolution: workspace - -dependencies: - fetch_client: ^1.2.1 - flutter_ai_core: ^0.1.13 - http: ^1.2.0 - -dev_dependencies: - lints: ^5.0.0 - test: ^1.25.0 diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/test/anthropic_provider_test.dart b/packages/flutter_ai/flutter_ai_provider_anthropic/test/anthropic_provider_test.dart deleted file mode 100644 index 09359ef..0000000 --- a/packages/flutter_ai/flutter_ai_provider_anthropic/test/anthropic_provider_test.dart +++ /dev/null @@ -1,652 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:flutter_ai_provider_anthropic/flutter_ai_provider_anthropic.dart'; -import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; -import 'package:test/test.dart'; - -/// Builds a streaming mock client that emits [lines] as an SSE body. -http.Client _sseClient(List lines, {int statusCode = 200}) { - return MockClient.streaming((request, bodyStream) async { - final body = lines.map((l) => '$l\n').join(); - return http.StreamedResponse( - Stream>.value(utf8.encode(body)), - statusCode, - ); - }); -} - -/// Wraps [data] objects as `data:` SSE lines (the provider ignores `event:`). -List _dataLines(List> data) => - [for (final d in data) 'data: ${jsonEncode(d)}']; - -void main() { - group('AnthropicEventParser', () { - test('emits start, text deltas, and finish', () { - final parser = AnthropicEventParser(); - final events = [ - ...parser.parse({ - 'type': 'message_start', - 'message': { - 'id': 'msg_1', - 'role': 'assistant', - 'usage': { - 'input_tokens': 10, - 'cache_read_input_tokens': 4, - 'cache_creation_input_tokens': 6, - 'output_tokens': 1, - }, - }, - }), - ...parser.parse({ - 'type': 'content_block_start', - 'index': 0, - 'content_block': {'type': 'text', 'text': ''}, - }), - ...parser.parse({ - 'type': 'content_block_delta', - 'index': 0, - 'delta': {'type': 'text_delta', 'text': 'Hello'}, - }), - ...parser.parse({ - 'type': 'content_block_delta', - 'index': 0, - 'delta': {'type': 'text_delta', 'text': ' world'}, - }), - ...parser.parse({ - 'type': 'message_delta', - 'delta': {'stop_reason': 'end_turn'}, - 'usage': {'output_tokens': 25}, - }), - ...parser.parse({'type': 'message_stop'}), - ]; - - expect(events.first, isA()); - expect((events.first as MessageStarted).messageId, 'msg_1'); - expect(events.whereType().map((e) => e.delta), [ - 'Hello', - ' world', - ]); - final finished = events.last as MessageFinished; - expect(finished.reason, FinishReason.stop); - // input folds in cache read + cache write subsets: 10 + 4 + 6. - expect(finished.usage?.inputTokens, 20); - expect(finished.usage?.cachedInputTokens, 4); - expect(finished.usage?.cacheCreationTokens, 6); - expect(finished.usage?.outputTokens, 25); - }); - - test('cache_creation_input_tokens lands in cacheCreationTokens only', () { - final parser = AnthropicEventParser(); - final events = [ - ...parser.parse({ - 'type': 'message_start', - 'message': { - 'id': 'msg_cc', - 'role': 'assistant', - 'usage': { - 'input_tokens': 50, - 'cache_creation_input_tokens': 30, - 'output_tokens': 1, - }, - }, - }), - ...parser.parse({'type': 'message_stop'}), - ]; - final usage = (events.last as MessageFinished).usage!; - expect(usage.cacheCreationTokens, 30); - expect(usage.cachedInputTokens, isNull); // no cache read reported - expect(usage.inputTokens, 80); // 50 + 30 cache write subset - }); - - test('maps thinking deltas to ReasoningDelta', () { - final parser = AnthropicEventParser(); - final events = parser.parse({ - 'type': 'content_block_delta', - 'index': 0, - 'delta': {'type': 'thinking_delta', 'thinking': 'Let me reason.'}, - }); - expect(events.single, isA()); - expect((events.single as ReasoningDelta).delta, 'Let me reason.'); - }); - - test('threads streamed tool calls by index and readies them', () { - final parser = AnthropicEventParser(); - final events = [ - ...parser.parse({ - 'type': 'content_block_start', - 'index': 1, - 'content_block': { - 'type': 'tool_use', - 'id': 'toolu_a', - 'name': 'get_weather', - }, - }), - ...parser.parse({ - 'type': 'content_block_delta', - 'index': 1, - 'delta': {'type': 'input_json_delta', 'partial_json': '{"ci'}, - }), - ...parser.parse({ - 'type': 'content_block_delta', - 'index': 1, - 'delta': { - 'type': 'input_json_delta', - 'partial_json': 'ty":"London"}' - }, - }), - ...parser.parse({'type': 'content_block_stop', 'index': 1}), - ...parser.parse({ - 'type': 'message_delta', - 'delta': {'stop_reason': 'tool_use'}, - }), - ...parser.parse({'type': 'message_stop'}), - ]; - - expect( - events.whereType().single.toolName, 'get_weather'); - expect(events.whereType().map((e) => e.argumentsDelta), [ - '{"ci', - 'ty":"London"}', - ]); - expect(events.whereType().single.toolCallId, 'toolu_a'); - expect( - events.whereType().single.reason, - FinishReason.toolCalls, - ); - }); - - test('surfaces the structured-output tool input as JSON text', () { - final parser = AnthropicEventParser(structuredToolName: 'result'); - final events = [ - ...parser.parse({ - 'type': 'message_start', - 'message': {'id': 'm', 'role': 'assistant'}, - }), - ...parser.parse({ - 'type': 'content_block_start', - 'index': 0, - 'content_block': {'type': 'tool_use', 'id': 't', 'name': 'result'}, - }), - ...parser.parse({ - 'type': 'content_block_delta', - 'index': 0, - 'delta': {'type': 'input_json_delta', 'partial_json': '{"x":1}'}, - }), - ...parser.parse({'type': 'content_block_stop', 'index': 0}), - ...parser.parse({ - 'type': 'message_delta', - 'delta': {'stop_reason': 'tool_use'}, - }), - ...parser.parse({'type': 'message_stop'}), - ]; - - // The forced tool surfaces as text, not a tool call, and finishes as stop. - expect(events.whereType(), isEmpty); - expect( - events.whereType().map((e) => e.delta).join(), - '{"x":1}', - ); - expect( - events.whereType().single.reason, - FinishReason.stop, - ); - }); - - test('maps an error event to StreamErrorEvent', () { - final parser = AnthropicEventParser(); - final events = parser.parse({ - 'type': 'error', - 'error': {'type': 'overloaded_error', 'message': 'Overloaded'}, - }); - expect(events.single, isA()); - expect((events.single as StreamErrorEvent).error, 'Overloaded'); - }); - - test('an error event suppresses the synthetic finalize (no fake success)', - () { - final parser = AnthropicEventParser(); - parser.parse({ - 'type': 'message_start', - 'message': {'id': 'a1'} - }); - parser.parse({ - 'type': 'error', - 'error': {'type': 'overloaded_error', 'message': 'Overloaded'}, - }); - // The stream closes after the error; finalize() must not emit a - // MessageFinished(stop) that would overwrite the error status. - expect(parser.finalize(), isEmpty); - }); - }); - - group('AnthropicProvider.send', () { - test('streams events end-to-end over a mock client', () async { - final provider = AnthropicProvider( - apiKey: 'test', - client: _sseClient([ - 'event: message_start', - ..._dataLines([ - { - 'type': 'message_start', - 'message': {'id': 'msg_1', 'role': 'assistant'}, - }, - ]), - 'event: content_block_delta', - ..._dataLines([ - { - 'type': 'content_block_delta', - 'index': 0, - 'delta': {'type': 'text_delta', 'text': 'Hi'}, - }, - { - 'type': 'content_block_delta', - 'index': 0, - 'delta': {'type': 'text_delta', 'text': '!'}, - }, - { - 'type': 'message_delta', - 'delta': {'stop_reason': 'end_turn'}, - }, - {'type': 'message_stop'}, - ]), - ]), - ); - - final events = await provider - .send(const AiConversation(id: 'c', messages: [])) - .toList(); - - final processor = MessageProcessor(); - for (final event in events) { - processor.apply(event); - } - expect(processor.conversation.messages.single.text, 'Hi!'); - expect( - processor.conversation.messages.single.status, - AiMessageStatus.complete, - ); - }); - - test('emits a StreamErrorEvent on a non-200 response', () async { - final provider = AnthropicProvider( - apiKey: 'bad', - client: _sseClient(['nope'], statusCode: 401), - ); - final events = await provider - .send(const AiConversation(id: 'c', messages: [])) - .toList(); - expect(events.single, isA()); - }); - - test('sends required headers, max_tokens, and folds system messages', - () async { - late http.Request captured; - final provider = AnthropicProvider( - apiKey: 'sk-test', - client: MockClient.streaming((request, bodyStream) async { - captured = request as http.Request; - return http.StreamedResponse( - Stream>.value( - utf8.encode('data: ${jsonEncode({'type': 'message_stop'})}\n'), - ), - 200, - ); - }), - ); - - await provider - .send( - const AiConversation( - id: 'c', - messages: [ - AiMessage( - id: 's', - role: AiRole.system, - parts: [TextPart('Be terse.')], - ), - AiMessage( - id: 'u', - role: AiRole.user, - parts: [TextPart('Hi')], - ), - ], - ), - ) - .toList(); - - expect(captured.headers['x-api-key'], 'sk-test'); - expect(captured.headers['anthropic-version'], '2023-06-01'); - final body = (jsonDecode(captured.body) as Map).cast(); - expect(body['system'], 'Be terse.'); - expect(body['max_tokens'], 4096); - expect(body['stream'], true); - final messages = body['messages']! as List; - expect(messages, hasLength(1)); // system is hoisted out of messages - expect((messages.single as Map)['role'], 'user'); - }); - - test('cachePrompt marks system and the last tool with cache_control', - () async { - late http.Request captured; - final provider = AnthropicProvider( - apiKey: 'sk', - client: MockClient.streaming((request, bodyStream) async { - captured = request as http.Request; - return http.StreamedResponse( - Stream>.value( - utf8.encode('data: ${jsonEncode({'type': 'message_stop'})}\n'), - ), - 200, - ); - }), - ); - - await provider - .send( - const AiConversation( - id: 'c', - messages: [ - AiMessage( - id: 's', - role: AiRole.system, - parts: [TextPart('Be terse.')], - ), - AiMessage(id: 'u', role: AiRole.user, parts: [TextPart('Hi')]), - ], - ), - tools: [ - const ToolDefinition( - name: 'get_weather', - description: 'w', - parametersSchema: {'type': 'object'}, - ), - ], - options: const AiRequestOptions(cachePrompt: true), - ) - .toList(); - - final body = (jsonDecode(captured.body) as Map).cast(); - final system = (body['system'] as List).cast>(); - expect(system.single['cache_control'], {'type': 'ephemeral'}); - final tools = (body['tools'] as List).cast>(); - expect(tools.last['cache_control'], {'type': 'ephemeral'}); - }); - - test( - 'replays a signed thinking block before tool_use in the assistant turn', - () async { - late http.Request captured; - final provider = AnthropicProvider( - apiKey: 'sk', - client: MockClient.streaming((request, bodyStream) async { - captured = request as http.Request; - return http.StreamedResponse( - Stream>.value( - utf8.encode('data: ${jsonEncode({'type': 'message_stop'})}\n'), - ), - 200, - ); - }), - ); - - await provider - .send( - const AiConversation( - id: 'c', - messages: [ - AiMessage(id: 'u', role: AiRole.user, parts: [TextPart('hi')]), - AiMessage( - id: 'a', - role: AiRole.assistant, - parts: [ - ReasoningPart('let me think', signature: 'sig-abc'), - ToolCallPart( - toolCallId: 't1', - toolName: 'get_weather', - args: {'city': 'Lisbon'}, - ), - ], - ), - AiMessage( - id: 'tr', - role: AiRole.tool, - parts: [ToolResultPart(toolCallId: 't1', result: 'sunny')], - ), - ], - ), - ) - .toList(); - - final body = (jsonDecode(captured.body) as Map).cast(); - final messages = (body['messages'] as List).cast>(); - final assistant = messages.firstWhere((m) => m['role'] == 'assistant'); - final blocks = - (assistant['content'] as List).cast>(); - // Thinking block (with signature) comes first, before tool_use. - expect(blocks.first['type'], 'thinking'); - expect(blocks.first['signature'], 'sig-abc'); - expect(blocks.any((b) => b['type'] == 'tool_use'), isTrue); - expect( - blocks.indexWhere((b) => b['type'] == 'thinking') < - blocks.indexWhere((b) => b['type'] == 'tool_use'), - isTrue, - ); - }); - - test('emits a StreamErrorEvent when the transport throws', () async { - final provider = AnthropicProvider( - apiKey: 'test', - client: MockClient.streaming((request, bodyStream) async { - throw const SocketException('connection refused'); - }), - ); - final events = await provider - .send(const AiConversation(id: 'c', messages: [])) - .toList(); - expect(events.single, isA()); - }); - - test('retries a transient 503 then succeeds', () async { - var calls = 0; - final provider = AnthropicProvider( - apiKey: 'k', - client: MockClient.streaming((request, _) async { - calls++; - if (calls == 1) { - return http.StreamedResponse( - Stream>.value(utf8.encode('busy')), - 503, - ); - } - return http.StreamedResponse( - Stream>.value( - utf8.encode('data: ${jsonEncode({'type': 'message_stop'})}\n'), - ), - 200, - ); - }), - ); - final events = await provider - .send(const AiConversation(id: 'c', messages: [])) - .toList(); - expect(calls, 2); - expect(events.whereType(), isEmpty); - }); - - test('emits a StreamErrorEvent on a wrong-shape chunk without throwing', - () async { - // Valid JSON, wrong shape: a content_block whose value is a String makes - // the parser's `as Map?` cast throw; the stream must continue, not die. - final provider = AnthropicProvider( - apiKey: 'k', - client: _sseClient(_dataLines([ - { - 'type': 'message_start', - 'message': {'id': 'msg_1', 'role': 'assistant'}, - }, - {'type': 'content_block_start', 'index': 0, 'content_block': 'oops'}, - { - 'type': 'content_block_delta', - 'index': 0, - 'delta': {'type': 'text_delta', 'text': 'ok'}, - }, - {'type': 'message_stop'}, - ])), - ); - final events = await provider - .send(const AiConversation(id: 'c', messages: [])) - .toList(); - expect(events.whereType(), isNotEmpty); - expect(events.whereType().map((e) => e.delta), contains('ok')); - }); - - test('finalizes a stream that ends without a message_stop', () async { - final provider = AnthropicProvider( - apiKey: 'k', - client: _sseClient(_dataLines([ - { - 'type': 'message_start', - 'message': {'id': 'msg_1', 'role': 'assistant'}, - }, - { - 'type': 'content_block_delta', - 'index': 0, - 'delta': {'type': 'text_delta', 'text': 'hi'}, - }, - ])), - ); - final processor = MessageProcessor(); - for (final e in await provider - .send(const AiConversation(id: 'c', messages: [])) - .toList()) { - processor.apply(e); - } - expect( - processor.conversation.messages.single.status, - AiMessageStatus.complete, - ); - }); - - test('surfaces a message-scoped StreamErrorEvent (no finalize) on a stall', - () async { - final controller = StreamController>(); - controller.add(utf8.encode(_dataLines([ - { - 'type': 'message_start', - 'message': {'id': 'msg_1', 'role': 'assistant'}, - }, - ]).map((l) => '$l\n').join())); - final provider = AnthropicProvider( - apiKey: 'k', - timeout: const Duration(milliseconds: 50), - client: MockClient.streaming((request, _) async { - return http.StreamedResponse(controller.stream, 200); - }), - ); - final events = await provider - .send(const AiConversation(id: 'c', messages: [])) - .toList(); - await controller.close(); - final errors = events.whereType().toList(); - expect(errors, isNotEmpty); - expect(errors.last.messageId, 'msg_1'); - expect(events.whereType(), isEmpty); - }); - - test('merges adjacent same-role turns so roles alternate', () async { - late http.Request captured; - final provider = AnthropicProvider( - apiKey: 'k', - client: MockClient.streaming((request, _) async { - captured = request as http.Request; - return http.StreamedResponse( - Stream>.value( - utf8.encode('data: ${jsonEncode({'type': 'message_stop'})}\n'), - ), - 200, - ); - }), - ); - // A normal user turn immediately followed by a tool-result turn (also - // mapped to role `user`) would otherwise produce two user turns in a row. - await provider - .send( - const AiConversation( - id: 'c', - messages: [ - AiMessage( - id: 'u', - role: AiRole.user, - parts: [TextPart('Hi')], - ), - AiMessage( - id: 't', - role: AiRole.tool, - parts: [ - ToolResultPart( - toolCallId: 'call_1', - result: 'done', - ), - ], - ), - ], - ), - ) - .toList(); - final body = (jsonDecode(captured.body) as Map).cast(); - final messages = (body['messages']! as List).cast>(); - expect(messages, hasLength(1)); - expect(messages.single['role'], 'user'); - // Both the text and the tool_result are concatenated into one content[]. - final content = messages.single['content'] as List; - expect(content.any((p) => (p as Map)['type'] == 'text'), isTrue); - expect(content.any((p) => (p as Map)['type'] == 'tool_result'), isTrue); - }); - }); - - test('encodes image attachments as base64 image blocks', () async { - late http.Request captured; - final provider = AnthropicProvider( - apiKey: 'k', - client: MockClient.streaming((request, _) async { - captured = request as http.Request; - return http.StreamedResponse( - Stream>.value( - utf8.encode('data: {"type":"message_stop"}\n')), - 200, - ); - }), - ); - await provider - .send( - AiConversation( - id: 'c', - messages: [ - AiMessage( - id: 'u', - role: AiRole.user, - parts: [ - const TextPart('what is this?'), - FilePart( - mediaType: 'image/png', - bytes: Uint8List.fromList([1, 2, 3])), - ], - ), - ], - ), - ) - .toList(); - final body = (jsonDecode(captured.body) as Map).cast(); - final content = - ((body['messages']! as List).first as Map)['content'] as List; - final image = - content.firstWhere((p) => (p as Map)['type'] == 'image') as Map; - final source = image['source'] as Map; - expect(source['media_type'], 'image/png'); - expect(source['data'], 'AQID'); - }); -} diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/test/default_http_client_test.dart b/packages/flutter_ai/flutter_ai_provider_anthropic/test/default_http_client_test.dart deleted file mode 100644 index b45bba7..0000000 --- a/packages/flutter_ai/flutter_ai_provider_anthropic/test/default_http_client_test.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:flutter_ai_provider_anthropic/src/default_http_client.dart'; -import 'package:http/http.dart' as http; -import 'package:test/test.dart'; - -void main() { - test('createDefaultHttpClient returns a usable client on this platform', () { - final client = createDefaultHttpClient(); - addTearDown(client.close); - expect(client, isA()); - }); -} diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/test/live_test.dart b/packages/flutter_ai/flutter_ai_provider_anthropic/test/live_test.dart deleted file mode 100644 index 812f13c..0000000 --- a/packages/flutter_ai/flutter_ai_provider_anthropic/test/live_test.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_ai_provider_anthropic/flutter_ai_provider_anthropic.dart'; -import 'package:test/test.dart'; - -/// Live smoke test against the real Anthropic API. Skipped unless ANTHROPIC_API_KEY -/// is set, so it's safe in CI. Run with: -/// ANTHROPIC_API_KEY=... dart test test/live_test.dart -void main() { - final key = Platform.environment['ANTHROPIC_API_KEY']; - final skip = key == null ? 'set ANTHROPIC_API_KEY to run live tests' : null; - - test('streams a short reply from the live API', () async { - final provider = AnthropicProvider(apiKey: key!); - final processor = MessageProcessor(); - await for (final event in provider.send( - const AiConversation( - id: 'c', - messages: [ - AiMessage( - id: 'u', - role: AiRole.user, - parts: [TextPart('Reply with a short friendly greeting.')], - ), - ], - ), - )) { - processor.apply(event); - } - final message = processor.conversation.messages.single; - expect(message.text.trim(), isNotEmpty); - expect(message.status, AiMessageStatus.complete); - }, skip: skip); -} diff --git a/packages/flutter_ai/flutter_ai_provider_anthropic/test/reasoning_effort_test.dart b/packages/flutter_ai/flutter_ai_provider_anthropic/test/reasoning_effort_test.dart deleted file mode 100644 index fe5eb68..0000000 --- a/packages/flutter_ai/flutter_ai_provider_anthropic/test/reasoning_effort_test.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_ai_provider_anthropic/flutter_ai_provider_anthropic.dart'; -import 'package:http/http.dart' as http; -import 'package:http/testing.dart'; -import 'package:test/test.dart'; - -Future> _capture(AiRequestOptions options) async { - late Map payload; - final provider = AnthropicProvider( - apiKey: 'secret', - client: MockClient.streaming((request, bodyStream) async { - payload = (jsonDecode(await bodyStream.bytesToString()) as Map) - .cast(); - return http.StreamedResponse(const Stream>.empty(), 200); - }), - ); - await provider - .send( - const AiConversation( - id: 'c', - messages: [ - AiMessage(id: 'm', role: AiRole.user, parts: [TextPart('Hi')]), - ], - ), - options: options, - ) - .toList(); - return payload; -} - -void main() { - test('reasoningEffort enables adaptive thinking on 4.6+ (default model)', - () async { - // The default model (claude-opus-4-8) is adaptive-only: it rejects - // budget_tokens, so we must emit {type: adaptive}. - final payload = await _capture( - const AiRequestOptions(reasoningEffort: ReasoningEffort.low)); - final thinking = (payload['thinking'] as Map).cast(); - expect(thinking['type'], 'adaptive'); - expect(thinking.containsKey('budget_tokens'), isFalse); - }); - - test('reasoningEffort uses the budgeted shape on legacy models', () async { - final payload = await _capture(const AiRequestOptions( - model: 'claude-3-7-sonnet-latest', - reasoningEffort: ReasoningEffort.low, - )); - final thinking = (payload['thinking'] as Map).cast(); - expect(thinking['type'], 'enabled'); - expect(thinking['budget_tokens'], ReasoningEffort.low.budgetTokens); - }); - - test('raises max_tokens above the budget on legacy models', () async { - // high budget (24576) exceeds the default max_tokens (4096); only the - // budgeted shape needs the bump. - final payload = await _capture(const AiRequestOptions( - model: 'claude-sonnet-4-5', - reasoningEffort: ReasoningEffort.high, - )); - expect(payload['max_tokens'] as int, - greaterThan(ReasoningEffort.high.budgetTokens)); - }); - - test('drops temperature when thinking is enabled', () async { - final payload = await _capture(const AiRequestOptions( - reasoningEffort: ReasoningEffort.medium, - temperature: 0.7, - )); - expect(payload.containsKey('temperature'), isFalse); - expect(payload.containsKey('thinking'), isTrue); - }); - - test('drops thinking when a responseFormat is set (forced tool choice)', - () async { - final payload = await _capture(const AiRequestOptions( - reasoningEffort: ReasoningEffort.high, - responseFormat: AiResponseFormat( - name: 'result', - schema: {'type': 'object'}, - ), - )); - // Forced tool_choice + thinking is a 400; structured output wins. - expect(payload.containsKey('thinking'), isFalse); - expect((payload['tool_choice'] as Map)['type'], 'tool'); - }); - - test('an explicit thinking block in extra takes precedence', () async { - final payload = await _capture(const AiRequestOptions( - reasoningEffort: ReasoningEffort.high, - extra: { - 'thinking': {'type': 'enabled', 'budget_tokens': 5000}, - }, - )); - final thinking = (payload['thinking'] as Map).cast(); - expect(thinking['budget_tokens'], 5000); - }); -} diff --git a/packages/flutter_ai/flutter_ai_tools/CHANGELOG.md b/packages/flutter_ai/flutter_ai_tools/CHANGELOG.md deleted file mode 100644 index ebf8b93..0000000 --- a/packages/flutter_ai/flutter_ai_tools/CHANGELOG.md +++ /dev/null @@ -1,34 +0,0 @@ -# Changelog - -## 0.1.4 - -- Docs: shortened the pubspec `description` into pub.dev's 60–180 character - window so it renders in full in search results. No code changes. - -## 0.1.3 - -- Docs: refreshed the README listing with a hero image, screenshot gallery, - and badges (consistent across the package family). No code changes. - -## 0.1.2 - -- Declares supported `platforms:` (all 6) for the pub.dev listing. - -## 0.1.1 - -- Docs: added a "Buy me a coffee" (Ko-fi) support section to the README. No code - changes. - -## 0.1.0 - -Initial release. - -- `ToolSpec` — a tool declaration (`name`, `description`, JSON-Schema - `parametersSchema`) plus an optional client-side `execute`; `toDefinition()` - yields the model-facing `ToolDefinition`. -- `ToolRegistry` — registers tools, exposes their `definitions` for a provider, - and `run`s a `ToolCallPart` into a `ToolResultPart`, capturing unknown tools - and thrown executors as error results instead of crashing. -- `WebSearchAdapter` + `webSearchTool` + `SearchResult` — expose any web-search - backend as a callable tool. -- Pure Dart; re-exports `flutter_ai_core`. diff --git a/packages/flutter_ai/flutter_ai_tools/LICENSE b/packages/flutter_ai/flutter_ai_tools/LICENSE deleted file mode 100644 index 56023ee..0000000 --- a/packages/flutter_ai/flutter_ai_tools/LICENSE +++ /dev/null @@ -1,29 +0,0 @@ -BSD 3-Clause License - -Copyright (c) 2026, The flutter_ai authors. -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/flutter_ai/flutter_ai_tools/README.md b/packages/flutter_ai/flutter_ai_tools/README.md deleted file mode 100644 index a27702e..0000000 --- a/packages/flutter_ai/flutter_ai_tools/README.md +++ /dev/null @@ -1,80 +0,0 @@ -

flutter_ai_tools

- -

Provider-neutral tool calling for flutter_ai — declare a ToolSpec, register it, and let the agent loop run it. Pure Dart, with a web-search adapter included.

- -

- Tool calls flowing through the agent loop -

- -

- flutter_ai_tools on pub.dev - pub points - License: BSD-3-Clause -

- -

- Family: flutter_ai · - core · client · elements · - mcp · voice
- Recipes · Migrating from the Vercel AI SDK -

- ---- - -Provider-neutral tool calling for the [`flutter_ai`](../../README.md) family. -Pure Dart, no Flutter dependency. - -## What it does - -- **`ToolSpec`** — declare a tool (name, description, JSON-Schema parameters) and - an optional client-side executor. -- **`ToolRegistry`** — collect tools, hand their `definitions` to a provider, and - `run` a `ToolCallPart` into a `ToolResultPart`. Unknown tools and thrown - executors become *error results*, never crashes. -- **Web search** — `webSearchTool(adapter)` turns any `WebSearchAdapter` - (Tavily, Brave, SerpAPI, custom) into a callable tool returning `SearchResult`s. - -## Usage - -```dart -final tools = ToolRegistry([ - ToolSpec( - name: 'get_weather', - description: 'Get the weather for a city', - parametersSchema: const { - 'type': 'object', - 'properties': {'city': {'type': 'string'}}, - 'required': ['city'], - }, - execute: (args) => weatherApi.fetch(args['city']! as String), - ), -]); - -// Advertise to a provider: -controller.setTools(tools.definitions); - -// Fulfill a call the model made: -final result = await tools.run(toolCallPart); // -> ToolResultPart -``` - -### Web search - -```dart -final tools = ToolRegistry([webSearchTool(MyTavilyAdapter())]); -``` - -```dart -class MyTavilyAdapter implements WebSearchAdapter { - @override - Future> search(String query, {int? maxResults}) async { - // call your search backend, map hits into SearchResult - } -} -``` - -## Status - -Published on pub.dev (see the CHANGELOG); depends on `flutter_ai_core`. -See [`example/`](example/). - -_If `flutter_ai` saves you time, you can [buy me a coffee ☕](https://ko-fi.com/ananmouaz)._ diff --git a/packages/flutter_ai/flutter_ai_tools/analysis_options.yaml b/packages/flutter_ai/flutter_ai_tools/analysis_options.yaml deleted file mode 100644 index bddaa31..0000000 --- a/packages/flutter_ai/flutter_ai_tools/analysis_options.yaml +++ /dev/null @@ -1,2 +0,0 @@ -# Inherits the workspace-wide strict configuration. -include: ../../analysis_options.yaml diff --git a/packages/flutter_ai/flutter_ai_tools/example/flutter_ai_tools_example.dart b/packages/flutter_ai/flutter_ai_tools/example/flutter_ai_tools_example.dart deleted file mode 100644 index 80d961d..0000000 --- a/packages/flutter_ai/flutter_ai_tools/example/flutter_ai_tools_example.dart +++ /dev/null @@ -1,39 +0,0 @@ -// Declares a tool, advertises it, and fulfills a tool call. -// -// Run with: dart run example/flutter_ai_tools_example.dart -import 'package:flutter_ai_tools/flutter_ai_tools.dart'; - -Future main() async { - final tools = ToolRegistry([ - ToolSpec( - name: 'get_weather', - description: 'Get the current weather for a city', - parametersSchema: const { - 'type': 'object', - 'properties': { - 'city': {'type': 'string'}, - }, - 'required': ['city'], - }, - execute: (args) async { - final city = args['city']! as String; - // Pretend to call a weather API. - return {'city': city, 'tempC': 21, 'condition': 'Cloudy'}; - }, - ), - ]); - - // These definitions are what you pass to an LlmProvider / UseChatController. - print('Advertised tools: ${tools.definitions.map((d) => d.name).toList()}'); - - // Simulate a tool call the model emitted, then fulfill it. - const call = ToolCallPart( - toolCallId: 'call-1', - toolName: 'get_weather', - args: {'city': 'London'}, - state: ToolCallState.inputAvailable, - ); - - final result = await tools.run(call); - print('Result (isError=${result.isError}): ${result.result}'); -} diff --git a/packages/flutter_ai/flutter_ai_tools/lib/flutter_ai_tools.dart b/packages/flutter_ai/flutter_ai_tools/lib/flutter_ai_tools.dart deleted file mode 100644 index 426ff87..0000000 --- a/packages/flutter_ai/flutter_ai_tools/lib/flutter_ai_tools.dart +++ /dev/null @@ -1,15 +0,0 @@ -/// Provider-neutral tool calling for the `flutter_ai` family. -/// -/// Declare tools with `ToolSpec`, collect them in a `ToolRegistry` (which both -/// advertises `ToolDefinition`s to a provider and executes incoming tool calls -/// into `ToolResultPart`s), and expose web search as a tool via `webSearchTool` -/// over a host-provided `WebSearchAdapter`. -/// -/// Pure Dart — no Flutter dependency. Re-exports `flutter_ai_core`. -library; - -export 'package:flutter_ai_core/flutter_ai_core.dart'; - -export 'src/tool_registry.dart'; -export 'src/tool_spec.dart'; -export 'src/web_search.dart'; diff --git a/packages/flutter_ai/flutter_ai_tools/lib/src/tool_registry.dart b/packages/flutter_ai/flutter_ai_tools/lib/src/tool_registry.dart deleted file mode 100644 index 6bbe383..0000000 --- a/packages/flutter_ai/flutter_ai_tools/lib/src/tool_registry.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:flutter_ai_core/flutter_ai_core.dart'; -import 'package:flutter_ai_tools/src/tool_spec.dart'; - -/// A collection of [ToolSpec]s that can advertise themselves to a provider and -/// execute incoming tool calls. -/// -/// The registry is the optional "auto round-tripping" seam: feed it a -/// [ToolCallPart] and it returns the matching [ToolResultPart], catching any -/// failure as an error result rather than throwing — so a misbehaving tool can -/// never crash the chat loop. -class ToolRegistry { - /// Creates a registry seeded with [tools]. - ToolRegistry([Iterable tools = const []]) { - for (final tool in tools) { - register(tool); - } - } - - final Map _tools = {}; - - /// Registers [tool], replacing any existing tool with the same name. - void register(ToolSpec tool) => _tools[tool.name] = tool; - - /// The tool registered under [name], or `null`. - ToolSpec? operator [](String name) => _tools[name]; - - /// Whether no tools are registered. - bool get isEmpty => _tools.isEmpty; - - /// The model-facing declarations for every registered tool, suitable for - /// passing to an `LlmProvider`. - List get definitions => - [for (final tool in _tools.values) tool.toDefinition()]; - - /// Executes [call] against its registered tool and returns the result. - /// - /// If the tool is unknown or has no executor, or if the executor throws, an - /// error [ToolResultPart] is returned rather than throwing. - Future run(ToolCallPart call) async { - final tool = _tools[call.toolName]; - if (tool?.execute == null) { - return ToolResultPart( - toolCallId: call.toolCallId, - result: 'No executor registered for tool "${call.toolName}"', - isError: true, - ); - } - try { - final result = await tool!.execute!(call.args); - return ToolResultPart(toolCallId: call.toolCallId, result: result); - } on Object catch (error) { - return ToolResultPart( - toolCallId: call.toolCallId, - result: error.toString(), - isError: true, - ); - } - } -} diff --git a/packages/flutter_ai/flutter_ai_tools/lib/src/tool_spec.dart b/packages/flutter_ai/flutter_ai_tools/lib/src/tool_spec.dart deleted file mode 100644 index 9c882bc..0000000 --- a/packages/flutter_ai/flutter_ai_tools/lib/src/tool_spec.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_ai_core/flutter_ai_core.dart'; - -/// Runs a tool's logic given its decoded arguments, returning a JSON-encodable -/// result (or a [Future] of one). -typedef ToolExecutor = FutureOr Function(Map args); - -/// A tool the model can call, pairing a [ToolDefinition] with the client-side -/// [execute] logic that fulfills it. -/// -/// The declaration half ([name], [description], [parametersSchema]) is what the -/// model sees; [execute] is optional — omit it for tools the server runs. -final class ToolSpec { - /// Creates a tool specification. - const ToolSpec({ - required this.name, - required this.description, - this.parametersSchema = const {}, - this.execute, - }); - - /// The tool's unique name, referenced in tool calls. - final String name; - - /// Natural-language description the model uses to decide when to call it. - final String description; - - /// A JSON Schema object describing the tool's arguments. - final Map parametersSchema; - - /// Client-side implementation, or `null` if the tool executes elsewhere. - final ToolExecutor? execute; - - /// The model-facing declaration for this tool. - ToolDefinition toDefinition() => ToolDefinition( - name: name, - description: description, - parametersSchema: parametersSchema, - ); - - @override - String toString() => 'ToolSpec($name)'; -} diff --git a/packages/flutter_ai/flutter_ai_tools/lib/src/web_search.dart b/packages/flutter_ai/flutter_ai_tools/lib/src/web_search.dart deleted file mode 100644 index b050888..0000000 --- a/packages/flutter_ai/flutter_ai_tools/lib/src/web_search.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'package:flutter_ai_tools/src/tool_spec.dart'; - -/// A single web-search hit. -final class SearchResult { - /// Creates a search result. - const SearchResult({required this.title, required this.url, this.snippet}); - - /// Reconstructs a [SearchResult] from [json]. - factory SearchResult.fromJson(Map json) => SearchResult( - title: json['title']! as String, - url: Uri.parse(json['url']! as String), - snippet: json['snippet'] as String?, - ); - - /// The result's title. - final String title; - - /// The result's location. - final Uri url; - - /// A short snippet/summary, if available. - final String? snippet; - - /// Serializes this result. - Map toJson() => { - 'title': title, - 'url': url.toString(), - if (snippet != null) 'snippet': snippet, - }; - - @override - bool operator ==(Object other) => - identical(this, other) || - (other is SearchResult && - other.title == title && - other.url == url && - other.snippet == snippet); - - @override - int get hashCode => Object.hash(title, url, snippet); - - @override - String toString() => 'SearchResult($title, $url)'; -} - -/// A backend that performs web searches (Tavily, Brave, SerpAPI, a custom -/// endpoint, …). Host apps provide the implementation; this package only knows -/// the contract. -abstract interface class WebSearchAdapter { - /// Returns up to [maxResults] hits for [query]; `null` lets the adapter pick - /// its own limit. - Future> search(String query, {int? maxResults}); -} - -/// Builds a [ToolSpec] that exposes [adapter] to the model as a callable tool. -/// -/// The tool takes a `query` string and returns `{ "results": [...] }`, where -/// each entry is a serialized [SearchResult]. Map those into `SourcePart`s in -/// your UI to render citations. -ToolSpec webSearchTool( - WebSearchAdapter adapter, { - String name = 'web_search', - String description = 'Search the web for up-to-date information.', - int maxResults = 5, -}) { - return ToolSpec( - name: name, - description: description, - parametersSchema: const { - 'type': 'object', - 'properties': { - 'query': { - 'type': 'string', - 'description': 'The search query.', - }, - }, - 'required': ['query'], - }, - execute: (args) async { - final query = (args['query'] as String?)?.trim() ?? ''; - if (query.isEmpty) { - return {'results': []}; - } - final results = await adapter.search(query, maxResults: maxResults); - return { - 'results': [for (final result in results) result.toJson()], - }; - }, - ); -} diff --git a/packages/flutter_ai/flutter_ai_tools/pubspec.yaml b/packages/flutter_ai/flutter_ai_tools/pubspec.yaml deleted file mode 100644 index f310433..0000000 --- a/packages/flutter_ai/flutter_ai_tools/pubspec.yaml +++ /dev/null @@ -1,32 +0,0 @@ -name: flutter_ai_tools -description: "Provider-neutral tool calling for flutter_ai: declare tools with executors, run tool calls into results, and expose web search as a tool. Pure Dart." -version: 0.1.4 -homepage: https://github.com/ananmouaz/flutter_ai -repository: https://github.com/ananmouaz/flutter_ai/tree/main/packages/flutter_ai_tools -issue_tracker: https://github.com/ananmouaz/flutter_ai/issues -topics: - - ai - - llm - - tool-calling - - chat - - flutter - -environment: - sdk: ^3.6.0 - -platforms: - android: - ios: - linux: - macos: - web: - windows: - -resolution: workspace - -dependencies: - flutter_ai_core: ^0.1.0 - -dev_dependencies: - lints: ^5.0.0 - test: ^1.25.0 diff --git a/packages/flutter_ai/flutter_ai_tools/test/tools_test.dart b/packages/flutter_ai/flutter_ai_tools/test/tools_test.dart deleted file mode 100644 index 35cb9ee..0000000 --- a/packages/flutter_ai/flutter_ai_tools/test/tools_test.dart +++ /dev/null @@ -1,151 +0,0 @@ -import 'package:flutter_ai_tools/flutter_ai_tools.dart'; -import 'package:test/test.dart'; - -class _FakeSearch implements WebSearchAdapter { - String? lastQuery; - int? lastMax; - - @override - Future> search(String query, {int? maxResults}) async { - lastQuery = query; - lastMax = maxResults; - return [ - SearchResult( - title: 'Flutter', - url: Uri.parse('https://flutter.dev'), - snippet: 'UI toolkit', - ), - ]; - } -} - -void main() { - group('ToolSpec', () { - test('toDefinition drops the executor', () { - final spec = ToolSpec( - name: 'noop', - description: 'does nothing', - parametersSchema: const {'type': 'object'}, - execute: (args) => null, - ); - final def = spec.toDefinition(); - expect(def.name, 'noop'); - expect(def.description, 'does nothing'); - expect(def.parametersSchema, {'type': 'object'}); - }); - }); - - group('ToolRegistry', () { - test('definitions lists all registered tools', () { - final registry = ToolRegistry([ - const ToolSpec(name: 'a', description: 'A'), - const ToolSpec(name: 'b', description: 'B'), - ]); - expect(registry.definitions.map((d) => d.name), ['a', 'b']); - expect(registry.isEmpty, isFalse); - }); - - test('register replaces a tool with the same name and [] looks it up', () { - final registry = ToolRegistry([ - const ToolSpec(name: 'a', description: 'first'), - ]) - ..register(const ToolSpec(name: 'a', description: 'second')); - expect(registry['a']?.description, 'second'); - expect(registry['missing'], isNull); - expect(registry.definitions, hasLength(1)); - }); - - test('an empty registry reports isEmpty', () { - expect(ToolRegistry().isEmpty, isTrue); - }); - - test('run executes the matching tool', () async { - final registry = ToolRegistry([ - ToolSpec( - name: 'add', - description: 'add', - execute: (args) => (args['a']! as int) + (args['b']! as int), - ), - ]); - final result = await registry.run( - const ToolCallPart( - toolCallId: 'c1', - toolName: 'add', - args: {'a': 2, 'b': 3}, - state: ToolCallState.inputAvailable, - ), - ); - expect(result.result, 5); - expect(result.isError, isFalse); - expect(result.toolCallId, 'c1'); - }); - - test('run returns an error result for an unknown tool', () async { - final registry = ToolRegistry(); - final result = await registry.run( - const ToolCallPart(toolCallId: 'c1', toolName: 'ghost'), - ); - expect(result.isError, isTrue); - }); - - test('run captures a thrown executor as an error result', () async { - final registry = ToolRegistry([ - ToolSpec( - name: 'boom', - description: 'throws', - execute: (args) => throw StateError('nope'), - ), - ]); - final result = await registry.run( - const ToolCallPart(toolCallId: 'c1', toolName: 'boom'), - ); - expect(result.isError, isTrue); - expect(result.result, contains('nope')); - }); - - test('run reports tools that have no executor', () async { - final registry = ToolRegistry([ - const ToolSpec(name: 'server_side', description: 'no exec'), - ]); - final result = await registry.run( - const ToolCallPart(toolCallId: 'c1', toolName: 'server_side'), - ); - expect(result.isError, isTrue); - }); - }); - - group('webSearchTool', () { - test('forwards the query and maps results', () async { - final adapter = _FakeSearch(); - final tool = webSearchTool(adapter, maxResults: 3); - final output = - await tool.execute!({'query': 'flutter'}) as Map; - - expect(adapter.lastQuery, 'flutter'); - expect(adapter.lastMax, 3); - final results = output['results']! as List; - expect(results, hasLength(1)); - expect((results.first as Map)['url'], 'https://flutter.dev'); - }); - - test('short-circuits an empty query', () async { - final adapter = _FakeSearch(); - final tool = webSearchTool(adapter); - final output = - await tool.execute!({'query': ' '}) as Map; - expect(output['results'], isEmpty); - expect(adapter.lastQuery, isNull); - }); - }); - - group('SearchResult', () { - test('round-trips through JSON', () { - final result = SearchResult( - title: 'T', - url: Uri.parse('https://x.test'), - snippet: 's', - ); - expect(SearchResult.fromJson(result.toJson()), result); - }); - }); -} diff --git a/pubspec.lock b/pubspec.lock index df2237e..734c449 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -178,37 +178,47 @@ packages: flutter_ai_client: dependency: "direct main" description: - path: "packages/flutter_ai/flutter_ai_client" - relative: true - source: path + path: "packages/flutter_ai_client" + ref: "589a47d2262864dfd0ddbac27de0926748b26130" + resolved-ref: "589a47d2262864dfd0ddbac27de0926748b26130" + url: "https://github.com/HeZeBang/flutter_ai.git" + source: git version: "0.3.0" flutter_ai_core: dependency: "direct main" description: - path: "packages/flutter_ai/flutter_ai_core" - relative: true - source: path + path: "packages/flutter_ai_core" + ref: "589a47d2262864dfd0ddbac27de0926748b26130" + resolved-ref: "589a47d2262864dfd0ddbac27de0926748b26130" + url: "https://github.com/HeZeBang/flutter_ai.git" + source: git version: "0.1.14" flutter_ai_elements: dependency: "direct main" description: - path: "packages/flutter_ai/flutter_ai_elements" - relative: true - source: path + path: "packages/flutter_ai_elements" + ref: "589a47d2262864dfd0ddbac27de0926748b26130" + resolved-ref: "589a47d2262864dfd0ddbac27de0926748b26130" + url: "https://github.com/HeZeBang/flutter_ai.git" + source: git version: "0.2.0" flutter_ai_provider_anthropic: dependency: "direct main" description: - path: "packages/flutter_ai/flutter_ai_provider_anthropic" - relative: true - source: path + path: "packages/flutter_ai_provider_anthropic" + ref: "589a47d2262864dfd0ddbac27de0926748b26130" + resolved-ref: "589a47d2262864dfd0ddbac27de0926748b26130" + url: "https://github.com/HeZeBang/flutter_ai.git" + source: git version: "0.1.12" flutter_ai_tools: dependency: "direct main" description: - path: "packages/flutter_ai/flutter_ai_tools" - relative: true - source: path + path: "packages/flutter_ai_tools" + ref: "589a47d2262864dfd0ddbac27de0926748b26130" + resolved-ref: "589a47d2262864dfd0ddbac27de0926748b26130" + url: "https://github.com/HeZeBang/flutter_ai.git" + source: git version: "0.1.4" flutter_inappwebview: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index c3c80c7..81c1ea6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,19 +38,34 @@ dependencies: dynamic_color: ">=1.7.0 <1.8.0" flutter: sdk: flutter - # AI chat UI: the flutter_ai library family, vendored under packages/ - # (upstream ananmouaz/flutter_ai + local OHOS/tool-call fixes not yet - # published). + # AI chat UI: the flutter_ai library family, from the HeZeBang fork + # (upstream ananmouaz/flutter_ai + OHOS/tool-call fixes not yet published + # to pub.dev). Pinned to a commit for reproducible builds. flutter_ai_client: - path: packages/flutter_ai/flutter_ai_client + git: + url: https://github.com/HeZeBang/flutter_ai.git + path: packages/flutter_ai_client + ref: 589a47d2262864dfd0ddbac27de0926748b26130 flutter_ai_core: - path: packages/flutter_ai/flutter_ai_core + git: + url: https://github.com/HeZeBang/flutter_ai.git + path: packages/flutter_ai_core + ref: 589a47d2262864dfd0ddbac27de0926748b26130 flutter_ai_elements: - path: packages/flutter_ai/flutter_ai_elements + git: + url: https://github.com/HeZeBang/flutter_ai.git + path: packages/flutter_ai_elements + ref: 589a47d2262864dfd0ddbac27de0926748b26130 flutter_ai_provider_anthropic: - path: packages/flutter_ai/flutter_ai_provider_anthropic + git: + url: https://github.com/HeZeBang/flutter_ai.git + path: packages/flutter_ai_provider_anthropic + ref: 589a47d2262864dfd0ddbac27de0926748b26130 flutter_ai_tools: - path: packages/flutter_ai/flutter_ai_tools + git: + url: https://github.com/HeZeBang/flutter_ai.git + path: packages/flutter_ai_tools + ref: 589a47d2262864dfd0ddbac27de0926748b26130 flutter_secure_storage: ^9.2.4 flutter_secure_storage_ohos: # HarmonyOS 安全存储 git: @@ -83,12 +98,18 @@ dependency_overrides: ref: master # The flutter_ai packages declare transitive deps on flutter_ai_core/client - # as hosted (pub.dev) ranges, but we consume them from the vendored copies. - # Force every transitive reference to the local path so version solving agrees. + # as hosted (pub.dev) ranges, but we consume them from the fork. Force every + # transitive reference to the same git source so version solving agrees. flutter_ai_client: - path: packages/flutter_ai/flutter_ai_client + git: + url: https://github.com/HeZeBang/flutter_ai.git + path: packages/flutter_ai_client + ref: 589a47d2262864dfd0ddbac27de0926748b26130 flutter_ai_core: - path: packages/flutter_ai/flutter_ai_core + git: + url: https://github.com/HeZeBang/flutter_ai.git + path: packages/flutter_ai_core + ref: 589a47d2262864dfd0ddbac27de0926748b26130 # OpenHarmony-SIG forks add OHOS platform implementations for plugins # whose upstream pub.dev releases don't ship them. Without these, the From f40a54a71d5c7a27b6f3a07a6be5f40ecd3de99b Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Sun, 26 Jul 2026 19:15:17 +0800 Subject: [PATCH 12/15] feat: gate AI demo entry behind the debug-mode setting Co-Authored-By: Claude Fable 5 --- lib/models/feature.dart | 5 +++++ lib/pages/home_page.dart | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/models/feature.dart b/lib/models/feature.dart index 7a82239..b86ae80 100644 --- a/lib/models/feature.dart +++ b/lib/models/feature.dart @@ -24,6 +24,9 @@ class Feature { final Icon icon; final void Function(BuildContext context)? nativeEntry; + /// Only shown when the debug-mode setting is on. + final bool debugOnly; + Feature({ required this.id, required this.description, @@ -32,6 +35,7 @@ class Feature { this.cookieType, required this.icon, this.nativeEntry, + this.debugOnly = false, }); } @@ -74,6 +78,7 @@ final featureEntries = [ id: 'ai_demo', description: 'AI 演示', mode: FeatureMode.native, + debugOnly: true, nativeEntry: (context) => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const AiDemoPage()), ), diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 9d2408d..b8fe11a 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -309,7 +309,9 @@ class _HomePageState extends State with TickerProviderStateMixin { ), ), const SizedBox(height: 16), - _buildAppCard(featureEntries), + _buildAppCard( + featureEntries.where((f) => !f.debugOnly || isDebug).toList(), + ), const SizedBox(height: 16), _buildTodayClasses(theme, auth.isLoggedIn), const SizedBox(height: 8), From f28e113e73af97a4f3a25dce8eb978b34727edfd Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Mon, 27 Jul 2026 00:38:39 +0800 Subject: [PATCH 13/15] feat: single-instance floating feedback banner that glides with the bottom nav Replace ScaffoldMessenger snackbars with an app-level AdaptiveFeedbackHost overlay above the Navigator. The messenger rendered the same snackbar in both scaffolds during route transitions, ghosting when pages with and without the bottom nav gave it different offsets. The single banner now animates its bottom clearance and corner radius in step with the page transition: floating above the glass capsule on shell pages, near the edge on pushed pages. Route depth is tracked via a NavigatorObserver (dialogs excluded); showAdaptiveFeedback API unchanged. Theme-level floating snackbar style kept for direct showSnackBar callers. Co-Authored-By: Claude Fable 5 --- lib/main.dart | 5 + lib/services/theme_service.dart | 20 +++ lib/widgets/adaptive_feedback.dart | 269 +++++++++++++++++++++++++---- 3 files changed, 265 insertions(+), 29 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index dcb2e1b..30426d1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -291,6 +291,11 @@ class _TechPieAppState extends State { aiService: widget.aiService, child: MaterialApp( scaffoldMessengerKey: rootMessengerKey, + navigatorObservers: [FeedbackRouteObserver()], + builder: (context, child) => AdaptiveFeedbackHost( + key: adaptiveFeedbackHostKey, + child: child ?? const SizedBox.shrink(), + ), title: 'TechPie', theme: _withAiExtension(widget.themeService.lightTheme), darkTheme: _withAiExtension(widget.themeService.darkTheme), diff --git a/lib/services/theme_service.dart b/lib/services/theme_service.dart index ae97c3f..d6c655c 100644 --- a/lib/services/theme_service.dart +++ b/lib/services/theme_service.dart @@ -159,6 +159,11 @@ class ThemeService extends ChangeNotifier { scrolledUnderElevation: 0.5, surfaceTintColor: Colors.transparent, ), + snackBarTheme: const SnackBarThemeData( + behavior: SnackBarBehavior.floating, + shape: _snackBarShape, + insetPadding: _snackBarInsets, + ), ); } @@ -171,6 +176,13 @@ class ThemeService extends ChangeNotifier { return _buildIosTheme(theme, brightness: Brightness.dark); } + /// Snackbars float as rounded cards above the glass bottom nav (which is a + /// 56dp capsule with 8dp margins) instead of a full-width strip glued to it. + static const _snackBarShape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ); + static const _snackBarInsets = EdgeInsets.fromLTRB(12, 0, 12, 12); + ThemeData _buildDesktopTheme(ThemeData base) { return base.copyWith( appBarTheme: base.appBarTheme.copyWith( @@ -179,6 +191,11 @@ class ThemeService extends ChangeNotifier { scrolledUnderElevation: 0.5, surfaceTintColor: Colors.transparent, ), + snackBarTheme: base.snackBarTheme.copyWith( + behavior: SnackBarBehavior.floating, + shape: _snackBarShape, + insetPadding: _snackBarInsets, + ), ); } @@ -258,6 +275,9 @@ class ThemeService extends ChangeNotifier { contentTextStyle: TextStyle( color: isDark ? Colors.white : Colors.black, ), + behavior: SnackBarBehavior.floating, + shape: _snackBarShape, + insetPadding: _snackBarInsets, ), inputDecorationTheme: InputDecorationTheme( filled: true, diff --git a/lib/widgets/adaptive_feedback.dart b/lib/widgets/adaptive_feedback.dart index 8886e57..feda53e 100644 --- a/lib/widgets/adaptive_feedback.dart +++ b/lib/widgets/adaptive_feedback.dart @@ -1,8 +1,38 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import '../utils/platform.dart'; +import 'app_shell/tg_bottom_nav_bar.dart'; + final GlobalKey rootMessengerKey = GlobalKey(); +final GlobalKey adaptiveFeedbackHostKey = + GlobalKey(); + +/// Tracks how many page routes are stacked above the shell so the feedback +/// banner knows whether the bottom nav is on screen. Dialog/popup routes +/// don't count — the nav stays visible underneath them. +final ValueNotifier _pageRouteDepth = ValueNotifier(0); + +class FeedbackRouteObserver extends NavigatorObserver { + @override + void didPush(Route route, Route? previousRoute) { + if (route is PageRoute) _pageRouteDepth.value++; + } + + @override + void didPop(Route route, Route? previousRoute) { + if (route is PageRoute) _pageRouteDepth.value--; + } + + @override + void didRemove(Route route, Route? previousRoute) { + if (route is PageRoute) _pageRouteDepth.value--; + } +} + enum AdaptiveFeedbackStyle { info, success, error } void showAdaptiveFeedback({ @@ -13,39 +43,220 @@ void showAdaptiveFeedback({ String? actionLabel, VoidCallback? onAction, }) { - final messenger = context != null - ? ScaffoldMessenger.maybeOf(context) - : rootMessengerKey.currentState; - if (messenger == null) return; - - final theme = messenger.context.mounted - ? Theme.of(messenger.context) - : ThemeData.fallback(); - final feedbackStyle = _FeedbackStyle.from(theme, style); - - messenger - ..clearSnackBars() - ..showSnackBar( - SnackBar( - backgroundColor: feedbackStyle.backgroundColor, - content: Row( - children: [ - Icon(feedbackStyle.icon, color: feedbackStyle.foregroundColor), - const SizedBox(width: 12), - Expanded( - child: Text( - message, - style: TextStyle(color: feedbackStyle.foregroundColor), - ), + adaptiveFeedbackHostKey.currentState?.show( + message: message, + style: style, + duration: duration, + actionLabel: actionLabel, + onAction: onAction, + ); +} + +/// App-level host for feedback banners, mounted above the [Navigator] (via +/// `MaterialApp.builder`). +/// +/// A [SnackBar] can't be used here: the root [ScaffoldMessenger] renders the +/// current snackbar in BOTH the outgoing and incoming route's Scaffold during +/// a page transition, and since pages with and without the bottom nav give it +/// different bottom offsets, the same bar briefly shows twice ("ghosting"). +/// Hosting a single banner above the Navigator means there is exactly one +/// instance, and its bottom clearance simply animates in step with the route +/// transition: flush-ish near the screen edge on pushed pages, floating above +/// the glass capsule (with a larger corner radius) on shell pages. +class AdaptiveFeedbackHost extends StatefulWidget { + final Widget child; + + const AdaptiveFeedbackHost({super.key, required this.child}); + + @override + State createState() => AdaptiveFeedbackHostState(); +} + +class AdaptiveFeedbackHostState extends State + with SingleTickerProviderStateMixin { + // Matches the FadeThroughTransition page-transition timing so the banner + // glides together with the bottom nav's appearance. + static const _repositionDuration = Duration(milliseconds: 300); + static const _repositionCurve = Curves.easeInOutCubic; + + late final AnimationController _controller; + late final Animation _fade; + late final Animation _slide; + + _FeedbackEntry? _entry; + Timer? _dismissTimer; + + @override + void initState() { + super.initState(); + // The host is (re)built before the Navigator pushes its initial route + // (fresh start and hot restart alike), so the route count starts clean. + _pageRouteDepth.value = 0; + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 250), + reverseDuration: const Duration(milliseconds: 200), + ); + _fade = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic); + _slide = Tween( + begin: const Offset(0, 0.4), + end: Offset.zero, + ).animate(_fade); + } + + void show({ + required String message, + required AdaptiveFeedbackStyle style, + required Duration duration, + String? actionLabel, + VoidCallback? onAction, + }) { + _dismissTimer?.cancel(); + setState(() { + _entry = _FeedbackEntry( + message: message, + style: style, + actionLabel: actionLabel, + onAction: onAction, + ); + }); + _controller.forward(from: _controller.value == 0 ? 0 : _controller.value); + _dismissTimer = Timer(duration, dismiss); + } + + void dismiss() { + _dismissTimer?.cancel(); + _dismissTimer = null; + unawaited( + _controller.reverse().whenComplete(() { + if (mounted && _dismissTimer == null) setState(() => _entry = null); + }), + ); + } + + @override + void dispose() { + _dismissTimer?.cancel(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + widget.child, + if (_entry != null) + Positioned.fill( + child: ValueListenableBuilder( + valueListenable: _pageRouteDepth, + builder: (context, depth, banner) { + final width = MediaQuery.sizeOf(context).width; + final safeBottom = MediaQuery.viewPaddingOf(context).bottom; + final navVisible = depth <= 1 && width < 600; + final navClearance = isIos() + ? 52.0 + 12 + : TgBottomNavBar.barHeight + 2 * TgBottomNavBar.margin; + final bottom = safeBottom + (navVisible ? navClearance : 12); + return IgnorePointer( + ignoring: _entry!.actionLabel == null, + child: AnimatedPadding( + duration: _repositionDuration, + curve: _repositionCurve, + padding: EdgeInsets.fromLTRB(12, 0, 12, bottom), + child: Align( + alignment: Alignment.bottomCenter, + child: FadeTransition( + opacity: _fade, + child: SlideTransition( + position: _slide, + child: _FeedbackBanner( + entry: _entry!, + rounded: navVisible, + repositionDuration: _repositionDuration, + repositionCurve: _repositionCurve, + ), + ), + ), + ), + ), + ); + }, + ), + ), + ], + ); + } +} + +class _FeedbackEntry { + const _FeedbackEntry({ + required this.message, + required this.style, + this.actionLabel, + this.onAction, + }); + + final String message; + final AdaptiveFeedbackStyle style; + final String? actionLabel; + final VoidCallback? onAction; +} + +class _FeedbackBanner extends StatelessWidget { + const _FeedbackBanner({ + required this.entry, + required this.rounded, + required this.repositionDuration, + required this.repositionCurve, + }); + + final _FeedbackEntry entry; + final bool rounded; + final Duration repositionDuration; + final Curve repositionCurve; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final style = _FeedbackStyle.from(theme, entry.style); + final radius = BorderRadius.circular(rounded ? 16 : 10); + + return AnimatedContainer( + duration: repositionDuration, + curve: repositionCurve, + decoration: BoxDecoration( + color: style.backgroundColor, + borderRadius: radius, + boxShadow: kElevationToShadow[6], + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(style.icon, color: style.foregroundColor), + const SizedBox(width: 12), + Flexible( + child: Text( + entry.message, + style: theme.textTheme.bodyMedium + ?.copyWith(color: style.foregroundColor), + ), + ), + if (entry.actionLabel != null) ...[ + const SizedBox(width: 8), + TextButton( + onPressed: () { + entry.onAction?.call(); + adaptiveFeedbackHostKey.currentState?.dismiss(); + }, + child: Text(entry.actionLabel!), ), ], - ), - duration: duration, - action: actionLabel != null && onAction != null - ? SnackBarAction(label: actionLabel, onPressed: onAction) - : null, + ], ), ); + } } class _FeedbackStyle { From 967ea48a328c91c7bb0b2ba6fce7bd5df5604432 Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Mon, 27 Jul 2026 00:41:24 +0800 Subject: [PATCH 14/15] fix: route AI demo page snackbar through the unified feedback host Co-Authored-By: Claude Fable 5 --- lib/pages/ai_demo/ai_demo_page.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/pages/ai_demo/ai_demo_page.dart b/lib/pages/ai_demo/ai_demo_page.dart index 743c0b1..d18fdb3 100644 --- a/lib/pages/ai_demo/ai_demo_page.dart +++ b/lib/pages/ai_demo/ai_demo_page.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_ai_elements/flutter_ai_elements.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../widgets/adaptive_feedback.dart'; import 'code_highlighter.dart'; import 'demo_data.dart'; import 'demo_provider.dart'; @@ -386,9 +387,10 @@ class _ChatScreenState extends State { ), ]; - void _snack(BuildContext context, String text) => - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(text), duration: const Duration(seconds: 1)), + void _snack(BuildContext context, String text) => showAdaptiveFeedback( + context: context, + message: text, + duration: const Duration(seconds: 1), ); Future _editPrecedingUserMessage( From e9b4561de09b4a0b5c2ba2c8f1867f0397e20f3e Mon Sep 17 00:00:00 2001 From: ZAMBAR Date: Mon, 27 Jul 2026 00:38:39 +0800 Subject: [PATCH 15/15] feat: single-instance floating feedback banner that glides with the bottom nav Replace ScaffoldMessenger snackbars with an app-level AdaptiveFeedbackHost overlay above the Navigator. The messenger rendered the same snackbar in both scaffolds during route transitions, ghosting when pages with and without the bottom nav gave it different offsets. The single banner now animates its bottom clearance and corner radius in step with the page transition: floating above the glass capsule on shell pages, near the edge on pushed pages. Route depth is tracked via a NavigatorObserver (dialogs excluded); showAdaptiveFeedback API unchanged. Theme-level floating snackbar style kept for direct showSnackBar callers. Co-Authored-By: Claude Fable 5 --- lib/main.dart | 5 + lib/services/theme_service.dart | 20 +++ lib/widgets/adaptive_feedback.dart | 269 +++++++++++++++++++++++++---- 3 files changed, 265 insertions(+), 29 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index c9dabcc..ba19aa0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -282,6 +282,11 @@ class _TechPieAppState extends State { syncService: widget.syncService, child: MaterialApp( scaffoldMessengerKey: rootMessengerKey, + navigatorObservers: [FeedbackRouteObserver()], + builder: (context, child) => AdaptiveFeedbackHost( + key: adaptiveFeedbackHostKey, + child: child ?? const SizedBox.shrink(), + ), title: 'TechPie', theme: widget.themeService.lightTheme, darkTheme: widget.themeService.darkTheme, diff --git a/lib/services/theme_service.dart b/lib/services/theme_service.dart index ae97c3f..d6c655c 100644 --- a/lib/services/theme_service.dart +++ b/lib/services/theme_service.dart @@ -159,6 +159,11 @@ class ThemeService extends ChangeNotifier { scrolledUnderElevation: 0.5, surfaceTintColor: Colors.transparent, ), + snackBarTheme: const SnackBarThemeData( + behavior: SnackBarBehavior.floating, + shape: _snackBarShape, + insetPadding: _snackBarInsets, + ), ); } @@ -171,6 +176,13 @@ class ThemeService extends ChangeNotifier { return _buildIosTheme(theme, brightness: Brightness.dark); } + /// Snackbars float as rounded cards above the glass bottom nav (which is a + /// 56dp capsule with 8dp margins) instead of a full-width strip glued to it. + static const _snackBarShape = RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ); + static const _snackBarInsets = EdgeInsets.fromLTRB(12, 0, 12, 12); + ThemeData _buildDesktopTheme(ThemeData base) { return base.copyWith( appBarTheme: base.appBarTheme.copyWith( @@ -179,6 +191,11 @@ class ThemeService extends ChangeNotifier { scrolledUnderElevation: 0.5, surfaceTintColor: Colors.transparent, ), + snackBarTheme: base.snackBarTheme.copyWith( + behavior: SnackBarBehavior.floating, + shape: _snackBarShape, + insetPadding: _snackBarInsets, + ), ); } @@ -258,6 +275,9 @@ class ThemeService extends ChangeNotifier { contentTextStyle: TextStyle( color: isDark ? Colors.white : Colors.black, ), + behavior: SnackBarBehavior.floating, + shape: _snackBarShape, + insetPadding: _snackBarInsets, ), inputDecorationTheme: InputDecorationTheme( filled: true, diff --git a/lib/widgets/adaptive_feedback.dart b/lib/widgets/adaptive_feedback.dart index 8886e57..feda53e 100644 --- a/lib/widgets/adaptive_feedback.dart +++ b/lib/widgets/adaptive_feedback.dart @@ -1,8 +1,38 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import '../utils/platform.dart'; +import 'app_shell/tg_bottom_nav_bar.dart'; + final GlobalKey rootMessengerKey = GlobalKey(); +final GlobalKey adaptiveFeedbackHostKey = + GlobalKey(); + +/// Tracks how many page routes are stacked above the shell so the feedback +/// banner knows whether the bottom nav is on screen. Dialog/popup routes +/// don't count — the nav stays visible underneath them. +final ValueNotifier _pageRouteDepth = ValueNotifier(0); + +class FeedbackRouteObserver extends NavigatorObserver { + @override + void didPush(Route route, Route? previousRoute) { + if (route is PageRoute) _pageRouteDepth.value++; + } + + @override + void didPop(Route route, Route? previousRoute) { + if (route is PageRoute) _pageRouteDepth.value--; + } + + @override + void didRemove(Route route, Route? previousRoute) { + if (route is PageRoute) _pageRouteDepth.value--; + } +} + enum AdaptiveFeedbackStyle { info, success, error } void showAdaptiveFeedback({ @@ -13,39 +43,220 @@ void showAdaptiveFeedback({ String? actionLabel, VoidCallback? onAction, }) { - final messenger = context != null - ? ScaffoldMessenger.maybeOf(context) - : rootMessengerKey.currentState; - if (messenger == null) return; - - final theme = messenger.context.mounted - ? Theme.of(messenger.context) - : ThemeData.fallback(); - final feedbackStyle = _FeedbackStyle.from(theme, style); - - messenger - ..clearSnackBars() - ..showSnackBar( - SnackBar( - backgroundColor: feedbackStyle.backgroundColor, - content: Row( - children: [ - Icon(feedbackStyle.icon, color: feedbackStyle.foregroundColor), - const SizedBox(width: 12), - Expanded( - child: Text( - message, - style: TextStyle(color: feedbackStyle.foregroundColor), - ), + adaptiveFeedbackHostKey.currentState?.show( + message: message, + style: style, + duration: duration, + actionLabel: actionLabel, + onAction: onAction, + ); +} + +/// App-level host for feedback banners, mounted above the [Navigator] (via +/// `MaterialApp.builder`). +/// +/// A [SnackBar] can't be used here: the root [ScaffoldMessenger] renders the +/// current snackbar in BOTH the outgoing and incoming route's Scaffold during +/// a page transition, and since pages with and without the bottom nav give it +/// different bottom offsets, the same bar briefly shows twice ("ghosting"). +/// Hosting a single banner above the Navigator means there is exactly one +/// instance, and its bottom clearance simply animates in step with the route +/// transition: flush-ish near the screen edge on pushed pages, floating above +/// the glass capsule (with a larger corner radius) on shell pages. +class AdaptiveFeedbackHost extends StatefulWidget { + final Widget child; + + const AdaptiveFeedbackHost({super.key, required this.child}); + + @override + State createState() => AdaptiveFeedbackHostState(); +} + +class AdaptiveFeedbackHostState extends State + with SingleTickerProviderStateMixin { + // Matches the FadeThroughTransition page-transition timing so the banner + // glides together with the bottom nav's appearance. + static const _repositionDuration = Duration(milliseconds: 300); + static const _repositionCurve = Curves.easeInOutCubic; + + late final AnimationController _controller; + late final Animation _fade; + late final Animation _slide; + + _FeedbackEntry? _entry; + Timer? _dismissTimer; + + @override + void initState() { + super.initState(); + // The host is (re)built before the Navigator pushes its initial route + // (fresh start and hot restart alike), so the route count starts clean. + _pageRouteDepth.value = 0; + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 250), + reverseDuration: const Duration(milliseconds: 200), + ); + _fade = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic); + _slide = Tween( + begin: const Offset(0, 0.4), + end: Offset.zero, + ).animate(_fade); + } + + void show({ + required String message, + required AdaptiveFeedbackStyle style, + required Duration duration, + String? actionLabel, + VoidCallback? onAction, + }) { + _dismissTimer?.cancel(); + setState(() { + _entry = _FeedbackEntry( + message: message, + style: style, + actionLabel: actionLabel, + onAction: onAction, + ); + }); + _controller.forward(from: _controller.value == 0 ? 0 : _controller.value); + _dismissTimer = Timer(duration, dismiss); + } + + void dismiss() { + _dismissTimer?.cancel(); + _dismissTimer = null; + unawaited( + _controller.reverse().whenComplete(() { + if (mounted && _dismissTimer == null) setState(() => _entry = null); + }), + ); + } + + @override + void dispose() { + _dismissTimer?.cancel(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + widget.child, + if (_entry != null) + Positioned.fill( + child: ValueListenableBuilder( + valueListenable: _pageRouteDepth, + builder: (context, depth, banner) { + final width = MediaQuery.sizeOf(context).width; + final safeBottom = MediaQuery.viewPaddingOf(context).bottom; + final navVisible = depth <= 1 && width < 600; + final navClearance = isIos() + ? 52.0 + 12 + : TgBottomNavBar.barHeight + 2 * TgBottomNavBar.margin; + final bottom = safeBottom + (navVisible ? navClearance : 12); + return IgnorePointer( + ignoring: _entry!.actionLabel == null, + child: AnimatedPadding( + duration: _repositionDuration, + curve: _repositionCurve, + padding: EdgeInsets.fromLTRB(12, 0, 12, bottom), + child: Align( + alignment: Alignment.bottomCenter, + child: FadeTransition( + opacity: _fade, + child: SlideTransition( + position: _slide, + child: _FeedbackBanner( + entry: _entry!, + rounded: navVisible, + repositionDuration: _repositionDuration, + repositionCurve: _repositionCurve, + ), + ), + ), + ), + ), + ); + }, + ), + ), + ], + ); + } +} + +class _FeedbackEntry { + const _FeedbackEntry({ + required this.message, + required this.style, + this.actionLabel, + this.onAction, + }); + + final String message; + final AdaptiveFeedbackStyle style; + final String? actionLabel; + final VoidCallback? onAction; +} + +class _FeedbackBanner extends StatelessWidget { + const _FeedbackBanner({ + required this.entry, + required this.rounded, + required this.repositionDuration, + required this.repositionCurve, + }); + + final _FeedbackEntry entry; + final bool rounded; + final Duration repositionDuration; + final Curve repositionCurve; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final style = _FeedbackStyle.from(theme, entry.style); + final radius = BorderRadius.circular(rounded ? 16 : 10); + + return AnimatedContainer( + duration: repositionDuration, + curve: repositionCurve, + decoration: BoxDecoration( + color: style.backgroundColor, + borderRadius: radius, + boxShadow: kElevationToShadow[6], + ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(style.icon, color: style.foregroundColor), + const SizedBox(width: 12), + Flexible( + child: Text( + entry.message, + style: theme.textTheme.bodyMedium + ?.copyWith(color: style.foregroundColor), + ), + ), + if (entry.actionLabel != null) ...[ + const SizedBox(width: 8), + TextButton( + onPressed: () { + entry.onAction?.call(); + adaptiveFeedbackHostKey.currentState?.dismiss(); + }, + child: Text(entry.actionLabel!), ), ], - ), - duration: duration, - action: actionLabel != null && onAction != null - ? SnackBarAction(label: actionLabel, onPressed: onAction) - : null, + ], ), ); + } } class _FeedbackStyle {