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,131 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../http_client.dart';
|
||||
import '../http_util.dart';
|
||||
import '../models.dart';
|
||||
import 'base_provider.dart';
|
||||
import 'webview_base_provider.dart';
|
||||
|
||||
class PhindAi extends BaseProvider {
|
||||
@override
|
||||
String get name => 'PhindAi';
|
||||
|
||||
@override
|
||||
String get label => 'PhindAi';
|
||||
|
||||
@override
|
||||
String get url => 'https://phindai.org';
|
||||
|
||||
@override
|
||||
bool get working => true;
|
||||
|
||||
@override
|
||||
bool get supportsStream => false;
|
||||
|
||||
@override
|
||||
Duration get requestTimeout => const Duration(seconds: 90);
|
||||
|
||||
@override
|
||||
String get defaultModel => 'deepseek-v3';
|
||||
|
||||
@override
|
||||
List<String> get models => ['deepseek-v3', 'deepseek'];
|
||||
|
||||
@override
|
||||
Stream<String> createAsyncGenerator(
|
||||
String model,
|
||||
List<Message> messages, {
|
||||
bool stream = false,
|
||||
}) async* {
|
||||
try {
|
||||
yield await _rest(messages);
|
||||
return;
|
||||
} catch (e) {
|
||||
debugPrint('Phind REST failed, trying origin WebView: $e');
|
||||
}
|
||||
yield await _viaWebView(messages);
|
||||
}
|
||||
|
||||
Future<String> _rest(List<Message> messages) async {
|
||||
final prompt = messages.last.content;
|
||||
final headers = {
|
||||
'Accept':
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
||||
'User-Agent': kDesktopUserAgent,
|
||||
};
|
||||
|
||||
final mainPageResponse = await g4fHttp.get(Uri.parse(url), headers: headers);
|
||||
if (mainPageResponse.statusCode != 200) {
|
||||
throw Exception('Failed to fetch PhindAi main page: ${mainPageResponse.statusCode}');
|
||||
}
|
||||
|
||||
final html = mainPageResponse.body;
|
||||
final match = RegExp(r'"nonce":"([a-f0-9]+)"').firstMatch(html) ??
|
||||
RegExp(r"'nonce':'([a-f0-9]+)'").firstMatch(html);
|
||||
if (match == null) {
|
||||
throw Exception('Failed to extract nonce from PhindAi response');
|
||||
}
|
||||
final nonce = match.group(1)!;
|
||||
|
||||
final response = await g4fHttp.post(
|
||||
Uri.parse('$url/wp-admin/admin-ajax.php'),
|
||||
headers: headers,
|
||||
body: {
|
||||
'action': 'phind_ai_send',
|
||||
'nonce': nonce,
|
||||
'message': prompt,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('PhindAi API returned status: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final data = jsonDecode(response.body);
|
||||
if (data is! Map || data['success'] != true) {
|
||||
throw Exception('PhindAi API returned success=False: $data');
|
||||
}
|
||||
final responseText = (data['data'] is Map ? data['data']['response'] : null) ?? '';
|
||||
final text = responseText.toString().trim();
|
||||
if (!looksLikeModelOutput(text)) {
|
||||
throw Exception('Phind REST unusable');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
Future<String> _viaWebView(List<Message> messages) async {
|
||||
final promptJson = jsonEncode(messages.last.content);
|
||||
final raw = await runOriginWebView(
|
||||
originUrl: url,
|
||||
timeout: requestTimeout,
|
||||
functionBody: '''
|
||||
const prompt = $promptJson;
|
||||
const html = document.documentElement.innerHTML;
|
||||
const m = html.match(/"nonce":"([a-f0-9]+)"/) || html.match(/'nonce':'([a-f0-9]+)'/);
|
||||
if (!m) throw new Error('no nonce in page');
|
||||
const body = new URLSearchParams();
|
||||
body.set('action', 'phind_ai_send');
|
||||
body.set('nonce', m[1]);
|
||||
body.set('message', prompt);
|
||||
const r = await fetch('/wp-admin/admin-ajax.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
credentials: 'same-origin',
|
||||
body
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!data || data.success !== true) {
|
||||
throw new Error('phind success=false ' + JSON.stringify(data).slice(0, 180));
|
||||
}
|
||||
return String((data.data && data.data.response) || '');
|
||||
''',
|
||||
);
|
||||
final text = raw.trim();
|
||||
if (!looksLikeModelOutput(text)) {
|
||||
throw Exception('Phind WebView unusable: ${text.substring(0, text.length.clamp(0, 180))}');
|
||||
}
|
||||
return text;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user