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:
commit
3fe1dc7bbf
642
example/usage_example.dart
Normal file
642
example/usage_example.dart
Normal 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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
6
lib/ai_assistant_client.dart
Normal file
6
lib/ai_assistant_client.dart
Normal 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
388
lib/src/client.dart
Normal 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
249
lib/src/models.dart
Normal 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',
|
||||
);
|
||||
}
|
||||
95
lib/src/stream_parser.dart
Normal file
95
lib/src/stream_parser.dart
Normal 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
18
pubspec.yaml
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user