import 'dart:convert'; /// Reassembles SSE / text lines that TCP may split across chunks. Stream splitSseLines(Stream input) async* { final buf = StringBuffer(); await for (final chunk in input) { buf.write(chunk); var text = buf.toString(); final parts = text.split('\n'); buf ..clear() ..write(parts.removeLast()); for (final part in parts) { yield part.endsWith('\r') ? part.substring(0, part.length - 1) : part; } } if (buf.isNotEmpty) { final rest = buf.toString(); yield rest.endsWith('\r') ? rest.substring(0, rest.length - 1) : rest; } } /// Pulls visible assistant text out of one SSE `data:` payload. String? extractSseContent(String data) { final trimmed = data.trim(); if (trimmed.isEmpty || trimmed == '[DONE]' || trimmed == 'null') return null; if (trimmed.startsWith('[DONE]')) return null; dynamic decoded; try { decoded = jsonDecode(trimmed); } catch (_) { // Plain-text SSE (Yqcloud / TeachAnything style). if (looksLikeJson(trimmed)) return null; return trimmed; } if (decoded is String) { return decoded.isEmpty ? null : decoded; } if (decoded is! Map) return null; final map = Map.from(decoded); if (map['action'] == 'error' || map['error'] != null) { final err = map['error'] ?? map['type'] ?? map['message']; throw Exception('SSE error: $err'); } // Duck.ai final duck = map['message']; if (duck is String && duck.isNotEmpty && map['role'] != 'user') { return duck; } // OpenAI chat.completions streaming final choices = map['choices']; if (choices is List && choices.isNotEmpty) { final choice = choices.first; if (choice is Map) { final delta = choice['delta']; if (delta is Map) { final content = delta['content']; if (content is String && content.isNotEmpty) return content; } final message = choice['message']; if (message is Map) { final content = message['content']; if (content is String && content.isNotEmpty) return content; } final text = choice['text']; if (text is String && text.isNotEmpty) return text; } } // GLM / z.ai if (map['type'] == 'chat:completion') { final inner = map['data']; if (inner is Map) { final err = inner['error']; if (err != null) { throw Exception('SSE error: $err'); } final phase = inner['phase']; if (phase == 'thinking') return null; final delta = inner['delta_content']; if (delta is String && delta.isNotEmpty && phase != 'thinking') { return delta; } final edit = inner['edit_content']; if (edit is String && edit.isNotEmpty) { return edit.split('\n\n').last; } } } final content = map['content']; if (content is String && content.isNotEmpty) return content; return null; } String? extractOpenAiMessage(String body) { final trimmed = body.trim(); if (trimmed.isEmpty) return null; try { final decoded = jsonDecode(trimmed); if (decoded is Map) { if (decoded['error'] != null) { throw Exception('API error: ${decoded['error']}'); } final choices = decoded['choices']; if (choices is List && choices.isNotEmpty) { final choice = choices.first; if (choice is Map) { final message = choice['message']; if (message is Map && message['content'] is String) { return message['content'] as String; } if (choice['text'] is String) return choice['text'] as String; } } if (decoded['output_text'] is String) { return decoded['output_text'] as String; } } } catch (e) { if (e.toString().startsWith('Exception: API error')) rethrow; } return null; } bool looksLikeJson(String text) { final t = text.trimLeft(); return t.startsWith('{') || t.startsWith('['); } /// Parses a full SSE document (e.g. WebView returning response.text()). Iterable extractAllSseContent(String raw) sync* { for (final line in raw.split(RegExp(r'\r?\n'))) { final trimmed = line.trim(); if (trimmed.isEmpty) continue; if (trimmed.startsWith('event:')) continue; String data = trimmed; if (trimmed.startsWith('data:')) { data = trimmed.substring(5).trim(); } final content = extractSseContent(data); if (content != null && content.isNotEmpty) yield content; } }