feat: 增加AI配置文件与TTS自动朗读,修复二级页面返回按钮与音频播放

This commit is contained in:
shenlei
2026-09-15 19:11:39 +09:00
parent 37c86f7ecb
commit 4341726162
18 changed files with 1322 additions and 397 deletions
+293 -201
View File
@@ -14,12 +14,17 @@ class AiConnectionResult {
}
/// Stores the secret separately from normal app settings. Compatible endpoints
/// use the OpenAI chat-completions shape, including a user-run CLIProxyAPI.
/// use the OpenAI chat-completions shape (/v1/chat/completions), including a user-run CLIProxyAPI.
class AiService {
AiService._();
static final instance = AiService._();
static const _keyName = 'ai_api_key';
final _secureStorage = const FlutterSecureStorage();
String? _fallbackApiKey;
void setFallbackApiKey(String? key) {
_fallbackApiKey = key?.trim();
}
Future<void> saveApiKey(String value) async {
if (value.trim().isEmpty) {
@@ -29,8 +34,55 @@ class AiService {
}
}
Future<String?> resolveApiKey([String? explicit]) async {
if (explicit != null && explicit.trim().isNotEmpty) {
return explicit.trim();
}
try {
final stored = await _secureStorage.read(key: _keyName);
if (stored != null && stored.trim().isNotEmpty) {
return stored.trim();
}
} catch (_) {}
if (_fallbackApiKey != null && _fallbackApiKey!.trim().isNotEmpty) {
return _fallbackApiKey!.trim();
}
return null;
}
Future<bool> hasApiKey() async =>
(await _secureStorage.read(key: _keyName))?.isNotEmpty ?? false;
(await resolveApiKey())?.isNotEmpty ?? false;
Future<String?> getApiKey() async => await resolveApiKey();
/// Resolves the target endpoint URI. For OpenAI and compatible endpoints,
/// all requests target the standard Chat Completions endpoint (/v1/chat/completions).
static Uri? resolveEndpointUri({
required AiProviderType provider,
required String endpoint,
required String model,
}) {
final base = endpoint.trim().replaceFirst(RegExp(r'/+$'), '');
if (base.isEmpty) return null;
if (provider == AiProviderType.gemini) {
if (base.contains(':generateContent')) {
return Uri.tryParse(base);
}
return Uri.tryParse('$base/models/$model:generateContent');
}
if (base.endsWith('/chat/completions')) {
return Uri.tryParse(base);
}
if (base.endsWith('/v1')) {
return Uri.tryParse('$base/chat/completions');
}
if (base.endsWith('/responses')) {
return Uri.tryParse(
base.replaceFirst(RegExp(r'/responses$'), '/chat/completions'),
);
}
return Uri.tryParse('$base/v1/chat/completions');
}
/// Returns a display-only Chinese gloss for an unknown word or phrase.
/// This is deliberately not a LexiconEntry and cannot affect review/mastery.
@@ -45,18 +97,17 @@ class AiService {
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',
final key = await resolveApiKey();
final uri = resolveEndpointUri(
provider: provider,
endpoint: endpoint,
model: model,
);
if (key == null ||
key.isEmpty ||
model.trim().isEmpty ||
uri == null ||
uri.scheme != 'https') {
(uri.scheme != 'https' && uri.scheme != 'http')) {
return null;
}
const instruction =
@@ -117,15 +168,16 @@ class AiService {
required AiProviderType provider,
required String endpoint,
required String model,
String? explicitApiKey,
}) 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);
final key = await resolveApiKey(explicitApiKey);
if (key == null || key.isEmpty) {
return const AiConnectionResult(ok: false, message: '请先保存 API Key。');
return const AiConnectionResult(ok: false, message: '请先填写或保存 API Key。');
}
if (endpoint.trim().isEmpty || model.trim().isEmpty) {
return const AiConnectionResult(
@@ -133,14 +185,16 @@ class AiService {
message: '请填写 Base URL 和模型名称。',
);
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
final uri = resolveEndpointUri(
provider: provider,
endpoint: endpoint,
model: model,
);
if (uri == null || uri.scheme != 'https') {
return const AiConnectionResult(ok: false, message: '请使用有效的 HTTPS 地址。');
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) {
return const AiConnectionResult(
ok: false,
message: '请使用有效的 HTTP 或 HTTPS 地址。',
);
}
try {
final response = await http
@@ -163,7 +217,8 @@ class AiService {
},
],
'generationConfig': {
'maxOutputTokens': 80,
'temperature': 0,
'maxOutputTokens': 60,
'responseMimeType': 'application/json',
},
}
@@ -172,35 +227,51 @@ class AiService {
'messages': [
{'role': 'user', 'content': probe},
],
'max_tokens': 80,
'temperature': 0,
'max_tokens': 60,
},
),
)
.timeout(const Duration(seconds: 30));
.timeout(const Duration(seconds: 15));
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: true,
message: '连接成功,AI 对话服务可用!',
);
}
return const AiConnectionResult(
ok: false,
message: '服务可连接,但未返回应用需要的结构化对话格式',
ok: true,
message: '连接成功,接口响应正常',
);
}
if (response.statusCode == 401 || response.statusCode == 403) {
return AiConnectionResult(
ok: false,
message: '鉴权失败 (HTTP ${response.statusCode}),请检查 API Key 是否正确。',
);
}
if (response.statusCode == 404) {
return const AiConnectionResult(
ok: false,
message: '鉴权失败,请检查 API Key',
message: '服务返回 404,请检查 Base URL(如是否缺少 /v1)或模型名称',
);
}
if (response.statusCode == 429) {
return const AiConnectionResult(
ok: false,
message: '请求受限 (HTTP 429),API 额度不足或达到并发限制。',
);
}
return AiConnectionResult(
ok: false,
message: '服务返回 ${response.statusCode},请检查地址和模型。',
message: '服务返回 HTTP ${response.statusCode},请检查地址和模型配置',
);
} catch (_) {
return const AiConnectionResult(
} catch (e) {
return AiConnectionResult(
ok: false,
message: '无法连接服务。请检查网络、地址或局域网连通性。',
message: '无法连接服务 ($e)。请检查网络、地址或代理连通性。',
);
}
}
@@ -215,20 +286,19 @@ class AiService {
if (provider == AiProviderType.mock) {
return null;
}
final key = await _secureStorage.read(key: _keyName);
final key = await resolveApiKey();
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return null;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
final uri = resolveEndpointUri(
provider: provider,
endpoint: endpoint,
model: model,
);
if (uri == null || uri.scheme != 'https') {
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) {
return null;
}
const system =
@@ -303,122 +373,21 @@ class AiService {
bool repairAttempt = false,
}) async {
if (provider == AiProviderType.mock) return null;
final key = await _secureStorage.read(key: _keyName);
final key = await resolveApiKey();
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return null;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
final uri = resolveEndpointUri(
provider: provider,
endpoint: endpoint,
model: model,
);
if (uri == null || uri.scheme != 'https') return null;
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) return null;
final instruction =
'''Return JSON only with exactly: schemaVersion, variantId, targetItemId, prompt, expectedAnswer.
schemaVersion must be "review-variant-1". targetItemId must be "$targetItemId".
Make one beginner A0 English review prompt. Do not add explanations, translations, markdown, or fields.
${repairAttempt ? 'The previous response was invalid. Fix the JSON schema exactly.' : ''}
Base task: $basePrompt''';
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},
],
},
],
'generationConfig': {
'temperature': 0.3,
'maxOutputTokens': 180,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'user', 'content': instruction},
],
'temperature': 0.3,
'max_tokens': 180,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
final raw = _extractResponseContent(provider, response.body);
final decoded = raw == null
? null
: decodeGeneratedReviewVariant(
raw,
expectedTargetItemId: targetItemId,
);
if (decoded != null || repairAttempt) return decoded;
return generateReviewVariant(
provider: provider,
endpoint: endpoint,
model: model,
targetItemId: targetItemId,
basePrompt: basePrompt,
repairAttempt: true,
);
} catch (_) {
return null;
}
}
/// Requests a short, teaching-oriented writing suggestion. The caller must
/// still run its local task validator; this response has no authority to
/// mark an answer correct or change mastery.
Future<WritingAiFeedback?> writingFeedback({
required AiProviderType provider,
required String endpoint,
required String model,
required String lessonId,
required String taskPrompt,
required String answer,
bool repairAttempt = false,
}) async {
if (provider == AiProviderType.mock) return null;
final key = await _secureStorage.read(key: _keyName);
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return null;
}
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 null;
final instruction =
'''Return JSON only with exactly these fields: schemaVersion, verdict, feedback, suggestion, missing, lessonId.
schemaVersion must be "writing-feedback-1" and lessonId must be "$lessonId".
verdict must be accepted, rewrite, or uncertain. feedback is one short helpful Chinese sentence (max 80 Chinese characters). suggestion is null or one simple A0 English rewrite (max 18 words). missing is an array of at most 3 short Chinese descriptions.
Assess only whether the learner expressed the task. Do not claim pronunciation, do not introduce grammar beyond A0, and do not invent facts the learner did not write.
${repairAttempt ? 'The previous response was invalid. Return the exact JSON schema now.' : ''}
Task: $taskPrompt
Learner answer: $answer''';
'Generate one A0 English review variant for item $targetItemId based on prompt "$basePrompt". Return JSON only with exactly: schemaVersion (must be "review-variant-1"), targetItemId (must be "$targetItemId"), prompt (short Chinese instruction), stimulus (English sentence, maximum 12 words), answer (exact expected English answer, maximum 10 words), acceptedAnswers (array of 1 to 4 strings), requiredAnyPhrases (array of 1 to 3 arrays of strings), forbiddenPhrases (array of up to 4 strings). Stay strictly within A0. Do not introduce new vocabulary. The answer must satisfy the spec.${repairAttempt ? ' Previous response failed schema or constraint validation: repair all errors.' : ''}';
try {
final response = await http
.post(
@@ -441,7 +410,7 @@ Learner answer: $answer''';
],
'generationConfig': {
'temperature': 0.2,
'maxOutputTokens': 240,
'maxOutputTokens': 120,
'responseMimeType': 'application/json',
},
}
@@ -451,7 +420,7 @@ Learner answer: $answer''';
{'role': 'user', 'content': instruction},
],
'temperature': 0.2,
'max_tokens': 240,
'max_tokens': 120,
},
),
)
@@ -459,27 +428,110 @@ Learner answer: $answer''';
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
final decoded = decodeWritingAiFeedback(
_extractResponseContent(provider, response.body) ?? '',
expectedLessonId: lessonId,
final content = _extractResponseContent(provider, response.body);
if (content == null) return null;
final variant = decodeGeneratedReviewVariant(
content,
expectedTargetItemId: targetItemId,
);
if (decoded != null || repairAttempt) return decoded;
return writingFeedback(
provider: provider,
endpoint: endpoint,
model: model,
lessonId: lessonId,
taskPrompt: taskPrompt,
answer: answer,
repairAttempt: true,
if (variant == null && !repairAttempt) {
return generateReviewVariant(
provider: provider,
endpoint: endpoint,
model: model,
targetItemId: targetItemId,
basePrompt: basePrompt,
repairAttempt: true,
);
}
return variant;
} catch (_) {
return null;
}
}
/// Evaluates an open-ended writing response against a bounded schema.
Future<WritingAiFeedback?> writingFeedback({
required AiProviderType provider,
required String endpoint,
required String model,
required String lessonId,
required String taskPrompt,
required String answer,
}) async {
if (provider == AiProviderType.mock) return null;
final key = await resolveApiKey();
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return null;
}
final uri = resolveEndpointUri(
provider: provider,
endpoint: endpoint,
model: model,
);
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) return null;
final instruction =
'''Return JSON only with exactly these fields: schemaVersion, verdict, feedback, suggestion, missing, lessonId.
schemaVersion must be "writing-feedback-1" and lessonId must be "$lessonId".
verdict must be accepted, rewrite, or uncertain. feedback is one short helpful Chinese sentence (max 80 Chinese characters). suggestion is null or one simple A0 English rewrite (max 18 words). missing is an array of at most 3 short Chinese descriptions.
Assess only whether the learner expressed the task. Do not claim pronunciation, do not introduce grammar beyond A0, and do not invent facts the learner did not write.
Task: $taskPrompt
Learner wrote: $answer''';
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},
],
},
],
'generationConfig': {
'temperature': 0,
'maxOutputTokens': 150,
'responseMimeType': 'application/json',
},
}
: {
'model': model,
'messages': [
{'role': 'user', 'content': instruction},
],
'temperature': 0,
'max_tokens': 150,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
final content = _extractResponseContent(provider, response.body);
if (content == null) return null;
return decodeWritingAiFeedback(
content,
expectedLessonId: lessonId,
);
} catch (_) {
return null;
}
}
/// Generates one bounded A0 reinforcement lesson for a stable core target.
/// It remains unpublished until local schema validation accepts it.
/// Requests a 4-skill adaptive mini-lesson that re-teaches a failed target.
Future<GeneratedLesson?> generateAdaptiveLesson({
required AiProviderType provider,
required String endpoint,
@@ -488,21 +540,20 @@ Learner answer: $answer''';
required String targetLabel,
bool repairAttempt = false,
}) async {
if (provider == AiProviderType.mock ||
!a0CoreItems.containsKey(targetItemId)) {
if (provider == AiProviderType.mock) return null;
final key = await resolveApiKey();
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return null;
}
final key = await _secureStorage.read(key: _keyName);
if (key == null || key.isEmpty || endpoint.isEmpty || model.isEmpty) {
return null;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
final uri = resolveEndpointUri(
provider: provider,
endpoint: endpoint,
model: model,
);
if (uri == null || uri.scheme != 'https') return null;
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) return null;
final lessonId = 'ai-a0-${targetItemId.toLowerCase()}-1';
final instruction =
'Return JSON only with exactly: schemaVersion, lessonId, revision, stageVersion, source, status, abilityIds, prerequisiteIds, targetItemIds, receptiveChunks, newItemIds, previewItemIds, estimatedMinutes, tasks. Use schemaVersion lesson-2, lessonId $lessonId, revision 1, stageVersion A0-1.0, source aiGenerated, status validated, targetItemIds [$targetItemId], and empty receptiveChunks, newItemIds, previewItemIds. Create exactly four tasks, one listening listenChoice, speaking repeat, reading readAnswer, writing writeAnswer. Every task has exactly taskId, skill, type, prompt, stimulus, answer, targetItemIds, answerSpec and targets [$targetItemId]. answerSpec has exactly requiredAnyPhrases (1-4 lists, each contains 1-4 accepted English phrases), acceptedAnswers (1-4 complete accepted English answers), forbiddenPhrases (possibly empty list). Make answer satisfy its answerSpec. Lesson duration is 8 to 15 minutes. Use only very simple A0 English for $targetLabel. No new vocabulary, markdown, real phone numbers, or personal data.${repairAttempt ? ' Previous response was invalid: repair all constraints.' : ''}';
@@ -527,8 +578,8 @@ Learner answer: $answer''';
},
],
'generationConfig': {
'temperature': 0.2,
'maxOutputTokens': 1200,
'temperature': 0.1,
'maxOutputTokens': 650,
'responseMimeType': 'application/json',
},
}
@@ -537,51 +588,58 @@ Learner answer: $answer''';
'messages': [
{'role': 'user', 'content': instruction},
],
'temperature': 0.2,
'max_tokens': 1200,
'temperature': 0.1,
'max_tokens': 650,
},
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) return null;
final decoded = decodeGeneratedLesson(
_extractResponseContent(provider, response.body) ?? '',
.timeout(const Duration(seconds: 45));
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
final content = _extractResponseContent(provider, response.body);
if (content == null) return null;
final lesson = decodeGeneratedLesson(
content,
expectedTargetItemId: targetItemId,
);
if (decoded != null || repairAttempt) return decoded;
return generateAdaptiveLesson(
provider: provider,
endpoint: endpoint,
model: model,
targetItemId: targetItemId,
targetLabel: targetLabel,
repairAttempt: true,
);
if (lesson == null && !repairAttempt) {
return generateAdaptiveLesson(
provider: provider,
endpoint: endpoint,
model: model,
targetItemId: targetItemId,
targetLabel: targetLabel,
repairAttempt: true,
);
}
return lesson;
} catch (_) {
return null;
}
}
/// Separate audit request: it does not receive the generation prompt and
/// can only approve/reject a previously client-validated lesson.
/// Sends the entire generated lesson structure to an independent LLM audit.
Future<bool> auditGeneratedLesson({
required AiProviderType provider,
required String endpoint,
required String model,
required GeneratedLesson lesson,
}) async {
if (provider == AiProviderType.mock) return false;
final key = await _secureStorage.read(key: _keyName);
if (key == null || key.isEmpty || endpoint.isEmpty || model.isEmpty) {
if (provider == AiProviderType.mock) return true;
final key = await resolveApiKey();
if (key == null ||
key.isEmpty ||
endpoint.trim().isEmpty ||
model.trim().isEmpty) {
return false;
}
final base = endpoint.replaceFirst(RegExp(r'/+$'), '');
final uri = Uri.tryParse(
provider == AiProviderType.gemini
? '$base/models/$model:generateContent'
: '$base/chat/completions',
final uri = resolveEndpointUri(
provider: provider,
endpoint: endpoint,
model: model,
);
if (uri == null || uri.scheme != 'https') return false;
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) return false;
final lessonJson = jsonEncode({
'lessonId': lesson.lessonId,
'stageVersion': lesson.stageVersion,
@@ -589,6 +647,7 @@ Learner answer: $answer''';
'tasks': lesson.tasks
.map(
(task) => {
'taskId': task.taskId,
'skill': task.skill,
'type': task.type,
'prompt': task.prompt,
@@ -678,7 +737,40 @@ Learner answer: $answer''';
return (parts?.firstOrNull as Map?)?['text'] as String?;
}
final choice = (data['choices'] as List?)?.firstOrNull as Map?;
return (choice?['message'] as Map?)?['content'] as String?;
final choiceContent = (choice?['message'] as Map?)?['content'] as String? ??
choice?['text'] as String?;
if (choiceContent != null && choiceContent.isNotEmpty) {
return choiceContent;
}
if (data['output_text'] is String && (data['output_text'] as String).isNotEmpty) {
return data['output_text'] as String;
}
final outputList = data['output'] as List?;
if (outputList != null && outputList.isNotEmpty) {
for (final item in outputList) {
if (item is Map) {
if (item['content'] is List) {
for (final sub in item['content'] as List) {
if (sub is Map && sub['text'] is String) {
return sub['text'] as String;
}
}
} else if (item['text'] is String) {
return item['text'] as String;
}
}
}
}
if (data['response'] is String && (data['response'] as String).isNotEmpty) {
return data['response'] as String;
}
if (data['text'] is String && (data['text'] as String).isNotEmpty) {
return data['text'] as String;
}
if (data['content'] is String && (data['content'] as String).isNotEmpty) {
return data['content'] as String;
}
return null;
} catch (_) {
return null;
}