chat_pilot_kit_flutter 1.0.0-beta.9
chat_pilot_kit_flutter: ^1.0.0-beta.9 copied to clipboard
Flutter widgets and Riverpod integration for chat_pilot_kit_dart.
chat_pilot_kit_flutter #
English | 简体中文
Flutter widgets and Riverpod integration for chat_pilot_kit_dart.
Provides ready-made conversation UI, 9 built-in node views, theming, streaming markdown rendering, and a NodeView extension system.
Features #
- 🎯 Ready-to-use NodeViews — 9 built-in node renderers (text, markdown, thinking, tool_call, image, file, audio, video, files_group)
- 🧩 NodeView Extension System — Three extension patterns:
customViewBuilders,flutterNodeView,getDefaultFlutterExtensions - 🎨 Theme System —
ChatPilotKitTheme+ChatPilotKitThemeScopewith automatic light/dark mode support - 🌊 Streaming Rendering — Real-time markdown and thinking block updates with no extra setup
- 🔌 Riverpod Integration — Out-of-the-box providers for reactive state management
- 📘 Full Dart Type Support — Strongly typed API aligned with React SDK
Install #
dependencies:
chat_pilot_kit_flutter: ^1.0.0-beta.1
chat_pilot_kit_dartis re-exported automatically — no need to add it separately.
Quick Start #
import 'package:flutter/material.dart';
import 'package:chat_pilot_kit_flutter/chat_pilot_kit_flutter.dart';
void main() {
// Store once — reuse for both controller and ConversationListView
final extensions = getDefaultFlutterExtensions();
final controller = createChatPilotKit(
agentService: MyAgentService(),
overrideExtensions: extensions,
);
runApp(
ChatPilotKitScope(
controller: controller,
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: ConversationListView(
extensions: extensions,
padding: const EdgeInsets.all(12),
),
),
);
}
}
ChatPilotKitScope provides the controller to all descendants via Riverpod. ConversationListView renders all conversations with built-in node views.
Core Concepts #
AgentService — Data Injection #
All node data is injected through the onData() method of AgentService. Extend BaseAgentService and implement your backend adapter:
class MyAgentService extends BaseAgentService {
@override
Future<void> query(
String text, {
List<AttachmentInput>? attachments,
QueryOptions? options,
}) async {
// Call your AI backend (SSE/WebSocket/REST, etc.)
await for (final chunk in connectToBackend(text)) {
// Inject data via onData, SDK routes to Extension by nodeType
onData(AgentMessageData(
answer: chunk.text, // Text content (incremental chunk)
nodeType: chunk.type, // 'markdown', 'thinking', 'image', ...
nodeData: chunk.data, // Node-specific structured data (see below)
queryId: queryId, // Current query ID
sessionId: sessionId, // Session ID
nodeCompleted: chunk.isLast, // Whether this is the last chunk for the node
));
}
onCompleted(queryId: queryId, sessionId: sessionId);
}
@override
void abort([String? reason]) {
// Cancel request
}
}
AgentMessageData Fields #
| Field | Type | Description |
|---|---|---|
answer |
String |
Text content (incremental or complete) |
nodeType |
String? |
Node type identifier (matches Extension name) |
nodeData |
Map<String, dynamic>? |
Node-specific structured data |
nodeBehavior |
NodeBehavior? |
Node behavior (create/append/replace/remove) |
nodeCompleted |
bool |
Whether this node finished streaming (default false) |
targetNode |
AgentTargetNode? |
Target node locator (by ID or matcher) |
queryId |
String |
Query identifier |
sessionId |
String |
Session identifier |
Built-in Node Types & Data Formats #
1. text — Plain Text #
onData(AgentMessageData(
answer: 'This is plain text',
nodeType: 'text',
queryId: queryId,
sessionId: sessionId,
));
2. markdown — Markdown Rendering (Streamable) #
onData(AgentMessageData(
answer: '# Heading\n\nThis is **bold** text',
nodeType: 'markdown',
queryId: queryId,
sessionId: sessionId,
nodeCompleted: false, // Streaming chunk
));
3. image — Image #
onData(AgentMessageData(
answer: '',
nodeType: 'image',
nodeData: {
'url': 'https://example.com/image.jpg',
'alt': 'Image description',
'caption': 'Image caption (optional)',
},
queryId: queryId,
sessionId: sessionId,
));
4. file — File #
onData(AgentMessageData(
answer: '',
nodeType: 'file',
nodeData: {
'url': 'https://example.com/document.pdf',
'fileName': 'report.pdf',
'fileSize': 2048000, // bytes
'fileType': 'application/pdf',
},
queryId: queryId,
sessionId: sessionId,
));
5. audio — Audio #
onData(AgentMessageData(
answer: '',
nodeType: 'audio',
nodeData: {
'url': 'https://example.com/audio.mp3',
'fileName': 'speech.mp3',
'duration': 120.5, // seconds (optional)
},
queryId: queryId,
sessionId: sessionId,
));
6. video — Video #
onData(AgentMessageData(
answer: '',
nodeType: 'video',
nodeData: {
'url': 'https://example.com/video.mp4',
'poster': 'https://example.com/thumb.jpg', // thumbnail (optional)
'duration': 180.0, // seconds (optional)
},
queryId: queryId,
sessionId: sessionId,
));
7. thinking — Thinking Block (Streamable) #
onData(AgentMessageData(
answer: 'Analyzing data...',
nodeType: 'thinking',
nodeData: {
'title': 'Thinking', // optional
'status': 'processing', // optional
},
queryId: queryId,
sessionId: sessionId,
nodeCompleted: false,
));
8. tool_call — Tool Call #
onData(AgentMessageData(
answer: '',
nodeType: 'tool_call',
nodeData: {
'name': 'search_web',
'input': {'query': 'Flutter tutorial'},
'output': {'results': [...]}, // optional
'status': 'completed', // pending/running/completed/error
},
queryId: queryId,
sessionId: sessionId,
));
9. files_group — Files Group (Streamable Append) #
For displaying multiple files (images, videos, documents) at once with automatic grouped layout:
onData(AgentMessageData(
answer: '',
nodeType: 'files_group',
nodeData: {
'items': [
{
'url': 'https://example.com/img1.jpg',
'fileName': 'photo1.jpg',
'fileType': 'image/jpeg',
},
{
'url': 'https://example.com/video.mp4',
'fileName': 'demo.mp4',
'fileType': 'video/mp4',
'poster': 'https://example.com/thumb.jpg',
'duration': 60.0,
},
{
'url': 'https://example.com/doc.pdf',
'fileName': 'report.pdf',
'fileType': 'application/pdf',
'fileSize': 1024000,
},
],
},
queryId: queryId,
sessionId: sessionId,
));
Streaming Append Example (RAG scenario):
// First batch
onData(AgentMessageData(
nodeType: 'files_group',
nodeData: {'items': [item1, item2]},
nodeBehavior: NodeBehavior.create,
queryId: queryId,
sessionId: sessionId,
));
// Append more
onData(AgentMessageData(
nodeType: 'files_group',
nodeData: {'items': [item3, item4]},
nodeBehavior: NodeBehavior.append, // Append to existing node
queryId: queryId,
sessionId: sessionId,
));
Getting Controller Instance #
There are three ways to get the ChatPilotKitController instance to call controller.query() and other methods:
Method 1: Via Riverpod Provider (Recommended) #
Use ref.watch() or ref.read() anywhere inside ChatPilotKitScope:
import 'package:flutter_riverpod/flutter_riverpod.dart';
// ConsumerWidget approach
class ChatPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// Get controller instance
final controller = ref.watch(chatPilotKitControllerProvider);
final isQuerying = ref.watch(isQueryingProvider).value ?? false;
return Column(
children: [
Expanded(child: ConversationListView(extensions: extensions)),
ElevatedButton(
onPressed: isQuerying
? controller.interrupt
: () => controller.query('Hello'), // ✅ Direct call
child: Text(isQuerying ? 'Stop' : 'Send'),
),
],
);
}
}
// Consumer approach
class MyButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer(
builder: (context, ref, child) {
final controller = ref.watch(chatPilotKitControllerProvider);
return ElevatedButton(
onPressed: () => controller.query('Hello'), // ✅ Direct call
child: Text('Send'),
);
},
);
}
}
Method 2: Use ref.read() in Callbacks (No Rebuild) #
If you only need it in callbacks and don't want to listen for changes, use ref.read():
class ChatInput extends ConsumerWidget {
final TextEditingController textController = TextEditingController();
@override
Widget build(BuildContext context, WidgetRef ref) {
return TextField(
controller: textController,
onSubmitted: (text) {
// ✅ Use ref.read() in callbacks, won't cause rebuild
final controller = ref.read(chatPilotKitControllerProvider);
controller.query(text);
textController.clear();
},
);
}
}
Method 3: Save Controller Reference (Use Outside Scope) #
If you need to use it outside ChatPilotKitScope, save a reference when creating:
class MyApp extends StatefulWidget {
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late final ChatPilotKitController controller;
late final List<MessageExtensionInstance> extensions;
@override
void initState() {
super.initState();
extensions = getDefaultFlutterExtensions();
// ✅ Create and save controller reference
controller = createChatPilotKit(
agentService: MyAgentService(),
overrideExtensions: extensions,
);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ChatPilotKitScope(
controller: controller,
child: MaterialApp(
home: Scaffold(
body: ConversationListView(extensions: extensions),
floatingActionButton: FloatingActionButton(
// ✅ Use saved controller reference directly
onPressed: () => controller.query('Hello'),
child: Icon(Icons.send),
),
),
),
);
}
}
Providers #
All providers are automatically available inside ChatPilotKitScope:
| Provider | Type | Description |
|---|---|---|
chatPilotKitControllerProvider |
Provider<ChatPilotKitController> |
The main controller instance |
conversationsProvider |
StreamProvider<List<ConversationBean>> |
Reactive conversation list |
isQueryingProvider |
StreamProvider<bool> |
Whether a query is in progress |
nodeInteractionProvider |
StreamProvider<NodeInteractionEvent> |
Global node interaction stream |
filteredNodeInteractionProvider |
StreamProvider.family |
Filtered node interaction stream |
Controller Common Methods #
final controller = ref.watch(chatPilotKitControllerProvider);
// Send query
await controller.query('Hello');
// Query with attachments
await controller.queryWithAttachments(
'Analyze this file',
[AttachmentInput(url: 'file:///path/to/file.pdf', ...)],
);
// Interrupt current query
controller.interrupt();
// Clear all conversations
controller.clear();
// Export conversations
final snapshots = controller.exportConversations();
// Import conversations
controller.importConversations(snapshots);
// Get current conversations
final conversations = controller.conversations;
Event Listening #
controller.events is a Stream<ChatEvent>. All events are emitted through this stream. Use Dart 3 pattern matching or type checking to listen for specific events:
// Method 1: Using if type checking
controller.events.listen((event) {
if (event is InterruptEvent) {
print('Query interrupted: queryId=${event.queryId}');
}
else if (event is ErrorEvent) {
print('Error occurred: ${event.error}');
}
else if (event is TtftEvent) {
print('TTFT: ${event.totalLatency}ms');
}
});
// Method 2: Using Dart 3 switch pattern matching
controller.events.listen((event) {
switch (event) {
case InterruptEvent(:final queryId, :final sessionId):
print('Query interrupted: $queryId');
case ErrorEvent(:final error, :final queryId):
print('Error: $error, queryId: $queryId');
case TtftEvent(:final totalLatency):
print('TTFT: ${totalLatency}ms');
case ConversationAddEvent(:final conversationId):
print('New conversation: $conversationId');
case NodeAddEvent(:final nodeType, :final nodeId):
print('New node: $nodeType, id: $nodeId');
case ReadyEvent():
print('System ready');
// ... other event types
}
});
// Method 3: Using StreamBuilder in a Widget
StreamBuilder<ChatEvent>(
stream: controller.events.where((e) => e is InterruptEvent),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data is InterruptEvent) {
final event = snapshot.data as InterruptEvent;
return Text('Query ${event.queryId} interrupted');
}
return const SizedBox.shrink();
},
)
Available Event Types
| Event Type | Description | Key Fields |
|---|---|---|
ConversationAddEvent |
New conversation added | conversationId, bean |
ConversationChangeEvent |
Conversation updated | conversationId, nodes, completed |
NodeAddEvent |
New node added | nodeId, nodeType, node |
NodeUpdateEvent |
Node updated | nodeId, nodeType, node |
InterruptEvent |
Query interrupted | queryId, sessionId |
ErrorEvent |
Error occurred | error, queryId, sessionId |
TtftEvent |
Time-to-first-token metric | totalLatency, queryId |
ClearEvent |
Conversations cleared | - |
ReadyEvent |
System ready | - |
HistoryImportEvent |
History import completed | count, position |
Subscription Management
Remember to cancel subscriptions when no longer needed:
class MyWidget extends StatefulWidget {
@override
State<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
StreamSubscription<ChatEvent>? _eventSubscription;
@override
void initState() {
super.initState();
final controller = ref.read(chatPilotKitControllerProvider);
_eventSubscription = controller.events.listen((event) {
if (event is InterruptEvent) {
// Handle interrupt event
}
});
}
@override
void dispose() {
_eventSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container();
}
}
Built-in Node Views #
| Node Type | Widget | Streamable | nodeData Fields |
|---|---|---|---|
text |
TextNodeView |
No | - |
markdown |
MarkdownNodeView |
Yes | - |
image |
ImageNodeView |
No | url, alt, caption |
audio |
AudioNodeView |
No | url, fileName, duration |
video |
VideoNodeView |
No | url, poster, duration |
file |
FileNodeView |
No | url, fileName, fileSize, fileType |
thinking |
ThinkingBlockNodeView |
Yes | title, status |
tool_call |
ToolCallNodeView |
No | name, input, output, status |
files_group |
FilesGroupView |
Yes (append) | items[] (each with url, fileName, fileType, etc.) |
NodeView Extension System #
1. customViewBuilders — Register a new node type #
ConversationListView(
extensions: extensions,
customViewBuilders: {
'rating': (props) => RatingNodeView(props: props),
'chart': (props) => ChartWidget(
data: (props.node as GenericNode).content,
),
},
)
Note: Custom node types also require registering a corresponding Extension to process the data, otherwise nodes won't be created.
class RatingExtension extends MessageExtensionConfig<GenericNode> {
@override
String get name => 'rating';
@override
bool canProcess(AgentMessageData data) => data.nodeType == 'rating';
@override
GenericNode process(AgentMessageData data, [Map<String, dynamic> options = const {}]) {
return GenericNode(
customType: 'rating',
content: data.nodeData ?? {},
);
}
}
// Register to Controller
final controller = createChatPilotKit(
agentService: myService,
extensions: [MessageExtensionInstance(RatingExtension())],
);
2. flutterNodeView — Override a built-in NodeView #
Keep the extension's processing/streaming logic, replace only the UI:
final styledText = flutterNodeView(
TextExtension(),
(props) => MyStyledTextView(props: props),
);
final controller = createChatPilotKit(
agentService: service,
overrideExtensions: [styledText, ...otherExtensions],
);
Equivalent to React SDK's Extension.extend({ addNodeView() { return ReactNodeViewRenderer(Component); } }).
3. getDefaultFlutterExtensions — Pre-bound extension set #
// Store once — reuse for both controller and ConversationListView
final extensions = getDefaultFlutterExtensions();
final controller = createChatPilotKit(
agentService: service,
overrideExtensions: extensions,
);
// Pass to ConversationListView for Extension-level NodeView dispatch
ConversationListView(extensions: extensions)
Equivalent to React SDK's getDefaultReactExtensions().
NodeViewProps #
Every node view receives NodeViewProps:
| Field | Type | Description |
|---|---|---|
node |
ConversationNode |
The node instance |
conversation |
ConversationBean |
Parent conversation |
role |
ConversationRole |
.client or .aiWorker |
completed |
bool |
Whether the node is done streaming |
updateContent |
Function(Object)? |
Update node content |
updateMetadata |
Function(Map)? |
Update node metadata |
emitInteraction |
Function(String, {data})? |
Emit interaction event |
destroy |
Function()? |
Dispose the node |
onCommand |
StreamSubscription Function(handler)? |
Subscribe to host commands (requires manual cancellation) |
Getting Node Type #
In custom NodeViews, access the node type via props.node.type and cast to specific node instances to access typed content:
class MyCustomNodeView extends StatelessWidget {
final NodeViewProps props;
const MyCustomNodeView({super.key, required this.props});
@override
Widget build(BuildContext context) {
// 1. Get node type (string)
final nodeType = props.node.type; // 'markdown', 'image', 'files_group', etc.
// 2. Cast to specific node instance by type
if (props.node is MarkdownNode) {
final markdownNode = props.node as MarkdownNode;
final content = markdownNode.content; // String
return Text('Markdown: $content');
}
if (props.node is ImageNode) {
final imageNode = props.node as ImageNode;
final content = imageNode.content; // ImageContent
return Image.network(content.url);
}
if (props.node is FilesGroupNode) {
final filesGroupNode = props.node as FilesGroupNode;
final items = filesGroupNode.content.items; // List<FilesGroupItem>
return Text('Files group contains ${items.length} files');
}
// 3. Generic node (custom types)
if (props.node is GenericNode) {
final genericNode = props.node as GenericNode;
final customType = genericNode.customType; // 'rating', 'chart', etc.
final content = genericNode.content; // Map<String, dynamic>
return Text('Custom node: $customType');
}
return const Text('Unknown node type');
}
}
Built-in Node Type Mapping #
| Node Type String | Node Class | Content Type |
|---|---|---|
'text' |
TextNode |
String |
'markdown' |
MarkdownNode |
String |
'image' |
ImageNode |
ImageContent |
'audio' |
AudioNode |
AudioContent |
'video' |
VideoNode |
VideoContent |
'file' |
FileNode |
FileContent |
'thinking' |
ThinkingNode |
ThinkingContent |
'tool_call' |
ToolCallNode |
ToolCallContent |
'files_group' |
FilesGroupNode |
FilesGroupContent |
| Custom | GenericNode |
Map<String, dynamic> |
Streaming Content Updates #
For streamable nodes, use StreamBuilder to listen for content changes:
class MyMarkdownView extends StatelessWidget {
final NodeViewProps props;
const MyMarkdownView({super.key, required this.props});
@override
Widget build(BuildContext context) {
final node = props.node as MarkdownNode;
return StreamBuilder<String>(
stream: node.onContentUpdate, // Stream<String>
initialData: node.content,
builder: (context, snapshot) {
final content = snapshot.data ?? '';
final isStreaming = !props.completed;
return Column(
children: [
Text(content),
if (isStreaming) const CircularProgressIndicator(),
],
);
},
);
}
}
Node Interaction #
// Listen to interactions from NodeViews
ref.listen(nodeInteractionProvider, (prev, next) {
final event = next.value;
if (event != null) {
print('[${event.type}] nodeId=${event.nodeId} data=${event.data}');
}
});
// Filter by node type
final toolCallSub = ref.watch(
filteredNodeInteractionProvider(
(event) => event.nodeType == 'tool_call',
),
);
// Send a command to a specific node
sendNodeCommand(controller, nodeId: 'node-123', type: 'highlight');
Theming #
Node views use ChatPilotKitTheme for consistent styling. By default, colors are derived from the surrounding ThemeData.
ChatPilotKitThemeScope(
theme: ChatPilotKitTheme(
bubbleAiBackground: Colors.grey.shade100,
bubbleUserBackground: Colors.blue,
bubbleAiForeground: Colors.black87,
bubbleUserForeground: Colors.white,
accent: Colors.blue,
codeBackground: const Color(0xFFF6F8FA),
codeForeground: const Color(0xFF1F2328),
inlineCodeBackground: const Color(0x1F818B98),
borderColor: const Color(0xFFD1D9E0),
mutedForeground: const Color(0xFF59636E),
bubbleRadius: 16,
),
child: ConversationListView(extensions: extensions),
)
Access in custom widgets:
final cpkTheme = ChatPilotKitThemeScope.of(context);
Streaming #
Streamable nodes (markdown, thinking, files_group) automatically update in real-time via StreamBuilder on node.onContentUpdate. No extra setup needed.
Attachments #
await controller.queryWithAttachments(
'Check this file',
[
AttachmentInput(
url: 'file:///path/to/doc.pdf',
fileName: 'doc.pdf',
fileSize: 2048000,
fileType: 'application/pdf',
),
],
);
Attachments are automatically converted to corresponding nodes (ImageNode, FileNode, etc.) based on MIME type and displayed in the user message bubble.
Conversation Persistence #
// Export
final snapshots = controller.exportConversations();
final json = snapshots.map((s) => s.toJson()).toList();
// Import (prepend, default)
controller.importConversations(inputs);
// Import (replace all)
controller.importConversations(
inputs,
options: const ImportOptions(position: ImportPosition.replace),
);
API Exports #
chat_pilot_kit_flutter exports all available APIs:
1. Complete Re-export of chat_pilot_kit_dart #
import 'package:chat_pilot_kit_flutter/chat_pilot_kit_flutter.dart';
// All chat_pilot_kit_dart content is directly available, including:
// - createChatPilotKit()
// - BaseAgentService
// - AgentMessageData
// - ConversationNode (and all node types)
// - MessageExtensionConfig
// - ChatPilotKitController
// - All event types (ConversationAddEvent, NodeAddEvent, etc.)
2. Theme System #
// Theme configuration
import 'package:chat_pilot_kit_flutter/chat_pilot_kit_flutter.dart';
ChatPilotKitTheme // Theme data class
ChatPilotKitThemeScope // Theme scope widget
3. Riverpod Providers #
// Providers (use inside ChatPilotKitScope)
chatPilotKitControllerProvider // Provider<ChatPilotKitController>
conversationsProvider // StreamProvider<List<ConversationBean>>
isQueryingProvider // StreamProvider<bool>
nodeInteractionProvider // StreamProvider<NodeInteractionEvent>
filteredNodeInteractionProvider // StreamProvider.family<NodeInteractionEvent>
4. Core Widgets #
// Container widget
ChatPilotKitScope // Controller scope provider
// Conversation list
ConversationListView // Full conversation list renderer
// Node renderer
NodeRenderer // Single node renderer (supports Extension dispatch)
NodeViewProps // Node view properties class
NodeViewBuilder // Node view builder type
5. Extension System #
// Flutter extension utilities
getDefaultFlutterExtensions() // Get pre-bound default extensions
flutterNodeView() // Create Flutter NodeView extension
6. Built-in NodeView Components (9 types) #
// Text types
TextNodeView // Plain text node view
MarkdownNodeView // Markdown rendering view
// Media types
ImageNodeView // Image node view
AudioNodeView // Audio node view
VideoNodeView // Video node view
FileNodeView // File node view
FilesGroupView // Files group node view (multiple files)
// AI special types
ThinkingBlockNodeView // Thinking block node view
ToolCallNodeView // Tool call node view
7. Helper Components #
ThinkingIndicator // Thinking animation indicator
StreamingDots // Streaming dots indicator (animated bouncing dots)
8. Node Interaction Utilities #
// Node interaction functions
sendNodeCommand() // Send command to node
Complete Import Example #
import 'package:chat_pilot_kit_flutter/chat_pilot_kit_flutter.dart';
// ✅ All content imported at once, including:
// - All chat_pilot_kit_dart APIs
// - Flutter UI components
// - Riverpod Providers
// - Theme system
// - Extension utilities
Selective Import Example #
If you only need specific content, you can import precisely:
// Only core functionality
import 'package:chat_pilot_kit_dart/chat_pilot_kit_dart.dart';
// Only Flutter layer
import 'package:chat_pilot_kit_flutter/src/widgets/conversation_list_view.dart';
import 'package:chat_pilot_kit_flutter/src/providers/chat_pilot_kit_scope.dart';
import 'package:chat_pilot_kit_flutter/src/theme/cpk_theme.dart';
Recommended: Use
import 'package:chat_pilot_kit_flutter/chat_pilot_kit_flutter.dart';to import everything at once for simplicity.
Full Example #
See the example app for a complete playground with:
- Chat tab with text input and attachment support
- Demos tab with behavior / target / media / NodeView extension demos
- Settings tab with theme switching and NodeView gallery
- NodeView Extension page showing all 3 extension patterns
cd example && flutter run -d <device>
Related Packages #
| Package | Description |
|---|---|
| chat_pilot_kit_dart | Headless core SDK, pure Dart with no Flutter dependency |
Publishing #
yarn dart:publish:dry # dry run
yarn dart:publish # publish to pub.dev
License #
MIT