From 3fe1dc7bbf42e075654881d1c055fc8aac5014e6 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 11 Mar 2026 07:08:48 +0000 Subject: [PATCH] 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 --- example/usage_example.dart | 642 +++++++++++++++++++++++++++++++++++ lib/ai_assistant_client.dart | 6 + lib/src/client.dart | 388 +++++++++++++++++++++ lib/src/models.dart | 249 ++++++++++++++ lib/src/stream_parser.dart | 95 ++++++ pubspec.yaml | 18 + 6 files changed, 1398 insertions(+) create mode 100644 example/usage_example.dart create mode 100644 lib/ai_assistant_client.dart create mode 100644 lib/src/client.dart create mode 100644 lib/src/models.dart create mode 100644 lib/src/stream_parser.dart create mode 100644 pubspec.yaml diff --git a/example/usage_example.dart b/example/usage_example.dart new file mode 100644 index 0000000..261a6fb --- /dev/null +++ b/example/usage_example.dart @@ -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 createState() => _AuthScreenState(); +} + +class _AuthScreenState extends State { + final _email = TextEditingController(); + final _password = TextEditingController(); + final _name = TextEditingController(); + bool _isLogin = true; + bool _loading = false; + String? _error; + + Future _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 createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + 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 _loadProfile() async { + final profile = await client.getProfile(); + if (mounted) setState(() => _profile = profile); + } + + Future _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 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 onModelChanged; + final ValueChanged 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( + 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( + 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? 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'), + ); + } +} diff --git a/lib/ai_assistant_client.dart b/lib/ai_assistant_client.dart new file mode 100644 index 0000000..e8d2442 --- /dev/null +++ b/lib/ai_assistant_client.dart @@ -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'; diff --git a/lib/src/client.dart b/lib/src/client.dart new file mode 100644 index 0000000..2c01f79 --- /dev/null +++ b/lib/src/client.dart @@ -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 get onAuthStateChange => + Supabase.instance.client.auth.onAuthStateChange; + + /// Headers for API requests. + Map get _headers { + final h = { + '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 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 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 signInWithOAuth(OAuthProvider provider) async { + return await Supabase.instance.client.auth.signInWithOAuth(provider); + } + + /// Sign out. + Future signOut() async { + await Supabase.instance.client.auth.signOut(); + conversationId = null; + } + + /// Send password reset email. + Future resetPassword(String email) async { + await Supabase.instance.client.auth.resetPasswordForEmail(email); + } + + /// Get user profile from chat_users. + Future 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; + if (data.containsKey('error')) return null; + return UserProfile.fromJson(data); + } + + // ── Chat ──────────────────────────────────────────────────────────── + + /// Build the request body with all options. + Map _buildBody( + String message, { + String? imageBase64, + String? audioBase64, + bool stream = true, + bool voice = false, + bool search = false, + String? forceTool, + String? accessCode, + }) { + final body = { + '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 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 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; + final result = ChatResponse.fromJson(data); + conversationId = result.conversationId; + return result; + } + + // ── Cancel ────────────────────────────────────────────────────────── + + /// Cancel the current streaming request. + Future 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> 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; + return (data['conversations'] as List) + .map((c) => Conversation.fromJson(c as Map)) + .toList(); + } + + /// Load messages for a conversation. + Future> 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; + return (data['messages'] as List) + .map((m) => ChatMessage.fromJson(m as Map)) + .toList(); + } + + /// Delete a conversation. + Future 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> 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; + return (data['models'] as List) + .map((m) => ModelInfo.fromJson(m as Map)) + .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)'; +} diff --git a/lib/src/models.dart b/lib/src/models.dart new file mode 100644 index 0000000..78cfb7f --- /dev/null +++ b/lib/src/models.dart @@ -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 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 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 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? images; + final AssistantAudio? audio; + final List? files; + + const ChatResponse({ + required this.response, + required this.conversationId, + this.guardrail, + this.images, + this.audio, + this.files, + }); + + factory ChatResponse.fromJson(Map 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)) + .toList(), + audio: json['audio'] != null + ? AssistantAudio.fromJson(json['audio'] as Map) + : null, + files: (json['files'] as List?) + ?.map((f) => AssistantFile.fromJson(f as Map)) + .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 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)) + .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 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 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 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 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 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', + ); +} diff --git a/lib/src/stream_parser.dart b/lib/src/stream_parser.dart new file mode 100644 index 0000000..dec2da3 --- /dev/null +++ b/lib/src/stream_parser.dart @@ -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 parseSseStream(Stream> 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 = []; + + 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; + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..f7b9ffa --- /dev/null +++ b/pubspec.yaml @@ -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