fbf04d90db
Port the Oracle off the Python g4f container onto on-device providers: Chromium HTTP via Cronet, a shared cookie session, IterList retry, hosted OpenAI-compat aggregators, and same-origin WebView fallbacks instead of CORS fetch.
151 lines
4.4 KiB
Dart
151 lines
4.4 KiB
Dart
import 'dart:convert';
|
|
|
|
/// Reassembles SSE / text lines that TCP may split across chunks.
|
|
Stream<String> splitSseLines(Stream<String> 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<String, dynamic>.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</details>\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<String> 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;
|
|
}
|
|
}
|