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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user