This repository has been archived on 2026-06-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
FabledApp/lib/data/api/chat_api.dart
T
bvandeusen 4da36aa31d Initial commit: Fabled Android app
Flutter Android client for FabledAssistant with:
- Session-cookie auth via persistent cookie jar (Dio + cookie_jar)
- OAuth/SSO login via in-app WebView (flutter_inappwebview)
- Notes: list, detail (markdown render), create/edit
- Tasks: list with status tabs, create/edit with priority
- Chat: SSE streaming bubbles, conversation management
- Quick Capture FAB for rapid note/task creation
- Settings screen (change server URL, logout)
- Android home screen widget → opens chat
- Riverpod state management, go_router navigation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 21:28:53 -05:00

121 lines
3.7 KiB
Dart

import 'dart:convert';
import 'package:dio/dio.dart';
import '../models/conversation.dart';
import '../models/message.dart';
import 'api_client.dart';
class ChatApi {
final Dio _dio;
const ChatApi(this._dio);
Future<List<Conversation>> getConversations() async {
try {
final response = await _dio.get('/api/chat/conversations');
final data = response.data as Map<String, dynamic>;
final list = data['conversations'] as List<dynamic>;
return list
.map((e) => Conversation.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
Future<Conversation> createConversation(String title) async {
try {
final response = await _dio.post('/api/chat/conversations', data: {
'title': title,
});
return Conversation.fromJson(response.data as Map<String, dynamic>);
} on DioException catch (e) {
throw dioToApp(e);
}
}
Future<void> deleteConversation(int id) async {
try {
await _dio.delete('/api/chat/conversations/$id');
} on DioException catch (e) {
throw dioToApp(e);
}
}
// Messages are embedded in the conversation detail response.
Future<List<Message>> getMessages(int conversationId) async {
try {
final response =
await _dio.get('/api/chat/conversations/$conversationId');
final conv = response.data as Map<String, dynamic>;
final list = conv['messages'] as List<dynamic>? ?? [];
return list
.map((e) => Message.fromJson(e as Map<String, dynamic>))
.where((m) => m.status != 'generating') // skip in-flight placeholders
.toList();
} on DioException catch (e) {
throw dioToApp(e);
}
}
// Step 1: POST the user message — server starts background generation.
Future<void> sendMessage(int conversationId, String content) async {
try {
await _dio.post(
'/api/chat/conversations/$conversationId/messages',
data: {'content': content},
);
} on DioException catch (e) {
throw dioToApp(e);
}
}
// Step 2: GET the SSE stream and yield text chunks.
Stream<String> streamGeneration(int conversationId) async* {
try {
final response = await _dio.get(
'/api/chat/conversations/$conversationId/generation/stream',
options: Options(
responseType: ResponseType.stream,
headers: {'Accept': 'text/event-stream'},
),
);
final stream = (response.data as ResponseBody).stream;
final buf = StringBuffer();
String currentEvent = '';
await for (final chunk in stream) {
buf.write(utf8.decode(chunk));
final raw = buf.toString();
final lines = raw.split('\n');
// Keep the last (potentially incomplete) line in the buffer.
buf.clear();
buf.write(lines.last);
for (final line in lines.sublist(0, lines.length - 1)) {
if (line.startsWith('event: ')) {
currentEvent = line.substring(7).trim();
} else if (line.startsWith('data: ')) {
final jsonStr = line.substring(6).trim();
if (currentEvent == 'chunk') {
try {
final data = json.decode(jsonStr) as Map<String, dynamic>;
final text = data['text'] as String? ?? '';
if (text.isNotEmpty) yield text;
} catch (_) {}
} else if (currentEvent == 'done' || currentEvent == 'error') {
return;
}
} else if (line.isEmpty) {
currentEvent = ''; // blank line = SSE event separator
}
}
}
} on DioException catch (e) {
throw dioToApp(e);
}
}
}