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,213 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../http_client.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../http_util.dart';
|
||||
import '../models.dart';
|
||||
import '../sse.dart';
|
||||
import 'webview_base_provider.dart';
|
||||
|
||||
/// Blackbox web chat. Tries REST after scraping a `validated` token, then WebView.
|
||||
class Blackbox extends WebViewBaseProvider {
|
||||
@override
|
||||
String get name => 'Blackbox';
|
||||
|
||||
@override
|
||||
bool get working => true;
|
||||
|
||||
@override
|
||||
List<String> get models => ['blackboxai', 'gpt-4o'];
|
||||
|
||||
@override
|
||||
String get defaultModel => 'blackboxai';
|
||||
|
||||
@override
|
||||
String get baseUrl => 'https://www.blackbox.ai/';
|
||||
|
||||
@override
|
||||
String get apiEndpoint => 'https://www.blackbox.ai/api/chat';
|
||||
|
||||
static const _uuid = Uuid();
|
||||
|
||||
@override
|
||||
Stream<String> createAsyncGenerator(
|
||||
String model,
|
||||
List<Message> messages, {
|
||||
bool stream = false,
|
||||
}) async* {
|
||||
try {
|
||||
await for (final chunk in _tryRest(messages, stream: stream)) {
|
||||
yield chunk;
|
||||
}
|
||||
return;
|
||||
} catch (_) {}
|
||||
await for (final chunk in super.createAsyncGenerator(model, messages, stream: stream)) {
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
|
||||
Stream<String> _tryRest(List<Message> messages, {required bool stream}) async* {
|
||||
final validated = await _scrapeValidated();
|
||||
final payload = {
|
||||
'messages': [
|
||||
for (final m in messages)
|
||||
{
|
||||
'id': _uuid.v4(),
|
||||
'content': m.content,
|
||||
'role': m.role.name,
|
||||
}
|
||||
],
|
||||
'id': _uuid.v4(),
|
||||
'previewToken': null,
|
||||
'userId': null,
|
||||
'codeModelMode': true,
|
||||
'agentMode': {},
|
||||
'trendingAgentMode': {},
|
||||
'isMicMode': false,
|
||||
'maxTokens': 1024,
|
||||
'isChromeExt': false,
|
||||
'clickedAnswer2': false,
|
||||
'clickedAnswer3': false,
|
||||
'clickedForceWebSearch': false,
|
||||
'visitFromDelta': false,
|
||||
'mobileClient': false,
|
||||
'userSelectedModel': null,
|
||||
if (validated != null) 'validated': validated,
|
||||
'imageGenerationMode': false,
|
||||
'webSearchMode': false,
|
||||
};
|
||||
|
||||
final urls = [
|
||||
'https://www.blackbox.ai/api/chat',
|
||||
'https://api.blackbox.ai/api/chat',
|
||||
];
|
||||
Object? lastError;
|
||||
for (final url in urls) {
|
||||
try {
|
||||
final req = http.Request('POST', Uri.parse(url));
|
||||
req.headers.addAll(browserHeaders(
|
||||
origin: 'https://www.blackbox.ai',
|
||||
referer: 'https://www.blackbox.ai/',
|
||||
contentType: 'application/json',
|
||||
));
|
||||
req.body = jsonEncode(payload);
|
||||
final res = await g4fHttp.send(req).timeout(const Duration(seconds: 20));
|
||||
if (res.statusCode != 200) {
|
||||
lastError = 'HTTP ${res.statusCode}';
|
||||
continue;
|
||||
}
|
||||
final collected = StringBuffer();
|
||||
await for (final chunk in res.stream.transform(utf8.decoder)) {
|
||||
if (chunk.isEmpty) continue;
|
||||
// Blackbox sometimes streams raw text, sometimes SSE.
|
||||
if (chunk.trimLeft().startsWith('data:')) {
|
||||
for (final piece in extractAllSseContent(chunk)) {
|
||||
collected.write(piece);
|
||||
if (stream) yield piece;
|
||||
}
|
||||
} else {
|
||||
collected.write(chunk);
|
||||
if (stream) yield chunk;
|
||||
}
|
||||
}
|
||||
final text = collected.toString().trim();
|
||||
if (!looksLikeModelOutput(text)) {
|
||||
lastError = 'unusable body';
|
||||
continue;
|
||||
}
|
||||
if (!stream) yield text;
|
||||
return;
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
}
|
||||
}
|
||||
throw Exception('Blackbox REST failed: $lastError');
|
||||
}
|
||||
|
||||
Future<String?> _scrapeValidated() async {
|
||||
try {
|
||||
final page = await g4fHttp.get(
|
||||
Uri.parse('https://www.blackbox.ai/'),
|
||||
headers: browserHeaders(referer: 'https://www.blackbox.ai/'),
|
||||
).timeout(const Duration(seconds: 12));
|
||||
if (page.statusCode != 200) return null;
|
||||
final chunks = RegExp(r'/_next/static/[^"\x27]+\.js').allMatches(page.body);
|
||||
final uuidRe = RegExp(
|
||||
r'[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}',
|
||||
caseSensitive: false,
|
||||
);
|
||||
for (final m in chunks.take(20)) {
|
||||
final src = m.group(0)!;
|
||||
final js = await g4fHttp.get(
|
||||
Uri.parse('https://www.blackbox.ai$src'),
|
||||
headers: browserHeaders(),
|
||||
).timeout(const Duration(seconds: 8));
|
||||
if (js.statusCode != 200) continue;
|
||||
if (!js.body.contains('validated') && !js.body.contains('api/chat')) {
|
||||
continue;
|
||||
}
|
||||
final match = uuidRe.firstMatch(js.body);
|
||||
if (match != null) return match.group(0);
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
String buildJsFetchCode(String model, List<Message> messages, bool stream) {
|
||||
final messagesJson = jsonEncode([
|
||||
for (final m in messages)
|
||||
{'id': _uuid.v4(), 'content': m.content, 'role': m.role.name}
|
||||
]);
|
||||
return '''
|
||||
(async function() {
|
||||
const payload = {
|
||||
messages: $messagesJson,
|
||||
id: crypto.randomUUID(),
|
||||
previewToken: null,
|
||||
userId: null,
|
||||
codeModelMode: true,
|
||||
agentMode: {},
|
||||
trendingAgentMode: {},
|
||||
isMicMode: false,
|
||||
maxTokens: 1024
|
||||
};
|
||||
const urls = ['/api/chat', 'https://www.blackbox.ai/api/chat'];
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (resp.status === 200) {
|
||||
return await resp.text();
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
return JSON.stringify({error: 'Blackbox chat endpoint not available'});
|
||||
})();
|
||||
''';
|
||||
}
|
||||
|
||||
@override
|
||||
String parseResponse(String rawResponse) {
|
||||
if (looksLikeModelOutput(rawResponse) && !rawResponse.trim().startsWith('{')) {
|
||||
return rawResponse.trim();
|
||||
}
|
||||
try {
|
||||
final json = jsonDecode(rawResponse);
|
||||
if (json is Map && json['error'] != null) {
|
||||
throw Exception(json['error']);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.toString().contains('Blackbox') || e.toString().contains('error')) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
final sse = extractAllSseContent(rawResponse).join();
|
||||
if (looksLikeModelOutput(sse)) return sse;
|
||||
if (looksLikeModelOutput(rawResponse)) return rawResponse.trim();
|
||||
throw Exception('Failed to parse Blackbox: ${rawResponse.substring(0, rawResponse.length.clamp(0, 180))}');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user