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