root 3fe1dc7bbf 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
2026-03-11 07:08:48 +00:00

389 lines
12 KiB
Dart

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