/// 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)'; }