import 'dart:convert'; import 'package:dio/dio.dart'; import '../models/conversation.dart'; import '../models/message.dart'; import 'api_client.dart'; sealed class ChatStreamEvent {} class ChatTextChunk extends ChatStreamEvent { final String text; ChatTextChunk(this.text); } class ChatStatusUpdate extends ChatStreamEvent { final String status; // empty string = clear status ChatStatusUpdate(this.status); } /// A single tool call fired during generation. Mirrors the `tool_call` SSE /// event emitted by `generation_task.py` and the `tool_calls` array persisted /// on the assistant Message row — same shape either way so the UI can render /// live chips during streaming and re-render them from storage after reload. class ChatToolCall extends ChatStreamEvent { final Map toolCall; ChatToolCall(this.toolCall); } class ChatApi { final Dio _dio; const ChatApi(this._dio); Future> getConversations() async { try { final response = await _dio.get('/api/chat/conversations'); final data = response.data as Map; final list = data['conversations'] as List; return list .map((e) => Conversation.fromJson(e as Map)) .toList(); } on DioException catch (e) { throw dioToApp(e); } } Future createConversation(String title) async { try { final response = await _dio.post('/api/chat/conversations', data: { 'title': title, }); return Conversation.fromJson(response.data as Map); } on DioException catch (e) { throw dioToApp(e); } } Future 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)> getMessages(int conversationId) async { try { final response = await _dio.get('/api/chat/conversations/$conversationId'); final conv = response.data as Map; final conversation = Conversation.fromJson(conv); final list = conv['messages'] as List? ?? []; final messages = list .map((e) => Message.fromJson(e as Map)) .toList(); return (conversation, messages); } on DioException catch (e) { throw dioToApp(e); } } // Step 1: POST the user message — server starts background generation. Future 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 typed events (text chunks + status updates). Stream 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; if (currentEvent == 'chunk' || currentEvent.isEmpty) { try { final obj = json.decode(data) as Map; final text = obj['text'] as String? ?? ''; if (text.isNotEmpty) yield ChatTextChunk(text); } catch (_) { if (data.isNotEmpty) yield ChatTextChunk(data); } } else if (currentEvent == 'status') { try { final obj = json.decode(data) as Map; final status = obj['status'] as String? ?? ''; yield ChatStatusUpdate(status); } catch (_) { // Ignore malformed status events } } else if (currentEvent == 'tool_call') { try { final obj = json.decode(data) as Map; final tc = obj['tool_call']; if (tc is Map) yield ChatToolCall(tc); } catch (_) { // Ignore malformed tool_call events } } } else if (line.isEmpty) { currentEvent = ''; // blank line = SSE event separator } } } } on DioException catch (e) { throw dioToApp(e); } } /// POST /api/chat/from-article/{itemId} /// Creates or retrieves a chat conversation seeded with the article. /// Returns the conversation_id. Future openArticleInChat(int itemId) async { try { final response = await _dio.post('/api/chat/from-article/$itemId', data: {}); final data = response.data as Map; return data['conversation_id'] as int; } on DioException catch (e) { throw dioToApp(e); } } }