Mztj67_#M}W?l>kYSliK<%xAp;0j{!}J0!o7b
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
literal 0
HcmV?d00001
diff --git a/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/kouyu_english/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..0467bf12aa4d28f374bb26596605a46dcbb3e7c8
GIT binary patch
literal 1418
zcmV;51$Fv~P)q
zKfU)WzW*n(@|xWGCA9ScMt*e9`2kdxPQ&&>|-UCa7_51w+
zLUsW@ZzZSW0y$)Hp~e9%PvP|a03ks1`~K?q{u;6NC8*{AOqIUq{CL&;p56Lf$oQGq
z^={4hPQv)y=I|4n+?>7Fim=dxt1
z2H+Dm+1+fh+IF>G0SjJMkQQre1x4|G*Z==(Ot&kCnUrL4I(rf(ucITwmuHf^hXiJT
zkdTm&kdTm&kdTm&kdP`esgWG0BcWCVkVZ&2dUwN`cgM8QJb`Z7Z~e<&Yj2(}>Tmf`
zm1{eLgw!b{bXkjWbF%dTkTZEJWyWOb##Lfw4EK2}<0d6%>AGS{po>WCOy&f$Tay_>
z?NBlkpo@s-O;0V%Y_Xa-G#_O08q5LR*~F%&)}{}r&L%Sbs8AS4t7Y0NEx*{soY=0MZExqA5XHQkqi#4gW3
zqODM^iyZl;dvf)-bOXtOru(s)Uc7~BFx{w-FK;2{`VA?(g&@3z&bfLFyctOH!cVsF
z7IL=fo-qBndRUm;kAdXR4e6>k-z|21AaN%ubeVrHl*<|s&Ax@W-t?LR(P-24A5=>a
z*R9#QvjzF8n%@1Nw@?CG@6(%>+-0ASK~jEmCV|&a*7-GKT72W<(TbSjf)&Eme6nGE
z>Gkj4Sq&2e+-G%|+NM8OOm5zVl9{Z8Dd8A5z3y8mZ=4Bv4%>as_{9cN#bm~;h>62(
zdqY93Zy}v&c4n($Vv!UybR8ocs7#zbfX1IY-*w~)p}XyZ-SFC~4w>BvMVr`dFbelV{lLL0bx7@*ZZdebr3`sP;?
zVImji)kG)(6Juv0lz@q`F!k1FE;CQ(D0iG$wchPbKZQELlsZ#~rt8#90Y_Xh&3U-<
z{s<&cCV_1`^TD^ia9!*mQDq&
zn2{r`j};V|uV%_wsP!zB?m%;FeaRe+X47K0e+KE!8C{gAWF8)lCd1u1%~|M!XNRvw
zvtqy3iz0WSpWdhn6$hP8PaRBmp)q`#PCA`Vd#Tc$@f1tAcM>f_I@bC)hkI9|o(Iqv
zo}Piadq!j76}004RBio<`)70k^`K1NK)q>w?p^C6J2ZC!+UppiK6&y3Kmbv&O!oYF
z34$0Z;QO!JOY#!`qyGH<3Pd}Pt@q*A0V=3SVtWKRR8d8Z&@)3qLPA19LPA19LPEUC
YUoZo%k(ykuW&i*H07*qoM6N<$f+CH{y8r+H
literal 0
HcmV?d00001
diff --git a/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
new file mode 100644
index 0000000..0bedcf2
--- /dev/null
+++ b/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage.png",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@3x.png",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
new file mode 100644
index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838
GIT binary patch
literal 68
zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J
Q1PU{Fy85}Sb4q9e0B4a5jsO4v
literal 0
HcmV?d00001
diff --git a/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
new file mode 100644
index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838
GIT binary patch
literal 68
zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J
Q1PU{Fy85}Sb4q9e0B4a5jsO4v
literal 0
HcmV?d00001
diff --git a/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
new file mode 100644
index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838
GIT binary patch
literal 68
zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J
Q1PU{Fy85}Sb4q9e0B4a5jsO4v
literal 0
HcmV?d00001
diff --git a/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
new file mode 100644
index 0000000..89c2725
--- /dev/null
+++ b/kouyu_english/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
@@ -0,0 +1,5 @@
+# Launch Screen Assets
+
+You can customize the launch screen with your own desired assets by replacing the image files in this directory.
+
+You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
\ No newline at end of file
diff --git a/kouyu_english/ios/Runner/Base.lproj/LaunchScreen.storyboard b/kouyu_english/ios/Runner/Base.lproj/LaunchScreen.storyboard
new file mode 100644
index 0000000..f2e259c
--- /dev/null
+++ b/kouyu_english/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/kouyu_english/ios/Runner/Base.lproj/Main.storyboard b/kouyu_english/ios/Runner/Base.lproj/Main.storyboard
new file mode 100644
index 0000000..f3c2851
--- /dev/null
+++ b/kouyu_english/ios/Runner/Base.lproj/Main.storyboard
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/kouyu_english/ios/Runner/Info.plist b/kouyu_english/ios/Runner/Info.plist
new file mode 100644
index 0000000..a69b455
--- /dev/null
+++ b/kouyu_english/ios/Runner/Info.plist
@@ -0,0 +1,74 @@
+
+
+
+
+ CADisableMinimumFrameDurationOnPhone
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Kouyu English
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ kouyu_english
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ LSRequiresIPhoneOS
+
+ NSMicrophoneUsageDescription
+ 用于录制和转写你的英语练习,供你回听或确认;录音只保存在本机。
+ NSSpeechRecognitionUsageDescription
+ 用于识别英语练习内容;你可以改用文字输入。
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneClassName
+ UIWindowScene
+ UISceneConfigurationName
+ flutter
+ UISceneDelegateClassName
+ $(PRODUCT_MODULE_NAME).SceneDelegate
+ UISceneStoryboardFile
+ Main
+
+
+
+
+ UIApplicationSupportsIndirectInputEvents
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+
+
diff --git a/kouyu_english/ios/Runner/Runner-Bridging-Header.h b/kouyu_english/ios/Runner/Runner-Bridging-Header.h
new file mode 100644
index 0000000..308a2a5
--- /dev/null
+++ b/kouyu_english/ios/Runner/Runner-Bridging-Header.h
@@ -0,0 +1 @@
+#import "GeneratedPluginRegistrant.h"
diff --git a/kouyu_english/ios/Runner/SceneDelegate.swift b/kouyu_english/ios/Runner/SceneDelegate.swift
new file mode 100644
index 0000000..b9ce8ea
--- /dev/null
+++ b/kouyu_english/ios/Runner/SceneDelegate.swift
@@ -0,0 +1,6 @@
+import Flutter
+import UIKit
+
+class SceneDelegate: FlutterSceneDelegate {
+
+}
diff --git a/kouyu_english/ios/RunnerTests/RunnerTests.swift b/kouyu_english/ios/RunnerTests/RunnerTests.swift
new file mode 100644
index 0000000..86a7c3b
--- /dev/null
+++ b/kouyu_english/ios/RunnerTests/RunnerTests.swift
@@ -0,0 +1,12 @@
+import Flutter
+import UIKit
+import XCTest
+
+class RunnerTests: XCTestCase {
+
+ func testExample() {
+ // If you add code to the Runner application, consider adding tests here.
+ // See https://developer.apple.com/documentation/xctest for more information about using XCTest.
+ }
+
+}
diff --git a/kouyu_english/lib/core/a0_core.dart b/kouyu_english/lib/core/a0_core.dart
new file mode 100644
index 0000000..4b65530
--- /dev/null
+++ b/kouyu_english/lib/core/a0_core.dart
@@ -0,0 +1,194 @@
+/// Frozen A0 v1 upgrade denominator from A0-STAGE-STANDARD.md.
+/// IDs are intentionally separate from generated wording and never change
+/// when a course uses an accepted slot value or contraction.
+final Map a0CoreItems = Map.unmodifiable({
+ for (final entry in _numbered('A0-W', const [
+ 'zero',
+ 'one',
+ 'two',
+ 'three',
+ 'four',
+ 'five',
+ 'six',
+ 'seven',
+ 'eight',
+ 'nine',
+ 'ten',
+ 'Monday',
+ 'Tuesday',
+ 'Wednesday',
+ 'Thursday',
+ 'Friday',
+ 'Saturday',
+ 'Sunday',
+ 'book',
+ 'phone',
+ 'pen',
+ 'bag',
+ 'key',
+ 'water',
+ 'mother',
+ 'father',
+ 'sister',
+ 'brother',
+ 'coffee',
+ 'tea',
+ 'music',
+ 'movies',
+ 'good',
+ 'okay',
+ 'tired',
+ 'hello',
+ 'hi',
+ 'yes',
+ 'no',
+ 'thanks',
+ ]))
+ entry.key: entry.value,
+ for (final entry in _numbered('A0-P', const [
+ "I'm [name].",
+ "What's your name?",
+ 'Nice to meet you.',
+ 'How do you spell that?',
+ 'How are you?',
+ "I'm [state].",
+ "What's your phone number?",
+ 'My number is [digits].',
+ "What's this?",
+ "It's a [object].",
+ 'Where are you from?',
+ "I'm from [place].",
+ 'This is my [person].',
+ 'What day is it today?',
+ "It's [weekday].",
+ 'What time is it?',
+ "It's [hour] o'clock.",
+ 'I like [thing].',
+ 'Do you like [thing]?',
+ 'Please say that again.',
+ ]))
+ entry.key: entry.value,
+});
+
+Iterable> _numbered(
+ String prefix,
+ List values,
+) sync* {
+ for (var index = 0; index < values.length; index++) {
+ yield MapEntry(
+ '$prefix${(index + 1).toString().padLeft(2, '0')}',
+ values[index],
+ );
+ }
+}
+
+class CoreReviewTemplate {
+ const CoreReviewTemplate({
+ required this.prompt,
+ required this.hint,
+ required this.skill,
+ });
+
+ final String prompt;
+ final String hint;
+ final String skill;
+}
+
+/// Offline, reviewed prompts keep initial reviews usable without an AI service.
+/// The prompt supplies a meaning or situation, never the English target.
+CoreReviewTemplate coreReviewTemplate(String id) {
+ final target = a0CoreItems[id] ?? id;
+ const meanings = {
+ 'A0-W01': '零',
+ 'A0-W02': '一',
+ 'A0-W03': '二',
+ 'A0-W04': '三',
+ 'A0-W05': '四',
+ 'A0-W06': '五',
+ 'A0-W07': '六',
+ 'A0-W08': '七',
+ 'A0-W09': '八',
+ 'A0-W10': '九',
+ 'A0-W11': '十',
+ 'A0-W12': '星期一',
+ 'A0-W13': '星期二',
+ 'A0-W14': '星期三',
+ 'A0-W15': '星期四',
+ 'A0-W16': '星期五',
+ 'A0-W17': '星期六',
+ 'A0-W18': '星期日',
+ 'A0-W19': '书',
+ 'A0-W20': '电话',
+ 'A0-W21': '笔',
+ 'A0-W22': '包',
+ 'A0-W23': '钥匙',
+ 'A0-W24': '水',
+ 'A0-W25': '妈妈',
+ 'A0-W26': '爸爸',
+ 'A0-W27': '姐妹',
+ 'A0-W28': '兄弟',
+ 'A0-W29': '咖啡',
+ 'A0-W30': '茶',
+ 'A0-W31': '音乐',
+ 'A0-W32': '电影',
+ 'A0-W33': '好',
+ 'A0-W34': '还可以',
+ 'A0-W35': '累',
+ 'A0-W36': '你好',
+ 'A0-W37': '嗨',
+ 'A0-W38': '是',
+ 'A0-W39': '不',
+ 'A0-W40': '谢谢',
+ };
+ final phrasePrompt = switch (id) {
+ 'A0-P01' => '向新同学介绍你的名字。',
+ 'A0-P02' => '问对方叫什么名字。',
+ 'A0-P03' => '对初次见面的人说“很高兴认识你”。',
+ 'A0-P04' => '问对方一个词怎样拼写。',
+ 'A0-P05' => '问对方今天怎么样。',
+ 'A0-P06' => '说出你今天的状态。',
+ 'A0-P07' => '问对方的电话号码。',
+ 'A0-P08' => '报出一个虚构的三位号码。',
+ 'A0-P09' => '指着身边的东西问“这是什么”。',
+ 'A0-P10' => '说出“这是一件物品”。',
+ 'A0-P11' => '问对方来自哪里。',
+ 'A0-P12' => '说出你来自哪里。',
+ 'A0-P13' => '介绍一位家人。',
+ 'A0-P14' => '问今天星期几。',
+ 'A0-P15' => '说出今天星期几。',
+ 'A0-P16' => '问现在几点。',
+ 'A0-P17' => '说出一个整点时间。',
+ 'A0-P18' => '说出你喜欢的一样东西。',
+ 'A0-P19' => '问对方是否喜欢一样东西。',
+ 'A0-P20' => '请对方再说一次。',
+ _ => null,
+ };
+ if (phrasePrompt != null) {
+ return CoreReviewTemplate(
+ prompt: phrasePrompt,
+ hint: target,
+ skill: '回忆表达',
+ );
+ }
+ return CoreReviewTemplate(
+ prompt: '把“${meanings[id] ?? '这个已学词'}”写成英文。',
+ hint: target,
+ skill: '词汇回忆',
+ );
+}
+
+/// Rotates an approved offline prompt family without changing the core target
+/// ID. These are a safe fallback when a dynamic AI variant is unavailable.
+CoreReviewTemplate coreReviewVariant(String id, int variantIndex) {
+ final base = coreReviewTemplate(id);
+ final lead = switch (variantIndex % 3) {
+ 0 => '换一个人物或地点,',
+ 1 => '在新的生活情境中,',
+ _ => '不看上次答案,',
+ };
+ return CoreReviewTemplate(
+ prompt: '$lead${base.prompt}',
+ hint: base.hint,
+ skill: base.skill,
+ );
+}
diff --git a/kouyu_english/lib/core/ai_service.dart b/kouyu_english/lib/core/ai_service.dart
new file mode 100644
index 0000000..b61c5c4
--- /dev/null
+++ b/kouyu_english/lib/core/ai_service.dart
@@ -0,0 +1,730 @@
+import 'dart:convert';
+
+import 'package:flutter_secure_storage/flutter_secure_storage.dart';
+import 'package:http/http.dart' as http;
+
+import 'models.dart';
+import 'generated_content.dart';
+import 'a0_core.dart';
+
+class AiConnectionResult {
+ const AiConnectionResult({required this.ok, required this.message});
+ final bool ok;
+ final String message;
+}
+
+/// Stores the secret separately from normal app settings. Compatible endpoints
+/// use the OpenAI chat-completions shape, including a user-run CLIProxyAPI.
+class AiService {
+ AiService._();
+ static final instance = AiService._();
+ static const _keyName = 'ai_api_key';
+ final _secureStorage = const FlutterSecureStorage();
+
+ Future saveApiKey(String value) async {
+ if (value.trim().isEmpty) {
+ await _secureStorage.delete(key: _keyName);
+ } else {
+ await _secureStorage.write(key: _keyName, value: value.trim());
+ }
+ }
+
+ Future hasApiKey() async =>
+ (await _secureStorage.read(key: _keyName))?.isNotEmpty ?? false;
+
+ /// Returns a display-only Chinese gloss for an unknown word or phrase.
+ /// This is deliberately not a LexiconEntry and cannot affect review/mastery.
+ Future temporaryDefinition({
+ required AiProviderType provider,
+ required String endpoint,
+ required String model,
+ required String text,
+ }) async {
+ if (provider == AiProviderType.mock ||
+ text.length > 120 ||
+ text.trim().isEmpty) {
+ return null;
+ }
+ final key = await _secureStorage.read(key: _keyName);
+ final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
+ final uri = Uri.tryParse(
+ provider == AiProviderType.gemini
+ ? '$base/models/$model:generateContent'
+ : '$base/chat/completions',
+ );
+ if (key == null ||
+ key.isEmpty ||
+ model.trim().isEmpty ||
+ uri == null ||
+ uri.scheme != 'https') {
+ return null;
+ }
+ const instruction =
+ 'Return JSON only: {"definition":"short simplified Chinese meaning"}. Do not include markdown, examples, or teaching claims.';
+ try {
+ final response = await http
+ .post(
+ uri,
+ headers: provider == AiProviderType.gemini
+ ? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
+ : {
+ 'Authorization': 'Bearer $key',
+ 'Content-Type': 'application/json',
+ },
+ body: jsonEncode(
+ provider == AiProviderType.gemini
+ ? {
+ 'contents': [
+ {
+ 'parts': [
+ {'text': '$instruction\nText: $text'},
+ ],
+ },
+ ],
+ 'generationConfig': {
+ 'maxOutputTokens': 100,
+ 'responseMimeType': 'application/json',
+ },
+ }
+ : {
+ 'model': model,
+ 'messages': [
+ {
+ 'role': 'user',
+ 'content': '$instruction\nText: $text',
+ },
+ ],
+ 'max_tokens': 100,
+ },
+ ),
+ )
+ .timeout(const Duration(seconds: 30));
+ if (response.statusCode < 200 || response.statusCode >= 300) return null;
+ final raw = _extractResponseContent(provider, response.body);
+ final data = raw == null ? null : jsonDecode(raw);
+ final definition = data is Map ? data['definition'] : null;
+ return definition is String &&
+ definition.trim().isNotEmpty &&
+ definition.length <= 160
+ ? definition.trim()
+ : null;
+ } catch (_) {
+ return null;
+ }
+ }
+
+ Future testConnection({
+ required AiProviderType provider,
+ required String endpoint,
+ required String model,
+ }) async {
+ if (provider == AiProviderType.mock) {
+ return const AiConnectionResult(ok: true, message: '内置练习模式可用,无需网络。');
+ }
+ const probe =
+ 'Return JSON only: {"reply":"Hi!","slots":{},"evidence":[],"suggestsComplete":false,"feedback":null}';
+ final key = await _secureStorage.read(key: _keyName);
+ if (key == null || key.isEmpty) {
+ return const AiConnectionResult(ok: false, message: '请先保存 API Key。');
+ }
+ if (endpoint.trim().isEmpty || model.trim().isEmpty) {
+ return const AiConnectionResult(
+ ok: false,
+ message: '请填写 Base URL 和模型名称。',
+ );
+ }
+ final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
+ final uri = Uri.tryParse(
+ provider == AiProviderType.gemini
+ ? '$base/models/$model:generateContent'
+ : '$base/chat/completions',
+ );
+ if (uri == null || uri.scheme != 'https') {
+ return const AiConnectionResult(ok: false, message: '请使用有效的 HTTPS 地址。');
+ }
+ try {
+ final response = await http
+ .post(
+ uri,
+ headers: provider == AiProviderType.gemini
+ ? {'x-goog-api-key': key, 'Content-Type': 'application/json'}
+ : {
+ 'Authorization': 'Bearer $key',
+ 'Content-Type': 'application/json',
+ },
+ body: jsonEncode(
+ provider == AiProviderType.gemini
+ ? {
+ 'contents': [
+ {
+ 'parts': [
+ {'text': probe},
+ ],
+ },
+ ],
+ 'generationConfig': {
+ 'maxOutputTokens': 80,
+ 'responseMimeType': 'application/json',
+ },
+ }
+ : {
+ 'model': model,
+ 'messages': [
+ {'role': 'user', 'content': probe},
+ ],
+ 'max_tokens': 80,
+ },
+ ),
+ )
+ .timeout(const Duration(seconds: 30));
+ if (response.statusCode >= 200 && response.statusCode < 300) {
+ final content = _extractResponseContent(provider, response.body);
+ if (_decodeDialogueResponse(content) != null) {
+ return const AiConnectionResult(ok: true, message: '连接成功,结构化对话可用。');
+ }
+ return const AiConnectionResult(
+ ok: false,
+ message: '服务可连接,但未返回应用需要的结构化对话格式。',
+ );
+ }
+ if (response.statusCode == 401 || response.statusCode == 403) {
+ return const AiConnectionResult(
+ ok: false,
+ message: '鉴权失败,请检查 API Key。',
+ );
+ }
+ return AiConnectionResult(
+ ok: false,
+ message: '服务返回 ${response.statusCode},请检查地址和模型。',
+ );
+ } catch (_) {
+ return const AiConnectionResult(
+ ok: false,
+ message: '无法连接服务。请检查网络、地址或局域网连通性。',
+ );
+ }
+ }
+
+ Future dialogueReply({
+ required AiProviderType provider,
+ required String endpoint,
+ required String model,
+ required List