feat: embed a Flutter G4F client with Cronet, cookie jar, and origin WebView

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.
This commit is contained in:
Alma Alexyz
2026-09-01 22:05:38 +05:00
parent f3e7412cf9
commit fbf04d90db
49 changed files with 4856 additions and 92 deletions
+150
View File
@@ -0,0 +1,150 @@
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;
}
}