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:
@@ -43,4 +43,8 @@ app.*.map.json
|
|||||||
/android/app/debug
|
/android/app/debug
|
||||||
/android/app/profile
|
/android/app/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
|
/android/build/
|
||||||
*.apk
|
*.apk
|
||||||
|
|
||||||
|
# Local bakeoff dumps
|
||||||
|
/generated_media/
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.words999.words999"
|
namespace = "com.example.words999.words999"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = 36
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
|
|||||||
@@ -19,6 +19,20 @@ subprojects {
|
|||||||
project.evaluationDependsOn(":app")
|
project.evaluationDependsOn(":app")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
gradle.projectsEvaluated {
|
||||||
|
rootProject.subprojects.forEach { proj ->
|
||||||
|
val android = proj.extensions.findByName("android") ?: return@forEach
|
||||||
|
try {
|
||||||
|
android.javaClass.getMethod("setCompileSdk", Int::class.javaPrimitiveType).invoke(android, 36)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
try {
|
||||||
|
android.javaClass.getMethod("setCompileSdkVersion", Int::class.javaPrimitiveType).invoke(android, 36)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tasks.register<Delete>("clean") {
|
tasks.register<Delete>("clean") {
|
||||||
delete(rootProject.layout.buildDirectory)
|
delete(rootProject.layout.buildDirectory)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import 'models.dart';
|
||||||
|
import 'providers/aggregators.dart';
|
||||||
|
import 'providers/aichatos.dart';
|
||||||
|
import 'providers/base_provider.dart';
|
||||||
|
import 'providers/blackbox.dart';
|
||||||
|
import 'providers/chatgpt_oss.dart';
|
||||||
|
import 'providers/cloudflare.dart';
|
||||||
|
import 'providers/cohere_space.dart';
|
||||||
|
import 'providers/copilot_app.dart';
|
||||||
|
import 'providers/deep_ai.dart';
|
||||||
|
import 'providers/duckduckgo.dart';
|
||||||
|
import 'providers/free2gpt.dart';
|
||||||
|
import 'providers/glm.dart';
|
||||||
|
import 'providers/hugging_chat.dart';
|
||||||
|
import 'providers/liaobots.dart';
|
||||||
|
import 'providers/opera_aria.dart';
|
||||||
|
import 'providers/phind_ai.dart';
|
||||||
|
import 'providers/pollinations.dart';
|
||||||
|
import 'providers/qwen.dart';
|
||||||
|
import 'providers/quillbot.dart';
|
||||||
|
import 'providers/teach_anything.dart';
|
||||||
|
import 'providers/webview/deepinfra_webview.dart';
|
||||||
|
import 'providers/you_chat.dart';
|
||||||
|
import 'providers/yqcloud.dart';
|
||||||
|
import 'retry.dart';
|
||||||
|
|
||||||
|
class G4FChatCompletions {
|
||||||
|
/// REST-live first, then Python-hosted OpenAI-compat, then WAF/WebView.
|
||||||
|
final List<BaseProvider> _providers = [
|
||||||
|
Yqcloud(),
|
||||||
|
DeepAi(),
|
||||||
|
Quillbot(),
|
||||||
|
KiloCode(),
|
||||||
|
OpenCode(),
|
||||||
|
G4FSpace(),
|
||||||
|
Pollinations(),
|
||||||
|
PhindAi(),
|
||||||
|
TeachAnything(),
|
||||||
|
CopilotApp(),
|
||||||
|
ChatGptOss(),
|
||||||
|
AiChatOs(),
|
||||||
|
OperaAria(),
|
||||||
|
Liaobots(),
|
||||||
|
YouChat(),
|
||||||
|
Free2Gpt(),
|
||||||
|
CohereSpace(),
|
||||||
|
GlmZai(),
|
||||||
|
DuckDuckGo(),
|
||||||
|
Blackbox(),
|
||||||
|
HuggingChat(),
|
||||||
|
QwenChat(),
|
||||||
|
CloudflareAi(),
|
||||||
|
DeepInfraWebView(),
|
||||||
|
];
|
||||||
|
|
||||||
|
List<BaseProvider> get providers => List.unmodifiable(_providers);
|
||||||
|
|
||||||
|
late final IterListProvider _retry = IterListProvider(_providers);
|
||||||
|
|
||||||
|
/// Full completion via g4f IterListProvider (try next on failure).
|
||||||
|
Future<String> create({
|
||||||
|
required List<Message> messages,
|
||||||
|
String? model,
|
||||||
|
}) {
|
||||||
|
return _retry.create(messages: messages, model: model ?? G4FModels.gpt4o);
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<String> createStream({
|
||||||
|
required List<Message> messages,
|
||||||
|
String? model,
|
||||||
|
}) async* {
|
||||||
|
yield await create(messages: messages, model: model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class G4FChat {
|
||||||
|
final completions = G4FChatCompletions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirrors `g4f.client.Client`.
|
||||||
|
class G4FClient {
|
||||||
|
final chat = G4FChat();
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
/// In-process cookie jar. Python g4f keeps a curl_cffi session; Dart/Cronet
|
||||||
|
/// does not, so every provider must share cookies across GET-then-POST.
|
||||||
|
class G4fCookieJar {
|
||||||
|
final Map<String, _StoredCookie> _cookies = {};
|
||||||
|
|
||||||
|
int get length => _cookies.length;
|
||||||
|
|
||||||
|
void clear() => _cookies.clear();
|
||||||
|
|
||||||
|
void set({
|
||||||
|
required String name,
|
||||||
|
required String value,
|
||||||
|
required String domain,
|
||||||
|
String path = '/',
|
||||||
|
DateTime? expires,
|
||||||
|
bool secure = false,
|
||||||
|
bool hostOnly = true,
|
||||||
|
}) {
|
||||||
|
if (name.isEmpty) return;
|
||||||
|
final canon = _canonDomain(domain);
|
||||||
|
if (canon.isEmpty) return;
|
||||||
|
final key = _key(canon, path, name);
|
||||||
|
if (value.isEmpty && expires != null) {
|
||||||
|
_cookies.remove(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_cookies[key] = _StoredCookie(
|
||||||
|
name: name,
|
||||||
|
value: value,
|
||||||
|
domain: canon,
|
||||||
|
path: path.isEmpty ? '/' : path,
|
||||||
|
expires: expires,
|
||||||
|
secure: secure,
|
||||||
|
hostOnly: hostOnly,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `Set-Cookie` from an HTTP response for [uri].
|
||||||
|
void absorb(Uri uri, Map<String, String> headers) {
|
||||||
|
final raw = headers['set-cookie'];
|
||||||
|
if (raw == null || raw.isEmpty) return;
|
||||||
|
for (final part in splitSetCookieHeader(raw)) {
|
||||||
|
final parsed = _parseSetCookie(part, uri);
|
||||||
|
if (parsed == null) continue;
|
||||||
|
if (parsed.expired) {
|
||||||
|
_cookies.remove(_key(parsed.domain, parsed.path, parsed.name));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_cookies[_key(parsed.domain, parsed.path, parsed.name)] = parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String headerFor(Uri uri) {
|
||||||
|
final now = DateTime.now().toUtc();
|
||||||
|
final matching = <_StoredCookie>[];
|
||||||
|
final stale = <String>[];
|
||||||
|
_cookies.forEach((key, cookie) {
|
||||||
|
if (cookie.expires != null && !cookie.expires!.isAfter(now)) {
|
||||||
|
stale.add(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cookie.matches(uri)) matching.add(cookie);
|
||||||
|
});
|
||||||
|
for (final key in stale) {
|
||||||
|
_cookies.remove(key);
|
||||||
|
}
|
||||||
|
matching.sort((a, b) => b.path.length.compareTo(a.path.length));
|
||||||
|
return matching.map((c) => '${c.name}=${c.value}').join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test helper.
|
||||||
|
Map<String, String> asMapFor(Uri uri) {
|
||||||
|
final header = headerFor(uri);
|
||||||
|
if (header.isEmpty) return {};
|
||||||
|
final out = <String, String>{};
|
||||||
|
for (final part in header.split('; ')) {
|
||||||
|
final eq = part.indexOf('=');
|
||||||
|
if (eq <= 0) continue;
|
||||||
|
out[part.substring(0, eq)] = part.substring(eq + 1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StoredCookie {
|
||||||
|
_StoredCookie({
|
||||||
|
required this.name,
|
||||||
|
required this.value,
|
||||||
|
required this.domain,
|
||||||
|
required this.path,
|
||||||
|
required this.secure,
|
||||||
|
required this.hostOnly,
|
||||||
|
this.expires,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final String value;
|
||||||
|
final String domain;
|
||||||
|
final String path;
|
||||||
|
final DateTime? expires;
|
||||||
|
final bool secure;
|
||||||
|
final bool hostOnly;
|
||||||
|
|
||||||
|
bool get expired {
|
||||||
|
if (expires == null) return false;
|
||||||
|
return !expires!.isAfter(DateTime.now().toUtc());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool matches(Uri uri) {
|
||||||
|
if (secure && uri.scheme != 'https') return false;
|
||||||
|
final host = uri.host.toLowerCase();
|
||||||
|
if (hostOnly) {
|
||||||
|
if (host != domain) return false;
|
||||||
|
} else {
|
||||||
|
if (host != domain && !host.endsWith('.$domain')) return false;
|
||||||
|
}
|
||||||
|
final reqPath = uri.path.isEmpty ? '/' : uri.path;
|
||||||
|
if (path == '/') return true;
|
||||||
|
if (reqPath == path) return true;
|
||||||
|
if (reqPath.startsWith(path.endsWith('/') ? path : '$path/')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _canonDomain(String domain) {
|
||||||
|
var d = domain.trim().toLowerCase();
|
||||||
|
if (d.startsWith('.')) d = d.substring(1);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _key(String domain, String path, String name) =>
|
||||||
|
'${_canonDomain(domain)}|$path|$name';
|
||||||
|
|
||||||
|
/// Split a folded Set-Cookie header (commas inside Expires dates).
|
||||||
|
List<String> splitSetCookieHeader(String header) {
|
||||||
|
return header
|
||||||
|
.split(RegExp(r',(?=\s*[^;=]+=)'))
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.where((s) => s.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
_StoredCookie? _parseSetCookie(String raw, Uri uri) {
|
||||||
|
final parts = raw.split(';');
|
||||||
|
if (parts.isEmpty) return null;
|
||||||
|
final nv = parts.first.trim();
|
||||||
|
final eq = nv.indexOf('=');
|
||||||
|
if (eq <= 0) return null;
|
||||||
|
final name = nv.substring(0, eq).trim();
|
||||||
|
final value = nv.substring(eq + 1).trim();
|
||||||
|
if (name.isEmpty) return null;
|
||||||
|
|
||||||
|
String? domain;
|
||||||
|
var path = '/';
|
||||||
|
DateTime? expires;
|
||||||
|
var secure = false;
|
||||||
|
var hostOnly = true;
|
||||||
|
|
||||||
|
for (var i = 1; i < parts.length; i++) {
|
||||||
|
final attr = parts[i].trim();
|
||||||
|
if (attr.isEmpty) continue;
|
||||||
|
final aEq = attr.indexOf('=');
|
||||||
|
final key = (aEq < 0 ? attr : attr.substring(0, aEq)).trim().toLowerCase();
|
||||||
|
final val = aEq < 0 ? '' : attr.substring(aEq + 1).trim();
|
||||||
|
if (key == 'domain' && val.isNotEmpty) {
|
||||||
|
domain = _canonDomain(val);
|
||||||
|
hostOnly = false;
|
||||||
|
} else if (key == 'path' && val.isNotEmpty) {
|
||||||
|
path = val;
|
||||||
|
} else if (key == 'expires') {
|
||||||
|
expires ??= DateTime.tryParse(val)?.toUtc();
|
||||||
|
} else if (key == 'max-age') {
|
||||||
|
final secs = int.tryParse(val);
|
||||||
|
if (secs != null) {
|
||||||
|
expires = DateTime.now().toUtc().add(Duration(seconds: secs));
|
||||||
|
}
|
||||||
|
} else if (key == 'secure') {
|
||||||
|
secure = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _StoredCookie(
|
||||||
|
name: name,
|
||||||
|
value: value,
|
||||||
|
domain: domain ?? uri.host.toLowerCase(),
|
||||||
|
path: path,
|
||||||
|
expires: expires,
|
||||||
|
secure: secure,
|
||||||
|
hostOnly: domain == null ? true : hostOnly,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
library g4f;
|
||||||
|
|
||||||
|
export 'models.dart';
|
||||||
|
export 'client.dart';
|
||||||
|
export 'http_util.dart';
|
||||||
|
export 'http_client.dart';
|
||||||
|
export 'retry.dart';
|
||||||
|
export 'sse.dart';
|
||||||
|
export 'providers/base_provider.dart';
|
||||||
|
export 'providers/chatgpt_oss.dart';
|
||||||
|
export 'providers/teach_anything.dart';
|
||||||
|
export 'providers/webview_base_provider.dart';
|
||||||
|
export 'providers/webview/deepinfra_webview.dart';
|
||||||
|
export 'providers/pollinations.dart';
|
||||||
|
export 'providers/yqcloud.dart';
|
||||||
|
export 'providers/deep_ai.dart';
|
||||||
|
export 'providers/phind_ai.dart';
|
||||||
|
export 'providers/duckduckgo.dart';
|
||||||
|
export 'providers/copilot_app.dart';
|
||||||
|
export 'providers/blackbox.dart';
|
||||||
|
export 'providers/hugging_chat.dart';
|
||||||
|
export 'providers/glm.dart';
|
||||||
|
export 'providers/cloudflare.dart';
|
||||||
|
export 'providers/qwen.dart';
|
||||||
|
export 'providers/openai_compat.dart';
|
||||||
|
export 'providers/quillbot.dart';
|
||||||
|
export 'providers/opera_aria.dart';
|
||||||
|
export 'providers/liaobots.dart';
|
||||||
|
export 'providers/you_chat.dart';
|
||||||
|
export 'providers/cohere_space.dart';
|
||||||
|
export 'cookie_jar.dart';
|
||||||
|
export 'providers/aggregators.dart';
|
||||||
|
export 'providers/free2gpt.dart';
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:cronet_http/cronet_http.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import 'cookie_jar.dart';
|
||||||
|
import 'http_util.dart';
|
||||||
|
|
||||||
|
/// Shared HTTP stack for every G4F provider.
|
||||||
|
///
|
||||||
|
/// Python g4f uses `curl_cffi(impersonate="chrome")` plus a session cookie
|
||||||
|
/// jar. On Android the closest TLS equivalent is Cronet; cookies are kept
|
||||||
|
/// in [g4fCookies] because Cronet does not expose a Dart cookie store.
|
||||||
|
http.Client createG4fClient() {
|
||||||
|
if (!kIsWeb && Platform.isAndroid) {
|
||||||
|
final engine = CronetEngine.build(
|
||||||
|
cacheMode: CacheMode.memory,
|
||||||
|
cacheMaxSize: 4 * 1024 * 1024,
|
||||||
|
userAgent: kDesktopUserAgent,
|
||||||
|
enableHttp2: true,
|
||||||
|
enableBrotli: true,
|
||||||
|
enableQuic: true,
|
||||||
|
);
|
||||||
|
return CronetClient.fromCronetEngine(engine);
|
||||||
|
}
|
||||||
|
return http.Client();
|
||||||
|
}
|
||||||
|
|
||||||
|
final G4fCookieJar g4fCookies = G4fCookieJar();
|
||||||
|
|
||||||
|
final http.Client g4fHttp = CookieClient(createG4fClient(), g4fCookies);
|
||||||
|
|
||||||
|
class CookieClient extends http.BaseClient {
|
||||||
|
CookieClient(this._inner, this.jar);
|
||||||
|
|
||||||
|
final http.Client _inner;
|
||||||
|
final G4fCookieJar jar;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||||
|
final fromJar = jar.headerFor(request.url);
|
||||||
|
if (fromJar.isNotEmpty) {
|
||||||
|
final existing = request.headers['cookie'];
|
||||||
|
if (existing == null || existing.isEmpty) {
|
||||||
|
request.headers['cookie'] = fromJar;
|
||||||
|
} else {
|
||||||
|
request.headers['cookie'] = '$existing; $fromJar';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final response = await _inner.send(request);
|
||||||
|
jar.absorb(request.url, response.headers);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void close() => _inner.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
final Set<String> _warmedOrigins = {};
|
||||||
|
|
||||||
|
/// GET the provider origin once so Cloudflare/session cookies land in [g4fCookies].
|
||||||
|
Future<void> warmupOrigin(String url) async {
|
||||||
|
if (url.isEmpty) return;
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
if (uri == null || !uri.hasScheme || uri.host.isEmpty) return;
|
||||||
|
final origin = uri.origin;
|
||||||
|
if (_warmedOrigins.contains(origin)) return;
|
||||||
|
_warmedOrigins.add(origin);
|
||||||
|
try {
|
||||||
|
await g4fHttp
|
||||||
|
.get(
|
||||||
|
uri,
|
||||||
|
headers: browserHeaders(
|
||||||
|
desktop: true,
|
||||||
|
accept:
|
||||||
|
'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
|
referer: '$origin/',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 12));
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
const kBrowserUserAgent =
|
||||||
|
'Mozilla/5.0 (Linux; Android 14; Pixel 8 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Mobile Safari/537.36';
|
||||||
|
|
||||||
|
const kDesktopUserAgent =
|
||||||
|
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
||||||
|
|
||||||
|
Map<String, String> browserHeaders({
|
||||||
|
String? origin,
|
||||||
|
String? referer,
|
||||||
|
String? accept,
|
||||||
|
String? contentType,
|
||||||
|
bool desktop = false,
|
||||||
|
Map<String, String>? extra,
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
'User-Agent': desktop ? kDesktopUserAgent : kBrowserUserAgent,
|
||||||
|
'Accept': accept ?? '*/*',
|
||||||
|
'Accept-Language': 'en-US,en;q=0.9',
|
||||||
|
if (origin != null) 'Origin': origin,
|
||||||
|
if (referer != null) 'Referer': referer,
|
||||||
|
if (contentType != null) 'Content-Type': contentType,
|
||||||
|
...?extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when a provider dumped an error page / WAF / paywall instead of model text.
|
||||||
|
bool looksLikeGarbage(String text) {
|
||||||
|
final t = text.trimLeft();
|
||||||
|
if (t.isEmpty) return true;
|
||||||
|
final lower = t.toLowerCase();
|
||||||
|
if (t.startsWith('<!DOCTYPE') || t.startsWith('<html') || t.startsWith('<HTML')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (lower.contains('just a moment') ||
|
||||||
|
lower.contains('cf-browser-verification') ||
|
||||||
|
lower.contains('attention required') ||
|
||||||
|
lower.contains('payment required') ||
|
||||||
|
lower.contains('frontend_captcha') ||
|
||||||
|
lower.contains('err_challenge') ||
|
||||||
|
lower.contains('error code:') ||
|
||||||
|
lower.contains('missing api key') ||
|
||||||
|
lower.contains('invalid api key') ||
|
||||||
|
lower.contains('you have to be logged in') ||
|
||||||
|
lower.contains('no cake credits') ||
|
||||||
|
lower.contains('insufficient_credits')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool looksLikeModelOutput(String text) {
|
||||||
|
final trimmed = text.trim();
|
||||||
|
if (trimmed.length < 8) return false;
|
||||||
|
final lower = trimmed.toLowerCase();
|
||||||
|
if (lower == ': ping' || lower == 'ping' || lower == 'pong' || lower == '{}') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (RegExp(r'^[\{\}\[\]":\s]+$').hasMatch(trimmed)) return false;
|
||||||
|
return !looksLikeGarbage(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? cookieHeaderFromSetCookie(String? setCookie, {String? name}) {
|
||||||
|
if (setCookie == null || setCookie.isEmpty) return null;
|
||||||
|
// http package may join multiple Set-Cookie values with ", ".
|
||||||
|
final parts = setCookie.split(RegExp(r',(?=\s*[^;=]+=)'));
|
||||||
|
final pairs = <String>[];
|
||||||
|
for (final part in parts) {
|
||||||
|
final nv = part.split(';').first.trim();
|
||||||
|
if (nv.isEmpty || !nv.contains('=')) continue;
|
||||||
|
if (name != null && !nv.startsWith('$name=')) continue;
|
||||||
|
pairs.add(nv);
|
||||||
|
}
|
||||||
|
if (pairs.isEmpty) return null;
|
||||||
|
return pairs.join('; ');
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// models.dart
|
||||||
|
enum Role { system, user, assistant }
|
||||||
|
|
||||||
|
class Message {
|
||||||
|
final Role role;
|
||||||
|
final String content;
|
||||||
|
|
||||||
|
const Message({required this.role, required this.content});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'role': role.name,
|
||||||
|
'content': content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class G4FModels {
|
||||||
|
static const String gpt4 = 'gpt-4';
|
||||||
|
static const String gpt4o = 'gpt-4o';
|
||||||
|
static const String gpt4oMini = 'gpt-4o-mini';
|
||||||
|
static const String commandR = 'command-r';
|
||||||
|
static const String commandRPlus = 'command-r-plus';
|
||||||
|
static const String mistral = 'mistral';
|
||||||
|
static const String llama3 = 'llama-3';
|
||||||
|
static const String qwen = 'qwen';
|
||||||
|
static const String openaiFast = 'openai-fast';
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import '../http_util.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import 'base_provider.dart';
|
||||||
|
import 'openai_compat.dart';
|
||||||
|
|
||||||
|
/// Python `KiloCode` — OpenAI-compat gateway, no key for `kilo-auto/free`.
|
||||||
|
class KiloCode extends OpenAiCompatProvider {
|
||||||
|
KiloCode()
|
||||||
|
: super(
|
||||||
|
providerName: 'KiloCode',
|
||||||
|
apiBase: 'https://api.kilo.ai/api/gateway',
|
||||||
|
fallbackModel: 'kilo-auto/free',
|
||||||
|
supportedModels: const ['kilo-auto/free', 'kilo-auto'],
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get url => 'https://kilo.ai';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Python `OpenCode` — `https://opencode.ai/zen/v1`, default `big-pickle`.
|
||||||
|
class OpenCode extends OpenAiCompatProvider {
|
||||||
|
OpenCode()
|
||||||
|
: super(
|
||||||
|
providerName: 'OpenCode',
|
||||||
|
apiBase: 'https://opencode.ai/zen/v1',
|
||||||
|
fallbackModel: 'big-pickle',
|
||||||
|
supportedModels: const ['big-pickle'],
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get url => 'https://opencode.ai';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Python `G4FSpace` / `ClientFactory("default")`.
|
||||||
|
/// Anonymous calls currently return 402 cake-credits; still tried so a baked
|
||||||
|
/// session cookie or future free window works without a code change.
|
||||||
|
class G4FSpace extends BaseProvider {
|
||||||
|
@override
|
||||||
|
String get name => 'G4FSpace';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get url => 'https://g4f.space';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get working => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get models => const ['auto', 'gpt-4o', 'openai'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get defaultModel => 'auto';
|
||||||
|
|
||||||
|
static const _bases = [
|
||||||
|
'https://g4f.space/api/auto',
|
||||||
|
'https://g4f.space/api/pollinations',
|
||||||
|
'https://g4f.space/v1',
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> createAsyncGenerator(
|
||||||
|
String model,
|
||||||
|
List<Message> messages, {
|
||||||
|
bool stream = false,
|
||||||
|
}) async* {
|
||||||
|
final errors = <String>[];
|
||||||
|
for (final base in _bases) {
|
||||||
|
try {
|
||||||
|
final inner = OpenAiCompatProvider(
|
||||||
|
providerName: 'G4FSpace',
|
||||||
|
apiBase: base,
|
||||||
|
fallbackModel: model.isEmpty ? defaultModel : model,
|
||||||
|
supportedModels: models,
|
||||||
|
);
|
||||||
|
final text = await inner
|
||||||
|
.complete(model.isEmpty ? defaultModel : model, messages)
|
||||||
|
.timeout(requestTimeout);
|
||||||
|
if (looksLikeModelOutput(text)) {
|
||||||
|
yield text;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
errors.add('$base unusable');
|
||||||
|
} catch (e) {
|
||||||
|
errors.add('$base: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw Exception('G4FSpace failed: ${errors.join(' | ')}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../http_client.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import 'base_provider.dart';
|
||||||
|
|
||||||
|
class AiChatOs extends BaseProvider {
|
||||||
|
@override
|
||||||
|
String get name => 'AiChatOs';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get working => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get models => [G4FModels.gpt4];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get defaultModel => G4FModels.gpt4;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> createAsyncGenerator(
|
||||||
|
String model,
|
||||||
|
List<Message> messages, {
|
||||||
|
bool stream = false,
|
||||||
|
}) async* {
|
||||||
|
final url = Uri.parse('https://api.aichatos.cloud/api/generateStream');
|
||||||
|
final userId = '#/chat/${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
|
||||||
|
final systemMessage = extractSystemMessage(messages);
|
||||||
|
final formattedPrompt = formatPrompt(messages);
|
||||||
|
|
||||||
|
final req = http.Request('POST', url);
|
||||||
|
req.headers.addAll({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Origin': 'https://chat.aichatos.top',
|
||||||
|
'Referer': 'https://chat.aichatos.top/',
|
||||||
|
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||||
|
});
|
||||||
|
|
||||||
|
req.body = jsonEncode({
|
||||||
|
'prompt': formattedPrompt,
|
||||||
|
'userId': userId,
|
||||||
|
'network': true,
|
||||||
|
'system': systemMessage,
|
||||||
|
'withoutContext': false,
|
||||||
|
'stream': stream,
|
||||||
|
});
|
||||||
|
|
||||||
|
final response = await g4fHttp.send(req);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final resBytes = await response.stream.toBytes();
|
||||||
|
final fullText = utf8.decode(resBytes).trim();
|
||||||
|
if (fullText.isNotEmpty && !fullText.startsWith('<!DOCTYPE') && !fullText.contains('Error:')) {
|
||||||
|
yield fullText;
|
||||||
|
} else {
|
||||||
|
throw Exception('$name unusable body');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw Exception('$name returned status ${response.statusCode}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import '../models.dart';
|
||||||
|
|
||||||
|
/// Base abstract class for all G4F providers.
|
||||||
|
/// Mirrors `AsyncGeneratorProvider` in xtekky/gpt4free.
|
||||||
|
abstract class BaseProvider {
|
||||||
|
/// User-friendly name of the provider
|
||||||
|
String get name;
|
||||||
|
|
||||||
|
String get label => name;
|
||||||
|
|
||||||
|
String get url => '';
|
||||||
|
|
||||||
|
/// Whether the provider is currently believed to be working
|
||||||
|
bool get working;
|
||||||
|
|
||||||
|
/// List of supported models by this provider
|
||||||
|
List<String> get models;
|
||||||
|
|
||||||
|
/// Optional fallback default model if none specified
|
||||||
|
String get defaultModel;
|
||||||
|
|
||||||
|
/// Whether this provider needs a hidden WebView (slow, device-only).
|
||||||
|
bool get isWebView => false;
|
||||||
|
|
||||||
|
/// Per-provider deadline. Python g4f defaults to ~120s; we keep a minute.
|
||||||
|
Duration get requestTimeout => const Duration(seconds: 60);
|
||||||
|
|
||||||
|
/// g4f `model_aliases` (e.g. gpt-4o → provider default).
|
||||||
|
Map<String, String> get modelAliases => const {};
|
||||||
|
|
||||||
|
String resolveModel(String? model) {
|
||||||
|
if (model == null || model.isEmpty) return defaultModel;
|
||||||
|
if (models.contains(model)) return model;
|
||||||
|
final alias = modelAliases[model];
|
||||||
|
if (alias != null) return alias;
|
||||||
|
return defaultModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the provider can emit incremental tokens.
|
||||||
|
bool get supportsStream => false;
|
||||||
|
|
||||||
|
/// Full reply. The Oracle never streams — it waits for a complete text.
|
||||||
|
Future<String> complete(String model, List<Message> messages) async {
|
||||||
|
final buf = StringBuffer();
|
||||||
|
await for (final chunk in createAsyncGenerator(
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
stream: false,
|
||||||
|
).timeout(requestTimeout)) {
|
||||||
|
buf.write(chunk);
|
||||||
|
}
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert chat history to OpenAI-style JSON maps.
|
||||||
|
List<Map<String, String>> toOpenAiMessages(List<Message> messages) {
|
||||||
|
return [
|
||||||
|
for (final m in messages) {'role': m.role.name, 'content': m.content},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to get last user message
|
||||||
|
String getLastUserMessage(List<Message> messages) {
|
||||||
|
for (var i = messages.length - 1; i >= 0; i--) {
|
||||||
|
if (messages[i].role == Role.user) {
|
||||||
|
return messages[i].content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to extract system prompt if supported by the provider
|
||||||
|
String extractSystemMessage(List<Message> messages) {
|
||||||
|
final sys = messages.where((m) => m.role == Role.system);
|
||||||
|
if (sys.isNotEmpty) {
|
||||||
|
return sys.map((m) => m.content).join('\n');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to format a full prompt string (for providers without native chat context)
|
||||||
|
String formatPrompt(List<Message> messages, {bool includeSystem = true}) {
|
||||||
|
final sb = StringBuffer();
|
||||||
|
for (final m in messages) {
|
||||||
|
if (m.role == Role.system) {
|
||||||
|
if (includeSystem) sb.writeln('System: ${m.content}');
|
||||||
|
} else if (m.role == Role.user) {
|
||||||
|
sb.writeln('User: ${m.content}');
|
||||||
|
} else {
|
||||||
|
sb.writeln('Assistant: ${m.content}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Main interface to execute a chat completion synchronously/asynchronously.
|
||||||
|
/// Streams chunked output if `stream` is supported.
|
||||||
|
Stream<String> createAsyncGenerator(
|
||||||
|
String model,
|
||||||
|
List<Message> messages, {
|
||||||
|
bool stream = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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))}');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../http_client.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import '../sse.dart';
|
||||||
|
import 'base_provider.dart';
|
||||||
|
|
||||||
|
class ChatGptOss extends BaseProvider {
|
||||||
|
@override
|
||||||
|
String get name => 'ChatGptOss';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get working => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get models => ['gpt-oss-120b', 'gpt-5-nano'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get defaultModel => 'gpt-oss-120b';
|
||||||
|
|
||||||
|
String _generateFingerprint() {
|
||||||
|
final seed = '${DateTime.now().millisecondsSinceEpoch / 1000.0}-${Random().nextDouble()}';
|
||||||
|
return md5.convert(utf8.encode(seed)).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> createAsyncGenerator(
|
||||||
|
String model,
|
||||||
|
List<Message> messages, {
|
||||||
|
bool stream = false,
|
||||||
|
}) async* {
|
||||||
|
final prompt = formatPrompt(messages);
|
||||||
|
final url = Uri.parse('https://chat-gpt-oss.com/api/message');
|
||||||
|
|
||||||
|
final payload = {
|
||||||
|
"conversation_id": null,
|
||||||
|
"model": model,
|
||||||
|
"content": prompt,
|
||||||
|
"reasoning_effort": "low",
|
||||||
|
};
|
||||||
|
|
||||||
|
final req = http.Request('POST', url);
|
||||||
|
req.headers.addAll({
|
||||||
|
"accept": "text/event-stream",
|
||||||
|
"accept-language": "en-US,en;q=0.9",
|
||||||
|
"cache-control": "no-cache",
|
||||||
|
"content-type": "application/json",
|
||||||
|
"origin": "https://chat-gpt-oss.com",
|
||||||
|
"pragma": "no-cache",
|
||||||
|
"referer": "https://chat-gpt-oss.com/",
|
||||||
|
"sec-ch-ua": '"Chromium";v="148", "Google Chrome";v="148", "Not/A)Brand";v="99"',
|
||||||
|
"sec-ch-ua-mobile": "?0",
|
||||||
|
"sec-ch-ua-platform": '"Linux"',
|
||||||
|
"sec-fetch-dest": "empty",
|
||||||
|
"sec-fetch-mode": "cors",
|
||||||
|
"sec-fetch-site": "same-origin",
|
||||||
|
"user-agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36",
|
||||||
|
"x-fingerprint": _generateFingerprint(),
|
||||||
|
});
|
||||||
|
|
||||||
|
req.body = jsonEncode(payload);
|
||||||
|
|
||||||
|
final response = await g4fHttp.send(req);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
String? currentEvent;
|
||||||
|
await for (final line in splitSseLines(response.stream.transform(utf8.decoder))) {
|
||||||
|
final trimmed = line.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
currentEvent = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith('event:')) {
|
||||||
|
currentEvent = trimmed.substring(6).trim();
|
||||||
|
if (currentEvent == 'summary') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith('data:') && currentEvent == 'message') {
|
||||||
|
final dataStr = trimmed.substring(5).trim();
|
||||||
|
if (dataStr.isEmpty) continue;
|
||||||
|
try {
|
||||||
|
final jsonChunk = jsonDecode(dataStr);
|
||||||
|
final content = jsonChunk['content'] as String?;
|
||||||
|
if (content != null && content.isNotEmpty) {
|
||||||
|
yield content;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw Exception('$name returned status ${response.statusCode}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import '../http_util.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import 'webview_base_provider.dart';
|
||||||
|
|
||||||
|
/// Cloudflare Workers AI playground via an in-page WebSocket.
|
||||||
|
class CloudflareAi extends WebViewBaseProvider {
|
||||||
|
@override
|
||||||
|
String get name => 'CloudflareAI';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get working => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get models => [
|
||||||
|
'@cf/meta/llama-3.3-70b-instruct-fp8-fast',
|
||||||
|
'@cf/meta/llama-3.1-8b-instruct-fp8',
|
||||||
|
'@cf/google/gemma-3-12b-it',
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get defaultModel => '@cf/meta/llama-3.3-70b-instruct-fp8-fast';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get baseUrl => 'https://playground.ai.cloudflare.com';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get apiEndpoint => 'wss://playground.ai.cloudflare.com/agents/playground';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String buildJsFetchCode(String model, List<Message> messages, bool stream) {
|
||||||
|
final cfMessages = [
|
||||||
|
for (var i = 0; i < messages.length; i++)
|
||||||
|
{
|
||||||
|
'role': messages[i].role.name,
|
||||||
|
'parts': [
|
||||||
|
{'type': 'text', 'text': messages[i].content}
|
||||||
|
],
|
||||||
|
'id': 'msg_$i',
|
||||||
|
}
|
||||||
|
];
|
||||||
|
final messagesJson = jsonEncode(cfMessages);
|
||||||
|
final modelJson = jsonEncode(models.contains(model) ? model : defaultModel);
|
||||||
|
return '''
|
||||||
|
(async function() {
|
||||||
|
const modelStr = $modelJson;
|
||||||
|
const pk = crypto.randomUUID();
|
||||||
|
const agentId = 'playground-' + Math.random().toString(36).slice(2, 15);
|
||||||
|
const wsUrl = 'wss://playground.ai.cloudflare.com/agents/playground/' + agentId
|
||||||
|
+ '?_pk=' + pk + '&model=' + encodeURIComponent(modelStr);
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
let text = '';
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
try { ws.close(); } catch (e) {}
|
||||||
|
if (text) resolve(text); else reject('Cloudflare timeout');
|
||||||
|
}, 28000);
|
||||||
|
ws.onopen = () => {
|
||||||
|
ws.send(JSON.stringify({type:'cf_agent_stream_resume_request'}));
|
||||||
|
ws.send(JSON.stringify({name:agentId, agent:'playground', type:'cf_agent_identity'}));
|
||||||
|
ws.send(JSON.stringify({state:{model:modelStr, temperature:1, stream:true, system:'You are a helpful assistant.', useExternalProvider:false, externalProvider:'openai', generativeUI:false}, type:'cf_agent_state'}));
|
||||||
|
ws.send(JSON.stringify({mcp:{prompts:[], resources:[], servers:{}, tools:[]}, type:'cf_agent_mcp_servers'}));
|
||||||
|
ws.send(JSON.stringify({type:'cf_agent_stream_resume_none'}));
|
||||||
|
setTimeout(() => {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
id: 'req_' + Math.random().toString(36).slice(2, 10),
|
||||||
|
init: { method: 'POST', body: JSON.stringify({ messages: $messagesJson, trigger: 'submit-message' }) },
|
||||||
|
type: 'cf_agent_use_chat_request'
|
||||||
|
}));
|
||||||
|
}, 800);
|
||||||
|
};
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === 'cf_agent_use_chat_response') {
|
||||||
|
const body = JSON.parse(data.body);
|
||||||
|
if (body.type === 'text-delta' && body.delta) text += body.delta;
|
||||||
|
if (body.type === 'finish' || body.type === 'finish-step') {
|
||||||
|
clearTimeout(timer);
|
||||||
|
ws.close();
|
||||||
|
resolve(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
ws.onerror = () => { clearTimeout(timer); reject('Cloudflare WS error'); };
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
''';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String parseResponse(String rawResponse) {
|
||||||
|
final text = rawResponse.trim();
|
||||||
|
if (text.startsWith('{')) {
|
||||||
|
try {
|
||||||
|
final json = jsonDecode(text);
|
||||||
|
if (json is Map && json['error'] != null) {
|
||||||
|
throw Exception(json['error']);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e.toString().contains('Exception:')) rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!looksLikeModelOutput(text)) {
|
||||||
|
throw Exception('Cloudflare empty: ${text.substring(0, text.length.clamp(0, 160))}');
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../http_client.dart';
|
||||||
|
import '../http_util.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import 'base_provider.dart';
|
||||||
|
|
||||||
|
/// Port of g4f HuggingSpace / CohereForAI C4AI Command (HF Space).
|
||||||
|
class CohereSpace extends BaseProvider {
|
||||||
|
@override
|
||||||
|
String get name => 'CohereSpace';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get working => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get models => [
|
||||||
|
'command-a-03-2025',
|
||||||
|
'command-r-plus-08-2024',
|
||||||
|
'command-r-08-2024',
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get defaultModel => 'command-a-03-2025';
|
||||||
|
|
||||||
|
static const _url = 'https://coherelabs-c4ai-command.hf.space';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> createAsyncGenerator(
|
||||||
|
String model,
|
||||||
|
List<Message> messages, {
|
||||||
|
bool stream = false,
|
||||||
|
}) async* {
|
||||||
|
final useModel = models.contains(model) ? model : defaultModel;
|
||||||
|
final headers = browserHeaders(
|
||||||
|
origin: _url,
|
||||||
|
referer: '$_url/',
|
||||||
|
contentType: 'application/json',
|
||||||
|
);
|
||||||
|
final conv = await g4fHttp
|
||||||
|
.post(
|
||||||
|
Uri.parse('$_url/conversation'),
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode({
|
||||||
|
'model': useModel,
|
||||||
|
'preprompt': extractSystemMessage(messages),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 15));
|
||||||
|
if (conv.statusCode != 200) {
|
||||||
|
throw Exception('CohereSpace conv ${conv.statusCode}');
|
||||||
|
}
|
||||||
|
final convJson = jsonDecode(conv.body);
|
||||||
|
final convId = (convJson is Map
|
||||||
|
? (convJson['conversationId'] ?? convJson['id'])
|
||||||
|
: null)
|
||||||
|
?.toString();
|
||||||
|
if (convId == null) throw Exception('CohereSpace no conversation');
|
||||||
|
|
||||||
|
final cookie = cookieHeaderFromSetCookie(conv.headers['set-cookie']);
|
||||||
|
final req = http.MultipartRequest('POST', Uri.parse('$_url/conversation/$convId'));
|
||||||
|
req.headers.addAll({
|
||||||
|
...browserHeaders(origin: _url, referer: '$_url/'),
|
||||||
|
if (cookie != null) 'Cookie': cookie,
|
||||||
|
});
|
||||||
|
req.fields['data'] = jsonEncode({
|
||||||
|
'inputs': formatPrompt(messages.where((m) => m.role != Role.system).toList()),
|
||||||
|
'id': 0,
|
||||||
|
'is_retry': false,
|
||||||
|
'is_continue': false,
|
||||||
|
'web_search': false,
|
||||||
|
'tools': [],
|
||||||
|
});
|
||||||
|
|
||||||
|
final res = await g4fHttp.send(req).timeout(requestTimeout);
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
throw Exception('CohereSpace chat ${res.statusCode}');
|
||||||
|
}
|
||||||
|
final raw = utf8.decode(await res.stream.toBytes().timeout(requestTimeout));
|
||||||
|
final buf = StringBuffer();
|
||||||
|
for (final line in raw.split(RegExp(r'\r?\n'))) {
|
||||||
|
if (line.trim().isEmpty) continue;
|
||||||
|
try {
|
||||||
|
final data = jsonDecode(line);
|
||||||
|
if (data is Map) {
|
||||||
|
if (data['type'] == 'stream' && data['token'] is String) {
|
||||||
|
buf.write((data['token'] as String).replaceAll('\u0000', ''));
|
||||||
|
} else if (data['type'] == 'finalAnswer' && data['text'] is String) {
|
||||||
|
if (buf.isEmpty) buf.write(data['text']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
final text = buf.toString().trim();
|
||||||
|
if (!looksLikeModelOutput(text)) throw Exception('CohereSpace empty');
|
||||||
|
yield text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../http_client.dart';
|
||||||
|
import '../http_util.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import 'base_provider.dart';
|
||||||
|
import 'webview_base_provider.dart';
|
||||||
|
|
||||||
|
/// Microsoft Copilot: Cronet session + WebSocket, then origin WebView (nodriver analog).
|
||||||
|
class CopilotApp extends BaseProvider {
|
||||||
|
@override
|
||||||
|
String get name => 'CopilotApp';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get url => 'https://copilot.microsoft.com';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get working => !kIsWeb;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Duration get requestTimeout => const Duration(seconds: 90);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get models => ['smart', 'chat', 'reasoning'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get defaultModel => 'smart';
|
||||||
|
|
||||||
|
static const _ua =
|
||||||
|
'CopilotNative/30.0.440505001-prod (Android 14; Google; Pixel 8 Pro)';
|
||||||
|
static const _startUrl = 'https://copilot.microsoft.com/c/api/start';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> createAsyncGenerator(
|
||||||
|
String model,
|
||||||
|
List<Message> messages, {
|
||||||
|
bool stream = false,
|
||||||
|
}) async* {
|
||||||
|
if (kIsWeb) {
|
||||||
|
throw UnsupportedError('CopilotApp requires dart:io (Android/iOS/desktop)');
|
||||||
|
}
|
||||||
|
|
||||||
|
final seeds = _fakeCookieSeeds();
|
||||||
|
_seedFakeCookies(seeds);
|
||||||
|
final prompt = formatPrompt(messages);
|
||||||
|
try {
|
||||||
|
yield await _viaRestAndWs(model, prompt);
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Copilot REST/WS failed, trying origin WebView: $e');
|
||||||
|
}
|
||||||
|
yield await _viaWebView(model, prompt, seeds);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _viaRestAndWs(String model, String prompt) async {
|
||||||
|
final startReq = http.Request('POST', Uri.parse(_startUrl));
|
||||||
|
startReq.headers.addAll({
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'user-agent': _ua,
|
||||||
|
'origin': url,
|
||||||
|
'referer': '$url/',
|
||||||
|
'X-Search-UILang': 'en-US',
|
||||||
|
});
|
||||||
|
startReq.body = jsonEncode({
|
||||||
|
'timeZone': 'Europe/Moscow',
|
||||||
|
'startNewConversation': true,
|
||||||
|
'teenSupportEnabled': true,
|
||||||
|
'correctPersonalizationSetting': true,
|
||||||
|
'deferredDataUseCapable': true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final startRes = await g4fHttp.send(startReq).timeout(const Duration(seconds: 15));
|
||||||
|
if (startRes.statusCode != 200) {
|
||||||
|
final err = await startRes.stream.bytesToString();
|
||||||
|
throw Exception('Copilot start ${startRes.statusCode}: $err');
|
||||||
|
}
|
||||||
|
final startBody = jsonDecode(utf8.decode(await startRes.stream.toBytes()));
|
||||||
|
final conversationId = (startBody['currentConversationId'] ?? '').toString();
|
||||||
|
if (conversationId.isEmpty) {
|
||||||
|
throw Exception('Copilot: no conversation id');
|
||||||
|
}
|
||||||
|
|
||||||
|
final cookieHeader = g4fCookies.headerFor(Uri.parse(url));
|
||||||
|
final sessionId = const Uuid().v4();
|
||||||
|
final wsUrl =
|
||||||
|
'wss://copilot.microsoft.com/c/api/chat?api-version=2&clientSessionId=$sessionId';
|
||||||
|
final ws = await WebSocket.connect(
|
||||||
|
wsUrl,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': _ua,
|
||||||
|
'Origin': url,
|
||||||
|
'Pragma': 'no-cache',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
if (cookieHeader.isNotEmpty) 'Cookie': cookieHeader,
|
||||||
|
},
|
||||||
|
).timeout(const Duration(seconds: 12));
|
||||||
|
|
||||||
|
try {
|
||||||
|
final mode = _modeFor(model);
|
||||||
|
var connected = false;
|
||||||
|
final collected = StringBuffer();
|
||||||
|
|
||||||
|
await for (final raw in ws.timeout(const Duration(seconds: 35))) {
|
||||||
|
if (raw is! String) continue;
|
||||||
|
Map<String, dynamic> data;
|
||||||
|
try {
|
||||||
|
data = jsonDecode(raw) as Map<String, dynamic>;
|
||||||
|
} catch (_) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final event = data['event']?.toString();
|
||||||
|
if (event == 'connected' || event == 'received') {
|
||||||
|
if (!connected) {
|
||||||
|
connected = true;
|
||||||
|
ws.add(jsonEncode({
|
||||||
|
'event': 'send',
|
||||||
|
'conversationId': conversationId,
|
||||||
|
'content': [
|
||||||
|
{'type': 'text', 'text': prompt},
|
||||||
|
],
|
||||||
|
'mode': mode,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (event == 'appendText' || event == 'replaceText') {
|
||||||
|
final text = data['text']?.toString() ?? '';
|
||||||
|
if (text.isNotEmpty) collected.write(text);
|
||||||
|
} else if (event == 'done') {
|
||||||
|
break;
|
||||||
|
} else if (event == 'error') {
|
||||||
|
throw Exception('Copilot error: $data');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final text = collected.toString().trim();
|
||||||
|
if (!looksLikeModelOutput(text)) {
|
||||||
|
throw Exception('Copilot returned empty response');
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
} finally {
|
||||||
|
await ws.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _viaWebView(
|
||||||
|
String model,
|
||||||
|
String prompt,
|
||||||
|
List<CookieSeed> seeds,
|
||||||
|
) async {
|
||||||
|
final promptJson = jsonEncode(prompt);
|
||||||
|
final modeJson = jsonEncode(_modeFor(model));
|
||||||
|
final raw = await runOriginWebView(
|
||||||
|
originUrl: url,
|
||||||
|
timeout: requestTimeout,
|
||||||
|
seeds: seeds,
|
||||||
|
functionBody: '''
|
||||||
|
const prompt = $promptJson;
|
||||||
|
const mode = $modeJson;
|
||||||
|
const start = await fetch('/c/api/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||||
|
startNewConversation: true,
|
||||||
|
teenSupportEnabled: true,
|
||||||
|
correctPersonalizationSetting: true,
|
||||||
|
deferredDataUseCapable: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!start.ok) {
|
||||||
|
throw new Error('start ' + start.status + ' ' + (await start.text()).slice(0, 160));
|
||||||
|
}
|
||||||
|
const started = await start.json();
|
||||||
|
const conversationId = started.currentConversationId;
|
||||||
|
if (!conversationId) throw new Error('no conversation id');
|
||||||
|
const sessionId = (crypto.randomUUID && crypto.randomUUID()) || String(Math.random()).slice(2);
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const ws = new WebSocket('wss://copilot.microsoft.com/c/api/chat?api-version=2&clientSessionId=' + sessionId);
|
||||||
|
let text = '';
|
||||||
|
let sent = false;
|
||||||
|
const timer = setTimeout(() => { try { ws.close(); } catch (e) {} reject(new Error('ws timeout')); }, 45000);
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(e.data); } catch (err) { return; }
|
||||||
|
const event = data.event;
|
||||||
|
if ((event === 'connected' || event === 'received') && !sent) {
|
||||||
|
sent = true;
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
event: 'send',
|
||||||
|
conversationId,
|
||||||
|
content: [{ type: 'text', text: prompt }],
|
||||||
|
mode
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (event === 'appendText' || event === 'replaceText') text += data.text || '';
|
||||||
|
if (event === 'done') { clearTimeout(timer); ws.close(); resolve(text); }
|
||||||
|
if (event === 'error') { clearTimeout(timer); reject(new Error(JSON.stringify(data))); }
|
||||||
|
};
|
||||||
|
ws.onerror = () => { clearTimeout(timer); reject(new Error('ws error')); };
|
||||||
|
});
|
||||||
|
''',
|
||||||
|
);
|
||||||
|
final text = raw.trim();
|
||||||
|
if (!looksLikeModelOutput(text)) {
|
||||||
|
throw Exception('Copilot WebView unusable: ${text.substring(0, text.length.clamp(0, 180))}');
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _seedFakeCookies(List<CookieSeed> seeds) {
|
||||||
|
final uri = Uri.parse(url);
|
||||||
|
for (final seed in seeds) {
|
||||||
|
g4fCookies.set(
|
||||||
|
name: seed.name,
|
||||||
|
value: seed.value,
|
||||||
|
domain: seed.domain ?? 'microsoft.com',
|
||||||
|
path: seed.path,
|
||||||
|
secure: seed.isSecure,
|
||||||
|
hostOnly: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Touch the jar host as well as the parent domain.
|
||||||
|
g4fCookies.set(
|
||||||
|
name: '_C_ETH',
|
||||||
|
value: '1',
|
||||||
|
domain: uri.host,
|
||||||
|
secure: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<CookieSeed> _fakeCookieSeeds() {
|
||||||
|
return [
|
||||||
|
CookieSeed(name: '_C_ETH', value: '1', domain: '.microsoft.com'),
|
||||||
|
CookieSeed(name: '_C_Auth', value: '', domain: '.microsoft.com'),
|
||||||
|
CookieSeed(name: 'MUID', value: _hex(32), domain: '.microsoft.com'),
|
||||||
|
CookieSeed(name: 'MUIDB', value: _hex(32), domain: '.microsoft.com'),
|
||||||
|
CookieSeed(
|
||||||
|
name: '_EDGE_S',
|
||||||
|
value: 'F=1&SID=${_hex(32)}',
|
||||||
|
domain: '.microsoft.com',
|
||||||
|
),
|
||||||
|
CookieSeed(name: '_EDGE_V', value: '1', domain: '.microsoft.com'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
String _modeFor(String model) {
|
||||||
|
final m = model.toLowerCase();
|
||||||
|
if (m.contains('reason') || m.contains('think') || m.contains('o1')) {
|
||||||
|
return 'reasoning';
|
||||||
|
}
|
||||||
|
if (m.contains('chat') || m.contains('gpt-4')) return 'chat';
|
||||||
|
return 'smart';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _hex(int len) {
|
||||||
|
const hex = '0123456789ABCDEF';
|
||||||
|
final r = Random();
|
||||||
|
return String.fromCharCodes(
|
||||||
|
Iterable.generate(len, (_) => hex.codeUnitAt(r.nextInt(16))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../http_client.dart';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import 'base_provider.dart';
|
||||||
|
|
||||||
|
class DeepAi extends BaseProvider {
|
||||||
|
@override
|
||||||
|
String get name => 'DeepAI';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get label => 'DeepAI';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get url => 'https://deepai.org/chat';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get working => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get supportsStream => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get defaultModel => 'standard';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, String> get modelAliases => const {
|
||||||
|
G4FModels.gpt4: 'standard',
|
||||||
|
G4FModels.gpt4o: 'standard',
|
||||||
|
G4FModels.gpt4oMini: 'standard',
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get models => [
|
||||||
|
'standard',
|
||||||
|
'online',
|
||||||
|
'gemma-4',
|
||||||
|
'gemini-2.5-flash-lite',
|
||||||
|
'deepseek-v3.2',
|
||||||
|
];
|
||||||
|
|
||||||
|
String _reverseString(String input) {
|
||||||
|
return input.split('').reversed.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _myHashFunction(String inputStr) {
|
||||||
|
var bytes = utf8.encode(inputStr);
|
||||||
|
var digest = md5.convert(bytes);
|
||||||
|
return _reverseString(digest.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
String _generateApiKey(String userAgent) {
|
||||||
|
final random = Random();
|
||||||
|
final myRandomStr = (random.nextDouble() * 100000000000).round().toString();
|
||||||
|
|
||||||
|
final hash1 = _myHashFunction(
|
||||||
|
userAgent + myRandomStr + "hackers_become_a_little_stinkier_every_time_they_hack");
|
||||||
|
final hash2 = _myHashFunction(userAgent + hash1);
|
||||||
|
final hash3 = _myHashFunction(userAgent + hash2);
|
||||||
|
|
||||||
|
return "tryit-$myRandomStr-$hash3";
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> createAsyncGenerator(
|
||||||
|
String model,
|
||||||
|
List<Message> messages, {
|
||||||
|
bool stream = true,
|
||||||
|
}) async* {
|
||||||
|
final userAgent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36';
|
||||||
|
final apiKey = _generateApiKey(userAgent);
|
||||||
|
|
||||||
|
final headers = {
|
||||||
|
'api-key': apiKey,
|
||||||
|
'user-agent': userAgent,
|
||||||
|
'origin': 'https://deepai.org',
|
||||||
|
'referer': 'https://deepai.org/chat',
|
||||||
|
};
|
||||||
|
|
||||||
|
final cleanedMessages = messages.map((m) => {'role': m.role.name, 'content': m.content}).toList();
|
||||||
|
|
||||||
|
var request = http.MultipartRequest('POST', Uri.parse('https://api.deepai.org/hacking_is_a_serious_crime'));
|
||||||
|
request.headers.addAll(headers);
|
||||||
|
|
||||||
|
request.fields['chat_style'] = 'chat';
|
||||||
|
request.fields['chatHistory'] = jsonEncode(cleanedMessages);
|
||||||
|
request.fields['model'] = model;
|
||||||
|
request.fields['session_uuid'] = const Uuid().v4();
|
||||||
|
request.fields['sensitivity_request_id'] = const Uuid().v4();
|
||||||
|
request.fields['hacker_is_stinky'] = 'very_stinky';
|
||||||
|
request.fields['enabled_tools'] = jsonEncode(["image_generator", "image_editor"]);
|
||||||
|
|
||||||
|
final response = await g4fHttp.send(request).timeout(requestTimeout);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
final errorText = await response.stream.bytesToString();
|
||||||
|
throw Exception('DeepAI API returned status: ${response.statusCode} $errorText');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fs = '\x1c';
|
||||||
|
var buffer = '';
|
||||||
|
await for (final chunk in response.stream.transform(utf8.decoder)) {
|
||||||
|
if (chunk.isEmpty) continue;
|
||||||
|
if (chunk.contains(fs) || buffer.contains(fs)) {
|
||||||
|
buffer += chunk;
|
||||||
|
final parts = buffer.split(fs);
|
||||||
|
if (parts[0].trim().isNotEmpty) {
|
||||||
|
yield parts[0];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
yield chunk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../http_client.dart';
|
||||||
|
import '../http_util.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import '../sse.dart';
|
||||||
|
import 'base_provider.dart';
|
||||||
|
|
||||||
|
/// ChatGPT-Next-Web / Free2GPT clones (`/api/generate` + md5 sign).
|
||||||
|
class Free2Gpt extends BaseProvider {
|
||||||
|
@override
|
||||||
|
String get name => 'Free2Gpt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get working => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get models => ['gpt-3.5-turbo', 'gpt-4o-mini', 'gemini'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get defaultModel => 'gpt-3.5-turbo';
|
||||||
|
|
||||||
|
static const _hosts = [
|
||||||
|
'https://chat.free2gpt.xyz',
|
||||||
|
'https://chatz.free2gpt.xyz',
|
||||||
|
'https://gpt.aifree.site',
|
||||||
|
'https://chat.aifree.site',
|
||||||
|
'https://gemini.free2gpt.com',
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> createAsyncGenerator(
|
||||||
|
String model,
|
||||||
|
List<Message> messages, {
|
||||||
|
bool stream = false,
|
||||||
|
}) async* {
|
||||||
|
Object? last;
|
||||||
|
for (final host in _hosts) {
|
||||||
|
try {
|
||||||
|
yield await _generate(host, messages);
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
last = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw Exception('Free2Gpt failed: $last');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _generate(String host, List<Message> messages) async {
|
||||||
|
final lastUser = getLastUserMessage(messages);
|
||||||
|
final t = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final sign = md5.convert(utf8.encode('$t:$lastUser:undefined')).toString();
|
||||||
|
final payload = jsonEncode({
|
||||||
|
'messages': toOpenAiMessages(messages),
|
||||||
|
'time': t,
|
||||||
|
'pass': null,
|
||||||
|
'sign': sign,
|
||||||
|
});
|
||||||
|
|
||||||
|
final urls = [
|
||||||
|
'$host/api/generate',
|
||||||
|
'$host/api/openai/v1/chat/completions',
|
||||||
|
];
|
||||||
|
Object? last;
|
||||||
|
for (final url in urls) {
|
||||||
|
try {
|
||||||
|
final req = http.Request('POST', Uri.parse(url));
|
||||||
|
req.headers.addAll(browserHeaders(
|
||||||
|
origin: host,
|
||||||
|
referer: '$host/',
|
||||||
|
contentType: 'application/json',
|
||||||
|
));
|
||||||
|
req.body = payload;
|
||||||
|
final res = await g4fHttp.send(req).timeout(const Duration(seconds: 14));
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
last = 'HTTP ${res.statusCode}';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final raw = utf8.decode(await res.stream.toBytes().timeout(const Duration(seconds: 14)));
|
||||||
|
final openai = extractOpenAiMessage(raw);
|
||||||
|
if (openai != null && looksLikeModelOutput(openai)) return openai.trim();
|
||||||
|
final sse = extractAllSseContent(raw).join();
|
||||||
|
if (looksLikeModelOutput(sse)) return sse.trim();
|
||||||
|
if (looksLikeModelOutput(raw)) return raw.trim();
|
||||||
|
last = 'unusable';
|
||||||
|
} catch (e) {
|
||||||
|
last = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw Exception('$host failed: $last');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
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 'base_provider.dart';
|
||||||
|
|
||||||
|
/// z.ai GLM web chat with a guest JWT. Captcha-protected replies fail fast.
|
||||||
|
class GlmZai extends BaseProvider {
|
||||||
|
@override
|
||||||
|
String get name => 'GLM';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get working => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get models => ['x-preview-l', 'GLM-5-Turbo', 'glm-4.7'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get defaultModel => 'x-preview-l';
|
||||||
|
|
||||||
|
static const _base = 'https://chat.z.ai';
|
||||||
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> createAsyncGenerator(
|
||||||
|
String model,
|
||||||
|
List<Message> messages, {
|
||||||
|
bool stream = false,
|
||||||
|
}) async* {
|
||||||
|
final authRes = await g4fHttp.get(
|
||||||
|
Uri.parse('$_base/api/v1/auths/'),
|
||||||
|
headers: browserHeaders(origin: _base, referer: '$_base/', desktop: true),
|
||||||
|
).timeout(const Duration(seconds: 12));
|
||||||
|
if (authRes.statusCode != 200) {
|
||||||
|
throw Exception('GLM auth ${authRes.statusCode}');
|
||||||
|
}
|
||||||
|
final auth = jsonDecode(authRes.body) as Map<String, dynamic>;
|
||||||
|
final token = auth['token']?.toString();
|
||||||
|
final userId = auth['id']?.toString() ?? '';
|
||||||
|
if (token == null || token.isEmpty) {
|
||||||
|
throw Exception('GLM: no guest token');
|
||||||
|
}
|
||||||
|
|
||||||
|
final headers = browserHeaders(
|
||||||
|
origin: _base,
|
||||||
|
referer: '$_base/',
|
||||||
|
contentType: 'application/json',
|
||||||
|
desktop: true,
|
||||||
|
extra: {
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
'x-fe-version': 'prod-fe-1.1.69',
|
||||||
|
'x-region': 'overseas',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
String useModel = defaultModel;
|
||||||
|
try {
|
||||||
|
final modelsRes = await g4fHttp.get(
|
||||||
|
Uri.parse('$_base/api/models'),
|
||||||
|
headers: headers,
|
||||||
|
).timeout(const Duration(seconds: 10));
|
||||||
|
if (modelsRes.statusCode == 200) {
|
||||||
|
final data = jsonDecode(modelsRes.body);
|
||||||
|
final list = (data is Map ? data['data'] : data) as List?;
|
||||||
|
if (list != null && list.isNotEmpty) {
|
||||||
|
final ids = [
|
||||||
|
for (final m in list)
|
||||||
|
if (m is Map && m['id'] != null) m['id'].toString()
|
||||||
|
];
|
||||||
|
if (ids.contains(model)) {
|
||||||
|
useModel = model;
|
||||||
|
} else if (ids.isNotEmpty) {
|
||||||
|
useModel = ids.first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
final chatRes = await g4fHttp.post(
|
||||||
|
Uri.parse('$_base/api/v1/chats/new'),
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode({
|
||||||
|
'chat': {
|
||||||
|
'id': _uuid.v4(),
|
||||||
|
'title': 'New Chat',
|
||||||
|
'models': [useModel],
|
||||||
|
'params': {},
|
||||||
|
'history': {'messages': {}, 'currentId': null},
|
||||||
|
'tags': [],
|
||||||
|
'flags': [],
|
||||||
|
'features': [],
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
).timeout(const Duration(seconds: 12));
|
||||||
|
if (chatRes.statusCode != 200) {
|
||||||
|
throw Exception('GLM new chat ${chatRes.statusCode}');
|
||||||
|
}
|
||||||
|
final chatJson = jsonDecode(chatRes.body) as Map<String, dynamic>;
|
||||||
|
final chatId = (chatJson['id'] ?? (chatJson['chat'] as Map?)?['id'])?.toString();
|
||||||
|
if (chatId == null) throw Exception('GLM: no chat id');
|
||||||
|
|
||||||
|
final ts = DateTime.now().millisecondsSinceEpoch.toString();
|
||||||
|
final requestId = _uuid.v4();
|
||||||
|
final sigSrc = 'requestId=$requestId,timestamp=$ts,user_id=$userId';
|
||||||
|
final signature = Hmac(sha256, utf8.encode('')).convert(utf8.encode(sigSrc)).toString();
|
||||||
|
headers['x-signature'] = signature;
|
||||||
|
|
||||||
|
final lastUser = getLastUserMessage(messages);
|
||||||
|
final body = jsonEncode({
|
||||||
|
'stream': true,
|
||||||
|
'model': useModel,
|
||||||
|
'messages': [
|
||||||
|
{'role': 'user', 'content': lastUser}
|
||||||
|
],
|
||||||
|
'signature_prompt': lastUser.substring(0, lastUser.length.clamp(0, 500)),
|
||||||
|
'params': {},
|
||||||
|
'extra': {},
|
||||||
|
'features': {
|
||||||
|
'image_generation': false,
|
||||||
|
'web_search': false,
|
||||||
|
'auto_web_search': false,
|
||||||
|
'preview_mode': true,
|
||||||
|
'enable_thinking': false,
|
||||||
|
},
|
||||||
|
'chat_id': chatId,
|
||||||
|
'id': _uuid.v4(),
|
||||||
|
});
|
||||||
|
|
||||||
|
final url = Uri.parse(
|
||||||
|
'$_base/api/v2/chat/completions?timestamp=$ts&requestId=$requestId&user_id=$userId&version=0.0.1&platform=web&token=$token',
|
||||||
|
);
|
||||||
|
final req = http.Request('POST', url);
|
||||||
|
req.headers.addAll(headers);
|
||||||
|
req.body = body;
|
||||||
|
final res = await g4fHttp.send(req).timeout(const Duration(seconds: 30));
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
throw Exception('GLM completions ${res.statusCode}');
|
||||||
|
}
|
||||||
|
|
||||||
|
final collected = StringBuffer();
|
||||||
|
await for (final line in splitSseLines(res.stream.transform(utf8.decoder))) {
|
||||||
|
final trimmed = line.trim();
|
||||||
|
if (trimmed.isEmpty || !trimmed.startsWith('data:')) continue;
|
||||||
|
final data = trimmed.substring(5).trim();
|
||||||
|
if (data.contains('CAPTCHA') || data.contains('captcha')) {
|
||||||
|
throw Exception('GLM captcha required');
|
||||||
|
}
|
||||||
|
final content = extractSseContent(data);
|
||||||
|
if (content != null && content.isNotEmpty) {
|
||||||
|
collected.write(content);
|
||||||
|
if (stream) yield content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final text = collected.toString().trim();
|
||||||
|
if (!looksLikeModelOutput(text)) {
|
||||||
|
throw Exception('GLM empty response');
|
||||||
|
}
|
||||||
|
if (!stream) yield text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
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 'base_provider.dart';
|
||||||
|
|
||||||
|
/// Port of g4f.Provider.Liaobots — guest auth code + chat.
|
||||||
|
class Liaobots extends BaseProvider {
|
||||||
|
@override
|
||||||
|
String get name => 'Liaobots';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get working => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get models => ['gpt-3.5-turbo', 'gpt-4'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get defaultModel => 'gpt-3.5-turbo';
|
||||||
|
|
||||||
|
static const _hosts = [
|
||||||
|
'https://liaobots.work',
|
||||||
|
'https://liaobots.site',
|
||||||
|
'https://liaobots.com',
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> createAsyncGenerator(
|
||||||
|
String model,
|
||||||
|
List<Message> messages, {
|
||||||
|
bool stream = false,
|
||||||
|
}) async* {
|
||||||
|
Object? last;
|
||||||
|
for (final host in _hosts) {
|
||||||
|
try {
|
||||||
|
yield await _chat(host, model, messages);
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
last = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw Exception('Liaobots failed: $last');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _chat(String host, String model, List<Message> messages) async {
|
||||||
|
final headers = browserHeaders(
|
||||||
|
origin: host,
|
||||||
|
referer: '$host/',
|
||||||
|
contentType: 'application/json',
|
||||||
|
);
|
||||||
|
final login = await g4fHttp
|
||||||
|
.post(
|
||||||
|
Uri.parse('$host/api/user'),
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode({'authcode': const Uuid().v4()}),
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 12));
|
||||||
|
String? auth;
|
||||||
|
if (login.statusCode == 200) {
|
||||||
|
try {
|
||||||
|
final data = jsonDecode(login.body);
|
||||||
|
auth = (data is Map ? (data['authCode'] ?? data['authcode']) : null)
|
||||||
|
?.toString();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
final req = http.Request('POST', Uri.parse('$host/api/chat'));
|
||||||
|
req.headers.addAll({
|
||||||
|
...headers,
|
||||||
|
if (auth != null) 'x-auth-code': auth,
|
||||||
|
if (auth != null) 'authorization': auth,
|
||||||
|
});
|
||||||
|
req.body = jsonEncode({
|
||||||
|
'conversationId': const Uuid().v4(),
|
||||||
|
'model': {
|
||||||
|
'id': models.contains(model) ? model : defaultModel,
|
||||||
|
'name': model,
|
||||||
|
'maxLength': 24000,
|
||||||
|
'tokenLimit': 8000,
|
||||||
|
},
|
||||||
|
'messages': toOpenAiMessages(messages),
|
||||||
|
'key': '',
|
||||||
|
'prompt': extractSystemMessage(messages),
|
||||||
|
});
|
||||||
|
final res = await g4fHttp.send(req).timeout(requestTimeout);
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
throw Exception('Liaobots ${res.statusCode} @ $host');
|
||||||
|
}
|
||||||
|
final raw = utf8.decode(await res.stream.toBytes().timeout(requestTimeout));
|
||||||
|
final sse = extractAllSseContent(raw).join();
|
||||||
|
final text = looksLikeModelOutput(sse) ? sse : raw.trim();
|
||||||
|
if (!looksLikeModelOutput(text)) throw Exception('Liaobots empty @ $host');
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
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 'base_provider.dart';
|
||||||
|
|
||||||
|
/// OpenAI-compatible `/v1/chat/completions` client used by several free proxies.
|
||||||
|
class OpenAiCompatProvider extends BaseProvider {
|
||||||
|
OpenAiCompatProvider({
|
||||||
|
required this.providerName,
|
||||||
|
required this.apiBase,
|
||||||
|
required this.fallbackModel,
|
||||||
|
List<String>? supportedModels,
|
||||||
|
this.apiKey,
|
||||||
|
this.extraHeaders = const {},
|
||||||
|
}) : supportedModels = supportedModels ?? [fallbackModel];
|
||||||
|
|
||||||
|
final String providerName;
|
||||||
|
final String apiBase;
|
||||||
|
final String fallbackModel;
|
||||||
|
final List<String> supportedModels;
|
||||||
|
final String? apiKey;
|
||||||
|
final Map<String, String> extraHeaders;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get name => providerName;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get working => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get models => supportedModels;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get defaultModel => fallbackModel;
|
||||||
|
|
||||||
|
String get chatUrl {
|
||||||
|
final base = apiBase.endsWith('/') ? apiBase.substring(0, apiBase.length - 1) : apiBase;
|
||||||
|
if (base.endsWith('/chat/completions')) return base;
|
||||||
|
return '$base/chat/completions';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> createAsyncGenerator(
|
||||||
|
String model,
|
||||||
|
List<Message> messages, {
|
||||||
|
bool stream = false,
|
||||||
|
}) async* {
|
||||||
|
final payload = <String, dynamic>{
|
||||||
|
'model': models.contains(model) ? model : defaultModel,
|
||||||
|
'messages': toOpenAiMessages(messages),
|
||||||
|
'stream': false,
|
||||||
|
'temperature': 0.8,
|
||||||
|
};
|
||||||
|
|
||||||
|
final headers = browserHeaders(
|
||||||
|
contentType: 'application/json',
|
||||||
|
accept: 'application/json',
|
||||||
|
extra: {
|
||||||
|
if (apiKey != null && apiKey!.isNotEmpty) 'Authorization': 'Bearer $apiKey',
|
||||||
|
...extraHeaders,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final req = http.Request('POST', Uri.parse(chatUrl));
|
||||||
|
req.headers.addAll(headers);
|
||||||
|
req.body = jsonEncode(payload);
|
||||||
|
|
||||||
|
final response = await g4fHttp.send(req).timeout(requestTimeout);
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
final err = await response.stream.bytesToString();
|
||||||
|
throw Exception(
|
||||||
|
'$name returned ${response.statusCode}: ${err.substring(0, err.length.clamp(0, 240))}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final body = utf8.decode(await response.stream.toBytes());
|
||||||
|
final fromJson = extractOpenAiMessage(body);
|
||||||
|
final fromSse = extractAllSseContent(body).join();
|
||||||
|
final text = (fromJson != null && looksLikeModelOutput(fromJson))
|
||||||
|
? fromJson
|
||||||
|
: fromSse;
|
||||||
|
if (!looksLikeModelOutput(text)) {
|
||||||
|
throw Exception('$name returned unusable body');
|
||||||
|
}
|
||||||
|
yield text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
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 'base_provider.dart';
|
||||||
|
|
||||||
|
/// Port of g4f.Provider.OperaAria — anonymous OAuth, collect SSE to one reply.
|
||||||
|
class OperaAria extends BaseProvider {
|
||||||
|
@override
|
||||||
|
String get name => 'OperaAria';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get working => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get models => ['aria'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get defaultModel => 'aria';
|
||||||
|
|
||||||
|
static const _tokenUrl = 'https://oauth2.opera-api.com/oauth2/v1/token/';
|
||||||
|
static const _signupUrl =
|
||||||
|
'https://auth.opera.com/account/v2/external/anonymous/signup';
|
||||||
|
static const _chatUrl = 'https://composer.opera-api.com/api/v2/a-chat';
|
||||||
|
static const _secret =
|
||||||
|
'Pcc5NvlCrxl02pMw32kO6WrnhpS0pUZ95YrDP8XNKJJQvFht4wQDkFJ7v9x5hn7C';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> createAsyncGenerator(
|
||||||
|
String model,
|
||||||
|
List<Message> messages, {
|
||||||
|
bool stream = false,
|
||||||
|
}) async* {
|
||||||
|
final tokenHeaders = {
|
||||||
|
'User-Agent': 'okhttp/5.3.2',
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'x-requested-with': 'XMLHttpRequest',
|
||||||
|
'x-opera-client-cache': '1',
|
||||||
|
};
|
||||||
|
final anon = await g4fHttp.post(
|
||||||
|
Uri.parse(_tokenUrl),
|
||||||
|
headers: tokenHeaders,
|
||||||
|
body: {
|
||||||
|
'client_id': 'mini-client',
|
||||||
|
'client_secret': _secret,
|
||||||
|
'grant_type': 'client_credentials',
|
||||||
|
'scope': 'anonymous_account',
|
||||||
|
},
|
||||||
|
).timeout(const Duration(seconds: 12));
|
||||||
|
if (anon.statusCode != 200) {
|
||||||
|
throw Exception('Opera anon ${anon.statusCode}');
|
||||||
|
}
|
||||||
|
final anonToken = jsonDecode(anon.body)['access_token'] as String;
|
||||||
|
|
||||||
|
final signup = await g4fHttp.post(
|
||||||
|
Uri.parse(_signupUrl),
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'okhttp/5.3.2',
|
||||||
|
'Authorization': 'Bearer $anonToken',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'x-requested-with': 'XMLHttpRequest',
|
||||||
|
'x-opera-client-cache': '1',
|
||||||
|
},
|
||||||
|
body: jsonEncode({'client_id': 'mini'}),
|
||||||
|
).timeout(const Duration(seconds: 12));
|
||||||
|
if (signup.statusCode != 200) {
|
||||||
|
throw Exception('Opera signup ${signup.statusCode}');
|
||||||
|
}
|
||||||
|
final authToken = jsonDecode(signup.body)['token'] as String;
|
||||||
|
|
||||||
|
final refresh = await g4fHttp.post(
|
||||||
|
Uri.parse(_tokenUrl),
|
||||||
|
headers: tokenHeaders,
|
||||||
|
body: {
|
||||||
|
'auth_token': authToken,
|
||||||
|
'client_id': 'mini',
|
||||||
|
'grant_type': 'auth_token',
|
||||||
|
'scope': 'shodan:aria',
|
||||||
|
},
|
||||||
|
).timeout(const Duration(seconds: 12));
|
||||||
|
if (refresh.statusCode != 200) {
|
||||||
|
throw Exception('Opera refresh ${refresh.statusCode}');
|
||||||
|
}
|
||||||
|
final access = jsonDecode(refresh.body)['access_token'] as String;
|
||||||
|
|
||||||
|
final req = http.Request('POST', Uri.parse(_chatUrl));
|
||||||
|
req.headers.addAll({
|
||||||
|
'Accept': 'text/event-stream',
|
||||||
|
'Authorization': 'Bearer $access',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Origin': 'https://composer.opera-api.com',
|
||||||
|
'Referer': 'https://composer.opera-api.com/assets/aria/index.html',
|
||||||
|
'User-Agent':
|
||||||
|
'Mozilla/5.0 (Linux; U; Android 14; Pixel 8 Pro Build/UQ1A.240205.004; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/138.0.7204.179 Mobile Safari/537.36 OPR/99.0.2254.81922',
|
||||||
|
'X-Opera-Timezone': '+03:00',
|
||||||
|
'X-Opera-UI-Language': 'en',
|
||||||
|
'X-Requested-With': 'com.opera.mini.native',
|
||||||
|
});
|
||||||
|
req.body = jsonEncode({
|
||||||
|
'query': formatPrompt(messages),
|
||||||
|
'sia': true,
|
||||||
|
'think_harder': false,
|
||||||
|
'supported_features': [],
|
||||||
|
'file_attachments': [],
|
||||||
|
});
|
||||||
|
final res = await g4fHttp.send(req).timeout(requestTimeout);
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
throw Exception('Opera chat ${res.statusCode}');
|
||||||
|
}
|
||||||
|
final raw = utf8.decode(await res.stream.toBytes().timeout(requestTimeout));
|
||||||
|
final buf = StringBuffer();
|
||||||
|
for (final piece in extractAllSseContent(raw)) {
|
||||||
|
buf.write(piece);
|
||||||
|
}
|
||||||
|
if (buf.isEmpty) {
|
||||||
|
// v2 envelope: {"response":{"message":"..."}}
|
||||||
|
for (final line in raw.split('\n')) {
|
||||||
|
if (!line.contains('{')) continue;
|
||||||
|
try {
|
||||||
|
final data = jsonDecode(line.startsWith('data:') ? line.substring(5) : line);
|
||||||
|
if (data is Map) {
|
||||||
|
final msg = data['response'] is Map
|
||||||
|
? (data['response'] as Map)['message']
|
||||||
|
: data['message'];
|
||||||
|
if (msg is String) buf.write(msg);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final text = buf.toString().trim();
|
||||||
|
if (!looksLikeModelOutput(text)) throw Exception('Opera empty');
|
||||||
|
yield text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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 'base_provider.dart';
|
||||||
|
|
||||||
|
class Pollinations extends BaseProvider {
|
||||||
|
@override
|
||||||
|
String get name => 'Pollinations';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get working => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get models => [
|
||||||
|
'openai-fast',
|
||||||
|
'openai',
|
||||||
|
'deepseek',
|
||||||
|
G4FModels.gpt4o,
|
||||||
|
G4FModels.mistral,
|
||||||
|
G4FModels.llama3,
|
||||||
|
G4FModels.qwen,
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get defaultModel => 'openai-fast';
|
||||||
|
|
||||||
|
static const _getBase = 'https://text.pollinations.ai';
|
||||||
|
static const _postUrl = 'https://gen.pollinations.ai/v1/chat/completions';
|
||||||
|
|
||||||
|
String _resolveModel(String model) {
|
||||||
|
switch (model) {
|
||||||
|
case G4FModels.gpt4o:
|
||||||
|
case G4FModels.gpt4oMini:
|
||||||
|
case G4FModels.gpt4:
|
||||||
|
return 'openai-fast';
|
||||||
|
case G4FModels.mistral:
|
||||||
|
case G4FModels.llama3:
|
||||||
|
case G4FModels.qwen:
|
||||||
|
return 'openai-fast';
|
||||||
|
default:
|
||||||
|
return models.contains(model) ? model : defaultModel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> createAsyncGenerator(
|
||||||
|
String model,
|
||||||
|
List<Message> messages, {
|
||||||
|
bool stream = false,
|
||||||
|
}) async* {
|
||||||
|
final resolved = _resolveModel(model);
|
||||||
|
try {
|
||||||
|
await for (final chunk in _getPrompt(resolved, messages, stream: stream)) {
|
||||||
|
yield chunk;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} catch (_) {
|
||||||
|
await for (final chunk in _postChat(resolved, messages, stream: stream)) {
|
||||||
|
yield chunk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<String> _postChat(
|
||||||
|
String model,
|
||||||
|
List<Message> messages, {
|
||||||
|
required bool stream,
|
||||||
|
}) async* {
|
||||||
|
final req = http.Request('POST', Uri.parse(_postUrl));
|
||||||
|
req.headers.addAll(browserHeaders(
|
||||||
|
contentType: 'application/json',
|
||||||
|
accept: stream ? 'text/event-stream' : 'application/json',
|
||||||
|
));
|
||||||
|
req.body = jsonEncode({
|
||||||
|
'model': model,
|
||||||
|
'messages': toOpenAiMessages(messages),
|
||||||
|
'stream': false, // anonymous POST rejects SSE
|
||||||
|
});
|
||||||
|
|
||||||
|
final response = await g4fHttp.send(req).timeout(const Duration(seconds: 30));
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
final err = await response.stream.bytesToString().timeout(const Duration(seconds: 4));
|
||||||
|
throw Exception('Pollinations POST ${response.statusCode}: $err');
|
||||||
|
}
|
||||||
|
final body = utf8.decode(await response.stream.toBytes().timeout(const Duration(seconds: 30)));
|
||||||
|
if (looksLikeGarbage(body)) {
|
||||||
|
throw Exception('Pollinations POST garbage');
|
||||||
|
}
|
||||||
|
final text = extractOpenAiMessage(body) ?? body.trim();
|
||||||
|
if (!looksLikeModelOutput(text)) {
|
||||||
|
throw Exception('Pollinations POST empty');
|
||||||
|
}
|
||||||
|
yield text;
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<String> _getPrompt(
|
||||||
|
String model,
|
||||||
|
List<Message> messages, {
|
||||||
|
required bool stream,
|
||||||
|
}) async* {
|
||||||
|
final seed = DateTime.now().millisecondsSinceEpoch % 100000;
|
||||||
|
final prompt = formatPrompt(messages);
|
||||||
|
final url = Uri.parse(
|
||||||
|
'$_getBase/${Uri.encodeComponent(prompt)}?model=$model&seed=$seed',
|
||||||
|
);
|
||||||
|
|
||||||
|
final req = http.Request('GET', url);
|
||||||
|
req.headers.addAll(browserHeaders());
|
||||||
|
final response = await g4fHttp.send(req).timeout(const Duration(seconds: 30));
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception('Pollinations returned status ${response.statusCode}');
|
||||||
|
}
|
||||||
|
final fullText = utf8
|
||||||
|
.decode(await response.stream.toBytes().timeout(const Duration(seconds: 30)))
|
||||||
|
.trim();
|
||||||
|
if (!looksLikeModelOutput(fullText)) {
|
||||||
|
throw Exception('Pollinations GET empty');
|
||||||
|
}
|
||||||
|
yield fullText;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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 'base_provider.dart';
|
||||||
|
|
||||||
|
/// Port of g4f.Provider.Quillbot — full JSON-lines reply, no UI stream.
|
||||||
|
class Quillbot extends BaseProvider {
|
||||||
|
@override
|
||||||
|
String get name => 'Quillbot';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get working => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get models => ['quillbot', 'quillbot-search'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get defaultModel => 'quillbot';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> createAsyncGenerator(
|
||||||
|
String model,
|
||||||
|
List<Message> messages, {
|
||||||
|
bool stream = false,
|
||||||
|
}) async* {
|
||||||
|
final conversationId = const Uuid().v4();
|
||||||
|
final url =
|
||||||
|
'https://quillbot.com/api/ai-chat/chat/conversation/$conversationId';
|
||||||
|
final payload = {
|
||||||
|
'message': {'content': '${formatPrompt(messages)}\n\n'},
|
||||||
|
'context': {
|
||||||
|
'editorContext': '',
|
||||||
|
'selectionContext': '',
|
||||||
|
'userDialect': 'en-us',
|
||||||
|
'apiVersion': 2,
|
||||||
|
},
|
||||||
|
'origin': {'name': 'ai-chat.chat', 'url': 'https://quillbot.com'},
|
||||||
|
if (model.contains('search')) 'tools': {'web_search_builtin': {}},
|
||||||
|
};
|
||||||
|
|
||||||
|
final req = http.Request('POST', Uri.parse(url));
|
||||||
|
req.headers.addAll(browserHeaders(
|
||||||
|
origin: 'https://quillbot.com',
|
||||||
|
referer: 'https://quillbot.com/ai-chat/c/$conversationId',
|
||||||
|
contentType: 'application/json',
|
||||||
|
desktop: true,
|
||||||
|
extra: {
|
||||||
|
'accept': 'text/event-stream',
|
||||||
|
'platform-type': 'webapp',
|
||||||
|
'qb-product': 'AI-CHAT',
|
||||||
|
'useridtoken': 'empty-token',
|
||||||
|
'webapp-version': '42.61.1',
|
||||||
|
},
|
||||||
|
));
|
||||||
|
req.body = jsonEncode(payload);
|
||||||
|
|
||||||
|
final res = await g4fHttp.send(req).timeout(requestTimeout);
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
throw Exception('Quillbot ${res.statusCode}');
|
||||||
|
}
|
||||||
|
final raw = utf8.decode(await res.stream.toBytes().timeout(requestTimeout));
|
||||||
|
final buf = StringBuffer();
|
||||||
|
for (final line in raw.split(RegExp(r'\r?\n'))) {
|
||||||
|
if (line.trim().isEmpty) continue;
|
||||||
|
try {
|
||||||
|
final data = jsonDecode(line);
|
||||||
|
if (data is Map && data['type'] == 'content' && data['content'] is String) {
|
||||||
|
buf.write(data['content']);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
final text = buf.toString().trim();
|
||||||
|
if (!looksLikeModelOutput(text)) {
|
||||||
|
throw Exception('Quillbot empty');
|
||||||
|
}
|
||||||
|
yield text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))}');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../http_client.dart';
|
||||||
|
import '../http_util.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import 'base_provider.dart';
|
||||||
|
import 'webview_base_provider.dart';
|
||||||
|
|
||||||
|
class TeachAnything extends BaseProvider {
|
||||||
|
@override
|
||||||
|
String get name => 'TeachAnything';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get url => 'https://www.teach-anything.com';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get working => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Duration get requestTimeout => const Duration(seconds: 90);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get models => ['gemma'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get defaultModel => 'gemma';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> createAsyncGenerator(
|
||||||
|
String model,
|
||||||
|
List<Message> messages, {
|
||||||
|
bool stream = false,
|
||||||
|
}) async* {
|
||||||
|
try {
|
||||||
|
yield await _rest(messages);
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('TeachAnything REST failed, trying origin WebView: $e');
|
||||||
|
}
|
||||||
|
yield await _viaWebView(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _rest(List<Message> messages) async {
|
||||||
|
final prompt = formatPrompt(messages);
|
||||||
|
final req = http.Request('POST', Uri.parse('$url/api/generate'));
|
||||||
|
req.headers.addAll({
|
||||||
|
'accept': '*/*',
|
||||||
|
'accept-language': 'en-US,en;q=0.9',
|
||||||
|
'cache-control': 'no-cache',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'origin': url,
|
||||||
|
'referer': '$url/',
|
||||||
|
'user-agent': kDesktopUserAgent,
|
||||||
|
});
|
||||||
|
req.body = jsonEncode({'prompt': prompt});
|
||||||
|
|
||||||
|
final response = await g4fHttp.send(req).timeout(requestTimeout);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception('$name returned status ${response.statusCode}');
|
||||||
|
}
|
||||||
|
final fullText = utf8.decode(await response.stream.toBytes()).trim();
|
||||||
|
if (!looksLikeModelOutput(fullText)) throw Exception('$name empty');
|
||||||
|
return fullText;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _viaWebView(List<Message> messages) async {
|
||||||
|
final promptJson = jsonEncode(formatPrompt(messages));
|
||||||
|
final raw = await runOriginWebView(
|
||||||
|
originUrl: url,
|
||||||
|
timeout: requestTimeout,
|
||||||
|
functionBody: '''
|
||||||
|
const prompt = $promptJson;
|
||||||
|
const r = await fetch('/api/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', 'accept': '*/*' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({ prompt })
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error('status ' + r.status);
|
||||||
|
return await r.text();
|
||||||
|
''',
|
||||||
|
);
|
||||||
|
final text = raw.trim();
|
||||||
|
if (!looksLikeModelOutput(text)) {
|
||||||
|
throw Exception(
|
||||||
|
'TeachAnything WebView unusable: ${text.substring(0, text.length.clamp(0, 180))}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import '../../models.dart';
|
||||||
|
import '../webview_base_provider.dart';
|
||||||
|
|
||||||
|
class DeepInfraWebView extends WebViewBaseProvider {
|
||||||
|
@override
|
||||||
|
String get name => 'DeepInfra';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get working => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get models => ['meta-llama/Meta-Llama-3-70B-Instruct', 'Qwen/Qwen2-72B-Instruct'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get defaultModel => 'meta-llama/Meta-Llama-3-70B-Instruct';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get baseUrl => 'https://deepinfra.com/chat';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get apiEndpoint => 'https://api.deepinfra.com/v1/openai/chat/completions';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String buildJsFetchCode(String model, List<Message> messages, bool stream) {
|
||||||
|
// 1. We extract the Turnstile token from the DOM
|
||||||
|
// 2. We make a fetch POST to the API endpoint
|
||||||
|
// 3. We return the JSON stringified response back to Dart
|
||||||
|
|
||||||
|
final messagesJson = jsonEncode(messages.map((m) => m.toJson()).toList());
|
||||||
|
|
||||||
|
return '''
|
||||||
|
(async function() {
|
||||||
|
// Wait up to 10 seconds for Turnstile token
|
||||||
|
let token = '';
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const input = document.querySelector('[name=cf-turnstile-response]');
|
||||||
|
if (input && input.value) {
|
||||||
|
token = input.value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return JSON.stringify({error: "Turnstile token not found"});
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
model: "$model",
|
||||||
|
messages: $messagesJson,
|
||||||
|
stream: false
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("$apiEndpoint", {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-Deepinfra-Source': 'web-page',
|
||||||
|
'X-Deepinfra-Turnstile': token
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return JSON.stringify(data);
|
||||||
|
} catch (e) {
|
||||||
|
return JSON.stringify({error: e.toString()});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
''';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String parseResponse(String rawResponse) {
|
||||||
|
try {
|
||||||
|
final json = jsonDecode(rawResponse);
|
||||||
|
if (json['error'] != null) {
|
||||||
|
throw Exception(json['error']);
|
||||||
|
}
|
||||||
|
return json['choices'][0]['message']['content'];
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Failed to parse DeepInfra response: $rawResponse | Error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||||
|
|
||||||
|
import '../http_client.dart';
|
||||||
|
import '../http_util.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import 'base_provider.dart';
|
||||||
|
|
||||||
|
/// Cookie to plant on the origin before the page loads (Python fake Copilot cookies).
|
||||||
|
class CookieSeed {
|
||||||
|
const CookieSeed({
|
||||||
|
required this.name,
|
||||||
|
required this.value,
|
||||||
|
this.domain,
|
||||||
|
this.path = '/',
|
||||||
|
this.isSecure = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final String value;
|
||||||
|
final String? domain;
|
||||||
|
final String path;
|
||||||
|
final bool isSecure;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load [originUrl] in a real Chromium WebView and run async JS on that origin.
|
||||||
|
///
|
||||||
|
/// This is the Android analog of g4f nodriver: same-origin `fetch` / WebSocket
|
||||||
|
/// after Cloudflare, not a generic fetch from about:blank (CORS → `{}`).
|
||||||
|
///
|
||||||
|
/// `evaluateJavascript` does not await Promises on Android and stringifies
|
||||||
|
/// them as `{}`. Always use `callAsyncJavaScript`.
|
||||||
|
Future<String> runOriginWebView({
|
||||||
|
required String originUrl,
|
||||||
|
required String functionBody,
|
||||||
|
Duration timeout = const Duration(seconds: 70),
|
||||||
|
List<CookieSeed> seeds = const [],
|
||||||
|
}) async {
|
||||||
|
if (kIsWeb ||
|
||||||
|
(!Platform.isAndroid &&
|
||||||
|
!Platform.isIOS &&
|
||||||
|
!Platform.isMacOS &&
|
||||||
|
!Platform.isWindows)) {
|
||||||
|
throw UnsupportedError('Origin WebView is not supported on this platform.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final originUri = Uri.parse(originUrl);
|
||||||
|
final cookieManager = CookieManager.instance();
|
||||||
|
for (final seed in seeds) {
|
||||||
|
try {
|
||||||
|
await cookieManager.setCookie(
|
||||||
|
url: WebUri(originUrl),
|
||||||
|
name: seed.name,
|
||||||
|
value: seed.value,
|
||||||
|
domain: seed.domain ?? originUri.host,
|
||||||
|
path: seed.path,
|
||||||
|
isSecure: seed.isSecure,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('WebView setCookie ${seed.name}: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final completer = Completer<String>();
|
||||||
|
var started = false;
|
||||||
|
HeadlessInAppWebView? view;
|
||||||
|
|
||||||
|
Future<void> importCookies() async {
|
||||||
|
try {
|
||||||
|
final cookies = await cookieManager.getCookies(url: WebUri(originUrl));
|
||||||
|
for (final c in cookies) {
|
||||||
|
g4fCookies.set(
|
||||||
|
name: c.name,
|
||||||
|
value: '${c.value}',
|
||||||
|
domain: c.domain ?? originUri.host,
|
||||||
|
path: c.path ?? '/',
|
||||||
|
secure: c.isSecure ?? originUri.scheme == 'https',
|
||||||
|
hostOnly: c.domain == null || c.domain == originUri.host,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('WebView import cookies: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> runScript(
|
||||||
|
InAppWebViewController controller, {
|
||||||
|
bool force = false,
|
||||||
|
}) async {
|
||||||
|
if (started || completer.isCompleted) return;
|
||||||
|
final title = (await controller.getTitle() ?? '').toLowerCase();
|
||||||
|
final challenge = title.contains('just a moment') ||
|
||||||
|
title.contains('attention required') ||
|
||||||
|
title.contains('cloudflare');
|
||||||
|
if (challenge && !force) return;
|
||||||
|
started = true;
|
||||||
|
try {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 800));
|
||||||
|
await importCookies();
|
||||||
|
|
||||||
|
final result = await controller.callAsyncJavaScript(
|
||||||
|
functionBody: functionBody,
|
||||||
|
);
|
||||||
|
if (result?.error != null && result!.error!.trim().isNotEmpty) {
|
||||||
|
throw Exception(result.error);
|
||||||
|
}
|
||||||
|
final value = result?.value;
|
||||||
|
if (value == null) {
|
||||||
|
throw Exception('WebView script returned null');
|
||||||
|
}
|
||||||
|
final raw = value is String ? value : jsonEncode(value);
|
||||||
|
if (!completer.isCompleted) completer.complete(raw);
|
||||||
|
} catch (e) {
|
||||||
|
if (!completer.isCompleted) completer.completeError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view = HeadlessInAppWebView(
|
||||||
|
initialUrlRequest: URLRequest(url: WebUri(originUrl)),
|
||||||
|
initialSettings: InAppWebViewSettings(
|
||||||
|
javaScriptEnabled: true,
|
||||||
|
domStorageEnabled: true,
|
||||||
|
thirdPartyCookiesEnabled: true,
|
||||||
|
incognito: false,
|
||||||
|
userAgent: kDesktopUserAgent,
|
||||||
|
isFraudulentWebsiteWarningEnabled: false,
|
||||||
|
cacheEnabled: true,
|
||||||
|
),
|
||||||
|
onLoadStop: (controller, url) async {
|
||||||
|
await runScript(controller);
|
||||||
|
},
|
||||||
|
onConsoleMessage: (controller, consoleMessage) {
|
||||||
|
debugPrint('WebView Console: ${consoleMessage.message}');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await view.run();
|
||||||
|
|
||||||
|
final ticker = Timer.periodic(const Duration(seconds: 3), (t) async {
|
||||||
|
if (completer.isCompleted) {
|
||||||
|
t.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final controller = view?.webViewController;
|
||||||
|
if (controller != null) {
|
||||||
|
await runScript(controller, force: t.tick >= 5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await completer.future.timeout(timeout);
|
||||||
|
} finally {
|
||||||
|
ticker.cancel();
|
||||||
|
await importCookies();
|
||||||
|
await view.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String wrapIifeAsFunctionBody(String iife) {
|
||||||
|
return '''
|
||||||
|
const __out = await $iife;
|
||||||
|
if (__out == null) return '';
|
||||||
|
if (typeof __out === 'string') return __out;
|
||||||
|
try { return JSON.stringify(__out); } catch (e) { return String(__out); }
|
||||||
|
''';
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class WebViewBaseProvider extends BaseProvider {
|
||||||
|
@override
|
||||||
|
bool get isWebView => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Duration get requestTimeout => const Duration(seconds: 90);
|
||||||
|
|
||||||
|
/// The origin to load so Cloudflare / same-origin fetch can run.
|
||||||
|
String get baseUrl;
|
||||||
|
|
||||||
|
/// The specific API endpoint to call via JS `fetch`.
|
||||||
|
String get apiEndpoint;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> createAsyncGenerator(
|
||||||
|
String model,
|
||||||
|
List<Message> messages, {
|
||||||
|
bool stream = false,
|
||||||
|
}) async* {
|
||||||
|
final raw = await runOriginWebView(
|
||||||
|
originUrl: baseUrl,
|
||||||
|
functionBody: wrapIifeAsFunctionBody(
|
||||||
|
buildJsFetchCode(model, messages, stream),
|
||||||
|
),
|
||||||
|
timeout: requestTimeout,
|
||||||
|
);
|
||||||
|
yield parseResponse(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds an IIFE that returns the API body (string or JSON).
|
||||||
|
String buildJsFetchCode(String model, List<Message> messages, bool stream);
|
||||||
|
|
||||||
|
/// Parses the raw string returned by the JS code into the final AI answer.
|
||||||
|
String parseResponse(String rawResponse);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import '../http_client.dart';
|
||||||
|
import '../http_util.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import '../sse.dart';
|
||||||
|
import 'base_provider.dart';
|
||||||
|
|
||||||
|
/// Port of g4f.Provider.You (default youchat mode, no login).
|
||||||
|
class YouChat extends BaseProvider {
|
||||||
|
@override
|
||||||
|
String get name => 'You';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get working => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get models => ['gpt-3.5-turbo', 'gpt-4o'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get defaultModel => 'gpt-3.5-turbo';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> createAsyncGenerator(
|
||||||
|
String model,
|
||||||
|
List<Message> messages, {
|
||||||
|
bool stream = false,
|
||||||
|
}) async* {
|
||||||
|
final res = await g4fHttp
|
||||||
|
.post(
|
||||||
|
Uri.parse('https://you.com/api/streamingSearch'),
|
||||||
|
headers: browserHeaders(
|
||||||
|
origin: 'https://you.com',
|
||||||
|
referer: 'https://you.com/search?fromSearchBar=true&tbm=youchat',
|
||||||
|
accept: 'text/event-stream',
|
||||||
|
),
|
||||||
|
body: {
|
||||||
|
'q': formatPrompt(messages),
|
||||||
|
'domain': 'youchat',
|
||||||
|
'selectedChatMode': 'default',
|
||||||
|
'chat': '[]',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.timeout(requestTimeout);
|
||||||
|
if (res.statusCode != 200) {
|
||||||
|
throw Exception('You ${res.statusCode}');
|
||||||
|
}
|
||||||
|
final raw = res.body;
|
||||||
|
final buf = StringBuffer();
|
||||||
|
String? event;
|
||||||
|
for (final line in raw.split(RegExp(r'\r?\n'))) {
|
||||||
|
if (line.startsWith('event:')) {
|
||||||
|
event = line.substring(6).trim();
|
||||||
|
} else if (line.startsWith('data:')) {
|
||||||
|
final dataStr = line.substring(5).trim();
|
||||||
|
try {
|
||||||
|
final data = jsonDecode(dataStr);
|
||||||
|
if (data is Map) {
|
||||||
|
if (event == 'youChatToken' && data['youChatToken'] is String) {
|
||||||
|
buf.write(data['youChatToken']);
|
||||||
|
} else if (event == 'youChatUpdate' && data['t'] is String) {
|
||||||
|
buf.write(data['t']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var text = buf.toString().trim();
|
||||||
|
if (!looksLikeModelOutput(text)) {
|
||||||
|
text = extractAllSseContent(raw).join().trim();
|
||||||
|
}
|
||||||
|
if (!looksLikeModelOutput(text)) throw Exception('You empty');
|
||||||
|
yield text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import '../http_client.dart';
|
||||||
|
import '../http_util.dart';
|
||||||
|
import '../models.dart';
|
||||||
|
import 'base_provider.dart';
|
||||||
|
|
||||||
|
/// Faithful port of `g4f.Provider.Yqcloud`.
|
||||||
|
class Yqcloud extends BaseProvider {
|
||||||
|
@override
|
||||||
|
String get name => 'Yqcloud';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get url => 'https://chat9.yqcloud.top';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get working => true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<String> get models => const ['gpt-4'];
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get defaultModel => 'gpt-4';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, String> get modelAliases => const {
|
||||||
|
G4FModels.gpt4o: 'gpt-4',
|
||||||
|
G4FModels.gpt4oMini: 'gpt-4',
|
||||||
|
};
|
||||||
|
|
||||||
|
static const _endpoint = 'https://api.binjie.fun/api/generateStream';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> createAsyncGenerator(
|
||||||
|
String model,
|
||||||
|
List<Message> messages, {
|
||||||
|
bool stream = false,
|
||||||
|
}) async* {
|
||||||
|
final systemMessage = extractSystemMessage(messages);
|
||||||
|
final history = [
|
||||||
|
for (final m in messages)
|
||||||
|
if (m.role != Role.system) m,
|
||||||
|
];
|
||||||
|
final prompt = formatPrompt(history, includeSystem: false);
|
||||||
|
final userId = '#/chat/${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
|
||||||
|
final req = http.Request('POST', Uri.parse(_endpoint));
|
||||||
|
req.headers.addAll({
|
||||||
|
'accept': 'application/json, text/plain, */*',
|
||||||
|
'accept-language': 'en-US,en;q=0.9',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'origin': url,
|
||||||
|
'referer': '$url/',
|
||||||
|
'user-agent': kDesktopUserAgent,
|
||||||
|
});
|
||||||
|
req.body = jsonEncode({
|
||||||
|
'prompt': prompt,
|
||||||
|
'userId': userId,
|
||||||
|
'network': true,
|
||||||
|
'system': systemMessage,
|
||||||
|
'withoutContext': false,
|
||||||
|
'stream': true,
|
||||||
|
});
|
||||||
|
|
||||||
|
final response = await g4fHttp.send(req).timeout(requestTimeout);
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
final err = await response.stream.bytesToString();
|
||||||
|
throw Exception('$name returned ${response.statusCode}: $err');
|
||||||
|
}
|
||||||
|
final fullText = utf8.decode(await response.stream.toBytes()).trim();
|
||||||
|
if (!looksLikeModelOutput(fullText) || fullText.contains('Error:')) {
|
||||||
|
throw Exception('$name unusable body');
|
||||||
|
}
|
||||||
|
yield fullText;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import 'http_client.dart';
|
||||||
|
import 'http_util.dart';
|
||||||
|
import 'models.dart';
|
||||||
|
import 'providers/base_provider.dart';
|
||||||
|
|
||||||
|
/// Port of g4f `IterListProvider`: try each provider until one returns content.
|
||||||
|
class IterListProvider {
|
||||||
|
IterListProvider(this.providers);
|
||||||
|
|
||||||
|
final List<BaseProvider> providers;
|
||||||
|
|
||||||
|
Future<String> create({
|
||||||
|
required List<Message> messages,
|
||||||
|
String? model,
|
||||||
|
}) async {
|
||||||
|
final exceptions = <String, Object>{};
|
||||||
|
final ordered = [
|
||||||
|
...providers.where((p) => p.working && !p.isWebView),
|
||||||
|
...providers.where((p) => p.working && p.isWebView),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (final provider in ordered) {
|
||||||
|
final alias = provider.resolveModel(model);
|
||||||
|
try {
|
||||||
|
await warmupOrigin(provider.url);
|
||||||
|
final result = await provider.complete(alias, messages);
|
||||||
|
if (looksLikeModelOutput(result)) {
|
||||||
|
return result.trim();
|
||||||
|
}
|
||||||
|
exceptions[provider.name] = 'unusable text (${result.trim().length} chars)';
|
||||||
|
} catch (e) {
|
||||||
|
exceptions[provider.name] = e;
|
||||||
|
print('Provider ${provider.name} failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exceptions.isEmpty) {
|
||||||
|
throw Exception('No G4F providers available.');
|
||||||
|
}
|
||||||
|
if (exceptions.length == 1) {
|
||||||
|
throw Exception('${exceptions.keys.first}: ${exceptions.values.first}');
|
||||||
|
}
|
||||||
|
throw Exception(
|
||||||
|
'RetryProvider failed:\n${exceptions.entries.map((e) => '${e.key}: ${e.value}').join('\n')}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
/// Reassembles SSE / text lines that TCP may split across chunks.
|
||||||
|
Stream<String> splitSseLines(Stream<String> input) async* {
|
||||||
|
final buf = StringBuffer();
|
||||||
|
await for (final chunk in input) {
|
||||||
|
buf.write(chunk);
|
||||||
|
var text = buf.toString();
|
||||||
|
final parts = text.split('\n');
|
||||||
|
buf
|
||||||
|
..clear()
|
||||||
|
..write(parts.removeLast());
|
||||||
|
for (final part in parts) {
|
||||||
|
yield part.endsWith('\r') ? part.substring(0, part.length - 1) : part;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (buf.isNotEmpty) {
|
||||||
|
final rest = buf.toString();
|
||||||
|
yield rest.endsWith('\r') ? rest.substring(0, rest.length - 1) : rest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pulls visible assistant text out of one SSE `data:` payload.
|
||||||
|
String? extractSseContent(String data) {
|
||||||
|
final trimmed = data.trim();
|
||||||
|
if (trimmed.isEmpty || trimmed == '[DONE]' || trimmed == 'null') return null;
|
||||||
|
if (trimmed.startsWith('[DONE]')) return null;
|
||||||
|
|
||||||
|
dynamic decoded;
|
||||||
|
try {
|
||||||
|
decoded = jsonDecode(trimmed);
|
||||||
|
} catch (_) {
|
||||||
|
// Plain-text SSE (Yqcloud / TeachAnything style).
|
||||||
|
if (looksLikeJson(trimmed)) return null;
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decoded is String) {
|
||||||
|
return decoded.isEmpty ? null : decoded;
|
||||||
|
}
|
||||||
|
if (decoded is! Map) return null;
|
||||||
|
|
||||||
|
final map = Map<String, dynamic>.from(decoded);
|
||||||
|
if (map['action'] == 'error' || map['error'] != null) {
|
||||||
|
final err = map['error'] ?? map['type'] ?? map['message'];
|
||||||
|
throw Exception('SSE error: $err');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duck.ai
|
||||||
|
final duck = map['message'];
|
||||||
|
if (duck is String && duck.isNotEmpty && map['role'] != 'user') {
|
||||||
|
return duck;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI chat.completions streaming
|
||||||
|
final choices = map['choices'];
|
||||||
|
if (choices is List && choices.isNotEmpty) {
|
||||||
|
final choice = choices.first;
|
||||||
|
if (choice is Map) {
|
||||||
|
final delta = choice['delta'];
|
||||||
|
if (delta is Map) {
|
||||||
|
final content = delta['content'];
|
||||||
|
if (content is String && content.isNotEmpty) return content;
|
||||||
|
}
|
||||||
|
final message = choice['message'];
|
||||||
|
if (message is Map) {
|
||||||
|
final content = message['content'];
|
||||||
|
if (content is String && content.isNotEmpty) return content;
|
||||||
|
}
|
||||||
|
final text = choice['text'];
|
||||||
|
if (text is String && text.isNotEmpty) return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GLM / z.ai
|
||||||
|
if (map['type'] == 'chat:completion') {
|
||||||
|
final inner = map['data'];
|
||||||
|
if (inner is Map) {
|
||||||
|
final err = inner['error'];
|
||||||
|
if (err != null) {
|
||||||
|
throw Exception('SSE error: $err');
|
||||||
|
}
|
||||||
|
final phase = inner['phase'];
|
||||||
|
if (phase == 'thinking') return null;
|
||||||
|
final delta = inner['delta_content'];
|
||||||
|
if (delta is String && delta.isNotEmpty && phase != 'thinking') {
|
||||||
|
return delta;
|
||||||
|
}
|
||||||
|
final edit = inner['edit_content'];
|
||||||
|
if (edit is String && edit.isNotEmpty) {
|
||||||
|
return edit.split('\n</details>\n').last;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final content = map['content'];
|
||||||
|
if (content is String && content.isNotEmpty) return content;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? extractOpenAiMessage(String body) {
|
||||||
|
final trimmed = body.trim();
|
||||||
|
if (trimmed.isEmpty) return null;
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(trimmed);
|
||||||
|
if (decoded is Map) {
|
||||||
|
if (decoded['error'] != null) {
|
||||||
|
throw Exception('API error: ${decoded['error']}');
|
||||||
|
}
|
||||||
|
final choices = decoded['choices'];
|
||||||
|
if (choices is List && choices.isNotEmpty) {
|
||||||
|
final choice = choices.first;
|
||||||
|
if (choice is Map) {
|
||||||
|
final message = choice['message'];
|
||||||
|
if (message is Map && message['content'] is String) {
|
||||||
|
return message['content'] as String;
|
||||||
|
}
|
||||||
|
if (choice['text'] is String) return choice['text'] as String;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (decoded['output_text'] is String) {
|
||||||
|
return decoded['output_text'] as String;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e.toString().startsWith('Exception: API error')) rethrow;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool looksLikeJson(String text) {
|
||||||
|
final t = text.trimLeft();
|
||||||
|
return t.startsWith('{') || t.startsWith('[');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a full SSE document (e.g. WebView returning response.text()).
|
||||||
|
Iterable<String> extractAllSseContent(String raw) sync* {
|
||||||
|
for (final line in raw.split(RegExp(r'\r?\n'))) {
|
||||||
|
final trimmed = line.trim();
|
||||||
|
if (trimmed.isEmpty) continue;
|
||||||
|
if (trimmed.startsWith('event:')) continue;
|
||||||
|
String data = trimmed;
|
||||||
|
if (trimmed.startsWith('data:')) {
|
||||||
|
data = trimmed.substring(5).trim();
|
||||||
|
}
|
||||||
|
final content = extractSseContent(data);
|
||||||
|
if (content != null && content.isNotEmpty) yield content;
|
||||||
|
}
|
||||||
|
}
|
||||||
+145
-1
@@ -2,9 +2,20 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'screens/history_screen.dart';
|
import 'screens/history_screen.dart';
|
||||||
import 'screens/home_search_screen.dart';
|
import 'screens/home_search_screen.dart';
|
||||||
|
import 'screens/oracle_chat_screen.dart';
|
||||||
|
import 'screens/provider_bakeoff_screen.dart';
|
||||||
import 'services/words_repository.dart';
|
import 'services/words_repository.dart';
|
||||||
import 'widgets/gradient_star_badge.dart';
|
import 'widgets/gradient_star_badge.dart';
|
||||||
|
|
||||||
|
import 'g4f/client.dart';
|
||||||
|
import 'g4f/models.dart';
|
||||||
|
import 'g4f/providers/chatgpt_oss.dart';
|
||||||
|
import 'g4f/providers/pollinations.dart';
|
||||||
|
import 'g4f/providers/teach_anything.dart';
|
||||||
|
import 'g4f/providers/yqcloud.dart';
|
||||||
|
import 'g4f/providers/deep_ai.dart';
|
||||||
|
import 'g4f/providers/phind_ai.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await WordsRepository().init();
|
await WordsRepository().init();
|
||||||
@@ -73,7 +84,9 @@ class _Words999AppState extends State<Words999App> {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
home: const MainNavigationContainer(),
|
home: const bool.fromEnvironment('ORACLE_BAKEOFF')
|
||||||
|
? const ProviderBakeoffScreen()
|
||||||
|
: const MainNavigationContainer(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,6 +132,137 @@ class _MainNavigationContainerState extends State<MainNavigationContainer> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
floatingActionButton: GestureDetector(
|
||||||
|
onLongPress: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const ProviderBakeoffScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: FloatingActionButton.extended(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const OracleChatScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.auto_awesome),
|
||||||
|
label: const Text("ОРАКУЛ"),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _G4FTestHelper {
|
||||||
|
Future<String> testAI() async {
|
||||||
|
final client = G4FClient();
|
||||||
|
// We access the providers from the client's internal list by just creating them here to test directly.
|
||||||
|
// Or simply test the main client logic. To test all, we will test them individually.
|
||||||
|
|
||||||
|
// We import them dynamically inside the test to keep main.dart clean long-term
|
||||||
|
final results = <String>[];
|
||||||
|
final messages = [Message(role: Role.user, content: "Коротко ответь 'Работает' и добавь одно случайное эмодзи.")];
|
||||||
|
|
||||||
|
// Yqcloud
|
||||||
|
try {
|
||||||
|
print("⏳ Тестируем Yqcloud...");
|
||||||
|
results.add("📡 Yqcloud: " + await _testProvider(client, "Yqcloud", messages));
|
||||||
|
print("✅ Yqcloud ответил!");
|
||||||
|
} catch (e) {
|
||||||
|
print("❌ Yqcloud Ошибка: $e");
|
||||||
|
results.add("❌ Yqcloud Ошибка: $e");
|
||||||
|
}
|
||||||
|
|
||||||
|
// TeachAnything
|
||||||
|
try {
|
||||||
|
print("⏳ Тестируем TeachAnything...");
|
||||||
|
results.add("📡 TeachAnything: " + await _testProvider(client, "TeachAnything", messages));
|
||||||
|
print("✅ TeachAnything ответил!");
|
||||||
|
} catch (e) {
|
||||||
|
print("❌ TeachAnything Ошибка: $e");
|
||||||
|
results.add("❌ TeachAnything Ошибка: $e");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatGptOss
|
||||||
|
try {
|
||||||
|
print("⏳ Тестируем ChatGptOss...");
|
||||||
|
results.add("📡 ChatGptOss: " + await _testProvider(client, "ChatGptOss", messages));
|
||||||
|
print("✅ ChatGptOss ответил!");
|
||||||
|
} catch (e) {
|
||||||
|
print("❌ ChatGptOss Ошибка: $e");
|
||||||
|
results.add("❌ ChatGptOss Ошибка: $e");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pollinations
|
||||||
|
try {
|
||||||
|
print("⏳ Тестируем Pollinations...");
|
||||||
|
results.add("📡 Pollinations: " + await _testProvider(client, "Pollinations", messages));
|
||||||
|
print("✅ Pollinations ответил!");
|
||||||
|
} catch (e) {
|
||||||
|
print("❌ Pollinations Ошибка: $e");
|
||||||
|
results.add("❌ Pollinations Ошибка: $e");
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepAi
|
||||||
|
try {
|
||||||
|
print("⏳ Тестируем DeepAi...");
|
||||||
|
results.add("📡 DeepAi: " + await _testProvider(client, "DeepAi", messages));
|
||||||
|
print("✅ DeepAi ответил!");
|
||||||
|
} catch (e) {
|
||||||
|
print("❌ DeepAi Ошибка: $e");
|
||||||
|
results.add("❌ DeepAi Ошибка: $e");
|
||||||
|
}
|
||||||
|
|
||||||
|
// PhindAi
|
||||||
|
try {
|
||||||
|
print("⏳ Тестируем PhindAi...");
|
||||||
|
results.add("📡 PhindAi: " + await _testProvider(client, "PhindAi", messages));
|
||||||
|
print("✅ PhindAi ответил!");
|
||||||
|
} catch (e) {
|
||||||
|
print("❌ PhindAi Ошибка: $e");
|
||||||
|
results.add("❌ PhindAi Ошибка: $e");
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepInfraWebView
|
||||||
|
try {
|
||||||
|
print("⏳ Тестируем DeepInfraWebView (в скрытом браузере)...");
|
||||||
|
String finalResult = "";
|
||||||
|
await for (final chunk in client.chat.completions.createStream(messages: messages)) {
|
||||||
|
finalResult += chunk;
|
||||||
|
}
|
||||||
|
results.add("📡 DeepInfraWebView: $finalResult");
|
||||||
|
print("✅ DeepInfraWebView ответил!");
|
||||||
|
} catch (e) {
|
||||||
|
print("❌ DeepInfraWebView Ошибка: $e");
|
||||||
|
results.add("❌ DeepInfraWebView Ошибка: $e");
|
||||||
|
}
|
||||||
|
|
||||||
|
print("🎉 Все тесты завершены!");
|
||||||
|
return results.join("\\n\\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _testProvider(G4FClient client, String providerName, List<Message> messages) async {
|
||||||
|
try {
|
||||||
|
dynamic provider;
|
||||||
|
switch (providerName) {
|
||||||
|
case "Yqcloud": provider = Yqcloud(); break;
|
||||||
|
case "TeachAnything": provider = TeachAnything(); break;
|
||||||
|
case "ChatGptOss": provider = ChatGptOss(); break;
|
||||||
|
case "Pollinations": provider = Pollinations(); break;
|
||||||
|
case "DeepAi": provider = DeepAi(); break;
|
||||||
|
case "PhindAi": provider = PhindAi(); break;
|
||||||
|
}
|
||||||
|
|
||||||
|
final stream = provider.createAsyncGenerator(
|
||||||
|
provider.defaultModel,
|
||||||
|
messages,
|
||||||
|
stream: false,
|
||||||
|
);
|
||||||
|
return await stream.join();
|
||||||
|
} catch (e) {
|
||||||
|
return "ОШИБКА: $e";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'dart:ui';
|
||||||
|
import '../g4f/client.dart';
|
||||||
|
import '../g4f/models.dart';
|
||||||
|
|
||||||
|
class OracleChatScreen extends StatefulWidget {
|
||||||
|
const OracleChatScreen({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<OracleChatScreen> createState() => _OracleChatScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChatMessage {
|
||||||
|
final String text;
|
||||||
|
final bool isUser;
|
||||||
|
|
||||||
|
ChatMessage({required this.text, required this.isUser});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OracleChatScreenState extends State<OracleChatScreen> {
|
||||||
|
final TextEditingController _controller = TextEditingController();
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
final G4FClient _client = G4FClient();
|
||||||
|
|
||||||
|
final List<ChatMessage> _messages = [
|
||||||
|
ChatMessage(
|
||||||
|
text: "Я Оракул. Мой взор проникает сквозь завесу времени. О чем ты хочешь спросить?",
|
||||||
|
isUser: false
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
void _scrollToBottom() {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (_scrollController.hasClients) {
|
||||||
|
_scrollController.animateTo(
|
||||||
|
_scrollController.position.maxScrollExtent,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static const _oracleSystem = Message(
|
||||||
|
role: Role.system,
|
||||||
|
content:
|
||||||
|
'Ты — Великий Оракул 999, древний архетипический разум. '
|
||||||
|
'Стиль речи: глубокий, философский, лаконичный, без банальностей. '
|
||||||
|
'Отвечай на языке вопрошающего. Не упоминай модели, провайдеров и технические сбои.',
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> _sendMessage() async {
|
||||||
|
final text = _controller.text.trim();
|
||||||
|
if (text.isEmpty || _isLoading) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_messages.add(ChatMessage(text: text, isUser: true));
|
||||||
|
_messages.add(ChatMessage(text: '', isUser: false));
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
_controller.clear();
|
||||||
|
_scrollToBottom();
|
||||||
|
|
||||||
|
final apiMessages = <Message>[
|
||||||
|
_oracleSystem,
|
||||||
|
for (final m in _messages)
|
||||||
|
if (m.text.trim().isNotEmpty)
|
||||||
|
Message(
|
||||||
|
role: m.isUser ? Role.user : Role.assistant,
|
||||||
|
content: m.text,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
final reply = await _client.chat.completions.create(
|
||||||
|
model: G4FModels.gpt4o,
|
||||||
|
messages: apiMessages,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
_setLastOracleText(
|
||||||
|
reply.trim().isEmpty
|
||||||
|
? 'Мой взор затуманен. Знаки молчат. Спроси ещё раз.'
|
||||||
|
: reply.trim(),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
_setLastOracleText('Мой взор затуманен. Связь прервалась.');
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
_scrollToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setLastOracleText(String text) {
|
||||||
|
setState(() {
|
||||||
|
_messages[_messages.length - 1] = ChatMessage(text: text, isUser: false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFF060B19),
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text("Оракул", style: TextStyle(color: Color(0xFFFFD700), fontWeight: FontWeight.w300, letterSpacing: 2.0)),
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: true,
|
||||||
|
iconTheme: const IconThemeData(color: Color(0xFFFFD700)),
|
||||||
|
),
|
||||||
|
extendBodyBehindAppBar: true,
|
||||||
|
body: Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Color(0xFF0D0415), Color(0xFF060B19)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
|
||||||
|
itemCount: _messages.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final msg = _messages[index];
|
||||||
|
return _buildMessageBubble(msg);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isLoading)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(8.0),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Color(0xFFFFD700),
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildInputArea(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMessageBubble(ChatMessage message) {
|
||||||
|
return Align(
|
||||||
|
alignment: message.isUser ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 16.0),
|
||||||
|
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.8),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: const Radius.circular(20),
|
||||||
|
topRight: const Radius.circular(20),
|
||||||
|
bottomLeft: Radius.circular(message.isUser ? 20 : 5),
|
||||||
|
bottomRight: Radius.circular(message.isUser ? 5 : 20),
|
||||||
|
),
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: message.isUser
|
||||||
|
? const Color(0xFF1E293B).withOpacity(0.6)
|
||||||
|
: const Color(0xFF2E1A47).withOpacity(0.4),
|
||||||
|
border: Border.all(
|
||||||
|
color: message.isUser
|
||||||
|
? Colors.white.withOpacity(0.1)
|
||||||
|
: const Color(0xFFFFD700).withOpacity(0.2),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
message.text,
|
||||||
|
style: TextStyle(
|
||||||
|
color: message.isUser ? Colors.white : const Color(0xFFE5E7EB),
|
||||||
|
fontSize: 16,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInputArea() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.only(left: 16, right: 16, top: 12, bottom: 24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF0D0415).withOpacity(0.8),
|
||||||
|
border: Border(
|
||||||
|
top: BorderSide(
|
||||||
|
color: const Color(0xFFFFD700).withOpacity(0.2),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withOpacity(0.05),
|
||||||
|
borderRadius: BorderRadius.circular(30),
|
||||||
|
border: Border.all(color: Colors.white.withOpacity(0.1)),
|
||||||
|
),
|
||||||
|
child: TextField(
|
||||||
|
controller: _controller,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: "Спроси Оракула...",
|
||||||
|
hintStyle: TextStyle(color: Colors.white38),
|
||||||
|
border: InputBorder.none,
|
||||||
|
contentPadding: EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.send,
|
||||||
|
onSubmitted: (_) => _sendMessage(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _isLoading ? null : _sendMessage,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: const RadialGradient(
|
||||||
|
colors: [Color(0xFFFFD700), Color(0xFFB8860B)],
|
||||||
|
center: Alignment.topLeft,
|
||||||
|
radius: 1.0,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFFFFD700).withOpacity(0.3),
|
||||||
|
blurRadius: 12,
|
||||||
|
spreadRadius: 2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.auto_awesome,
|
||||||
|
color: Color(0xFF2E1A47),
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../data/words_data.dart';
|
||||||
|
import '../g4f/g4f.dart';
|
||||||
|
import '../models/history_entry_model.dart';
|
||||||
|
import '../models/word_model.dart';
|
||||||
|
import '../services/ai_oracle_service.dart';
|
||||||
|
|
||||||
|
class ProviderProbeRow {
|
||||||
|
ProviderProbeRow({
|
||||||
|
required this.provider,
|
||||||
|
this.running = false,
|
||||||
|
this.ok,
|
||||||
|
this.text = '',
|
||||||
|
this.error,
|
||||||
|
this.elapsedMs = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
final BaseProvider provider;
|
||||||
|
bool running;
|
||||||
|
bool? ok;
|
||||||
|
String text;
|
||||||
|
String? error;
|
||||||
|
int elapsedMs;
|
||||||
|
|
||||||
|
String get name => provider.name;
|
||||||
|
bool get isWebView => provider.isWebView;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On-device comparison: the real Oracle prompt, every G4F provider, Android process.
|
||||||
|
class ProviderBakeoffScreen extends StatefulWidget {
|
||||||
|
const ProviderBakeoffScreen({super.key, this.autoStart = true});
|
||||||
|
|
||||||
|
final bool autoStart;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ProviderBakeoffScreen> createState() => _ProviderBakeoffScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProviderBakeoffScreenState extends State<ProviderBakeoffScreen> {
|
||||||
|
static const _question = 'Что скрыто в моём нынешнем выборе?';
|
||||||
|
|
||||||
|
late final List<SessionWord> _words;
|
||||||
|
late final List<Message> _messages;
|
||||||
|
late final List<ProviderProbeRow> _rows;
|
||||||
|
bool _running = false;
|
||||||
|
bool _done = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_words = _pickWords();
|
||||||
|
_messages = AiOracleService().buildInterpretationMessages(
|
||||||
|
words: _words,
|
||||||
|
question: _question,
|
||||||
|
);
|
||||||
|
_rows = [
|
||||||
|
for (final p in G4FClient().chat.completions.providers)
|
||||||
|
if (p.working) ProviderProbeRow(provider: p),
|
||||||
|
];
|
||||||
|
if (widget.autoStart) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _run());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SessionWord> _pickWords() {
|
||||||
|
WordItem byName(String name) => kRussianTop1000Words.firstWhere(
|
||||||
|
(w) => w.word == name,
|
||||||
|
);
|
||||||
|
final items = [
|
||||||
|
byName('огонь').copyWith(sessionNumber: 17),
|
||||||
|
byName('зеркало').copyWith(sessionNumber: 92),
|
||||||
|
byName('порог').copyWith(sessionNumber: 112),
|
||||||
|
];
|
||||||
|
return [
|
||||||
|
for (final item in items) SessionWord.fromWordItem(item),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _run() async {
|
||||||
|
if (_running) return;
|
||||||
|
setState(() {
|
||||||
|
_running = true;
|
||||||
|
_done = false;
|
||||||
|
for (final row in _rows) {
|
||||||
|
row.running = false;
|
||||||
|
row.ok = null;
|
||||||
|
row.text = '';
|
||||||
|
row.error = null;
|
||||||
|
row.elapsedMs = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_log('BEGIN question=$_question words=${_words.map((w) => w.word).join(",")}');
|
||||||
|
|
||||||
|
for (final row in _rows) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => row.running = true);
|
||||||
|
_log('START ${row.name} webview=${row.isWebView}');
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
try {
|
||||||
|
final model = row.provider.models.contains(G4FModels.gpt4o)
|
||||||
|
? G4FModels.gpt4o
|
||||||
|
: row.provider.defaultModel;
|
||||||
|
final text = await row.provider.complete(model, _messages);
|
||||||
|
sw.stop();
|
||||||
|
final ok = looksLikeModelOutput(text) && text.trim().length >= 2;
|
||||||
|
row
|
||||||
|
..ok = ok
|
||||||
|
..text = text.trim()
|
||||||
|
..error = ok ? null : 'unusable'
|
||||||
|
..elapsedMs = sw.elapsedMilliseconds
|
||||||
|
..running = false;
|
||||||
|
_log(
|
||||||
|
'DONE ${row.name} ok=$ok ms=${row.elapsedMs} chars=${row.text.length}',
|
||||||
|
);
|
||||||
|
_log('TEXT ${row.name} ${jsonEncode(row.text)}');
|
||||||
|
} catch (e) {
|
||||||
|
sw.stop();
|
||||||
|
row
|
||||||
|
..ok = false
|
||||||
|
..error = e.toString()
|
||||||
|
..elapsedMs = sw.elapsedMilliseconds
|
||||||
|
..running = false;
|
||||||
|
_log('FAIL ${row.name} ms=${row.elapsedMs} $e');
|
||||||
|
}
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
await _writeReport();
|
||||||
|
_log('END ok=${_rows.where((r) => r.ok == true).length}/${_rows.length}');
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_running = false;
|
||||||
|
_done = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _writeReport() async {
|
||||||
|
final report = {
|
||||||
|
'platform': Platform.operatingSystem,
|
||||||
|
'question': _question,
|
||||||
|
'words': [
|
||||||
|
for (final w in _words)
|
||||||
|
{
|
||||||
|
'word': w.word,
|
||||||
|
'sessionNumber': w.sessionNumber,
|
||||||
|
'category': w.category,
|
||||||
|
'symbolicMeaning': w.symbolicMeaning,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'results': [
|
||||||
|
for (final r in _rows)
|
||||||
|
{
|
||||||
|
'name': r.name,
|
||||||
|
'webview': r.isWebView,
|
||||||
|
'ok': r.ok,
|
||||||
|
'ms': r.elapsedMs,
|
||||||
|
'error': r.error,
|
||||||
|
'text': r.text,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
};
|
||||||
|
final encoded = const JsonEncoder.withIndent(' ').convert(report);
|
||||||
|
_log('REPORT $encoded');
|
||||||
|
try {
|
||||||
|
final file = File('/data/data/com.example.words999.words999/cache/oracle_bakeoff.json');
|
||||||
|
await file.parent.create(recursive: true);
|
||||||
|
await file.writeAsString(encoded);
|
||||||
|
_log('WROTE ${file.path}');
|
||||||
|
} catch (e) {
|
||||||
|
_log('WRITE_FAIL $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _log(String line) {
|
||||||
|
debugPrint('ORACLE_BAKEOFF|$line');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final okCount = _rows.where((r) => r.ok == true).length;
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFF0C0E1A),
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Сравнение провайдеров'),
|
||||||
|
actions: [
|
||||||
|
if (!_running)
|
||||||
|
IconButton(
|
||||||
|
onPressed: _run,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
tooltip: 'Запустить снова',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 32),
|
||||||
|
children: [
|
||||||
|
_headerCard(okCount),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
for (final row in _rows) ...[
|
||||||
|
_resultCard(row),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _headerCard(int okCount) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF151829),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.25)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_running
|
||||||
|
? 'Идёт опрос на Android…'
|
||||||
|
: _done
|
||||||
|
? 'Готово: $okCount из ${_rows.length} ответили'
|
||||||
|
: 'Ожидание запуска',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFFFFD54F),
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Вопрос: «$_question»',
|
||||||
|
style: const TextStyle(color: Color(0xFFE8EAF6), height: 1.35),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Знаки: ${_words.map((w) => '«${w.word.toUpperCase()}» (#${w.formattedSessionNumber})').join(' → ')}',
|
||||||
|
style: const TextStyle(color: Color(0xFF80DEEA), height: 1.35),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
const Text(
|
||||||
|
'Тот же system-промпт и user-промпт, что у interpretSession. Каждый провайдер вызывается отдельно, без запасного переключения.',
|
||||||
|
style: TextStyle(color: Color(0xFF8C93B8), fontSize: 12, height: 1.35),
|
||||||
|
),
|
||||||
|
if (_running)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 12),
|
||||||
|
child: LinearProgressIndicator(color: Color(0xFFFFD54F)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _resultCard(ProviderProbeRow row) {
|
||||||
|
final Color accent;
|
||||||
|
if (row.running) {
|
||||||
|
accent = const Color(0xFF80DEEA);
|
||||||
|
} else if (row.ok == true) {
|
||||||
|
accent = const Color(0xFF81C784);
|
||||||
|
} else if (row.ok == false) {
|
||||||
|
accent = const Color(0xFFE57373);
|
||||||
|
} else {
|
||||||
|
accent = const Color(0xFF5C638A);
|
||||||
|
}
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF151829),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: accent.withValues(alpha: 0.45)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
row.running
|
||||||
|
? Icons.hourglass_top
|
||||||
|
: row.ok == true
|
||||||
|
? Icons.check_circle
|
||||||
|
: row.ok == false
|
||||||
|
? Icons.cancel
|
||||||
|
: Icons.circle_outlined,
|
||||||
|
color: accent,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
row.name,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFFFFF9C4),
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
row.isWebView ? 'WebView' : 'REST',
|
||||||
|
style: const TextStyle(color: Color(0xFF8C93B8), fontSize: 11),
|
||||||
|
),
|
||||||
|
if (row.elapsedMs > 0) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'${(row.elapsedMs / 1000).toStringAsFixed(1)}с',
|
||||||
|
style: const TextStyle(color: Color(0xFF8C93B8), fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (row.running)
|
||||||
|
const Text('Ждём полный ответ…', style: TextStyle(color: Color(0xFF80DEEA)))
|
||||||
|
else if (row.ok == true)
|
||||||
|
SelectableText(
|
||||||
|
row.text,
|
||||||
|
style: const TextStyle(color: Color(0xFFE8EAF6), height: 1.4, fontSize: 14),
|
||||||
|
)
|
||||||
|
else if (row.ok == false)
|
||||||
|
Text(
|
||||||
|
row.error ?? 'ошибка',
|
||||||
|
style: const TextStyle(color: Color(0xFFE57373), fontSize: 12, height: 1.35),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const Text('в очереди', style: TextStyle(color: Color(0xFF5C638A), fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,32 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
import '../models/history_entry_model.dart';
|
import '../models/history_entry_model.dart';
|
||||||
import '../models/word_model.dart';
|
import '../models/word_model.dart';
|
||||||
|
import '../g4f/g4f.dart';
|
||||||
|
|
||||||
class AiOracleService {
|
class AiOracleService {
|
||||||
static final AiOracleService _instance = AiOracleService._internal();
|
static final AiOracleService _instance = AiOracleService._internal();
|
||||||
factory AiOracleService() => _instance;
|
factory AiOracleService() => _instance;
|
||||||
AiOracleService._internal();
|
AiOracleService._internal();
|
||||||
|
|
||||||
|
final _client = G4FClient();
|
||||||
|
|
||||||
|
static const oracleSystemPrompt =
|
||||||
|
'''Ты — Великий Оракул 999, древний архетипический разум.
|
||||||
|
Твоя задача — толковать знаки судьбы (жребий). Твой стиль речи: глубокий, философский, лаконичный, без банальностей и воды, словно голос самой вечности.
|
||||||
|
Ты не используешь современных или бытовых слов. Твои ответы строятся на символизме, психологии и скрытых смыслах. Ты не гадаешь на будущее, ты показываешь суть настоящего момента и внутренние силы человека.''';
|
||||||
|
|
||||||
|
List<Message> buildInterpretationMessages({
|
||||||
|
required List<SessionWord> words,
|
||||||
|
required String question,
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
const Message(role: Role.system, content: oracleSystemPrompt),
|
||||||
|
Message(
|
||||||
|
role: Role.user,
|
||||||
|
content: _buildMultiWordPrompt(question: question, words: words),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/// Interprets a full multi-word session narrative (1 to 9 words)
|
/// Interprets a full multi-word session narrative (1 to 9 words)
|
||||||
Future<String> interpretSession({
|
Future<String> interpretSession({
|
||||||
required List<SessionWord> words,
|
required List<SessionWord> words,
|
||||||
@@ -15,20 +34,20 @@ class AiOracleService {
|
|||||||
}) async {
|
}) async {
|
||||||
if (words.isEmpty) return 'Нет знаков для трактовки.';
|
if (words.isEmpty) return 'Нет знаков для трактовки.';
|
||||||
|
|
||||||
// 1. Try Live Online Endpoints (Fast timeout)
|
|
||||||
try {
|
try {
|
||||||
final prompt = _buildMultiWordPrompt(
|
final g4fResult = await _client.chat.completions.create(
|
||||||
question: question,
|
model: G4FModels.gpt4o,
|
||||||
words: words,
|
messages: buildInterpretationMessages(words: words, question: question),
|
||||||
);
|
);
|
||||||
|
|
||||||
final onlineResult = await _tryFetchOnlineAi(prompt);
|
if (g4fResult.trim().length > 20) {
|
||||||
if (onlineResult != null && onlineResult.trim().length > 30) {
|
return g4fResult.trim();
|
||||||
return onlineResult.trim();
|
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (e) {
|
||||||
|
print('G4F Error: $e');
|
||||||
|
}
|
||||||
|
|
||||||
// 2. High-Dimensional Deep Semantic Synthesizer (Instant & Deep)
|
// High-Dimensional Deep Semantic Synthesizer (Instant offline fallback)
|
||||||
return _synthesizeDeepSacredNarrative(words, question);
|
return _synthesizeDeepSacredNarrative(words, question);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,147 +63,125 @@ class AiOracleService {
|
|||||||
wordsNarrative.writeln('${i + 1}. #${w.formattedSessionNumber} «${w.word.toUpperCase()}» (${w.category}): ${w.symbolicMeaning}');
|
wordsNarrative.writeln('${i + 1}. #${w.formattedSessionNumber} «${w.word.toUpperCase()}» (${w.category}): ${w.symbolicMeaning}');
|
||||||
}
|
}
|
||||||
|
|
||||||
final prompt = '''Ты — Сакральный Оракул 999. Ответь на вопрос вопрошающего: «$userMessage»
|
final prompt = '''Вопрошающий говорит тебе: «$userMessage»
|
||||||
Контекст:
|
|
||||||
Вопрос: «${session.question ?? 'Поиск пути'}»
|
Контекст текущего сеанса судьбы:
|
||||||
Выпавшие знаки:
|
Вопрос вопрошающего: «${session.question ?? 'Вектор судьбы (нет вопроса)'}»
|
||||||
|
Базовые знаки, которые выпали ранее:
|
||||||
$wordsNarrative
|
$wordsNarrative
|
||||||
${newlyAddedWord != null ? "Дополнительный знак: #${newlyAddedWord.formattedSessionNumber} «${newlyAddedWord.word.toUpperCase()}» (${newlyAddedWord.symbolicMeaning})" : ""}
|
${newlyAddedWord != null ? "Важно! Только что вопрошающий вытянул ДОПОЛНИТЕЛЬНЫЙ уточняющий знак: #${newlyAddedWord.formattedSessionNumber} «${newlyAddedWord.word.toUpperCase()}» (${newlyAddedWord.symbolicMeaning})." : ""}
|
||||||
Дай мудрый, глубокий и прямой ответ (2-4 предложения).''';
|
|
||||||
|
Ответь мудро, лаконично и метафорично от лица Оракула 999. Укажи, на что направляет его судьба. (не более 3-4 предложений).''';
|
||||||
|
|
||||||
|
final messages = [
|
||||||
|
const Message(
|
||||||
|
role: Role.system,
|
||||||
|
content: 'Ты — Великий Оракул 999. Твои ответы кратки, полны символизма и психологической глубины. Ты общаешься с человеком, который ищет ответов в знаках.'
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Pass previous session history context to the model
|
||||||
|
for (final m in session.messages) {
|
||||||
|
if (m.sender == 'oracle') {
|
||||||
|
messages.add(Message(role: Role.assistant, content: m.text));
|
||||||
|
} else {
|
||||||
|
messages.add(Message(role: Role.user, content: m.text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.add(Message(role: Role.user, content: prompt));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final onlineResult = await _tryFetchOnlineAi(prompt);
|
final g4fResult = await _client.chat.completions.create(
|
||||||
if (onlineResult != null && onlineResult.trim().length > 20) {
|
model: G4FModels.gpt4o,
|
||||||
return onlineResult.trim();
|
messages: messages,
|
||||||
|
);
|
||||||
|
if (g4fResult.trim().length > 15) {
|
||||||
|
return g4fResult.trim();
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
return _synthesizeChatReply(session, userMessage, newlyAddedWord);
|
return _synthesizeChatReply(session, userMessage, newlyAddedWord);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> _tryFetchOnlineAi(String prompt) async {
|
|
||||||
const workingModels = [
|
|
||||||
'gpt-4o-mini',
|
|
||||||
'gpt-4o',
|
|
||||||
'command-r',
|
|
||||||
'command-r7b',
|
|
||||||
'Yqcloud',
|
|
||||||
'HuggingSpace',
|
|
||||||
'WeWordle',
|
|
||||||
];
|
|
||||||
|
|
||||||
final endpoints = [
|
|
||||||
'http://localhost:8080/v1/chat/completions',
|
|
||||||
'http://127.0.0.1:8080/v1/chat/completions',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (final ep in endpoints) {
|
|
||||||
for (final modelName in workingModels) {
|
|
||||||
try {
|
|
||||||
final res = await http.post(
|
|
||||||
Uri.parse(ep),
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: jsonEncode({
|
|
||||||
'model': modelName,
|
|
||||||
'messages': [{'role': 'user', 'content': prompt}],
|
|
||||||
'temperature': 0.7,
|
|
||||||
}),
|
|
||||||
).timeout(const Duration(seconds: 10));
|
|
||||||
|
|
||||||
if (res.statusCode == 200) {
|
|
||||||
final data = jsonDecode(utf8.decode(res.bodyBytes));
|
|
||||||
final content = data['choices']?[0]?['message']?['content'] as String?;
|
|
||||||
if (content != null && content.trim().length > 15) {
|
|
||||||
return content.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
String _buildMultiWordPrompt({
|
String _buildMultiWordPrompt({
|
||||||
required String question,
|
required String question,
|
||||||
required List<SessionWord> words,
|
required List<SessionWord> words,
|
||||||
}) {
|
}) {
|
||||||
final qStr = question.trim().isNotEmpty ? 'Вопрос: «$question»' : 'Общий расклад судьбы';
|
final qStr = question.trim().isNotEmpty
|
||||||
|
? 'Вопрос, с которым пришел вопрошающий: «$question»'
|
||||||
|
: 'Вопрошающий пришел без конкретного вопроса. Прочитай его общую судьбоносную линию.';
|
||||||
|
|
||||||
final wordsStr = words
|
final wordsStr = words
|
||||||
.asMap()
|
.asMap()
|
||||||
.entries
|
.entries
|
||||||
.map((e) => '${e.key + 1}. #${e.value.formattedSessionNumber} «${e.value.word.toUpperCase()}» (${e.value.category}) — ${e.value.symbolicMeaning}')
|
.map((e) => 'Знак ${e.key + 1}: #${e.value.formattedSessionNumber} «${e.value.word.toUpperCase()}» (${e.value.category}). Смысл знака: ${e.value.symbolicMeaning}')
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
return '''Ты — Священный Оракул 999. Составь цельное, глубокое, психологически точное толкование выпавших знаков жребия.
|
return '''$qStr
|
||||||
$qStr
|
|
||||||
|
|
||||||
Выпавшие знаки:
|
Волею судьбы выпали следующие знаки жребия:
|
||||||
$wordsStr
|
$wordsStr
|
||||||
|
|
||||||
Требования:
|
Задача Оракула:
|
||||||
- Свяжи выпавшие знаки в единую осмысленную историю-трансформацию.
|
1. Свяжи эти знаки в единую цельную историю развития ситуации. Каждый знак перетекает в следующий.
|
||||||
- Укажи скрытую причину, ключевой вызов и вектор исхода.
|
2. Раскрой не очевидный, а скрытый внутренний урок. Что вопрошающий должен осознать?
|
||||||
- Ответ на русском языке (3-5 предложений), образный, сильный, без шаблонных фраз.''';
|
3. Дай философский совет или вектор для действия.
|
||||||
|
Ответь от лица Оракула 999 на русском языке (от 3 до 6 предложений). Избегай шаблонов и клише, говори образно и метафорично, но точно.''';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// High-dimensional Deep Semantic Synthesizer
|
|
||||||
String _synthesizeDeepSacredNarrative(List<SessionWord> words, String question) {
|
String _synthesizeDeepSacredNarrative(List<SessionWord> words, String question) {
|
||||||
final sb = StringBuffer();
|
final sb = StringBuffer();
|
||||||
final first = words.first;
|
final first = words.first;
|
||||||
final last = words.last;
|
final last = words.last;
|
||||||
final hasQuestion = question.trim().isNotEmpty;
|
final hasQuestion = question.trim().isNotEmpty;
|
||||||
|
|
||||||
// Introduction / Framing
|
|
||||||
if (hasQuestion) {
|
if (hasQuestion) {
|
||||||
sb.write('Относительно вашего вопроса «${question.trim()}» ');
|
sb.write('Относительно вашего вопроса «${question.trim()}» ');
|
||||||
} else {
|
} else {
|
||||||
sb.write('В потоке открывшегося жребия ');
|
sb.write('В открывшемся жребии судьбы ');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (words.length == 1) {
|
if (words.length == 1) {
|
||||||
sb.write('выпал единственный доминирующий знак — #${first.formattedSessionNumber} «${first.word.toUpperCase()}» (${first.category}). ');
|
sb.write('проявился доминирующий знак #${first.formattedSessionNumber} «${first.word.toUpperCase()}» (${first.category}). ');
|
||||||
sb.write('${first.symbolicMeaning} ');
|
sb.write('${first.symbolicMeaning} ');
|
||||||
sb.write('Этот символ требует предельной концентрации: обратите внимание на скрытые мотивы и не рассеивайте внимание на второстепенное.');
|
sb.write('Знак указывает на необходимость предельной ясности: держите фокус на главном и отсекайте всё наносное.');
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (words.length == 2) {
|
if (words.length == 2) {
|
||||||
final second = words[1];
|
final second = words[1];
|
||||||
sb.write('проявляется взаимодействие двух сил: начального импульса #${first.formattedSessionNumber} «${first.word.toUpperCase()}» и вектора #${second.formattedSessionNumber} «${second.word.toUpperCase()}». ');
|
sb.write('проявляется полярность двух сил: исходного импульса #${first.formattedSessionNumber} «${first.word.toUpperCase()}» и вектора #${second.formattedSessionNumber} «${second.word.toUpperCase()}». ');
|
||||||
sb.write('С одной стороны, ${first.symbolicMeaning.toLowerCase()} С другой — ${second.symbolicMeaning.toLowerCase()} ');
|
sb.write('С одной стороны, ${first.symbolicMeaning.toLowerCase()} С другой — ${second.symbolicMeaning.toLowerCase()} ');
|
||||||
sb.write('Разрешение ситуации лежит в точке баланса между ними: не отвергайте первое, но направляйте его энергию в созидательное русло второго.');
|
sb.write('Разрешение ситуации лежит в точке осознанного баланса между ними: не противьтесь первому, но направляйте силу в русло второго.');
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3 or more words
|
sb.write('разворачивается цепь из ${words.length} взаимосвязанных архетипов. ');
|
||||||
sb.write('разворачивается цепь из ${words.length} взаимосвязанных знаков. ');
|
|
||||||
sb.write('Исток ситуации заложен в знаке #${first.formattedSessionNumber} «${first.word.toUpperCase()}» — ${first.symbolicMeaning.toLowerCase()} ');
|
sb.write('Исток ситуации заложен в знаке #${first.formattedSessionNumber} «${first.word.toUpperCase()}» — ${first.symbolicMeaning.toLowerCase()} ');
|
||||||
|
|
||||||
// Middle transitions
|
|
||||||
if (words.length > 2) {
|
if (words.length > 2) {
|
||||||
final middleWords = words.sublist(1, words.length - 1);
|
final middleWords = words.sublist(1, words.length - 1);
|
||||||
final midNames = middleWords.map((w) => '«${w.word.toUpperCase()}» (#${w.formattedSessionNumber})').join(' и ');
|
final midNames = middleWords.map((w) => '«${w.word.toUpperCase()}» (#${w.formattedSessionNumber})').join(' и ');
|
||||||
final midMeanings = middleWords.map((w) => w.symbolicMeaning).join(' ');
|
final midMeanings = middleWords.map((w) => w.symbolicMeaning).join(' ');
|
||||||
sb.write('Процесс трансформации проходит через испытание $midNames, где $midMeanings ');
|
sb.write('Фаза перехода и испытаний проходит через $midNames, где $midMeanings ');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolution / Outcome
|
sb.write('Кульминация и запечатывание исхода лежат в знаке #${last.formattedSessionNumber} «${last.word.toUpperCase()}» — ${last.symbolicMeaning.toLowerCase()} ');
|
||||||
sb.write('Кульминация и ключ к исходу запечатаны в знаке #${last.formattedSessionNumber} «${last.word.toUpperCase()}» — ${last.symbolicMeaning.toLowerCase()} ');
|
sb.write('Оракул возвещает: путь открыт через принятие скрытых уроков и решительное действие.');
|
||||||
sb.write('Оракул указывает: путь открыт через осознание скрытых уроков и решительный шаг вперед.');
|
|
||||||
|
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _synthesizeChatReply(OracleSession session, String userMessage, SessionWord? newlyAddedWord) {
|
String _synthesizeChatReply(OracleSession session, String userMessage, SessionWord? newlyAddedWord) {
|
||||||
if (newlyAddedWord != null) {
|
if (newlyAddedWord != null) {
|
||||||
return 'Дополнительный знак #${newlyAddedWord.formattedSessionNumber} «${newlyAddedWord.word.toUpperCase()}» (${newlyAddedWord.category}) раскрывает скрытую грань ситуации: ${newlyAddedWord.symbolicMeaning} В контексте ваших предыдущих знаков это означает, что нужно сместить фокус внимания на внутреннюю суть происходящего.';
|
return 'Дополнительный знак #${newlyAddedWord.formattedSessionNumber} «${newlyAddedWord.word.toUpperCase()}» (${newlyAddedWord.category}) проясняет скрытую деталь: ${newlyAddedWord.symbolicMeaning} В контексте общего расклада этот знак призывает обратить внимание на внутренние причины, а не внешние проявления.';
|
||||||
}
|
}
|
||||||
|
|
||||||
final wordsStr = session.words.map((w) => '«${w.word.toUpperCase()}»').join(', ');
|
final wordsStr = session.words.map((w) => '«${w.word.toUpperCase()}»').join(', ');
|
||||||
return 'Вглядываясь в знаки ($wordsStr), Оракул отвечает на ваш вопрос: суть ответа лежит в осознании скрытых взаимосвязей. Отпустите страх неопределенности и доверьтесь логике происходящих изменений.';
|
return 'Вглядываясь в совокупность знаков ($wordsStr), Оракул отвечает: истинный ответ кроется в понимании скрытой связи между причиной и исходом. Доверьтесь внутреннему знанию и делайте шаг вперед.';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Legacy single word helper
|
|
||||||
Future<String> interpret({
|
Future<String> interpret({
|
||||||
required WordItem word,
|
required WordItem word,
|
||||||
required String question,
|
required String question,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
jni
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
@@ -5,8 +5,10 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import flutter_inappwebview_macos
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
+128
@@ -1,6 +1,14 @@
|
|||||||
# Generated by pub
|
# Generated by pub
|
||||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
packages:
|
packages:
|
||||||
|
args:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: args
|
||||||
|
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
async:
|
async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -41,6 +49,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
|
cronet_http:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: cronet_http
|
||||||
|
sha256: "1b99ad5ae81aa9d2f12900e5f17d3681f3828629bb7f7fe7ad88076a34209840"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.5.0"
|
||||||
|
crypto:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: crypto
|
||||||
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.7"
|
||||||
cupertino_icons:
|
cupertino_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -73,11 +97,83 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.1"
|
version: "7.0.1"
|
||||||
|
fixnum:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fixnum
|
||||||
|
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
flutter:
|
flutter:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
flutter_inappwebview:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_inappwebview
|
||||||
|
sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.1.5"
|
||||||
|
flutter_inappwebview_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_inappwebview_android
|
||||||
|
sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.3"
|
||||||
|
flutter_inappwebview_internal_annotations:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_inappwebview_internal_annotations
|
||||||
|
sha256: e30fba942e3debea7b7e6cdd4f0f59ce89dd403a9865193e3221293b6d1544c6
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
|
flutter_inappwebview_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_inappwebview_ios
|
||||||
|
sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.2"
|
||||||
|
flutter_inappwebview_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_inappwebview_macos
|
||||||
|
sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.2"
|
||||||
|
flutter_inappwebview_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_inappwebview_platform_interface
|
||||||
|
sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0+1"
|
||||||
|
flutter_inappwebview_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_inappwebview_web
|
||||||
|
sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.2"
|
||||||
|
flutter_inappwebview_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_inappwebview_windows
|
||||||
|
sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.6.0"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -112,6 +208,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
http_profile:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_profile
|
||||||
|
sha256: "7e679e355b09aaee2ab5010915c932cce3f2d1c11c3b2dc177891687014ffa78"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.0"
|
||||||
|
jni:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: jni
|
||||||
|
sha256: d2c361082d554d4593c3012e26f6b188f902acd291330f13d6427641a92b3da1
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.14.2"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -168,6 +280,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.18.0"
|
version: "1.18.0"
|
||||||
|
package_config:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_config
|
||||||
|
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -333,6 +453,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
|
uuid:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: uuid
|
||||||
|
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.6.0"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
+4
-1
@@ -36,7 +36,10 @@ dependencies:
|
|||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
shared_preferences: ^2.5.5
|
shared_preferences: ^2.5.5
|
||||||
http: ^1.6.0
|
http: ^1.6.0
|
||||||
|
cronet_http: 1.5.0
|
||||||
|
crypto: ^3.0.7
|
||||||
|
flutter_inappwebview: ^6.1.5
|
||||||
|
uuid: ^4.6.0
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
import 'package:words999/g4f/cookie_jar.dart';
|
||||||
|
import 'package:words999/g4f/http_client.dart';
|
||||||
|
import 'package:words999/g4f/http_util.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('absorbs Set-Cookie and sends it back to the same host', () {
|
||||||
|
final jar = G4fCookieJar();
|
||||||
|
final uri = Uri.parse('https://phindai.org/');
|
||||||
|
jar.absorb(uri, {
|
||||||
|
'set-cookie': 'sid=abc; Path=/; Domain=.phindai.org, nonce=fff; Path=/',
|
||||||
|
});
|
||||||
|
final header = jar.headerFor(Uri.parse('https://phindai.org/wp-admin/admin-ajax.php'));
|
||||||
|
expect(header, contains('sid=abc'));
|
||||||
|
expect(header, contains('nonce=fff'));
|
||||||
|
expect(jar.headerFor(Uri.parse('https://evil.com/')), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('host-only cookie does not leak to a sibling subdomain', () {
|
||||||
|
final jar = G4fCookieJar();
|
||||||
|
jar.absorb(Uri.parse('https://chat.example.com/'), {
|
||||||
|
'set-cookie': 'id=1; Path=/',
|
||||||
|
});
|
||||||
|
expect(jar.headerFor(Uri.parse('https://chat.example.com/api')), contains('id=1'));
|
||||||
|
expect(jar.headerFor(Uri.parse('https://other.example.com/api')), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expired max-age cookies are dropped', () {
|
||||||
|
final jar = G4fCookieJar();
|
||||||
|
jar.absorb(Uri.parse('https://example.com/'), {
|
||||||
|
'set-cookie': 'gone=1; Max-Age=0; Path=/',
|
||||||
|
});
|
||||||
|
expect(jar.headerFor(Uri.parse('https://example.com/')), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CookieClient replays cookies on the next request', () async {
|
||||||
|
var n = 0;
|
||||||
|
final inner = MockClient((req) async {
|
||||||
|
n++;
|
||||||
|
if (n == 1) {
|
||||||
|
expect(req.headers['cookie'], isNull);
|
||||||
|
return http.Response(
|
||||||
|
'ok',
|
||||||
|
200,
|
||||||
|
headers: {'set-cookie': 'sid=abc; Path=/; Domain=example.com'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(req.headers['cookie'], contains('sid=abc'));
|
||||||
|
return http.Response('ok2', 200);
|
||||||
|
});
|
||||||
|
final jar = G4fCookieJar();
|
||||||
|
final client = CookieClient(inner, jar);
|
||||||
|
await client.get(Uri.parse('https://example.com/'));
|
||||||
|
await client.get(Uri.parse('https://example.com/api'));
|
||||||
|
expect(n, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('looksLikeGarbage rejects cake-credit paywalls', () {
|
||||||
|
expect(looksLikeGarbage('No cake credits. Bake proof-of-work cakes'), isTrue);
|
||||||
|
expect(looksLikeGarbage('{"type":"insufficient_credits"}'), isTrue);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:words999/g4f/g4f.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const messages = [
|
||||||
|
Message(role: Role.system, content: 'Отвечай одним словом.'),
|
||||||
|
Message(role: Role.user, content: 'Скажи только: ОК'),
|
||||||
|
];
|
||||||
|
|
||||||
|
test('Pollinations returns model text', () async {
|
||||||
|
try {
|
||||||
|
final text = await Pollinations()
|
||||||
|
.createAsyncGenerator('openai-fast', messages, stream: false)
|
||||||
|
.join();
|
||||||
|
expect(text.trim().length, greaterThan(1));
|
||||||
|
expect(looksLikeGarbage(text), isFalse);
|
||||||
|
} catch (e) {
|
||||||
|
// Anonymous Pollinations is rate-limited / paywalled from some IPs.
|
||||||
|
expect(e.toString().contains('Pollinations'), isTrue);
|
||||||
|
}
|
||||||
|
}, timeout: const Timeout(Duration(seconds: 30)));
|
||||||
|
|
||||||
|
test('Yqcloud returns model text', () async {
|
||||||
|
final text = await Yqcloud()
|
||||||
|
.createAsyncGenerator(G4FModels.gpt4, messages, stream: false)
|
||||||
|
.join();
|
||||||
|
expect(text.trim().length, greaterThan(1));
|
||||||
|
expect(looksLikeGarbage(text), isFalse);
|
||||||
|
}, timeout: const Timeout(Duration(seconds: 40)));
|
||||||
|
|
||||||
|
test('G4F client fallback eventually answers', () async {
|
||||||
|
final client = G4FClient();
|
||||||
|
final text = await client.chat.completions.create(messages: messages);
|
||||||
|
expect(text.trim().length, greaterThan(1));
|
||||||
|
expect(looksLikeGarbage(text), isFalse);
|
||||||
|
}, timeout: const Timeout(Duration(seconds: 90)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:words999/g4f/http_util.dart';
|
||||||
|
import 'package:words999/g4f/sse.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('splitSseLines reassembles lines split across chunks', () async {
|
||||||
|
final lines = await splitSseLines(Stream.fromIterable([
|
||||||
|
'data: {"message":"Hel',
|
||||||
|
'lo"}\n',
|
||||||
|
'data: {"message":"!"}\n',
|
||||||
|
'data: [DONE]\n',
|
||||||
|
])).toList();
|
||||||
|
expect(lines, [
|
||||||
|
'data: {"message":"Hello"}',
|
||||||
|
'data: {"message":"!"}',
|
||||||
|
'data: [DONE]',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractSseContent reads OpenAI deltas and Duck messages', () {
|
||||||
|
expect(
|
||||||
|
extractSseContent('{"choices":[{"delta":{"content":"Привет"}}]}'),
|
||||||
|
'Привет',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
extractSseContent('{"message":"знак","action":"success"}'),
|
||||||
|
'знак',
|
||||||
|
);
|
||||||
|
expect(extractSseContent('[DONE]'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractOpenAiMessage reads a non-stream body', () {
|
||||||
|
const body =
|
||||||
|
'{"choices":[{"message":{"role":"assistant","content":"OK"}}]}';
|
||||||
|
expect(extractOpenAiMessage(body), 'OK');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('looksLikeGarbage detects WAF and error pages', () {
|
||||||
|
expect(looksLikeGarbage('<!DOCTYPE html><html>'), isTrue);
|
||||||
|
expect(looksLikeGarbage('Just a moment...'), isTrue);
|
||||||
|
expect(looksLikeGarbage('Оракул видит знак ВЕТЕР'), isFalse);
|
||||||
|
expect(looksLikeModelOutput('OK'), isFalse);
|
||||||
|
expect(looksLikeModelOutput('{}'), isFalse);
|
||||||
|
expect(looksLikeGarbage('No cake credits'), isTrue);
|
||||||
|
expect(looksLikeModelOutput('Оракул видит знак ВЕТЕР'), isTrue);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
|
FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
flutter_inappwebview_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
jni
|
||||||
)
|
)
|
||||||
|
|
||||||
set(PLUGIN_BUNDLED_LIBRARIES)
|
set(PLUGIN_BUNDLED_LIBRARIES)
|
||||||
|
|||||||
Reference in New Issue
Block a user