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
+114
View File
@@ -0,0 +1,114 @@
import 'dart:convert';
import '../http_util.dart';
import '../models.dart';
import '../sse.dart';
import 'webview_base_provider.dart';
/// HuggingChat (huggingface.co/chat). Guest REST is blocked; WebView carries cookies.
class HuggingChat extends WebViewBaseProvider {
@override
String get name => 'HuggingChat';
@override
bool get working => true;
@override
List<String> get models => [
'omni',
'meta-llama/Llama-3.1-8B-Instruct',
'Qwen/Qwen3.8-27B',
'zai-org/GLM-5.3-Flash',
];
@override
String get defaultModel => 'omni';
@override
String get baseUrl => 'https://huggingface.co/chat/';
@override
String get apiEndpoint => 'https://huggingface.co/chat/api/v2/conversations';
@override
String buildJsFetchCode(String model, List<Message> messages, bool stream) {
final modelJson = jsonEncode(models.contains(model) ? model : defaultModel);
final promptJson = jsonEncode(formatPrompt(messages));
return '''
(async function() {
const headers = {'Content-Type': 'application/json', 'Accept': 'application/json'};
let convRes = await fetch('/chat/api/v2/conversations', {
method: 'POST',
headers,
body: JSON.stringify({ model: $modelJson })
});
if (!convRes.ok) {
convRes = await fetch('/chat/conversation', {
method: 'POST',
headers,
body: JSON.stringify({ model: $modelJson })
});
}
if (!convRes.ok) {
return JSON.stringify({error: 'HuggingChat login required: ' + convRes.status + ' ' + await convRes.text()});
}
const conv = await convRes.json();
const id = conv.json?.id || conv.id || conv.conversationId;
if (!id) return JSON.stringify({error: 'no conversation id', raw: conv});
const payload = {
id: crypto.randomUUID(),
inputs: $promptJson,
is_retry: false,
is_continue: false,
web_search: false,
tools: []
};
let msgRes = await fetch('/chat/api/v2/conversations/' + id, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'Accept': 'text/event-stream'},
body: JSON.stringify(payload)
});
if (!msgRes.ok) {
msgRes = await fetch('/chat/conversation/' + id, {
method: 'POST',
headers: {'Content-Type': 'application/json', 'Accept': 'text/event-stream'},
body: JSON.stringify(payload)
});
}
const body = await msgRes.text();
return JSON.stringify({status: msgRes.status, body});
})();
''';
}
@override
String parseResponse(String rawResponse) {
try {
final json = jsonDecode(rawResponse);
if (json is Map && json['error'] != null) {
throw Exception(json['error']);
}
final body = json is Map ? (json['body']?.toString() ?? rawResponse) : rawResponse;
final sse = extractAllSseContent(body).join();
if (looksLikeModelOutput(sse)) return sse;
// chat-ui tokens: {"type":"stream","token":"..."}
final tokens = <String>[];
for (final line in body.split('\n')) {
try {
final m = jsonDecode(line);
if (m is Map) {
final token = m['token'] ?? m['text'] ?? m['generated_text'];
if (token is String && token.isNotEmpty) tokens.add(token);
}
} catch (_) {}
}
final joined = tokens.join();
if (looksLikeModelOutput(joined)) return joined;
if (looksLikeModelOutput(body)) return body.trim();
throw Exception('HuggingChat unusable: ${body.substring(0, body.length.clamp(0, 180))}');
} catch (e) {
if (looksLikeModelOutput(rawResponse)) return rawResponse.trim();
throw Exception('Failed to parse HuggingChat: $e');
}
}
}