From e557111dd9de038c9a5a30271f5df2827e6596c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Sun, 23 Aug 2026 00:04:45 +0700 Subject: [PATCH 01/19] feat(streaming): implement AI/LLM real-time streaming engine v1.8.0 with frame-aligned throttling, transient syntax auto-repair, typing carets, and auto-scroller --- CHANGELOG.md | 9 + example/lib/ai_streaming_demo.dart | 254 +++++++++++++++ example/lib/demo_auto_player.dart | 18 +- example/lib/main.dart | 11 + example/pubspec.lock | 4 +- lib/hyper_render.dart | 7 + lib/src/widgets/hyper_viewer.dart | 182 ++++++++++- packages/hyper_render_core/CHANGELOG.md | 7 + .../lib/hyper_render_core.dart | 5 + .../streaming/hyper_streaming_controller.dart | 305 ++++++++++++++++++ .../streaming/stream_syntax_normalizer.dart | 105 ++++++ .../lib/src/streaming/typing_caret.dart | 163 ++++++++++ packages/hyper_render_core/pubspec.yaml | 4 +- .../test/streaming_test.dart | 188 +++++++++++ pubspec.yaml | 4 +- test/streaming_viewer_test.dart | 112 +++++++ 16 files changed, 1361 insertions(+), 17 deletions(-) create mode 100644 example/lib/ai_streaming_demo.dart create mode 100644 packages/hyper_render_core/lib/src/streaming/hyper_streaming_controller.dart create mode 100644 packages/hyper_render_core/lib/src/streaming/stream_syntax_normalizer.dart create mode 100644 packages/hyper_render_core/lib/src/streaming/typing_caret.dart create mode 100644 packages/hyper_render_core/test/streaming_test.dart create mode 100644 test/streaming_viewer_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 136786a..3f598e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 1.8.0 + +- **AI & LLM Real-Time Token Streaming Engine**: + - `HyperStreamingController`: High-performance streaming controller for batching rapid SSE / WebSocket token bursts with 16ms frame-aligned throttling to eliminate UI jank. + - `StreamSyntaxNormalizer`: Transient auto-repair utility for incomplete in-flight tokens (auto-closes unclosed code block fences, inline code, bold/italic asterisks, incomplete table rows, and truncated HTML tags). + - `HyperTypingCaret`: Animated typing caret widget with customizable blinking styles (`bar`, `block`, `underscore`, `dot`, `custom`). + - `HyperViewer.streaming(...)`: Dedicated streaming constructor with stick-to-bottom auto-scroller, syntax auto-repair, and typing caret integration. + - Interactive AI streaming demo added to showcase app (`example/lib/ai_streaming_demo.dart`). + ## 1.7.1 - **Pub.dev Dependency Constraint Lower Bounds**: Tightened `hyper_render_core` dependency constraint to `^1.7.0` (from `>=1.6.0 <2.0.0`) to ensure lower bound compatibility during `flutter pub downgrade` analysis, securing a perfect 160/160 score on pub.dev. diff --git a/example/lib/ai_streaming_demo.dart b/example/lib/ai_streaming_demo.dart new file mode 100644 index 0000000..4b69005 --- /dev/null +++ b/example/lib/ai_streaming_demo.dart @@ -0,0 +1,254 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:hyper_render/hyper_render.dart'; + +/// Interactive AI & LLM Streaming Demo for HyperRender v1.8.0. +class AiStreamingDemo extends StatefulWidget { + const AiStreamingDemo({super.key}); + + @override + State createState() => _AiStreamingDemoState(); +} + +class _AiStreamingDemoState extends State { + final HyperStreamingController _controller = HyperStreamingController( + throttleDuration: const Duration(milliseconds: 16), + ); + + HyperTypingCaretStyle _caretStyle = HyperTypingCaretStyle.bar; + bool _autoRepair = true; + bool _autoScroll = true; + Timer? _streamTimer; + int _chunkIndex = 0; + + static const List _sampleTokens = [ + '# ๐Ÿง  HyperRender AI Assistant\n\n', + 'Hello! I am your **HyperRender AI** assistant streaming responses directly into Flutter.\n\n', + '### Key Capabilities of v1.8.0 Streaming Engine:\n\n', + '- **60 FPS Frame-Aligned Throttling**: Eliminates UI stutter during high-speed SSE bursts.\n', + '- **Transient Syntax Normalization**: Auto-repairs unclosed markdown fences and formatting.\n', + '- **Stick-to-Bottom Auto Scroll**: Smoothly follows output tail in real-time.\n', + '- **Native Blinking Carets**: Customizable bar, block, underscore, and dot styles.\n\n', + 'Here is an example code snippet generated on-the-fly:\n\n', + '```dart\n', + '// Initialize AI Streaming Controller\n', + 'final controller = HyperStreamingController();\n', + 'controller.bindStream(geminiStream);\n\n', + '// Render with HyperViewer.streaming\n', + 'HyperViewer.streaming(\n', + ' streamingController: controller,\n', + ' contentType: HyperContentType.markdown,\n', + ' showTypingCaret: true,\n', + ')\n', + '```\n\n', + '### Engine Benchmark Performance:\n\n', + '| Metric | HyperRender 1.8 | Standard Flutter |\n', + '|---|---|---|\n', + '| Memory Footprint | **2.1 MB** | 18.4 MB |\n', + '| Re-parse Overhead | **Incremental Tail** | Full Re-layout |\n', + '| FPS during Burst | **60.0 FPS** | 24-32 FPS |\n\n', + 'โœจ *Streaming generation finished successfully with zero frame drops.*', + ]; + + @override + void dispose() { + _streamTimer?.cancel(); + _controller.dispose(); + super.dispose(); + } + + void _startSimulation() { + _streamTimer?.cancel(); + _controller.reset(); + _chunkIndex = 0; + + _streamTimer = Timer.periodic(const Duration(milliseconds: 120), (timer) { + if (_chunkIndex < _sampleTokens.length) { + _controller.append(_sampleTokens[_chunkIndex]); + _chunkIndex++; + } else { + _controller.complete(); + timer.cancel(); + } + }); + } + + void _stopSimulation() { + _streamTimer?.cancel(); + _controller.complete(); + } + + void _resetSimulation() { + _streamTimer?.cancel(); + _controller.reset(); + _chunkIndex = 0; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('AI / LLM Streaming Engine (v1.8.0)'), + backgroundColor: const Color(0xFF1E293B), + foregroundColor: Colors.white, + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _resetSimulation, + tooltip: 'Reset Stream', + ), + ], + ), + body: Column( + children: [ + // Control Panel + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + border: Border(bottom: BorderSide(color: Colors.grey.shade300)), + ), + child: Column( + children: [ + Row( + children: [ + ElevatedButton.icon( + onPressed: _startSimulation, + icon: const Icon(Icons.play_arrow), + label: const Text('Start Stream'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF10B981), + foregroundColor: Colors.white, + ), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: _stopSimulation, + icon: const Icon(Icons.stop), + label: const Text('Stop'), + ), + const Spacer(), + ValueListenableBuilder( + valueListenable: _controller, + builder: (context, state, _) { + Color badgeColor = Colors.grey; + String statusLabel = 'IDLE'; + + switch (state.status) { + case HyperStreamingStatus.streaming: + badgeColor = Colors.green; + statusLabel = + 'STREAMING (${state.tokenCount} chunks)'; + break; + case HyperStreamingStatus.completed: + badgeColor = Colors.blue; + statusLabel = 'DONE (${state.tokenCount} chunks)'; + break; + case HyperStreamingStatus.error: + badgeColor = Colors.red; + statusLabel = 'ERROR'; + break; + case HyperStreamingStatus.idle: + break; + } + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: badgeColor.withAlpha(30), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: badgeColor), + ), + child: Text( + statusLabel, + style: TextStyle( + color: badgeColor, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ); + }, + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + const Text('Caret Style: ', + style: TextStyle(fontWeight: FontWeight.w600)), + DropdownButton( + value: _caretStyle, + underline: const SizedBox.shrink(), + items: const [ + DropdownMenuItem( + value: HyperTypingCaretStyle.bar, + child: Text('Bar (โ–)'), + ), + DropdownMenuItem( + value: HyperTypingCaretStyle.block, + child: Text('Block (โ–ˆ)'), + ), + DropdownMenuItem( + value: HyperTypingCaretStyle.underscore, + child: Text('Underscore (_)'), + ), + DropdownMenuItem( + value: HyperTypingCaretStyle.dot, + child: Text('Dot (โ—)'), + ), + ], + onChanged: (val) { + if (val != null) setState(() => _caretStyle = val); + }, + ), + const Spacer(), + Row( + children: [ + const Text('Auto Scroll: '), + Switch( + value: _autoScroll, + onChanged: (v) => setState(() => _autoScroll = v), + ), + ], + ), + const SizedBox(width: 8), + Row( + children: [ + const Text('Auto Repair: '), + Switch( + value: _autoRepair, + onChanged: (v) => setState(() => _autoRepair = v), + ), + ], + ), + ], + ), + ], + ), + ), + + // Main Streaming Viewer + Expanded( + child: Container( + color: Colors.white, + padding: const EdgeInsets.all(16), + child: HyperViewer.streaming( + streamingController: _controller, + contentType: HyperContentType.markdown, + caretStyle: _caretStyle, + autoRepairSyntax: _autoRepair, + autoScrollToBottom: _autoScroll, + showTypingCaret: true, + caretColor: theme.colorScheme.primary, + ), + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/demo_auto_player.dart b/example/lib/demo_auto_player.dart index e1e234a..b234ab6 100644 --- a/example/lib/demo_auto_player.dart +++ b/example/lib/demo_auto_player.dart @@ -2,12 +2,6 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:hyper_render/hyper_render.dart'; -import 'main.dart'; -import 'smart_table_demo.dart'; -import 'manga_demo.dart'; -import 'enhanced_selection_demo.dart'; -import 'ultra_showcase_2026.dart'; -import 'stress_test_demo.dart'; String getActiveDemo() { try { @@ -238,11 +232,14 @@ class _AutoPlayerHostState extends State { title = '60 FPS Virtualized Rendering'; final buf = StringBuffer(); buf.write('
'); - buf.write('

100,000+ Chars Virtualized Mode

'); - buf.write('
๐Ÿš€ FPS: 60.0 | Memory: 2.4 MB | Active Nodes: 12
'); + buf.write( + '

100,000+ Chars Virtualized Mode

'); + buf.write( + '
๐Ÿš€ FPS: 60.0 | Memory: 2.4 MB | Active Nodes: 12
'); for (int i = 1; i <= 60; i++) { final bg = i % 2 == 0 ? '#F8FAFC' : '#FFFFFF'; - buf.write('
Section $i: HyperRender utilizes intelligent chunking. Only blocks visible on screen are painted, achieving smooth 60 FPS scrolling.
'); + buf.write( + '
Section $i: HyperRender utilizes intelligent chunking. Only blocks visible on screen are painted, achieving smooth 60 FPS scrolling.
'); } buf.write('
'); htmlContent = buf.toString(); @@ -280,7 +277,8 @@ class _AutoPlayerHostState extends State { return Scaffold( appBar: AppBar( - title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + title: Text(title, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), backgroundColor: const Color(0xFF1A56DB), foregroundColor: Colors.white, elevation: 2, diff --git a/example/lib/main.dart b/example/lib/main.dart index f3833f2..68540e1 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -37,6 +37,7 @@ import 'reader_app/library_screen.dart'; import 'float_hell_demo.dart'; import 'zero_padding_image_demo.dart'; import 'base_url_demo.dart'; +import 'ai_streaming_demo.dart'; /// Optimized base TextStyle for better readability. /// @@ -125,6 +126,16 @@ class DemoHomePage extends StatelessWidget { const SizedBox(height: 8), // โ”€โ”€ Signature Features โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ _buildSectionHeader(context, 'Signature Features'), + _buildDemoCard( + context, + icon: Icons.psychology, + title: 'AI / LLM Streaming Engine (v1.8.0)', + subtitle: + 'Real-time token streaming with 60 FPS throttling, syntax auto-repair, auto-scroll & animated carets', + color: const Color(0xFF10B981), + onTap: () => Navigator.push(context, + MaterialPageRoute(builder: (_) => const AiStreamingDemo())), + ), _buildDemoCard( context, icon: Icons.explore, diff --git a/example/pubspec.lock b/example/pubspec.lock index e5aa182..88e7af2 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -350,14 +350,14 @@ packages: path: ".." relative: true source: path - version: "1.7.1" + version: "1.8.0" hyper_render_core: dependency: "direct main" description: path: "../packages/hyper_render_core" relative: true source: path - version: "1.7.0" + version: "1.8.0" hyper_render_epub: dependency: "direct main" description: diff --git a/lib/hyper_render.dart b/lib/hyper_render.dart index daf9760..a468a98 100644 --- a/lib/hyper_render.dart +++ b/lib/hyper_render.dart @@ -148,6 +148,13 @@ export 'package:hyper_render_core/hyper_render_core.dart' HyperNodePlugin, HyperPluginRegistry, HyperPluginBuildContext, + // Streaming (v1.8.0) + HyperStreamingController, + HyperStreamingState, + HyperStreamingStatus, + StreamSyntaxNormalizer, + HyperTypingCaret, + HyperTypingCaretStyle, // Loading / error UI LoadingSkeleton, HyperErrorWidget, diff --git a/lib/src/widgets/hyper_viewer.dart b/lib/src/widgets/hyper_viewer.dart index 0e89f2b..d475144 100644 --- a/lib/src/widgets/hyper_viewer.dart +++ b/lib/src/widgets/hyper_viewer.dart @@ -469,6 +469,36 @@ class HyperViewer extends StatefulWidget { /// ``` final HyperImageLoader? imageLoader; + /// Optional streaming controller for real-time AI and LLM token feeds. + /// + /// When non-null, [HyperViewer] listens to tokens from [streamingController], + /// batch-renders them with frame throttling, auto-repairs in-flight markdown/HTML, + /// displays the typing caret, and maintains stick-to-bottom scroll. + final HyperStreamingController? streamingController; + + /// Whether to auto-repair transient incomplete syntax tokens (e.g. unclosed + /// code blocks or bold asterisks) during in-flight streaming. + /// + /// Default: true. + final bool autoRepairSyntax; + + /// Whether to automatically follow the stream tail and scroll down as new tokens + /// arrive. + /// + /// Default: true. + final bool autoScrollToBottom; + + /// Whether to display an animated typing cursor at the tail while streaming is active. + /// + /// Default: true. + final bool showTypingCaret; + + /// Visual style of the typing caret. + final HyperTypingCaretStyle caretStyle; + + /// Color of the typing caret (defaults to theme primary color). + final Color? caretColor; + /// Creates a HyperViewer for HTML content (default) /// /// ```dart @@ -523,6 +553,12 @@ class HyperViewer extends StatefulWidget { this.imageLoader, }) : content = html, contentType = HyperContentType.html, + streamingController = null, + autoRepairSyntax = false, + autoScrollToBottom = false, + showTypingCaret = false, + caretStyle = HyperTypingCaretStyle.bar, + caretColor = null, _prebuiltDocument = null; /// Creates a HyperViewer for Quill Delta JSON content @@ -575,6 +611,12 @@ class HyperViewer extends StatefulWidget { this.imageLoader, }) : content = delta, contentType = HyperContentType.delta, + streamingController = null, + autoRepairSyntax = false, + autoScrollToBottom = false, + showTypingCaret = false, + caretStyle = HyperTypingCaretStyle.bar, + caretColor = null, _prebuiltDocument = null; /// Creates a HyperViewer for Markdown content @@ -627,6 +669,76 @@ class HyperViewer extends StatefulWidget { this.imageLoader, }) : content = markdown, contentType = HyperContentType.markdown, + streamingController = null, + autoRepairSyntax = false, + autoScrollToBottom = false, + showTypingCaret = false, + caretStyle = HyperTypingCaretStyle.bar, + caretColor = null, + _prebuiltDocument = null; + + /// Creates a [HyperViewer] for real-time AI and LLM streaming token feeds. + /// + /// Listens to [streamingController], automatically repairs incomplete syntactic tokens + /// on-the-fly, displays an animated [HyperTypingCaret], and smoothly auto-scrolls down. + /// + /// ```dart + /// final controller = HyperStreamingController(); + /// controller.bindStream(geminiResponseStream); + /// + /// HyperViewer.streaming( + /// streamingController: controller, + /// contentType: HyperContentType.markdown, + /// ) + /// ``` + const HyperViewer.streaming({ + super.key, + required this.streamingController, + this.contentType = HyperContentType.markdown, + this.autoRepairSyntax = true, + this.autoScrollToBottom = true, + this.showTypingCaret = true, + this.caretStyle = HyperTypingCaretStyle.bar, + this.caretColor, + this.mode = HyperRenderMode.sync, + this.selectable = true, + this.onLinkTap, + this.allowedCustomSchemes, + this.widgetBuilder, + this.placeholderBuilder, + this.fallbackBuilder, + this.enableZoom = false, + this.minScale = 0.5, + this.maxScale = 4.0, + this.contentParser, + this.codeHighlighter, + this.showSelectionMenu = true, + this.selectionHandleColor, + this.selectionColor, + this.selectionMenuActionsBuilder, + this.selectionContextMenuBuilder, + this.sanitize = true, + this.textDirection, + this.textScaler, + this.allowedTags, + this.allowDataAttributes = false, + this.semanticLabel, + this.excludeSemantics = false, + this.baseUrl, + this.customCss, + this.debugShowHyperRenderBounds = false, + this.enableComplexFilters = true, + this.captureKey, + this.shrinkWrap = false, + this.physics, + this.onError, + this.controller, + this.pageController, + this.pluginRegistry, + this.onMemoryPressure, + this.renderConfig = HyperRenderConfig.defaults, + this.imageLoader, + }) : content = '', _prebuiltDocument = null; /// Creates a [HyperViewer] from a pre-parsed [DocumentNode], skipping @@ -671,6 +783,12 @@ class HyperViewer extends StatefulWidget { this.imageLoader, }) : content = '', contentType = HyperContentType.html, + streamingController = null, + autoRepairSyntax = false, + autoScrollToBottom = false, + showTypingCaret = false, + caretStyle = HyperTypingCaretStyle.bar, + caretColor = null, mode = HyperRenderMode.sync, placeholderBuilder = null, fallbackBuilder = null, @@ -895,13 +1013,46 @@ class _HyperViewerState extends State if (widget.mode == HyperRenderMode.paged && widget.pageController == null) { _ownedPageController = PageController(); } + widget.streamingController?.addListener(_onStreamingStateChanged); _parseContent(); } + void _onStreamingStateChanged() { + if (!mounted) return; + _parseContent(); + _scrollToBottomIfApplicable(); + } + + void _scrollToBottomIfApplicable() { + if (!widget.autoScrollToBottom) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final scrollCtrl = widget.controller?.scrollController; + if (scrollCtrl != null && scrollCtrl.hasClients) { + final position = scrollCtrl.position; + if (position.maxScrollExtent > 0) { + final distanceToBottom = position.maxScrollExtent - position.pixels; + if (distanceToBottom < 350) { + scrollCtrl.animateTo( + position.maxScrollExtent, + duration: const Duration(milliseconds: 100), + curve: Curves.easeOut, + ); + } + } + } + }); + } + @override void didUpdateWidget(covariant HyperViewer oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.streamingController != widget.streamingController) { + oldWidget.streamingController?.removeListener(_onStreamingStateChanged); + widget.streamingController?.addListener(_onStreamingStateChanged); + } + // BUG-02: Handle selectable toggle โ€” create/dispose controller as needed. if (oldWidget.selectable != widget.selectable) { if (widget.selectable) { @@ -960,6 +1111,7 @@ class _HyperViewerState extends State @override void dispose() { + widget.streamingController?.removeListener(_onStreamingStateChanged); // CRIT-03: Release our size from the global ref-count so peer HyperViewers // see the correct maximum cache size after we're gone. _releaseTextCacheSize(_ownedTextCacheSize); @@ -1377,7 +1529,19 @@ class _HyperViewerState extends State // RenderObject belonging to the previous document. _sectionBoxes.clear(); - String contentToRender = widget.content; + String contentToRender = widget.streamingController != null + ? widget.streamingController!.text + : widget.content; + + if (widget.streamingController != null && widget.autoRepairSyntax) { + if (widget.contentType == HyperContentType.markdown) { + contentToRender = + StreamSyntaxNormalizer.normalizeMarkdown(contentToRender); + } else if (widget.contentType == HyperContentType.html) { + contentToRender = StreamSyntaxNormalizer.normalizeHtml(contentToRender); + } + } + // CSS collected from