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,183 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../http_client.dart';
|
||||
import '../http_util.dart';
|
||||
import '../models.dart';
|
||||
import '../sse.dart';
|
||||
import 'webview_base_provider.dart';
|
||||
|
||||
/// Duck.ai / DuckDuckGo chat. REST first; WebView executes the JS anti-bot hash.
|
||||
class DuckDuckGo extends WebViewBaseProvider {
|
||||
@override
|
||||
String get name => 'DuckDuckGo';
|
||||
|
||||
@override
|
||||
bool get working => true;
|
||||
|
||||
@override
|
||||
List<String> get models => [
|
||||
'gpt-5.6-luna',
|
||||
'gpt-5.4-mini',
|
||||
'claude-haiku-4-5',
|
||||
'mistralai/Mistral-Small-24B-Instruct-2501',
|
||||
'meta-llama/Llama-4-Scout',
|
||||
];
|
||||
|
||||
@override
|
||||
String get defaultModel => 'gpt-5.6-luna';
|
||||
|
||||
@override
|
||||
String get baseUrl => 'https://duck.ai/';
|
||||
|
||||
@override
|
||||
String get apiEndpoint => 'https://duckduckgo.com/duckchat/v1/chat';
|
||||
|
||||
static const _statusUrl = 'https://duckduckgo.com/duckchat/v1/status';
|
||||
static const _chatUrl = 'https://duckduckgo.com/duckchat/v1/chat';
|
||||
|
||||
@override
|
||||
Stream<String> createAsyncGenerator(
|
||||
String model,
|
||||
List<Message> messages, {
|
||||
bool stream = false,
|
||||
}) async* {
|
||||
try {
|
||||
await for (final chunk in _tryRest(model, messages, stream: stream)) {
|
||||
yield chunk;
|
||||
}
|
||||
return;
|
||||
} catch (_) {
|
||||
// 418 challenge — fall through to a real browser context.
|
||||
}
|
||||
await for (final chunk in super.createAsyncGenerator(model, messages, stream: stream)) {
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
|
||||
Stream<String> _tryRest(
|
||||
String model,
|
||||
List<Message> messages, {
|
||||
required bool stream,
|
||||
}) async* {
|
||||
final statusReq = http.Request('GET', Uri.parse(_statusUrl));
|
||||
statusReq.headers.addAll(browserHeaders(
|
||||
origin: 'https://duckduckgo.com',
|
||||
referer: 'https://duckduckgo.com/',
|
||||
accept: 'application/json',
|
||||
extra: {'x-vqd-accept': '1'},
|
||||
));
|
||||
final statusRes = await g4fHttp.send(statusReq).timeout(const Duration(seconds: 12));
|
||||
final vqd4 = statusRes.headers['x-vqd-4'];
|
||||
final hash1 = statusRes.headers['x-vqd-hash-1'];
|
||||
await statusRes.stream.drain();
|
||||
if (vqd4 == null || vqd4.isEmpty) {
|
||||
throw Exception('DuckDuckGo: no x-vqd-4 (challenge required)');
|
||||
}
|
||||
|
||||
final chatReq = http.Request('POST', Uri.parse(_chatUrl));
|
||||
chatReq.headers.addAll(browserHeaders(
|
||||
origin: 'https://duckduckgo.com',
|
||||
referer: 'https://duckduckgo.com/',
|
||||
contentType: 'application/json',
|
||||
accept: 'text/event-stream',
|
||||
extra: {
|
||||
'x-vqd-4': vqd4,
|
||||
if (hash1 != null && hash1.isNotEmpty) 'x-vqd-hash-1': hash1,
|
||||
},
|
||||
));
|
||||
chatReq.body = jsonEncode({
|
||||
'model': models.contains(model) ? model : defaultModel,
|
||||
'messages': toOpenAiMessages(messages),
|
||||
'canUseTools': false,
|
||||
'canUseApproxLocation': false,
|
||||
});
|
||||
|
||||
final chatRes = await g4fHttp.send(chatReq).timeout(const Duration(seconds: 25));
|
||||
if (chatRes.statusCode != 200) {
|
||||
throw Exception('DuckDuckGo chat ${chatRes.statusCode}');
|
||||
}
|
||||
|
||||
final collected = StringBuffer();
|
||||
await for (final line in splitSseLines(chatRes.stream.transform(utf8.decoder))) {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.isEmpty || !trimmed.startsWith('data:')) continue;
|
||||
final content = extractSseContent(trimmed.substring(5).trim());
|
||||
if (content != null && content.isNotEmpty) {
|
||||
collected.write(content);
|
||||
if (stream) yield content;
|
||||
}
|
||||
}
|
||||
final text = collected.toString();
|
||||
if (!looksLikeModelOutput(text)) {
|
||||
throw Exception('DuckDuckGo empty/challenge response');
|
||||
}
|
||||
if (!stream) yield text;
|
||||
}
|
||||
|
||||
@override
|
||||
String buildJsFetchCode(String model, List<Message> messages, bool stream) {
|
||||
final messagesJson = jsonEncode(toOpenAiMessages(messages));
|
||||
final modelJson = jsonEncode(models.contains(model) ? model : defaultModel);
|
||||
return '''
|
||||
(async function() {
|
||||
const status = await fetch('https://duckduckgo.com/duckchat/v1/status', {
|
||||
headers: { 'x-vqd-accept': '1', 'Accept': 'application/json' }
|
||||
});
|
||||
let vqd4 = status.headers.get('x-vqd-4') || '';
|
||||
let hash1 = status.headers.get('x-vqd-hash-1') || '';
|
||||
if (hash1) {
|
||||
try {
|
||||
let src = hash1;
|
||||
try { src = atob(hash1); } catch (e) {}
|
||||
if (src.includes('function') || src.trim().startsWith('(')) {
|
||||
const result = await (0, eval)(src);
|
||||
const value = (typeof result === 'function') ? await result() : result;
|
||||
hash1 = btoa(JSON.stringify(value));
|
||||
}
|
||||
} catch (e) {
|
||||
return JSON.stringify({error: 'hash failed: ' + e});
|
||||
}
|
||||
}
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream'
|
||||
};
|
||||
if (vqd4) headers['x-vqd-4'] = vqd4;
|
||||
if (hash1) headers['x-vqd-hash-1'] = hash1;
|
||||
const resp = await fetch('https://duckduckgo.com/duckchat/v1/chat', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: $modelJson,
|
||||
messages: $messagesJson,
|
||||
canUseTools: false,
|
||||
canUseApproxLocation: false
|
||||
})
|
||||
});
|
||||
const body = await resp.text();
|
||||
return JSON.stringify({status: resp.status, body: 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 text = extractAllSseContent(body).join();
|
||||
if (!looksLikeModelOutput(text)) {
|
||||
throw Exception('DuckDuckGo WebView unusable: ${body.substring(0, body.length.clamp(0, 180))}');
|
||||
}
|
||||
return text;
|
||||
} catch (e) {
|
||||
if (e.toString().contains('DuckDuckGo')) rethrow;
|
||||
final text = extractAllSseContent(rawResponse).join();
|
||||
if (looksLikeModelOutput(text)) return text;
|
||||
throw Exception('Failed to parse DuckDuckGo: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user