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 eef101bef3 Fix bugs found in full code audit
- Edit screens (note, task): cache _initFuture in initState instead of
  calling _loadExisting() from FutureBuilder on every rebuild. Prevents
  race condition where multiple concurrent loads could overwrite edits.
  Also removes unnecessary _loaded flag and simplifies the pattern.

- Note editor: remove onChanged: (_) => setState((){}) on the body
  TextField — triggered a full rebuild on every keystroke for no reason.

- SSE streaming: use utf8.decode(chunk, allowMalformed: true) so a
  malformed byte sequence from the server skips rather than throwing an
  unhandled FormatException that escaped all catch blocks.

- Chat provider: guard msgs.isEmpty before accessing msgs.last during
  SSE chunk processing to prevent a potential index error on empty state.

- Offline queue drain: check mounted before and after each async gap in
  _drainQueue so ref is never accessed on a disposed widget.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 11:56:26 -05:00

131 lines
4.1 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);
}
}
// Returns the conversation metadata AND its messages in a single request.
Future<(Conversation, 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 conversation = Conversation.fromJson(conv);
final list = conv['messages'] as List<dynamic>? ?? [];
final messages = list
.map((e) => Message.fromJson(e as Map<String, dynamic>))
.toList();
return (conversation, messages);
} 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,
receiveTimeout: Duration.zero, // SSE streams run indefinitely
sendTimeout: Duration.zero,
headers: {
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache',
},
),
);
final stream = (response.data as ResponseBody).stream;
final buf = StringBuffer();
String currentEvent = '';
await for (final chunk in stream) {
buf.write(utf8.decode(chunk, allowMalformed: true));
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 data = line.substring(6).trim();
if (data == '[DONE]') return;
if (currentEvent == 'done' || currentEvent == 'error') return;
// Parse as JSON if possible, otherwise yield raw text.
if (currentEvent == 'chunk' || currentEvent.isEmpty) {
try {
final obj = json.decode(data) as Map<String, dynamic>;
final text = obj['text'] as String? ?? '';
if (text.isNotEmpty) yield text;
} catch (_) {
if (data.isNotEmpty) yield data;
}
}
} else if (line.isEmpty) {
currentEvent = ''; // blank line = SSE event separator
}
}
}
} on DioException catch (e) {
throw dioToApp(e);
}
}
}