- 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
389 lines
12 KiB
Dart
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)';
|
|
}
|