ai_sdk_flutter_ui
A one-stop Flutter UI hub for AI SDK Dart: reactive
controllers (the Dart/Flutter equivalent of the Vercel AI SDK React hooks useChat,
useCompletion, useObject) plus a small library of prebuilt, themeable Material widgets to
render their state.
Each controller is a ChangeNotifier, so wrap it in a ListenableBuilder (or
AnimatedBuilder) and it rebuilds as tokens stream in. The widgets do this wiring for you.
No heavy platform dependencies: attachments and link-opening are exposed as callbacks rather than
pulling in image_picker/file_selector/url_launcher. Everything themes via
Theme.of(context).
Installation
dependencies:
ai_sdk_dart: ^3.0.0
ai_sdk_flutter_ui: ^3.0.0
ai_sdk_openai: ^3.0.0 # or another provider
How it works
Controllers drive a ToolLoopAgent — you build the
agent (which owns the model, tools, and step settings) and hand it to the controller. This keeps
the model/tool configuration in one place and lets a single agent back multiple controllers.
final agent = ToolLoopAgent(
model: openai('gpt-4.1-mini'),
instructions: 'You are a helpful assistant.',
// tools: {...}, maxSteps: 5, // optional
);
Controllers
ChatController — multi-turn streaming chat
import 'package:ai_sdk_dart/ai_sdk_dart.dart';
import 'package:ai_sdk_flutter_ui/ai_sdk_flutter_ui.dart';
import 'package:ai_sdk_openai/ai_sdk_openai.dart';
import 'package:flutter/material.dart';
class ChatPage extends StatefulWidget {
const ChatPage({super.key});
@override
State<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends State<ChatPage> {
late final ToolLoopAgent _agent;
late final ChatController _chat;
@override
void initState() {
super.initState();
_agent = ToolLoopAgent(model: openai('gpt-4.1-mini'));
_chat = ChatController(
onFinish: (message) => debugPrint('Done: ${message.content}'),
onError: (error) => debugPrint('Error: $error'),
);
}
@override
void dispose() {
_chat.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _chat,
builder: (context, _) {
return Column(
children: [
Expanded(
child: ListView.builder(
itemCount: _chat.messages.length,
itemBuilder: (context, i) {
final msg = _chat.messages[i];
return ListTile(
title: Text(msg.role.name),
subtitle: Text(msg.content ?? ''),
);
},
),
),
// Optimistic assistant bubble while the response streams in.
if (_chat.streamingContent.isNotEmpty) Text(_chat.streamingContent),
if (_chat.status == ChatStatus.streaming)
const LinearProgressIndicator(),
ElevatedButton(
onPressed: () =>
_chat.sendMessage(agent: _agent, text: 'Tell me a joke'),
child: const Text('Ask'),
),
],
);
},
);
}
}
State: messages (List<ModelMessage>), status (ChatStatus.ready|submitted|streaming|awaitingApproval|error),
isLoading, error, streamingContent (live buffer of the in-flight reply).
Methods: sendMessage({agent, text}), append(ModelMessage) (add without generating),
reload({agent}) / regenerate({agent}), stop(), clear(), clearError(),
addToolApprovalResponse({approvalId, approved, reason}) (for tools with needsApproval).
All three controllers expose an
isStreamingbool.ChatControlleradditionally exposes the richerstatus/isLoading(isStreamingthere is juststatus == ChatStatus.streaming).
CompletionController — single-turn completion
final completion = CompletionController(
agent: ToolLoopAgent(model: openai('gpt-4.1-mini')),
);
await completion.complete('Explain async/await in Dart.');
print(completion.completion); // accumulates as it streams
completion.dispose();
State: completion (String), isLoading, isStreaming, error.
Methods: complete(prompt), stop(), clear().
ObjectStreamController — live structured output
Two ways to drive it.
Ergonomic (submit) — pass a model and schema up front, then call submit(prompt). It
runs streamText(... output: Output.object(schema:)) and binds the partial-output stream for you,
giving true useObject-style ergonomics:
final schema = Schema<Map<String, dynamic>>(
jsonSchema: const {
'type': 'object',
'properties': {'title': {'type': 'string'}},
},
fromJson: (json) => json,
);
final controller = ObjectStreamController<Map<String, dynamic>>(
model: openai('gpt-4.1-mini'),
schema: schema,
);
await controller.submit('Give me a book title as JSON.');
print(controller.value); // partial object, updated as it streams
controller.dispose();
submit throws a StateError if model/schema were not provided.
Flexible (bind) — adapt any Stream<T> of partial values yourself. Build the stream with
streamText(... output: Output.object(...)) (or anything else) and bind its
partialOutputStream:
final streamResult = await streamText<Map<String, dynamic>>(
model: openai('gpt-4.1-mini'),
prompt: 'Give me a book title as JSON.',
output: Output.object(schema: schema),
);
await controller.bind(
streamResult.partialOutputStream.map((v) => v as Map<String, dynamic>),
);
State: value (T?), isLoading, isStreaming, error.
Methods: submit(prompt) (requires model+schema), bind(Stream<T>), stop(),
clear() / reset().
Prebuilt widgets
The package ships a small library of composable Material widgets that read only the controllers'
public state. Use them piecemeal, or drop in AiChatScaffold for a full chat screen.
| Widget | Purpose |
|---|---|
AiChatScaffold |
Drop-in chat body: ChatMessageList + ChatComposer wired to a ChatController + ToolLoopAgent. |
ChatMessageList |
Renders a controller's history + an optimistic streaming bubble; auto-scrolls; optional messageBuilder. |
ChatMessageBubble |
A single message styled by role (user/assistant/tool), with selectable text. |
ChatComposer |
Text field + send button; disabled while loading; optional onStop and onAttach callbacks. |
StreamingTextView |
Text that grows as it streams, with a subtle blinking cursor. |
ToolCallCard |
A tool call (name + pretty-printed JSON args) and its result/error. |
ReasoningView |
A collapsible panel for reasoning / "thinking" text. |
SourceCitations |
A wrap of citation chips for source parts (title + link), with an onTap callback. |
Drop-in chat screen with AiChatScaffold
import 'package:ai_sdk_dart/ai_sdk_dart.dart';
import 'package:ai_sdk_flutter_ui/ai_sdk_flutter_ui.dart';
import 'package:ai_sdk_openai/ai_sdk_openai.dart';
import 'package:flutter/material.dart';
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
final _agent = ToolLoopAgent(
model: openai('gpt-4.1-mini'),
instructions: 'You are a helpful assistant.',
);
final _chat = ChatController();
@override
void dispose() {
_chat.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Chat')),
body: AiChatScaffold(
controller: _chat,
agent: _agent,
emptyState: const Center(child: Text('Ask me anything')),
// onAttach: () => pickAndAttachFile(), // wire your own picker
),
);
}
}
Need more control? Compose the pieces yourself — e.g. ChatMessageList over your own scroll view
plus a custom ChatComposer, or a custom messageBuilder that renders ToolCallCard,
ReasoningView, and SourceCitations inline for richer turns.
Riverpod and Bloc lifecycle recipes
The package stays framework-neutral at runtime. Concrete recipes live under
example/recipes and use flutter_riverpod and flutter_bloc as development
dependencies. Riverpod owns an injected backend through autoDispose; Bloc
owns its stream subscription and can either dispose an injected backend or
leave it to the caller. Both recipes support replacing a session by cancelling
the old subscription before attaching the new backend.
Persist ConversationCodec.encode(controller.conversation) and restore with
backend.restore(encoded) before attaching the screen. Restore only decodes
the snapshot and never executes tools. Call interrupt() before replacing or
disposing a backend.
Conversation backends
Conversation-aware applications can use ConversationController with either
LocalConversationBackend (a ToolLoopAgent) or
RemoteConversationBackend (RemoteConversationTransport). Both expose the
same typed Conversation snapshots and change stream, so widgets can render
either backend through the same bridge. ConversationCodec.decode is used for
restore and does not execute tools. Unknown parts are retained as
UnknownPart; provider-specific file payloads should remain opaque unless the
backend supplies a verified URI.
AiChatScaffold.conversation(conversationController: controller) renders the
same scaffold from a ConversationController instead of a bare
ChatController — inline ToolApprovalCards and its errorBuilder work
either way. When the backend also implements ConversationRetryBackend
(LocalConversationBackend and RemoteConversationBackend both do),
controller.retryInfo/retryLastTurn() report whether the last turn is
safely retryable — a turn that already executed a tool or provider action is
reported unsafe rather than silently replayed. RemoteConversationBackend
always reports unsupported today; only LocalConversationBackend can report
a retryable turn. Override any of the
scaffold's built-in copy (button labels, status text, a11y labels) by wrapping
it in AiSdkUiStringsScope(strings: const AiSdkUiStrings(...)).
License
MIT
Libraries
- ai_sdk_flutter_ui
- Flutter UI controllers and prebuilt widgets for AI SDK — chat, completion, and object streaming.