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,109 @@
|
||||
import 'dart:convert';
|
||||
import '../http_util.dart';
|
||||
import '../models.dart';
|
||||
import '../sse.dart';
|
||||
import 'webview_base_provider.dart';
|
||||
|
||||
/// Qwen chat (chat.qwen.ai). WAF is solved inside the hidden WebView.
|
||||
class QwenChat extends WebViewBaseProvider {
|
||||
@override
|
||||
String get name => 'Qwen';
|
||||
|
||||
@override
|
||||
bool get working => true;
|
||||
|
||||
@override
|
||||
List<String> get models => ['qwen3.5-flash', 'qwen3.7-plus', 'qwen3-max-2026-01-23'];
|
||||
|
||||
@override
|
||||
String get defaultModel => 'qwen3.5-flash';
|
||||
|
||||
@override
|
||||
String get baseUrl => 'https://chat.qwen.ai/';
|
||||
|
||||
@override
|
||||
String get apiEndpoint => 'https://chat.qwen.ai/api/v2/chat/completions';
|
||||
|
||||
@override
|
||||
String buildJsFetchCode(String model, List<Message> messages, bool stream) {
|
||||
final modelJson = jsonEncode(models.contains(model) ? model : defaultModel);
|
||||
final promptJson = jsonEncode(getLastUserMessage(messages));
|
||||
return '''
|
||||
(async function() {
|
||||
for (let i = 0; i < 16; i++) {
|
||||
if (!document.documentElement.innerHTML.includes('aliyun_waf_aa')) break;
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
}
|
||||
const headers = {'Content-Type': 'application/json', 'source': 'web'};
|
||||
const created = await fetch('/api/v2/chats/new', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
title: 'New Chat',
|
||||
models: [$modelJson],
|
||||
chat_mode: 'normal',
|
||||
chat_type: 't2t',
|
||||
timestamp: Date.now()
|
||||
})
|
||||
});
|
||||
const createdJson = await created.json();
|
||||
const chatId = createdJson.data && createdJson.data.id;
|
||||
if (!chatId) return JSON.stringify({error: createdJson});
|
||||
const resp = await fetch('/api/v2/chat/completions?chat_id=' + chatId, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
stream: false,
|
||||
version: '2.1',
|
||||
incremental_output: false,
|
||||
chat_id: chatId,
|
||||
chat_mode: 'normal',
|
||||
model: $modelJson,
|
||||
messages: [{
|
||||
fid: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: $promptJson,
|
||||
user_action: 'chat',
|
||||
files: [],
|
||||
timestamp: Date.now(),
|
||||
models: [$modelJson],
|
||||
chat_type: 't2t',
|
||||
extra: {meta: {subChatType: 't2t'}},
|
||||
sub_chat_type: 't2t'
|
||||
}],
|
||||
timestamp: Date.now()
|
||||
})
|
||||
});
|
||||
return await resp.text();
|
||||
})();
|
||||
''';
|
||||
}
|
||||
|
||||
@override
|
||||
String parseResponse(String rawResponse) {
|
||||
final sse = extractAllSseContent(rawResponse).join();
|
||||
if (looksLikeModelOutput(sse)) return sse;
|
||||
try {
|
||||
final json = jsonDecode(rawResponse);
|
||||
if (json is Map) {
|
||||
if (json['error'] != null) throw Exception(json['error']);
|
||||
final choices = json['choices'];
|
||||
if (choices is List && choices.isNotEmpty) {
|
||||
final c = choices.first;
|
||||
if (c is Map) {
|
||||
final msg = c['message'];
|
||||
if (msg is Map && msg['content'] is String) return msg['content'];
|
||||
final delta = c['delta'];
|
||||
if (delta is Map && delta['content'] is String) return delta['content'];
|
||||
}
|
||||
}
|
||||
final data = json['data'];
|
||||
if (data is Map && data['content'] is String) return data['content'];
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.toString().startsWith('Exception:')) rethrow;
|
||||
}
|
||||
if (looksLikeModelOutput(rawResponse)) return rawResponse.trim();
|
||||
throw Exception('Failed to parse Qwen: ${rawResponse.substring(0, rawResponse.length.clamp(0, 180))}');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user