Initial commit: AI Assistant Flutter client

- SSE streaming chat (real-time token delivery)
- Supabase Auth integration (signup, login, JWT)
- Multi-app support via app_id (room_decor, music, ecommerce)
- Model selection and critic toggle
- Conversation management (list, load, delete)
- Cancel streaming requests
- Full example with auth screen + chat UI
This commit is contained in:
root 2026-03-11 07:08:48 +00:00
commit 3fe1dc7bbf
6 changed files with 1398 additions and 0 deletions

642
example/usage_example.dart Normal file
View File

@ -0,0 +1,642 @@
/// Complete example: Supabase Auth + AI Assistant streaming.
///
/// Shows init, signup/login, streaming chat with model/critic selection,
/// and how different apps (room_decor, music, ecommerce) use the same client.
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:ai_assistant_client/ai_assistant_client.dart';
// App initialization
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize Supabase (once, at app startup)
await Supabase.initialize(
url: 'https://supabase.josradev.net',
anonKey:
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzcwODg0Mjc3LCJleHAiOjE5Mjg1NjQyNzd9.OQWYOhyF9YvUjk_C707_JkiqSaMRbzGENOLsG17LCc8',
);
// Pick your app:
runApp(const RoomDecorApp());
// runApp(const MusicApp());
// runApp(const EcommerceApp());
}
// Auth Screen
class AuthScreen extends StatefulWidget {
final String appId;
final String title;
const AuthScreen({super.key, required this.appId, required this.title});
@override
State<AuthScreen> createState() => _AuthScreenState();
}
class _AuthScreenState extends State<AuthScreen> {
final _email = TextEditingController();
final _password = TextEditingController();
final _name = TextEditingController();
bool _isLogin = true;
bool _loading = false;
String? _error;
Future<void> _submit() async {
setState(() {
_loading = true;
_error = null;
});
try {
final client = AiAssistantClient(
baseUrl: 'https://assistant.josradev.net',
appId: widget.appId,
);
if (_isLogin) {
await client.signIn(
email: _email.text.trim(),
password: _password.text,
);
} else {
await client.signUp(
email: _email.text.trim(),
password: _password.text,
displayName: _name.text.trim(),
);
}
if (mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => ChatScreen(
appId: widget.appId,
title: widget.title,
),
),
);
}
} on AuthException catch (e) {
setState(() => _error = e.message);
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.title)),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (!_isLogin)
TextField(
controller: _name,
decoration: const InputDecoration(labelText: 'Display Name'),
),
const SizedBox(height: 12),
TextField(
controller: _email,
decoration: const InputDecoration(labelText: 'Email'),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 12),
TextField(
controller: _password,
decoration: const InputDecoration(labelText: 'Password'),
obscureText: true,
onSubmitted: (_) => _submit(),
),
const SizedBox(height: 16),
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(_error!, style: const TextStyle(color: Colors.red)),
),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _loading ? null : _submit,
child: _loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: Text(_isLogin ? 'Sign In' : 'Sign Up'),
),
),
TextButton(
onPressed: () => setState(() => _isLogin = !_isLogin),
child: Text(_isLogin
? "Don't have an account? Sign Up"
: 'Already have an account? Sign In'),
),
],
),
),
);
}
}
// Chat Screen
class ChatScreen extends StatefulWidget {
final String appId;
final String title;
const ChatScreen({super.key, required this.appId, required this.title});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
late final AiAssistantClient client;
final _controller = TextEditingController();
final _scrollController = ScrollController();
final _messages = <_Bubble>[];
UserProfile? _profile;
String _currentResponse = '';
String _status = '';
bool _loading = false;
// Settings
String? _selectedModel;
bool? _criticEnabled; // null = auto
@override
void initState() {
super.initState();
client = AiAssistantClient(
baseUrl: 'https://assistant.josradev.net',
appId: widget.appId,
);
_loadProfile();
}
Future<void> _loadProfile() async {
final profile = await client.getProfile();
if (mounted) setState(() => _profile = profile);
}
Future<void> _send() async {
final text = _controller.text.trim();
if (text.isEmpty || _loading) return;
_controller.clear();
// Apply settings
client.model = _selectedModel;
client.criticEnabled = _criticEnabled;
setState(() {
_messages.add(_Bubble(role: 'user', text: text));
_currentResponse = '';
_status = '';
_loading = true;
});
_scrollToBottom();
final buffer = StringBuffer();
List<AssistantImage> images = [];
try {
await for (final event in client.chatStream(text)) {
switch (event.type) {
case StreamEventType.token:
buffer.write(event.token);
setState(() => _currentResponse = buffer.toString());
_scrollToBottom();
break;
case StreamEventType.status:
setState(() => _status = event.statusMessage);
break;
case StreamEventType.images:
images = event.images;
break;
case StreamEventType.done:
setState(() {
_messages.add(_Bubble(
role: 'assistant',
text: buffer.toString(),
images: images,
));
_currentResponse = '';
_status = '';
_loading = false;
});
break;
case StreamEventType.error:
setState(() {
_messages.add(
_Bubble(role: 'assistant', text: 'Error: ${event.data}'));
_loading = false;
});
break;
default:
break;
}
}
} catch (e) {
setState(() {
_messages.add(_Bubble(role: 'assistant', text: 'Error: $e'));
_loading = false;
});
}
}
void _scrollToBottom() {
Future.delayed(const Duration(milliseconds: 50), () {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
void _showSettings() {
showModalBottomSheet(
context: context,
builder: (ctx) => _SettingsSheet(
currentModel: _selectedModel,
criticEnabled: _criticEnabled,
profile: _profile,
onModelChanged: (m) => setState(() => _selectedModel = m),
onCriticChanged: (c) => setState(() => _criticEnabled = c),
onSignOut: () async {
await client.signOut();
if (mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => AuthScreen(
appId: widget.appId,
title: widget.title,
),
),
);
}
},
),
);
}
@override
void dispose() {
client.dispose();
_controller.dispose();
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
actions: [
// New conversation
IconButton(
icon: const Icon(Icons.add),
onPressed: () {
client.newConversation();
setState(() => _messages.clear());
},
),
// Settings (model, critic, profile)
IconButton(
icon: const Icon(Icons.settings),
onPressed: _showSettings,
),
],
),
body: Column(
children: [
// Messages
Expanded(
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount:
_messages.length + (_currentResponse.isNotEmpty ? 1 : 0),
itemBuilder: (context, index) {
if (index < _messages.length) {
final msg = _messages[index];
return _buildBubble(msg);
}
// Streaming response
return _buildStreamingBubble();
},
),
),
// Input
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, -1),
),
],
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
onSubmitted: (_) => _send(),
maxLines: null,
decoration: InputDecoration(
hintText: 'Type a message...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
),
),
),
const SizedBox(width: 8),
IconButton(
onPressed: _loading ? () => client.cancel() : _send,
icon: Icon(
_loading ? Icons.stop : Icons.send,
color: Theme.of(context).primaryColor,
),
),
],
),
),
],
),
);
}
Widget _buildBubble(_Bubble msg) {
final isUser = msg.role == 'user';
return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
constraints:
BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.8),
decoration: BoxDecoration(
color: isUser ? Colors.blue[100] : Colors.grey[200],
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(msg.text),
if (msg.images != null)
for (final img in msg.images!)
Padding(
padding: const EdgeInsets.only(top: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(img.url),
),
),
],
),
),
);
}
Widget _buildStreamingBubble() {
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
constraints:
BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.8),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_status.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.grey[500],
),
),
const SizedBox(width: 6),
Text(
_status,
style: TextStyle(color: Colors.grey[600], fontSize: 12),
),
],
),
),
if (_currentResponse.isNotEmpty) Text(_currentResponse),
if (_currentResponse.isEmpty && _status.isEmpty)
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.grey[400],
),
),
],
),
),
);
}
}
// Settings Sheet
class _SettingsSheet extends StatelessWidget {
final String? currentModel;
final bool? criticEnabled;
final UserProfile? profile;
final ValueChanged<String?> onModelChanged;
final ValueChanged<bool?> onCriticChanged;
final VoidCallback onSignOut;
const _SettingsSheet({
required this.currentModel,
required this.criticEnabled,
required this.profile,
required this.onModelChanged,
required this.onCriticChanged,
required this.onSignOut,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Profile
if (profile != null) ...[
Text('Signed in as ${profile!.name}',
style: const TextStyle(fontWeight: FontWeight.bold)),
Text('Role: ${profile!.role}',
style: TextStyle(color: Colors.grey[600], fontSize: 13)),
if (profile!.claudeCodeAccess)
const Text('Claude Code: Enabled',
style: TextStyle(color: Colors.green, fontSize: 13)),
const SizedBox(height: 16),
],
// Model selection
const Text('Model', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
DropdownButton<String?>(
value: currentModel,
isExpanded: true,
items: const [
DropdownMenuItem(value: null, child: Text('Default (server)')),
DropdownMenuItem(
value: 'gpt-oss:120b', child: Text('GPT-OSS 120B')),
DropdownMenuItem(
value: 'huihui_ai/qwen3-abliterated:32b',
child: Text('Qwen3 32B (Uncensored)')),
DropdownMenuItem(
value: 'gemini-2.5-flash',
child: Text('Gemini 2.5 Flash')),
DropdownMenuItem(
value: 'gemini-2.5-pro',
child: Text('Gemini 2.5 Pro')),
],
onChanged: (val) {
onModelChanged(val);
Navigator.pop(context);
},
),
const SizedBox(height: 16),
// Critic toggle
const Text('Response Critic',
style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
SegmentedButton<bool?>(
segments: const [
ButtonSegment(value: null, label: Text('Auto')),
ButtonSegment(value: true, label: Text('On')),
ButtonSegment(value: false, label: Text('Off')),
],
selected: {criticEnabled},
onSelectionChanged: (val) {
onCriticChanged(val.first);
Navigator.pop(context);
},
),
const SizedBox(height: 24),
// Sign out
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () {
Navigator.pop(context);
onSignOut();
},
child: const Text('Sign Out'),
),
),
const SizedBox(height: 8),
],
),
);
}
}
// Data class
class _Bubble {
final String role;
final String text;
final List<AssistantImage>? images;
const _Bubble({required this.role, required this.text, this.images});
}
// App entry points
// Each app is identical except for appId and title.
class RoomDecorApp extends StatelessWidget {
const RoomDecorApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Room Decor AI',
theme: ThemeData(
colorSchemeSeed: Colors.teal,
useMaterial3: true,
),
home: Supabase.instance.client.auth.currentSession != null
? const ChatScreen(appId: 'room_decor', title: 'Room Decor AI')
: const AuthScreen(appId: 'room_decor', title: 'Room Decor AI'),
);
}
}
class MusicApp extends StatelessWidget {
const MusicApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Music AI',
theme: ThemeData(
colorSchemeSeed: Colors.purple,
useMaterial3: true,
),
home: Supabase.instance.client.auth.currentSession != null
? const ChatScreen(appId: 'music', title: 'Music AI')
: const AuthScreen(appId: 'music', title: 'Music AI'),
);
}
}
class EcommerceApp extends StatelessWidget {
const EcommerceApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Shopping AI',
theme: ThemeData(
colorSchemeSeed: Colors.orange,
useMaterial3: true,
),
home: Supabase.instance.client.auth.currentSession != null
? const ChatScreen(appId: 'ecommerce', title: 'Shopping AI')
: const AuthScreen(appId: 'ecommerce', title: 'Shopping AI'),
);
}
}

View File

@ -0,0 +1,6 @@
/// AI Assistant Flutter client with SSE streaming support.
library ai_assistant_client;
export 'src/client.dart';
export 'src/models.dart';
export 'src/stream_parser.dart';

388
lib/src/client.dart Normal file
View File

@ -0,0 +1,388 @@
/// AI Assistant client with SSE streaming and Supabase Auth.
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:supabase_flutter/supabase_flutter.dart';
import 'models.dart';
import 'stream_parser.dart';
class AiAssistantClient {
/// Base URL of the AI Assistant API.
final String baseUrl;
/// App identifier determines system prompt and available tools.
/// Options: "room_decor", "music", "ecommerce", "general"
final String appId;
/// Current conversation ID (set after first message).
String? conversationId;
/// Model override (null = server default).
String? model;
/// Critic mode: true (always), false (never), null (auto).
bool? criticEnabled;
/// Critic model override.
String? criticModel;
/// HTTP client (injectable for testing).
final http.Client _http;
AiAssistantClient({
required this.baseUrl,
required this.appId,
this.conversationId,
this.model,
this.criticEnabled,
this.criticModel,
http.Client? httpClient,
}) : _http = httpClient ?? http.Client();
/// Get current Supabase user.
User? get currentUser => Supabase.instance.client.auth.currentUser;
/// Get current session.
Session? get currentSession => Supabase.instance.client.auth.currentSession;
/// Whether user is authenticated.
bool get isAuthenticated => currentSession != null;
/// Listen to auth state changes.
Stream<AuthState> get onAuthStateChange =>
Supabase.instance.client.auth.onAuthStateChange;
/// Headers for API requests.
Map<String, String> get _headers {
final h = <String, String>{
'Content-Type': 'application/json',
};
final token = currentSession?.accessToken;
if (token != null) {
h['Authorization'] = 'Bearer $token';
}
return h;
}
/// User ID (from Supabase session or null).
String? get _userId => currentUser?.id;
// Auth (Supabase SDK)
/// Sign up with email and password.
Future<AuthResponse> signUp({
required String email,
required String password,
String? displayName,
}) async {
final response = await Supabase.instance.client.auth.signUp(
email: email,
password: password,
data: displayName != null ? {'display_name': displayName} : null,
);
// Create chat_users entry via the API
if (response.user != null) {
try {
await _http.post(
Uri.parse('$baseUrl/auth/signup'),
headers: {'Content-Type': 'application/json'},
body: json.encode({
'email': email,
'password': password,
'name': displayName ?? email.split('@')[0],
}),
);
} catch (_) {
// Non-critical user will be auto-created on first chat
}
}
return response;
}
/// Sign in with email and password.
Future<AuthResponse> signIn({
required String email,
required String password,
}) async {
return await Supabase.instance.client.auth.signInWithPassword(
email: email,
password: password,
);
}
/// Sign in with OAuth (Google, Apple, GitHub, etc).
Future<bool> signInWithOAuth(OAuthProvider provider) async {
return await Supabase.instance.client.auth.signInWithOAuth(provider);
}
/// Sign out.
Future<void> signOut() async {
await Supabase.instance.client.auth.signOut();
conversationId = null;
}
/// Send password reset email.
Future<void> resetPassword(String email) async {
await Supabase.instance.client.auth.resetPasswordForEmail(email);
}
/// Get user profile from chat_users.
Future<UserProfile?> getProfile() async {
if (!isAuthenticated) return null;
final response = await _http.get(
Uri.parse('$baseUrl/auth/profile'),
headers: _headers,
);
if (response.statusCode >= 400) return null;
final data = json.decode(response.body) as Map<String, dynamic>;
if (data.containsKey('error')) return null;
return UserProfile.fromJson(data);
}
// Chat
/// Build the request body with all options.
Map<String, dynamic> _buildBody(
String message, {
String? imageBase64,
String? audioBase64,
bool stream = true,
bool voice = false,
bool search = false,
String? forceTool,
String? accessCode,
}) {
final body = <String, dynamic>{
'message': message,
'stream': stream,
'app_id': appId,
};
// Auth: prefer JWT, fallback to user_id
if (!isAuthenticated) {
body['user_id'] = 'anonymous';
}
if (conversationId != null) body['conversation_id'] = conversationId;
if (model != null) body['model'] = model;
if (criticEnabled != null) body['critic'] = criticEnabled;
if (criticModel != null) body['critic_model'] = criticModel;
if (imageBase64 != null) body['image'] = imageBase64;
if (audioBase64 != null) body['audio'] = audioBase64;
if (voice) body['voice'] = true;
if (search) body['search'] = true;
if (forceTool != null) body['force_tool'] = forceTool;
if (accessCode != null) body['claude_code'] = accessCode;
return body;
}
/// Send a message and receive a stream of [StreamEvent]s.
///
/// Tokens arrive as [StreamEventType.token] events append to build
/// the response incrementally. Tool execution shows as [StreamEventType.status].
Stream<StreamEvent> chatStream(
String message, {
String? imageBase64,
String? audioBase64,
bool voice = false,
bool search = false,
String? forceTool,
String? accessCode,
}) async* {
final body = _buildBody(
message,
imageBase64: imageBase64,
audioBase64: audioBase64,
stream: true,
voice: voice,
search: search,
forceTool: forceTool,
accessCode: accessCode,
);
final request = http.Request('POST', Uri.parse('$baseUrl/chat'));
request.headers.addAll(_headers);
request.body = json.encode(body);
final response = await _http.send(request);
if (response.statusCode == 401) {
// Try token refresh
try {
await Supabase.instance.client.auth.refreshSession();
// Retry with new token
final retry = http.Request('POST', Uri.parse('$baseUrl/chat'));
retry.headers.addAll(_headers);
retry.body = json.encode(body);
final retryResponse = await _http.send(retry);
if (retryResponse.statusCode >= 400) {
throw AiAssistantException(
'HTTP ${retryResponse.statusCode}',
statusCode: retryResponse.statusCode,
);
}
await for (final event in parseSseStream(retryResponse.stream)) {
if (event.type == StreamEventType.conversationId ||
event.type == StreamEventType.done) {
final cid = event.conversationId;
if (cid.isNotEmpty) conversationId = cid;
}
yield event;
}
return;
} catch (_) {
throw AiAssistantException('Authentication failed', statusCode: 401);
}
}
if (response.statusCode >= 400) {
final errorBody = await response.stream.bytesToString();
throw AiAssistantException(
'HTTP ${response.statusCode}',
statusCode: response.statusCode,
body: errorBody,
);
}
await for (final event in parseSseStream(response.stream)) {
if (event.type == StreamEventType.conversationId ||
event.type == StreamEventType.done) {
final cid = event.conversationId;
if (cid.isNotEmpty) conversationId = cid;
}
yield event;
}
}
/// Send a message and wait for the complete response.
Future<ChatResponse> chat(
String message, {
String? imageBase64,
String? audioBase64,
bool voice = false,
bool search = false,
String? forceTool,
String? accessCode,
}) async {
final body = _buildBody(
message,
imageBase64: imageBase64,
audioBase64: audioBase64,
stream: false,
voice: voice,
search: search,
forceTool: forceTool,
accessCode: accessCode,
);
final response = await _http.post(
Uri.parse('$baseUrl/chat'),
headers: _headers,
body: json.encode(body),
);
if (response.statusCode >= 400) {
throw AiAssistantException(
'HTTP ${response.statusCode}',
statusCode: response.statusCode,
body: response.body,
);
}
final data = json.decode(response.body) as Map<String, dynamic>;
final result = ChatResponse.fromJson(data);
conversationId = result.conversationId;
return result;
}
// Cancel
/// Cancel the current streaming request.
Future<bool> cancel() async {
if (conversationId == null) return false;
final response = await _http.post(
Uri.parse('$baseUrl/chat?cancel=true&conversation_id=$conversationId'),
headers: _headers,
body: json.encode({'conversation_id': conversationId}),
);
return response.statusCode < 400;
}
// Conversations
/// List user's conversations.
Future<List<Conversation>> listConversations() async {
final uid = _userId ?? 'anonymous';
final response = await _http.get(
Uri.parse('$baseUrl/chat?conversations=true&user_id=$uid'),
headers: _headers,
);
if (response.statusCode >= 400) return [];
final data = json.decode(response.body) as Map<String, dynamic>;
return (data['conversations'] as List)
.map((c) => Conversation.fromJson(c as Map<String, dynamic>))
.toList();
}
/// Load messages for a conversation.
Future<List<ChatMessage>> loadMessages(String convId) async {
final response = await _http.get(
Uri.parse('$baseUrl/chat?conversation_id=$convId'),
headers: _headers,
);
if (response.statusCode >= 400) return [];
final data = json.decode(response.body) as Map<String, dynamic>;
return (data['messages'] as List)
.map((m) => ChatMessage.fromJson(m as Map<String, dynamic>))
.toList();
}
/// Delete a conversation.
Future<void> deleteConversation(String convId) async {
await _http.delete(
Uri.parse('$baseUrl/chat?conversation_id=$convId'),
headers: _headers,
);
if (conversationId == convId) conversationId = null;
}
// Models
/// List available models.
Future<List<ModelInfo>> listModels() async {
final response = await _http.get(
Uri.parse('$baseUrl/chat'),
headers: _headers,
);
if (response.statusCode >= 400) return [];
final data = json.decode(response.body) as Map<String, dynamic>;
return (data['models'] as List)
.map((m) => ModelInfo.fromJson(m as Map<String, dynamic>))
.toList();
}
// Convenience
/// Start a new conversation.
void newConversation() => conversationId = null;
/// Dispose resources.
void dispose() => _http.close();
}
/// Exception thrown by the AI Assistant client.
class AiAssistantException implements Exception {
final String message;
final int? statusCode;
final String? body;
const AiAssistantException(this.message, {this.statusCode, this.body});
@override
String toString() => 'AiAssistantException: $message (HTTP $statusCode)';
}

249
lib/src/models.dart Normal file
View File

@ -0,0 +1,249 @@
/// Data models for the AI Assistant API.
/// Image returned by the assistant.
class AssistantImage {
final String url;
final String title;
const AssistantImage({required this.url, this.title = ''});
factory AssistantImage.fromJson(Map<String, dynamic> json) => AssistantImage(
url: json['url'] as String? ?? '',
title: json['title'] as String? ?? '',
);
}
/// Audio returned by the assistant.
class AssistantAudio {
final String url;
final String title;
final double? duration;
final int? expiresInSeconds;
const AssistantAudio({
required this.url,
this.title = '',
this.duration,
this.expiresInSeconds,
});
factory AssistantAudio.fromJson(Map<String, dynamic> json) => AssistantAudio(
url: json['url'] as String? ?? '',
title: json['title'] as String? ?? '',
duration: (json['duration'] as num?)?.toDouble(),
expiresInSeconds: json['expires_in_seconds'] as int?,
);
}
/// File returned by the assistant.
class AssistantFile {
final String filename;
final String? url;
final String? data;
final String? mimeType;
const AssistantFile({
required this.filename,
this.url,
this.data,
this.mimeType,
});
factory AssistantFile.fromJson(Map<String, dynamic> json) => AssistantFile(
filename: json['filename'] as String? ?? '',
url: json['url'] as String?,
data: json['data'] as String?,
mimeType: json['mime_type'] as String?,
);
}
/// Non-streaming chat response.
class ChatResponse {
final String response;
final String conversationId;
final String? guardrail;
final List<AssistantImage>? images;
final AssistantAudio? audio;
final List<AssistantFile>? files;
const ChatResponse({
required this.response,
required this.conversationId,
this.guardrail,
this.images,
this.audio,
this.files,
});
factory ChatResponse.fromJson(Map<String, dynamic> json) {
return ChatResponse(
response: json['response'] as String? ?? '',
conversationId: json['conversation_id'] as String? ?? '',
guardrail: json['guardrail'] as String?,
images: (json['images'] as List?)
?.map((i) => AssistantImage.fromJson(i as Map<String, dynamic>))
.toList(),
audio: json['audio'] != null
? AssistantAudio.fromJson(json['audio'] as Map<String, dynamic>)
: null,
files: (json['files'] as List?)
?.map((f) => AssistantFile.fromJson(f as Map<String, dynamic>))
.toList(),
);
}
}
/// SSE stream event types.
enum StreamEventType {
conversationId,
status,
token,
images,
audio,
files,
error,
cancelled,
done,
}
/// A single SSE event from the stream.
class StreamEvent {
final StreamEventType type;
final dynamic data;
const StreamEvent({required this.type, this.data});
/// Get the token text (only for token events).
String get token => type == StreamEventType.token ? (data as String) : '';
/// Get status message (only for status events).
String get statusMessage =>
type == StreamEventType.status ? (data as Map)['status'] as String : '';
/// Get tool name (only for status events).
String get toolName =>
type == StreamEventType.status ? (data as Map)['tool'] as String? ?? '' : '';
/// Get conversation ID (for conversationId and done events).
String get conversationId {
if (data is Map) return (data as Map)['conversation_id'] as String? ?? '';
return '';
}
/// Get images (only for images events).
List<AssistantImage> get images {
if (type != StreamEventType.images || data is! Map) return [];
final list = (data as Map)['images'] as List?;
if (list == null) return [];
return list
.map((i) => AssistantImage.fromJson(i as Map<String, dynamic>))
.toList();
}
}
/// Conversation summary.
class Conversation {
final String id;
final String summary;
final DateTime? updatedAt;
const Conversation({required this.id, this.summary = '', this.updatedAt});
factory Conversation.fromJson(Map<String, dynamic> json) => Conversation(
id: json['id'] as String,
summary: json['summary'] as String? ?? '',
updatedAt: json['updated_at'] != null
? DateTime.tryParse(json['updated_at'] as String)
: null,
);
}
/// User profile from chat_users.
class UserProfile {
final String userId;
final String name;
final String role;
final bool claudeCodeAccess;
final DateTime? createdAt;
const UserProfile({
required this.userId,
required this.name,
required this.role,
this.claudeCodeAccess = false,
this.createdAt,
});
bool get isAdmin => role == 'admin';
factory UserProfile.fromJson(Map<String, dynamic> json) => UserProfile(
userId: json['user_id'] as String? ?? '',
name: json['name'] as String? ?? '',
role: json['role'] as String? ?? 'user',
claudeCodeAccess: json['claude_code_access'] as bool? ?? false,
createdAt: json['created_at'] != null
? DateTime.tryParse(json['created_at'] as String)
: null,
);
}
/// Chat message from history.
class ChatMessage {
final int id;
final String role;
final String content;
final DateTime? createdAt;
const ChatMessage({
required this.id,
required this.role,
required this.content,
this.createdAt,
});
factory ChatMessage.fromJson(Map<String, dynamic> json) => ChatMessage(
id: json['id'] as int? ?? 0,
role: json['role'] as String? ?? '',
content: json['content'] as String? ?? '',
createdAt: json['created_at'] != null
? DateTime.tryParse(json['created_at'] as String)
: null,
);
}
/// Model info.
class ModelInfo {
final String id;
final String name;
final String size;
final String description;
final List<String> capabilities;
final bool isDefault;
final bool isVision;
final String provider;
const ModelInfo({
required this.id,
required this.name,
required this.size,
required this.description,
required this.capabilities,
this.isDefault = false,
this.isVision = false,
this.provider = 'ollama',
});
factory ModelInfo.fromJson(Map<String, dynamic> json) => ModelInfo(
id: json['id'] as String,
name: json['name'] as String? ?? '',
size: json['size'] as String? ?? '',
description: json['description'] as String? ?? '',
capabilities: (json['capabilities'] as List?)
?.map((c) => c as String)
.toList() ??
[],
isDefault: json['is_default'] as bool? ?? false,
isVision: json['is_vision'] as bool? ?? false,
provider: json['provider'] as String? ?? 'ollama',
);
}

View File

@ -0,0 +1,95 @@
/// SSE stream parser converts raw byte stream to typed events.
import 'dart:async';
import 'dart:convert';
import 'models.dart';
/// Parses a raw SSE byte stream into [StreamEvent] objects.
Stream<StreamEvent> parseSseStream(Stream<List<int>> byteStream) async* {
final buffer = StringBuffer();
await for (final chunk in byteStream.transform(utf8.decoder)) {
buffer.write(chunk);
// Process complete lines
while (true) {
final content = buffer.toString();
final doubleNewline = content.indexOf('\n\n');
if (doubleNewline == -1) break;
final block = content.substring(0, doubleNewline);
buffer.clear();
buffer.write(content.substring(doubleNewline + 2));
final event = _parseBlock(block);
if (event != null) yield event;
}
}
// Process remaining buffer
final remaining = buffer.toString().trim();
if (remaining.isNotEmpty) {
final event = _parseBlock(remaining);
if (event != null) yield event;
}
}
StreamEvent? _parseBlock(String block) {
String? eventType;
final dataLines = <String>[];
for (final line in block.split('\n')) {
if (line.startsWith('event: ')) {
eventType = line.substring(7).trim();
} else if (line.startsWith('data: ')) {
dataLines.add(line.substring(6));
} else if (line.startsWith('data:')) {
dataLines.add(line.substring(5));
}
}
if (eventType == null && dataLines.isEmpty) return null;
final rawData = dataLines.join('\n');
final type = _parseEventType(eventType ?? '');
// Parse JSON data
dynamic data;
try {
data = json.decode(rawData);
} catch (_) {
data = rawData;
}
// Extract token text for token events
if (type == StreamEventType.token && data is Map) {
return StreamEvent(type: type, data: data['token'] as String? ?? '');
}
return StreamEvent(type: type, data: data);
}
StreamEventType _parseEventType(String type) {
switch (type) {
case 'conversation_id':
return StreamEventType.conversationId;
case 'status':
return StreamEventType.status;
case 'token':
return StreamEventType.token;
case 'images':
return StreamEventType.images;
case 'audio':
return StreamEventType.audio;
case 'files':
return StreamEventType.files;
case 'error':
return StreamEventType.error;
case 'cancelled':
return StreamEventType.cancelled;
case 'done':
return StreamEventType.done;
default:
return StreamEventType.token;
}
}

18
pubspec.yaml Normal file
View File

@ -0,0 +1,18 @@
name: ai_assistant_client
description: Flutter client for AI Assistant API with SSE streaming, Supabase Auth, and multi-app support.
version: 1.0.0
environment:
sdk: ">=3.0.0 <4.0.0"
flutter: ">=3.10.0"
dependencies:
flutter:
sdk: flutter
http: ^1.2.0
supabase_flutter: ^2.8.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0