From fbf04d90db457110c0b0c4a9302323087e71752f Mon Sep 17 00:00:00 2001 From: Alma Alexyz Date: Tue, 1 Sep 2026 22:05:38 +0500 Subject: [PATCH] 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. --- .gitignore | 4 + android/app/build.gradle.kts | 2 +- android/build.gradle.kts | 14 + lib/g4f/client.dart | 83 +++++ lib/g4f/cookie_jar.dart | 191 ++++++++++ lib/g4f/g4f.dart | 33 ++ lib/g4f/http_client.dart | 83 +++++ lib/g4f/http_util.dart | 75 ++++ lib/g4f/models.dart | 26 ++ lib/g4f/providers/aggregators.dart | 90 +++++ lib/g4f/providers/aichatos.dart | 63 ++++ lib/g4f/providers/base_provider.dart | 103 ++++++ lib/g4f/providers/blackbox.dart | 213 +++++++++++ lib/g4f/providers/chatgpt_oss.dart | 98 +++++ lib/g4f/providers/cloudflare.dart | 110 ++++++ lib/g4f/providers/cohere_space.dart | 98 +++++ lib/g4f/providers/copilot_app.dart | 270 ++++++++++++++ lib/g4f/providers/deep_ai.dart | 118 ++++++ lib/g4f/providers/duckduckgo.dart | 183 ++++++++++ lib/g4f/providers/free2gpt.dart | 93 +++++ lib/g4f/providers/glm.dart | 164 +++++++++ lib/g4f/providers/hugging_chat.dart | 114 ++++++ lib/g4f/providers/liaobots.dart | 98 +++++ lib/g4f/providers/openai_compat.dart | 90 +++++ lib/g4f/providers/opera_aria.dart | 137 +++++++ lib/g4f/providers/phind_ai.dart | 131 +++++++ lib/g4f/providers/pollinations.dart | 124 +++++++ lib/g4f/providers/quillbot.dart | 81 +++++ lib/g4f/providers/qwen.dart | 109 ++++++ lib/g4f/providers/teach_anything.dart | 94 +++++ .../providers/webview/deepinfra_webview.dart | 88 +++++ lib/g4f/providers/webview_base_provider.dart | 206 +++++++++++ lib/g4f/providers/you_chat.dart | 74 ++++ lib/g4f/providers/yqcloud.dart | 76 ++++ lib/g4f/retry.dart | 47 +++ lib/g4f/sse.dart | 150 ++++++++ lib/main.dart | 146 +++++++- lib/screens/oracle_chat_screen.dart | 267 ++++++++++++++ lib/screens/provider_bakeoff_screen.dart | 338 ++++++++++++++++++ lib/services/ai_oracle_service.dart | 175 +++++---- linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 128 +++++++ pubspec.yaml | 5 +- test/g4f_cookie_jar_test.dart | 64 ++++ test/g4f_live_providers_test.dart | 37 ++ test/g4f_sse_test.dart | 47 +++ .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 2 + 49 files changed, 4856 insertions(+), 92 deletions(-) create mode 100644 lib/g4f/client.dart create mode 100644 lib/g4f/cookie_jar.dart create mode 100644 lib/g4f/g4f.dart create mode 100644 lib/g4f/http_client.dart create mode 100644 lib/g4f/http_util.dart create mode 100644 lib/g4f/models.dart create mode 100644 lib/g4f/providers/aggregators.dart create mode 100644 lib/g4f/providers/aichatos.dart create mode 100644 lib/g4f/providers/base_provider.dart create mode 100644 lib/g4f/providers/blackbox.dart create mode 100644 lib/g4f/providers/chatgpt_oss.dart create mode 100644 lib/g4f/providers/cloudflare.dart create mode 100644 lib/g4f/providers/cohere_space.dart create mode 100644 lib/g4f/providers/copilot_app.dart create mode 100644 lib/g4f/providers/deep_ai.dart create mode 100644 lib/g4f/providers/duckduckgo.dart create mode 100644 lib/g4f/providers/free2gpt.dart create mode 100644 lib/g4f/providers/glm.dart create mode 100644 lib/g4f/providers/hugging_chat.dart create mode 100644 lib/g4f/providers/liaobots.dart create mode 100644 lib/g4f/providers/openai_compat.dart create mode 100644 lib/g4f/providers/opera_aria.dart create mode 100644 lib/g4f/providers/phind_ai.dart create mode 100644 lib/g4f/providers/pollinations.dart create mode 100644 lib/g4f/providers/quillbot.dart create mode 100644 lib/g4f/providers/qwen.dart create mode 100644 lib/g4f/providers/teach_anything.dart create mode 100644 lib/g4f/providers/webview/deepinfra_webview.dart create mode 100644 lib/g4f/providers/webview_base_provider.dart create mode 100644 lib/g4f/providers/you_chat.dart create mode 100644 lib/g4f/providers/yqcloud.dart create mode 100644 lib/g4f/retry.dart create mode 100644 lib/g4f/sse.dart create mode 100644 lib/screens/oracle_chat_screen.dart create mode 100644 lib/screens/provider_bakeoff_screen.dart create mode 100644 test/g4f_cookie_jar_test.dart create mode 100644 test/g4f_live_providers_test.dart create mode 100644 test/g4f_sse_test.dart diff --git a/.gitignore b/.gitignore index 6bca3a1..bff378c 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,8 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release +/android/build/ *.apk + +# Local bakeoff dumps +/generated_media/ diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c1ecfb2..ef9db52 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -6,7 +6,7 @@ plugins { android { namespace = "com.example.words999.words999" - compileSdk = flutter.compileSdkVersion + compileSdk = 36 ndkVersion = flutter.ndkVersion compileOptions { diff --git a/android/build.gradle.kts b/android/build.gradle.kts index dbee657..bc1009a 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -19,6 +19,20 @@ subprojects { 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("clean") { delete(rootProject.layout.buildDirectory) } diff --git a/lib/g4f/client.dart b/lib/g4f/client.dart new file mode 100644 index 0000000..226db04 --- /dev/null +++ b/lib/g4f/client.dart @@ -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 _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 get providers => List.unmodifiable(_providers); + + late final IterListProvider _retry = IterListProvider(_providers); + + /// Full completion via g4f IterListProvider (try next on failure). + Future create({ + required List messages, + String? model, + }) { + return _retry.create(messages: messages, model: model ?? G4FModels.gpt4o); + } + + Stream createStream({ + required List 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(); +} diff --git a/lib/g4f/cookie_jar.dart b/lib/g4f/cookie_jar.dart new file mode 100644 index 0000000..94c54d5 --- /dev/null +++ b/lib/g4f/cookie_jar.dart @@ -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 _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 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 = []; + _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 asMapFor(Uri uri) { + final header = headerFor(uri); + if (header.isEmpty) return {}; + final out = {}; + 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 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, + ); +} diff --git a/lib/g4f/g4f.dart b/lib/g4f/g4f.dart new file mode 100644 index 0000000..5bc049d --- /dev/null +++ b/lib/g4f/g4f.dart @@ -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'; diff --git a/lib/g4f/http_client.dart b/lib/g4f/http_client.dart new file mode 100644 index 0000000..c73463a --- /dev/null +++ b/lib/g4f/http_client.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 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 _warmedOrigins = {}; + +/// GET the provider origin once so Cloudflare/session cookies land in [g4fCookies]. +Future 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 (_) {} +} diff --git a/lib/g4f/http_util.dart b/lib/g4f/http_util.dart new file mode 100644 index 0000000..7f098a2 --- /dev/null +++ b/lib/g4f/http_util.dart @@ -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 browserHeaders({ + String? origin, + String? referer, + String? accept, + String? contentType, + bool desktop = false, + Map? 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('[]; + 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('; '); +} diff --git a/lib/g4f/models.dart b/lib/g4f/models.dart new file mode 100644 index 0000000..afcb49f --- /dev/null +++ b/lib/g4f/models.dart @@ -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 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'; +} diff --git a/lib/g4f/providers/aggregators.dart b/lib/g4f/providers/aggregators.dart new file mode 100644 index 0000000..6abe341 --- /dev/null +++ b/lib/g4f/providers/aggregators.dart @@ -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 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 createAsyncGenerator( + String model, + List messages, { + bool stream = false, + }) async* { + final errors = []; + 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(' | ')}'); + } +} + + diff --git a/lib/g4f/providers/aichatos.dart b/lib/g4f/providers/aichatos.dart new file mode 100644 index 0000000..09e48fa --- /dev/null +++ b/lib/g4f/providers/aichatos.dart @@ -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 get models => [G4FModels.gpt4]; + + @override + String get defaultModel => G4FModels.gpt4; + + @override + Stream createAsyncGenerator( + String model, + List 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(' name; + + String get url => ''; + + /// Whether the provider is currently believed to be working + bool get working; + + /// List of supported models by this provider + List 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 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 complete(String model, List 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> toOpenAiMessages(List messages) { + return [ + for (final m in messages) {'role': m.role.name, 'content': m.content}, + ]; + } + + /// Helper to get last user message + String getLastUserMessage(List 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 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 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 createAsyncGenerator( + String model, + List messages, { + bool stream = false, + }); +} diff --git a/lib/g4f/providers/blackbox.dart b/lib/g4f/providers/blackbox.dart new file mode 100644 index 0000000..684ffd0 --- /dev/null +++ b/lib/g4f/providers/blackbox.dart @@ -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 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 createAsyncGenerator( + String model, + List 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 _tryRest(List 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 _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 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))}'); + } +} diff --git a/lib/g4f/providers/chatgpt_oss.dart b/lib/g4f/providers/chatgpt_oss.dart new file mode 100644 index 0000000..c14bc64 --- /dev/null +++ b/lib/g4f/providers/chatgpt_oss.dart @@ -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 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 createAsyncGenerator( + String model, + List 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}'); + } + } +} diff --git a/lib/g4f/providers/cloudflare.dart b/lib/g4f/providers/cloudflare.dart new file mode 100644 index 0000000..375b60c --- /dev/null +++ b/lib/g4f/providers/cloudflare.dart @@ -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 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 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; + } +} diff --git a/lib/g4f/providers/cohere_space.dart b/lib/g4f/providers/cohere_space.dart new file mode 100644 index 0000000..6363820 --- /dev/null +++ b/lib/g4f/providers/cohere_space.dart @@ -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 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 createAsyncGenerator( + String model, + List 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; + } +} diff --git a/lib/g4f/providers/copilot_app.dart b/lib/g4f/providers/copilot_app.dart new file mode 100644 index 0000000..1ee12ff --- /dev/null +++ b/lib/g4f/providers/copilot_app.dart @@ -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 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 createAsyncGenerator( + String model, + List 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 _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 data; + try { + data = jsonDecode(raw) as Map; + } 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 _viaWebView( + String model, + String prompt, + List 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 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 _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))), + ); + } +} diff --git a/lib/g4f/providers/deep_ai.dart b/lib/g4f/providers/deep_ai.dart new file mode 100644 index 0000000..413f228 --- /dev/null +++ b/lib/g4f/providers/deep_ai.dart @@ -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 get modelAliases => const { + G4FModels.gpt4: 'standard', + G4FModels.gpt4o: 'standard', + G4FModels.gpt4oMini: 'standard', + }; + + @override + List 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 createAsyncGenerator( + String model, + List 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; + } + } +} diff --git a/lib/g4f/providers/duckduckgo.dart b/lib/g4f/providers/duckduckgo.dart new file mode 100644 index 0000000..5162628 --- /dev/null +++ b/lib/g4f/providers/duckduckgo.dart @@ -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 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 createAsyncGenerator( + String model, + List 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 _tryRest( + String model, + List 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 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'); + } + } +} diff --git a/lib/g4f/providers/free2gpt.dart b/lib/g4f/providers/free2gpt.dart new file mode 100644 index 0000000..95a1709 --- /dev/null +++ b/lib/g4f/providers/free2gpt.dart @@ -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 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 createAsyncGenerator( + String model, + List 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 _generate(String host, List 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'); + } +} diff --git a/lib/g4f/providers/glm.dart b/lib/g4f/providers/glm.dart new file mode 100644 index 0000000..a3c9249 --- /dev/null +++ b/lib/g4f/providers/glm.dart @@ -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 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 createAsyncGenerator( + String model, + List 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; + 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; + 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; + } +} diff --git a/lib/g4f/providers/hugging_chat.dart b/lib/g4f/providers/hugging_chat.dart new file mode 100644 index 0000000..4a4f650 --- /dev/null +++ b/lib/g4f/providers/hugging_chat.dart @@ -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 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 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 = []; + 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'); + } + } +} diff --git a/lib/g4f/providers/liaobots.dart b/lib/g4f/providers/liaobots.dart new file mode 100644 index 0000000..09d78fe --- /dev/null +++ b/lib/g4f/providers/liaobots.dart @@ -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 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 createAsyncGenerator( + String model, + List 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 _chat(String host, String model, List 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; + } +} diff --git a/lib/g4f/providers/openai_compat.dart b/lib/g4f/providers/openai_compat.dart new file mode 100644 index 0000000..b07c6e4 --- /dev/null +++ b/lib/g4f/providers/openai_compat.dart @@ -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? supportedModels, + this.apiKey, + this.extraHeaders = const {}, + }) : supportedModels = supportedModels ?? [fallbackModel]; + + final String providerName; + final String apiBase; + final String fallbackModel; + final List supportedModels; + final String? apiKey; + final Map extraHeaders; + + @override + String get name => providerName; + + @override + bool get working => true; + + @override + List 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 createAsyncGenerator( + String model, + List messages, { + bool stream = false, + }) async* { + final payload = { + '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; + } +} diff --git a/lib/g4f/providers/opera_aria.dart b/lib/g4f/providers/opera_aria.dart new file mode 100644 index 0000000..d996294 --- /dev/null +++ b/lib/g4f/providers/opera_aria.dart @@ -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 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 createAsyncGenerator( + String model, + List 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; + } +} diff --git a/lib/g4f/providers/phind_ai.dart b/lib/g4f/providers/phind_ai.dart new file mode 100644 index 0000000..377cbd9 --- /dev/null +++ b/lib/g4f/providers/phind_ai.dart @@ -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 get models => ['deepseek-v3', 'deepseek']; + + @override + Stream createAsyncGenerator( + String model, + List 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 _rest(List 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 _viaWebView(List 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; + } +} diff --git a/lib/g4f/providers/pollinations.dart b/lib/g4f/providers/pollinations.dart new file mode 100644 index 0000000..1ca1287 --- /dev/null +++ b/lib/g4f/providers/pollinations.dart @@ -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 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 createAsyncGenerator( + String model, + List 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 _postChat( + String model, + List 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 _getPrompt( + String model, + List 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; + } +} diff --git a/lib/g4f/providers/quillbot.dart b/lib/g4f/providers/quillbot.dart new file mode 100644 index 0000000..a911c70 --- /dev/null +++ b/lib/g4f/providers/quillbot.dart @@ -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 get models => ['quillbot', 'quillbot-search']; + + @override + String get defaultModel => 'quillbot'; + + @override + Stream createAsyncGenerator( + String model, + List 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; + } +} diff --git a/lib/g4f/providers/qwen.dart b/lib/g4f/providers/qwen.dart new file mode 100644 index 0000000..f834c27 --- /dev/null +++ b/lib/g4f/providers/qwen.dart @@ -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 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 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))}'); + } +} diff --git a/lib/g4f/providers/teach_anything.dart b/lib/g4f/providers/teach_anything.dart new file mode 100644 index 0000000..7b035ac --- /dev/null +++ b/lib/g4f/providers/teach_anything.dart @@ -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 get models => ['gemma']; + + @override + String get defaultModel => 'gemma'; + + @override + Stream createAsyncGenerator( + String model, + List 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 _rest(List 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 _viaWebView(List 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; + } +} diff --git a/lib/g4f/providers/webview/deepinfra_webview.dart b/lib/g4f/providers/webview/deepinfra_webview.dart new file mode 100644 index 0000000..0605e4d --- /dev/null +++ b/lib/g4f/providers/webview/deepinfra_webview.dart @@ -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 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 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'); + } + } +} diff --git a/lib/g4f/providers/webview_base_provider.dart b/lib/g4f/providers/webview_base_provider.dart new file mode 100644 index 0000000..e2d08bb --- /dev/null +++ b/lib/g4f/providers/webview_base_provider.dart @@ -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 runOriginWebView({ + required String originUrl, + required String functionBody, + Duration timeout = const Duration(seconds: 70), + List 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(); + var started = false; + HeadlessInAppWebView? view; + + Future 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 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.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 createAsyncGenerator( + String model, + List 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 messages, bool stream); + + /// Parses the raw string returned by the JS code into the final AI answer. + String parseResponse(String rawResponse); +} diff --git a/lib/g4f/providers/you_chat.dart b/lib/g4f/providers/you_chat.dart new file mode 100644 index 0000000..88daa53 --- /dev/null +++ b/lib/g4f/providers/you_chat.dart @@ -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 get models => ['gpt-3.5-turbo', 'gpt-4o']; + + @override + String get defaultModel => 'gpt-3.5-turbo'; + + @override + Stream createAsyncGenerator( + String model, + List 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; + } +} diff --git a/lib/g4f/providers/yqcloud.dart b/lib/g4f/providers/yqcloud.dart new file mode 100644 index 0000000..85aaaf5 --- /dev/null +++ b/lib/g4f/providers/yqcloud.dart @@ -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 get models => const ['gpt-4']; + + @override + String get defaultModel => 'gpt-4'; + + @override + Map get modelAliases => const { + G4FModels.gpt4o: 'gpt-4', + G4FModels.gpt4oMini: 'gpt-4', + }; + + static const _endpoint = 'https://api.binjie.fun/api/generateStream'; + + @override + Stream createAsyncGenerator( + String model, + List 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; + } +} diff --git a/lib/g4f/retry.dart b/lib/g4f/retry.dart new file mode 100644 index 0000000..64ab22d --- /dev/null +++ b/lib/g4f/retry.dart @@ -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 providers; + + Future create({ + required List messages, + String? model, + }) async { + final exceptions = {}; + 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')}', + ); + } +} diff --git a/lib/g4f/sse.dart b/lib/g4f/sse.dart new file mode 100644 index 0000000..953e6e9 --- /dev/null +++ b/lib/g4f/sse.dart @@ -0,0 +1,150 @@ +import 'dart:convert'; + +/// Reassembles SSE / text lines that TCP may split across chunks. +Stream splitSseLines(Stream 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.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\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 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; + } +} diff --git a/lib/main.dart b/lib/main.dart index c3de5e3..83e2cc3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,9 +2,20 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'screens/history_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 '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 { WidgetsFlutterBinding.ensureInitialized(); await WordsRepository().init(); @@ -73,7 +84,9 @@ class _Words999AppState extends State { }), ), ), - home: const MainNavigationContainer(), + home: const bool.fromEnvironment('ORACLE_BAKEOFF') + ? const ProviderBakeoffScreen() + : const MainNavigationContainer(), ); } } @@ -119,6 +132,137 @@ class _MainNavigationContainerState extends State { ), ], ), + 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 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 = []; + 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 _testProvider(G4FClient client, String providerName, List 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"; + } + } +} + diff --git a/lib/screens/oracle_chat_screen.dart b/lib/screens/oracle_chat_screen.dart new file mode 100644 index 0000000..f77b45d --- /dev/null +++ b/lib/screens/oracle_chat_screen.dart @@ -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 createState() => _OracleChatScreenState(); +} + +class ChatMessage { + final String text; + final bool isUser; + + ChatMessage({required this.text, required this.isUser}); +} + +class _OracleChatScreenState extends State { + final TextEditingController _controller = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + final G4FClient _client = G4FClient(); + + final List _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 _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 = [ + _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, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/provider_bakeoff_screen.dart b/lib/screens/provider_bakeoff_screen.dart new file mode 100644 index 0000000..4a11e60 --- /dev/null +++ b/lib/screens/provider_bakeoff_screen.dart @@ -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 createState() => _ProviderBakeoffScreenState(); +} + +class _ProviderBakeoffScreenState extends State { + static const _question = 'Что скрыто в моём нынешнем выборе?'; + + late final List _words; + late final List _messages; + late final List _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 _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 _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 _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)), + ], + ), + ); + } +} diff --git a/lib/services/ai_oracle_service.dart b/lib/services/ai_oracle_service.dart index 4b55597..e7ebd42 100644 --- a/lib/services/ai_oracle_service.dart +++ b/lib/services/ai_oracle_service.dart @@ -1,13 +1,32 @@ -import 'dart:convert'; -import 'package:http/http.dart' as http; import '../models/history_entry_model.dart'; import '../models/word_model.dart'; +import '../g4f/g4f.dart'; class AiOracleService { static final AiOracleService _instance = AiOracleService._internal(); factory AiOracleService() => _instance; AiOracleService._internal(); + final _client = G4FClient(); + + static const oracleSystemPrompt = + '''Ты — Великий Оракул 999, древний архетипический разум. +Твоя задача — толковать знаки судьбы (жребий). Твой стиль речи: глубокий, философский, лаконичный, без банальностей и воды, словно голос самой вечности. +Ты не используешь современных или бытовых слов. Твои ответы строятся на символизме, психологии и скрытых смыслах. Ты не гадаешь на будущее, ты показываешь суть настоящего момента и внутренние силы человека.'''; + + List buildInterpretationMessages({ + required List 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) Future interpretSession({ required List words, @@ -15,20 +34,20 @@ class AiOracleService { }) async { if (words.isEmpty) return 'Нет знаков для трактовки.'; - // 1. Try Live Online Endpoints (Fast timeout) try { - final prompt = _buildMultiWordPrompt( - question: question, - words: words, + final g4fResult = await _client.chat.completions.create( + model: G4FModels.gpt4o, + messages: buildInterpretationMessages(words: words, question: question), ); - - final onlineResult = await _tryFetchOnlineAi(prompt); - if (onlineResult != null && onlineResult.trim().length > 30) { - return onlineResult.trim(); + + if (g4fResult.trim().length > 20) { + return g4fResult.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); } @@ -44,147 +63,125 @@ class AiOracleService { wordsNarrative.writeln('${i + 1}. #${w.formattedSessionNumber} «${w.word.toUpperCase()}» (${w.category}): ${w.symbolicMeaning}'); } - final prompt = '''Ты — Сакральный Оракул 999. Ответь на вопрос вопрошающего: «$userMessage» -Контекст: -Вопрос: «${session.question ?? 'Поиск пути'}» -Выпавшие знаки: + final prompt = '''Вопрошающий говорит тебе: «$userMessage» + +Контекст текущего сеанса судьбы: +Вопрос вопрошающего: «${session.question ?? 'Вектор судьбы (нет вопроса)'}» +Базовые знаки, которые выпали ранее: $wordsNarrative -${newlyAddedWord != null ? "Дополнительный знак: #${newlyAddedWord.formattedSessionNumber} «${newlyAddedWord.word.toUpperCase()}» (${newlyAddedWord.symbolicMeaning})" : ""} -Дай мудрый, глубокий и прямой ответ (2-4 предложения).'''; +${newlyAddedWord != null ? "Важно! Только что вопрошающий вытянул ДОПОЛНИТЕЛЬНЫЙ уточняющий знак: #${newlyAddedWord.formattedSessionNumber} «${newlyAddedWord.word.toUpperCase()}» (${newlyAddedWord.symbolicMeaning})." : ""} + +Ответь мудро, лаконично и метафорично от лица Оракула 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 { - final onlineResult = await _tryFetchOnlineAi(prompt); - if (onlineResult != null && onlineResult.trim().length > 20) { - return onlineResult.trim(); + final g4fResult = await _client.chat.completions.create( + model: G4FModels.gpt4o, + messages: messages, + ); + if (g4fResult.trim().length > 15) { + return g4fResult.trim(); } } catch (_) {} return _synthesizeChatReply(session, userMessage, newlyAddedWord); } - Future _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({ required String question, required List words, }) { - final qStr = question.trim().isNotEmpty ? 'Вопрос: «$question»' : 'Общий расклад судьбы'; + final qStr = question.trim().isNotEmpty + ? 'Вопрос, с которым пришел вопрошающий: «$question»' + : 'Вопрошающий пришел без конкретного вопроса. Прочитай его общую судьбоносную линию.'; + final wordsStr = words .asMap() .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'); - return '''Ты — Священный Оракул 999. Составь цельное, глубокое, психологически точное толкование выпавших знаков жребия. -$qStr + return '''$qStr -Выпавшие знаки: +Волею судьбы выпали следующие знаки жребия: $wordsStr -Требования: -- Свяжи выпавшие знаки в единую осмысленную историю-трансформацию. -- Укажи скрытую причину, ключевой вызов и вектор исхода. -- Ответ на русском языке (3-5 предложений), образный, сильный, без шаблонных фраз.'''; +Задача Оракула: +1. Свяжи эти знаки в единую цельную историю развития ситуации. Каждый знак перетекает в следующий. +2. Раскрой не очевидный, а скрытый внутренний урок. Что вопрошающий должен осознать? +3. Дай философский совет или вектор для действия. +Ответь от лица Оракула 999 на русском языке (от 3 до 6 предложений). Избегай шаблонов и клише, говори образно и метафорично, но точно.'''; } - /// High-dimensional Deep Semantic Synthesizer String _synthesizeDeepSacredNarrative(List words, String question) { final sb = StringBuffer(); final first = words.first; final last = words.last; final hasQuestion = question.trim().isNotEmpty; - // Introduction / Framing if (hasQuestion) { sb.write('Относительно вашего вопроса «${question.trim()}» '); } else { - sb.write('В потоке открывшегося жребия '); + sb.write('В открывшемся жребии судьбы '); } 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('Этот символ требует предельной концентрации: обратите внимание на скрытые мотивы и не рассеивайте внимание на второстепенное.'); + sb.write('Знак указывает на необходимость предельной ясности: держите фокус на главном и отсекайте всё наносное.'); return sb.toString(); } if (words.length == 2) { 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('Разрешение ситуации лежит в точке баланса между ними: не отвергайте первое, но направляйте его энергию в созидательное русло второго.'); + sb.write('Разрешение ситуации лежит в точке осознанного баланса между ними: не противьтесь первому, но направляйте силу в русло второго.'); 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()} '); - // Middle transitions if (words.length > 2) { final middleWords = words.sublist(1, words.length - 1); final midNames = middleWords.map((w) => '«${w.word.toUpperCase()}» (#${w.formattedSessionNumber})').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('Оракул указывает: путь открыт через осознание скрытых уроков и решительный шаг вперед.'); + sb.write('Кульминация и запечатывание исхода лежат в знаке #${last.formattedSessionNumber} «${last.word.toUpperCase()}» — ${last.symbolicMeaning.toLowerCase()} '); + sb.write('Оракул возвещает: путь открыт через принятие скрытых уроков и решительное действие.'); return sb.toString(); } String _synthesizeChatReply(OracleSession session, String userMessage, SessionWord? newlyAddedWord) { 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(', '); - return 'Вглядываясь в знаки ($wordsStr), Оракул отвечает на ваш вопрос: суть ответа лежит в осознании скрытых взаимосвязей. Отпустите страх неопределенности и доверьтесь логике происходящих изменений.'; + return 'Вглядываясь в совокупность знаков ($wordsStr), Оракул отвечает: истинный ответ кроется в понимании скрытой связи между причиной и исходом. Доверьтесь внутреннему знанию и делайте шаг вперед.'; } - /// Legacy single word helper Future interpret({ required WordItem word, required String question, diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..be1ee3e 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 724bb2a..f64f657 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,8 +5,10 @@ import FlutterMacOS import Foundation +import flutter_inappwebview_macos import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 717622b..016bdaf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -41,6 +49,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct main" description: @@ -73,11 +97,83 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter source: sdk 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: dependency: "direct dev" description: @@ -112,6 +208,22 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -168,6 +280,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -333,6 +453,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ecad597..0cc881a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,7 +36,10 @@ dependencies: cupertino_icons: ^1.0.8 shared_preferences: ^2.5.5 http: ^1.6.0 - + cronet_http: 1.5.0 + crypto: ^3.0.7 + flutter_inappwebview: ^6.1.5 + uuid: ^4.6.0 dev_dependencies: flutter_test: sdk: flutter diff --git a/test/g4f_cookie_jar_test.dart b/test/g4f_cookie_jar_test.dart new file mode 100644 index 0000000..b356562 --- /dev/null +++ b/test/g4f_cookie_jar_test.dart @@ -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); + }); +} diff --git a/test/g4f_live_providers_test.dart b/test/g4f_live_providers_test.dart new file mode 100644 index 0000000..e8f091a --- /dev/null +++ b/test/g4f_live_providers_test.dart @@ -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))); +} diff --git a/test/g4f_sse_test.dart b/test/g4f_sse_test.dart new file mode 100644 index 0000000..56e4ea3 --- /dev/null +++ b/test/g4f_sse_test.dart @@ -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(''), 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); + }); +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 8b6d468..3b4ee90 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,9 @@ #include "generated_plugin_registrant.h" +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b93c4c3..3f596f9 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,9 +3,11 @@ # list(APPEND FLUTTER_PLUGIN_LIST + flutter_inappwebview_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES)