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> 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 text chunks. 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; // Parse as JSON if possible, otherwise yield raw text. if (currentEvent == 'chunk' || currentEvent.isEmpty) { try { final obj = json.decode(data) as Map; 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); } } /// 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); } } }