feat: 重做阅读找答案题与 AI 情境对话,并归拢本地识别、查词等既有改动

阅读"在对话里找到答案":
- 16 道题全部重写,干扰项真实出现在对话里,靠说话人归属或否定句才能作答
- 选项按题目内容确定性打乱,答案不再固定排第一;听力环节同样处理
- 去掉超纲干扰项、重复题干,收紧自由作答匹配(过去单个字母也能判对)

AI 情境对话:
- 提示词区分"AI 这一句要做什么"与"学习者随后要完成什么",并下发已教词句清单
- JSON 只强制 reply,translation/feedback 可选;不再索要用不上的 slots/evidence
- AI 不可用时页面明确提示当前回复来自内置示范脚本
- 删掉按 stage 下标猜中文翻译的兜底,避免译文与英文对不上
- 整课对话改用逐轮必需表达校验,替换"关键词沾边就算过";修正自由场景正则误伤
- 总结的"完成任务"按实际通过的轮次生成;模型点评只在结束页呈现一次
- 自由场景支持草稿续练(独立存储槽);修正回答轮数文案与永不解锁的场景标注

同时提交此前工作区中累积的改动:SenseVoice 本地识别、查词/句型解析卡、
复习与测评页调整等,并补充对话校验、选项分布和句子解析的测试。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-16 16:38:23 +09:00
co-authored by Claude Opus 5
parent 0c16394b5a
commit 61abc69037
24 changed files with 2896 additions and 665 deletions
+35 -37
View File
@@ -16,6 +16,11 @@
- 本文“完成判定”是教学任务完成,不是词句掌握或阶段通过。带提示/文字可完成课程,但不算独立听说证据。出口门槛见 [A0 阶段标准](A0-STAGE-STANDARD.md),内容契约见 [开发与验收约定](M1-IMPLEMENTATION-CONTRACT.md)。
- 每段最多 8 个新增词汇项、2 个主动产出句型和 2 个理解用词块;同句型的槽位替换、缩写不重复计数。下面列的是主题总内容,不能一次全部塞入一个短课。
- 教学可看字幕/中文;独立听力先隐藏文字,查看后标记辅助。课内阅读题保留英文;正式写作评估不用课内填空替代。
- 听力题和阅读题都给三个选项,正确答案在界面上随机但稳定地排序,不能固定在第一项。阅读题必须靠对话内容才能回答:
只看说话人姓名标签或只凭常识就能猜中的题(如“谁叫 Mia?”)不合格,应改问对话里说过的句子、数字或信息。
- 阅读题至少要有两个选项在对话里真出现过,让用户必须靠“谁说的、有没有被否定”来区分;
如果只有正确答案出现在对话中,用户不读题也能挑出唯一见过的那个。干扰项一律使用已教词句,
未教过的英文(如 Goodbye./teacher/louder)不得作为选项。选项之间不能互为子串,否则判分会把两个选项当成同一个答案。
- 课程中可点英文单词或完整词块查词,优先显示当前短语的中文意思、示范音和本课例句;用户可加入复习。正式独立练习和出口评估隐藏查词入口,若需查词则改为教学练习并换题补测。
### 准备词块与分段(先教后用)
@@ -90,12 +95,12 @@
2. **词句跟读**:依次跟读 `Hello.``Im Shen.``Nice to meet you.`。每句可慢放和重录。
3. **阅读**
```text
Mia: Hello. Im Mia.
Mia: Hello. Im Mia. Whats your name?
Shen: Hi. Im Shen.
Mia: Nice to meet you.
Shen: Nice to meet you, too.
```
题目:谁叫 Mia?正确答案:第一位说话的人。
题目:Shen 说的最后一句是什么?选项为“Nice to meet you, too. / Whats your name? / Hi. Im Shen.”,正确答案:Nice to meet you, too.(三个选项都在对话里,必须分清谁说、说在哪一轮。)
4. **写一写**:填空 `Hello. Im _____.`;用户填写自己的名字。
5. **AI 对话**AI`Hi! Im Mia. Whats your name?`;用户说姓名。AI`Nice to meet you, [name].`;用户回应 `Nice to meet you, too.`
@@ -131,12 +136,12 @@
3. **跟读**:读 `S-H-E-N`,听示范并回听录音;然后拼读自己的所选姓名。识别不确定时先确认,不宣称已定位最不清晰的字母。
4. **阅读**
```text
Mia: Whats your name?
Shen: Shen.
Mia: How do you spell that?
Shen: S-H-E-N.
Mia: Im Mia. M-I-A.
Shen: Hi, Mia. Im Shen. S-H-E-N.
Mia: How do you spell Sam?
Shen: S-A-M.
```
排序题:把 `S / H / E / N` 排成正确顺序。
题目:Shen 自己的名字怎么拼写?选项为“S-H-E-N / M-I-A / S-A-M”,正确答案:S-H-E-N。三个拼写都在对话里,必须区分是谁的名字。另可加排序题:把 `S / H / E / N` 排成正确顺序。
5. **写一写**`My name is _____.` 填姓名,按实际姓名长度生成字母格,不固定三个字母。
6. **AI 对话**AI`Whats your name?` → 用户回答;AI`How do you spell that?` → 用户拼读;AI 用 `Thank you.` 收尾。
@@ -177,9 +182,9 @@
```text
Mia: Hi, Shen. How are you?
Shen: Im good, thanks. How are you?
Mia: Im okay.
Mia: Im tired today.
```
题目:Mia 状态如何?答案:okay。
题目:Mia 说自己今天怎么样?选项为“I’m tired today. / Im good, thanks. / Im okay.”,正确答案:Im tired today.(前两个选项都在对话里,必须分清是 Mia 还是 Shen 说的。)
5. **写一写**:选择一个真实状态完成 `Im ____ today.`
6. **AI 对话**AI`Hi, [name]. How are you today?`;用户回答并反问。AI 用一种状态回答。
@@ -217,10 +222,11 @@
4. **阅读**
```text
Mia: Whats your phone number?
Shen: One-three-eight.
Mia: Thank you.
Shen: My number is one-three-eight.
Mia: One-eight-three?
Shen: No. One-three-eight.
```
题目:Shen 的号码是?答案:138。
题目:Shen 的号码到底是哪一个?选项为“138 / 183 / 318”,正确答案:138。(138 和 183 都在对话里出现,必须读懂 Mia 复述错了、Shen 纠正了。C 段另用 502/520/205 一组,不与本题重复。)
5. **写一写**:将 `139` 写成英文数字(`one-three-nine`)。
6. **AI 对话**AI`Whats your phone number?`;用户用 3 位虚拟号码回答。AI 复述:`One-three-eight?`;用户说 `Yes.`
@@ -259,12 +265,10 @@
3. **跟读**`Whats this?`、`Its a pen.`、`Its my bag.`
4. **阅读**
```text
Mia: Whats this?
Shen: Its a book.
Mia: Is it your book?
Shen: Yes.
Mia: Whats this? Is it a pen?
Shen: No. Its a book.
```
题目:这是谁的书?答案:Shen 的。
题目:Shen 说那件东西是什么?选项为“A book / A pen / A phone”,正确答案:A book。(pen 也在对话里,必须读到被否定。)
5. **写一写**:先看手机图填 `It's my _____.`;再排序 `It's / a / pen`;收起句框和词库,换已教物品图片,独立写一句。笔图参考 `It's a pen.`,书图参考 `It's a book.`;需要帮助可恢复,但标记辅助完成。
6. **AI 对话**:AI 描述并出示物品图:`Whats this?`;用户回答。连续 3 个物品后结束。
@@ -302,11 +306,10 @@
3. **跟读**`Where are you from?` 和两个回答句。
4. **阅读**
```text
Mia: Where are you from?
Mia: Im from London. Where are you from?
Shen: Im from Hong Kong.
Mia: Nice!
```
题目:Shen 来自哪里?答案:Hong Kong。
题目:Shen 来自哪里?选项为“Hong Kong / London / Beijing”,正确答案:Hong Kong。(两个地点都在对话里,必须分清谁来自哪里。)
5. **写一写**:先填 `I'm from _____.`,再收起范句,仅显示中文任务“用完整英文句介绍你来自哪里”,允许自选已准备的真实/虚拟地点。参考 `I'm from China.`,只写 China 不算完整句。次日另用“介绍姓名”任务独立写 `I'm …` 或 `My name is …`,纳入复习预算。
6. **AI 对话**:AI 先问姓名(复习第 1 课),再问 `Where are you from?`;用户回答后反问 AI。
@@ -345,12 +348,10 @@
3. **跟读**`This is my mother.`、`This is my brother.`
4. **阅读**
```text
Mia: Who is this?
Shen: This is my father.
Mia: Your father?
Shen: Yes.
Mia: Who is this? Is this your father?
Shen: No. This is my mother.
```
题目:照片里的人是谁?答案:Shen 的父亲。
题目:Shen 介绍的是哪一位家人?选项为“My mother / My father / My sister”,正确答案:My mother。(father 也在对话里,必须读到被否定;干扰项只用已教的家庭成员词。)
5. **写一写**:先选词填 `This is my _____.`,再收起句框和词库,换一张已教家庭成员图独立写完整句。父亲图参考 `This is my father.`;允许虚构家庭。
6. **AI 对话**AI`Who is this?`(显示家庭成员图);用户回答。AI 追问 `Is this your mother?`;用户答 `Yes.` 或 `No.`
@@ -395,12 +396,10 @@
3. **跟读**:三句核心句,重点练 Wednesday。
4. **阅读**
```text
Mia: What day is it today?
Shen: Its Monday.
Mia: What time is it?
Shen: Its three oclock.
Mia: What day is it today? Is it Tuesday?
Shen: No. Its Monday.
```
题目:现在几点?答案:three oclock。
题目:对话中今天到底是星期几?选项为“Monday / Tuesday / Sunday”,正确答案:Monday。A/B/C 三段各用不同题干与不同星期/整点(A:Monday 猜错→TuesdayBSaturday 猜错→SundayCtwo oclock 猜错→three oclock),避免三段撞题
5. **写一写**C 段先看 5:00 填 `It's _____ o'clock.`,再收起句框,换已教整点钟面独立写完整句(3:00 参考 `It's three o'clock.`)。B 段以相同撤提示步骤练星期句,例如星期五卡参考 `It's Friday.`。
6. **AI 对话**A/B 段问 `What day is it today?`,仅用已教星期卡;七天都准备后可答真实星期。C 段再问 `What time is it?`,用 1–12 整点钟面。未教过的星期和数字不计错。
@@ -441,10 +440,9 @@
4. **阅读**
```text
Mia: I like tea. Do you like tea?
Shen: Yes, I do.
Mia: Great!
Shen: No, I dont. I like coffee.
```
题目:Shen 喜欢茶吗?答案:喜欢。
题目:Shen 喜欢喝什么?选项为“Coffee / Tea / Music”,正确答案:Coffee。(tea 也在对话里,必须读懂 `No, I dont.` 这个短答。)
5. **写一写**:先选词填 `I like _____.`,再收起句框和词库,只给中文目标“写一句你自己的喜好”。参考 `I like music.`,也接受其他已教对象,不要求照抄例句。
6. **AI 对话**AI`I like music. Do you like music?`;用户回答。AI 随后问另一项喜好,用户反问一次。
@@ -479,11 +477,11 @@
Alex: Hi! Im Alex. Whats your name?
Shen: Hi! Im Shen. Nice to meet you.
Alex: Nice to meet you, too. Where are you from?
Shen: Im from Hong Kong. How are you?
Alex: Im good, thanks. Do you like coffee?
Shen: Yes, I do.
Shen: Im from Hong Kong. Where are you from?
Alex: Im from London. Do you like coffee?
Shen: No, I dont. I like tea.
```
题目:Shen 来自哪里Shen 喜欢什么?
题目:Shen 来自哪里喜欢什么?选项为“Hong Kong, tea / London, coffee / Hong Kong, coffee”,正确答案:Hong Kong, tea。(两个地点、两种饮料都在对话里,必须同时读对来源和喜好。)
2. **排序**:将五张卡按自然对话顺序排列:问候 → 姓名 → 来自哪里 → 状态 → 喜好。
3. **关键句跟读**:用户任选 3 句重说;系统给“清楚/再慢一点/可重说”的支持性提示。
4. **写一写**:先填小抄 `I'm _____. I'm from _____. I like _____.` 作准备;完成另一项练习后收起小抄,仅按中文目标独立写姓名、地点和喜好三句,不提供句框或词库。原样立即抄写仅算练习;再用不同物品图片/钟面补一项完整句练习,可留到次日预算内完成。
+6
View File
@@ -108,6 +108,10 @@ M1 先提供与 A0 核心课对应的受控对话;这些场景是起始内容
AI 必须记住当前场景、用户身份、完成目标、已知信息及难度上限。它不能突然转换角色、重复问已回答的问题,或为了“自然聊天”而跳过任务目标。
给模型的指令必须把两件事分开:**本轮 AI 自己要说什么**,以及**学习者随后要完成什么任务**。两者混成一句会让模型替学习者把答案说出来。同时把当前课程及之前课程已教的词句作为可用语言清单随请求下发,A0 每轮最多 1 个清单外的词。
模型只需返回 `reply`(英文回复);`translation`(中文翻译)与 `feedback`(一句中文点评)可选,缺少可选字段不丢弃整条回复。是否完成任务由客户端按本轮必需表达判定,不采信模型的完成声明。AI 不可用时必须在对话页显示提示,说明当前这句来自内置示范脚本,不能让备用文案冒充 AI 回复。
## 8. 对话交互细节
### 8.1 输入与输出
@@ -135,6 +139,8 @@ AI 必须记住当前场景、用户身份、完成目标、已知信息及难
对话结束后才给完整的微反馈,且一次最多一个语言问题。零基础用户首先需要成功表达,不需要一次性接受语法课。
模型每轮返回的 `feedback` 只保留最后一条,在对话结束页和总结页以“下一次说得更好”呈现,不在对话过程中逐轮弹出。
M1 发音反馈限示范音、慢放、录音回听和可选通用练习,不评价像不像母语者。`three` 多次被转写为 `free` 可能是噪声或 ASR 错误,不能据此诊断 /θ/ 或降低掌握度;可在用户选择后展示通用对比词与动作提示,并标注“非个性化发音诊断”。真正的音素诊断需要音频证据和验证过的能力,后续另行评估。
## 9. 会话总结与复习
@@ -12,6 +12,10 @@ android {
// toolchain satisfies all plugin requirements for reproducible builds.
ndkVersion = "28.2.13676358"
androidResources {
noCompress += listOf("onnx")
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
+300 -36
View File
@@ -233,16 +233,16 @@ class AiService {
'input': messages,
'reasoning': {'effort': reasoningEffort},
'reasoning_effort': reasoningEffort,
if (temperature != null) 'temperature': temperature,
if (maxTokens != null) 'max_output_tokens': maxTokens,
'temperature': ?temperature,
'max_output_tokens': ?maxTokens,
};
}
return {
'model': model,
'messages': messages,
'reasoning_effort': reasoningEffort,
if (temperature != null) 'temperature': temperature,
if (maxTokens != null) 'max_tokens': maxTokens,
'temperature': ?temperature,
'max_tokens': ?maxTokens,
};
}
@@ -253,9 +253,9 @@ class AiService {
int thinkingBudget = 1024,
}) {
return {
if (temperature != null) 'temperature': temperature,
if (maxOutputTokens != null) 'maxOutputTokens': maxOutputTokens,
if (responseMimeType != null) 'responseMimeType': responseMimeType,
'temperature': ?temperature,
'maxOutputTokens': ?maxOutputTokens,
'responseMimeType': ?responseMimeType,
'thinkingConfig': {
'thinkingBudget': thinkingBudget,
},
@@ -345,6 +345,249 @@ class AiService {
}
}
/// Performs structured multi-dimensional analysis on an English sentence or phrase.
/// Provides translation, sentence pattern, grammar breakdown, pronunciation tips, and extracted phrases.
Future<SentenceAnalysisResult?> analyzeSentence({
required AiProviderType provider,
required String endpoint,
required String model,
required String text,
}) async {
final cleanText = text.trim();
if (cleanText.isEmpty || cleanText.length > 800) {
return null;
}
if (provider == AiProviderType.mock) {
return _buildMockSentenceAnalysis(cleanText);
}
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 != 'http')) {
return null;
}
const instruction =
'You are an expert oral English coach for beginner adult learners (A0-A1). '
'Analyze the given English sentence into clear, encouraging, beginner-friendly Chinese explanations. '
'Focus on practical oral usage, sentence structure, and linking/pronunciation hints. '
'Return ONLY valid JSON matching this schema, with no markdown or other text:\n'
'{\n'
' "translation": "准确通顺的中文整句翻译",\n'
' "sentencePattern": "核心口语句型结构 (如:I would like + 名词/动词原形)",\n'
' "grammarNote": "通俗易懂的语法与时态要点 (1-2句话,面向初学者,不要学术术语)",\n'
' "pronunciationTips": "口语连读/失爆/弱读技巧提示 (如:check in 连读为 /tʃe-kɪn/)",\n'
' "phrases": [\n'
' {\n'
' "phrase": "句子中的重点短语或搭配",\n'
' "ipa": "/音标/",\n'
' "meaning": "在句中的准确释义",\n'
' "usageNote": "简要口语用法说明或常见搭配"\n'
' }\n'
' ]\n'
'}';
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\n\nSentence: $cleanText'},
],
},
],
'generationConfig': _buildGeminiGenerationConfig(
maxOutputTokens: 800,
responseMimeType: 'application/json',
),
}
: _buildOpenAiPayload(
uri: uri,
model: model,
messages: [
{
'role': 'user',
'content': '$instruction\n\nSentence: $cleanText',
},
],
maxTokens: 800,
),
),
)
.timeout(const Duration(seconds: 30));
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
final content = _extractResponseContent(provider, response.body);
if (content == null || content.isEmpty) return null;
return _decodeSentenceAnalysis(
raw: content,
originalText: cleanText,
provider: provider.name,
model: model,
);
} catch (_) {
return null;
}
}
SentenceAnalysisResult? _decodeSentenceAnalysis({
required String raw,
required String originalText,
required String provider,
required String model,
}) {
try {
var sanitized = raw.trim();
if (sanitized.startsWith('```')) {
sanitized = sanitized.replaceFirst(RegExp(r'^```[a-zA-Z]*\s*'), '');
sanitized = sanitized.replaceFirst(RegExp(r'\s*```$'), '');
}
final jsonStart = sanitized.indexOf('{');
final jsonEnd = sanitized.lastIndexOf('}');
if (jsonStart >= 0 && jsonEnd > jsonStart) {
sanitized = sanitized.substring(jsonStart, jsonEnd + 1);
}
final data = jsonDecode(sanitized);
if (data is! Map<String, dynamic>) return null;
final translation = data['translation'] as String? ?? '';
if (translation.trim().isEmpty) return null;
final phrasesList = <PhraseBreakdownItem>[];
if (data['phrases'] is List) {
for (final item in data['phrases'] as List) {
if (item is Map<String, dynamic>) {
final phrase = item['phrase'] as String? ?? '';
final meaning = item['meaning'] as String? ?? '';
if (phrase.trim().isNotEmpty && meaning.trim().isNotEmpty) {
phrasesList.add(
PhraseBreakdownItem(
phrase: phrase.trim(),
meaning: meaning.trim(),
ipa: item['ipa'] as String?,
usageNote: item['usageNote'] as String?,
),
);
}
}
}
}
return SentenceAnalysisResult(
originalText: originalText,
translation: translation.trim(),
sentencePattern: data['sentencePattern'] as String?,
grammarNote: data['grammarNote'] as String?,
pronunciationTips: data['pronunciationTips'] as String?,
phrases: phrasesList,
provider: provider,
model: model,
createdAt: DateTime.now(),
);
} catch (_) {
return null;
}
}
SentenceAnalysisResult _buildMockSentenceAnalysis(String text) {
final lower = text.toLowerCase();
String translation = '(演示翻译)这是句子的中文参考释义。';
String? pattern = '常见日常交流句型';
String? grammar = '此句为日常口语高频表达,结构清晰,适合在日常与工作场景中直接套用。';
String? pronunciation = '注意单词间的自然停顿,句末语调自然微降。';
final phrases = <PhraseBreakdownItem>[];
if (lower.contains('would like') || lower.contains("i'd like")) {
translation = '我想办理相关事项/我想要这个,麻烦了。';
pattern = 'I would like + 名词/动词原形(礼貌请求句型)';
grammar = 'would like 相当于礼貌委婉的 want,是服务和工作场景中最得体的表达方式。';
pronunciation = 'would like 发音为 /wʊd laɪk/,注意 d 的微弱爆破。';
phrases.add(
const PhraseBreakdownItem(
phrase: 'would like',
ipa: '/wʊd laɪk/',
meaning: '想要(礼貌委婉)',
usageNote: "比 I want 更得体,常缩写为 I'd like",
),
);
} else if (lower.contains('nice to meet you')) {
translation = '初次见面,很高兴认识你。';
pattern = 'It is + 形容词 + to do sth.(社交问候句型)';
grammar = '初次与新朋友或客户见面时的标准礼貌问候,通常省略了句首的 It is。';
pronunciation = 'meet 与 you 发生音变连读为 /miːtʃuː/。';
phrases.add(
const PhraseBreakdownItem(
phrase: 'nice to meet you',
ipa: '/naɪs tuː miːt juː/',
meaning: '初次见面很高兴认识你',
usageNote: '仅用于初次相识;熟悉后再次见面用 Nice to see you again',
),
);
} else if (lower.contains('where is') || lower.contains("where's")) {
translation = '请问……在哪里?';
pattern = 'Where is + 目的地/物品?(询问地点句型)';
grammar = "where 引导的特殊疑问句,口语中常用缩读 Where's。";
pronunciation = 'Where 与 is 发生连读,读作 /weər ɪz/,疑问句末尾用降调。';
phrases.add(
const PhraseBreakdownItem(
phrase: 'where is',
ipa: '/weər ɪz/',
meaning: '……在哪里',
usageNote: '问路与寻物核心句型,句首加上 Excuse me 更礼貌',
),
);
} else {
final words = text
.split(RegExp(r'\s+'))
.map((w) => w.replaceAll(RegExp(r'[^a-zA-Z]'), ''))
.where((w) => w.length > 3)
.take(2);
for (final w in words) {
phrases.add(
PhraseBreakdownItem(
phrase: w,
meaning: '重点词汇',
usageNote: '句子中的核心实词',
),
);
}
}
return SentenceAnalysisResult(
originalText: text,
translation: translation,
sentencePattern: pattern,
grammarNote: grammar,
pronunciationTips: pronunciation,
phrases: phrases,
provider: 'mock',
model: 'local-mock',
createdAt: DateTime.now(),
);
}
Future<AiConnectionResult> testConnection({
required AiProviderType provider,
required String endpoint,
@@ -458,12 +701,17 @@ class AiService {
}
}
/// [aiGoal] is what Mia's own next line has to do; [learnerTask] is what the
/// learner has to say afterwards. They used to be the same string, so the
/// model was told to perform the learner's job.
Future<DialogueAiResponse?> dialogueReply({
required AiProviderType provider,
required String endpoint,
required String model,
required List<Map<String, String>> history,
required String requiredTask,
required String aiGoal,
required String learnerTask,
List<String> allowedLanguage = const [],
}) async {
if (provider == AiProviderType.mock) {
return null;
@@ -483,8 +731,23 @@ class AiService {
if (uri == null || (uri.scheme != 'https' && uri.scheme != 'http')) {
return null;
}
const system =
'You are Mia, a patient A0 English conversation partner. Use only very simple English. Reply in one short sentence or question. Do not explain grammar. Return JSON only, with exactly these fields: reply (string, maximum 20 English words), translation (string, simplified Chinese translation of reply), slots (object of short string values), evidence (array of exact learner quotes), suggestsComplete (boolean), feedback (string or null). The learner must now: ';
final vocabularyRule = allowedLanguage.isEmpty
? ''
: 'Build your reply from your goal wording, names, numbers and this '
'taught language: ${allowedLanguage.join('; ')}. '
'At most one word outside it per reply, and only if unavoidable. ';
final system =
'You are Mia, a patient A0 English conversation partner for a Chinese beginner. '
'Your own next line must do this: $aiGoal '
'After your line the learner has to: $learnerTask. '
'Do not say the learner sentence for them, and do not ask for anything else. '
'$vocabularyRule'
'Reply with one short sentence or question, at most 20 English words, in English only. '
'Do not explain grammar. '
'Return JSON only: {"reply": "your English line", '
'"translation": "reply 的简体中文翻译", '
'"feedback": "一句中文点评学习者上一句英文,没有要说的就用 null"}. '
'Only reply is required.';
try {
final response = await http
.post(
@@ -500,7 +763,7 @@ class AiService {
? {
'systemInstruction': {
'parts': [
{'text': '$system$requiredTask'},
{'text': system},
],
},
'contents': history
@@ -525,7 +788,7 @@ class AiService {
uri: uri,
model: model,
messages: [
{'role': 'system', 'content': '$system$requiredTask'},
{'role': 'system', 'content': system},
...history,
],
temperature: 0.3,
@@ -984,41 +1247,42 @@ Learner wrote: $answer''';
}
final data = jsonDecode(sanitized.trim()) as Map<String, dynamic>;
final reply = data['reply'] as String?;
final rawSlots = data['slots'];
final rawEvidence = data['evidence'];
final suggestsComplete = data['suggestsComplete'];
final feedback = data['feedback'];
if (reply == null ||
reply.trim().isEmpty ||
reply.length > 240 ||
rawSlots is! Map ||
rawEvidence is! List ||
suggestsComplete is! bool ||
(feedback != null && feedback is! String)) {
if (reply == null || reply.trim().isEmpty || reply.length > 240) {
return null;
}
// Only `reply` is mandatory. A model that omits or malforms an optional
// field used to make the whole turn fall back to the canned script.
final slots = <String, String>{};
for (final entry in rawSlots.entries) {
if (entry.key is! String || entry.value is! String) return null;
if ((entry.key as String).length > 40 ||
(entry.value as String).length > 80) {
return null;
final rawSlots = data['slots'];
if (rawSlots is Map) {
for (final entry in rawSlots.entries) {
final key = entry.key;
final value = entry.value;
if (key is! String || value is! String) continue;
if (key.length > 40 || value.length > 80) continue;
slots[key] = value;
}
slots[entry.key as String] = entry.value as String;
}
final evidence = <String>[];
for (final item in rawEvidence) {
if (item is! String || item.length > 240) return null;
evidence.add(item);
final rawEvidence = data['evidence'];
if (rawEvidence is List) {
for (final item in rawEvidence) {
if (item is String && item.length <= 240) evidence.add(item);
}
}
final translation = data['translation'] as String?;
final translation = data['translation'];
final feedback = data['feedback'];
return DialogueAiResponse(
reply: reply.trim(),
translation: translation?.trim(),
translation: translation is String && translation.trim().isNotEmpty
? translation.trim()
: null,
slots: slots,
evidence: evidence,
suggestsComplete: suggestsComplete,
feedback: feedback as String?,
suggestsComplete: data['suggestsComplete'] == true,
feedback: feedback is String && feedback.trim().isNotEmpty
? feedback.trim()
: null,
);
} catch (_) {
return null;
+131 -34
View File
@@ -45,6 +45,10 @@ class AppState extends ChangeNotifier {
final List<AssessmentRecord> assessments = [];
AssessmentDraft? assessmentDraft;
DialogueDraft? dialogueDraft;
/// The free "初次见面" scene keeps its own slot, so resuming a scene never
/// overwrites an unfinished lesson dialogue.
DialogueDraft? sceneDialogueDraft;
LessonStep lessonStep = LessonStep.preview;
int previewIndex = 0;
bool lessonListeningComplete = false;
@@ -80,6 +84,7 @@ class AppState extends ChangeNotifier {
String? adaptiveLessonDraftRecordingPath;
final Set<String> reportedAdaptiveLessonIds = {};
final Map<String, TemporaryLexiconEntry> temporaryLexicon = {};
final Map<String, SentenceAnalysisResult> sentenceAnalyses = {};
final List<ReviewItem> reviewQueue = [];
final List<AttemptEvidence> attemptEvidence = [];
@@ -255,6 +260,17 @@ class AppState extends ChangeNotifier {
notifyListeners();
}
void saveSceneDialogueDraft(DialogueDraft draft) {
sceneDialogueDraft = draft;
notifyListeners();
}
void clearSceneDialogueDraft() {
if (sceneDialogueDraft == null) return;
sceneDialogueDraft = null;
notifyListeners();
}
int get usableMasteryCount => mastery.values
.where(
(item) =>
@@ -407,6 +423,24 @@ class AppState extends ChangeNotifier {
),
);
}
final savedSentenceAnalyses = data['sentenceAnalyses'] as List<dynamic>?;
if (savedSentenceAnalyses != null) {
sentenceAnalyses
..clear()
..addEntries(
savedSentenceAnalyses
.whereType<Map<String, dynamic>>()
.map((item) {
final query = item['query'] as String? ?? '';
final payload = item['payload'] as Map<String, dynamic>? ?? {};
return MapEntry(
_normalizeLexiconQuery(query),
SentenceAnalysisResult.fromJson(payload),
);
})
.where((entry) => entry.key.isNotEmpty && entry.value.translation.isNotEmpty),
);
}
aiProvider = _enumValue(
AiProviderType.values,
data['aiProvider'] as String?,
@@ -580,26 +614,51 @@ class AppState extends ChangeNotifier {
.map((key, value) => MapEntry(key, value == true)),
);
}
final savedDialogue = data['dialogueDraft'] as Map<String, dynamic>?;
if (savedDialogue != null) {
dialogueDraft = DialogueDraft(
lessonId: savedDialogue['lessonId'] as String,
stage: savedDialogue['stage'] as int? ?? 0,
usedHelp: savedDialogue['usedHelp'] as bool? ?? false,
turns: (savedDialogue['turns'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(
(turn) => DialogueTurn(
text: turn['text'] as String,
isLearner: turn['isLearner'] as bool? ?? false,
translation: turn['translation'] as String?,
),
)
.toList(),
);
}
dialogueDraft = _dialogueDraftFromJson(
data['dialogueDraft'] as Map<String, dynamic>?,
);
sceneDialogueDraft = _dialogueDraftFromJson(
data['sceneDialogueDraft'] as Map<String, dynamic>?,
);
}
DialogueDraft? _dialogueDraftFromJson(Map<String, dynamic>? saved) {
if (saved == null || saved['lessonId'] is! String) return null;
return DialogueDraft(
lessonId: saved['lessonId'] as String,
stage: saved['stage'] as int? ?? 0,
usedHelp: saved['usedHelp'] as bool? ?? false,
turns: (saved['turns'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(
(turn) => DialogueTurn(
text: turn['text'] as String,
isLearner: turn['isLearner'] as bool? ?? false,
translation: turn['translation'] as String?,
),
)
.toList(),
);
}
Map<String, dynamic>? _dialogueDraftToJson(DialogueDraft? draft) =>
draft == null
? null
: {
'lessonId': draft.lessonId,
'stage': draft.stage,
'usedHelp': draft.usedHelp,
'turns': draft.turns
.map(
(turn) => {
'text': turn.text,
'isLearner': turn.isLearner,
'translation': turn.translation,
},
)
.toList(),
};
T _enumValue<T extends Enum>(List<T> values, String? name, T fallback) =>
values.where((value) => value.name == name).firstOrNull ?? fallback;
@@ -663,6 +722,17 @@ class AppState extends ChangeNotifier {
},
)
.toList(),
'sentenceAnalyses': sentenceAnalyses.entries
.map(
(entry) => {
'query': entry.key,
'payload': entry.value.toJson(),
'provider': entry.value.provider,
'model': entry.value.model,
'createdAt': entry.value.createdAt.toIso8601String(),
},
)
.toList(),
'aiProvider': aiProvider.name,
'lessonStep': lessonStep.name,
'previewIndex': previewIndex,
@@ -754,22 +824,8 @@ class AppState extends ChangeNotifier {
'taskIndex': assessmentDraft!.taskIndex,
'results': assessmentDraft!.results,
},
'dialogueDraft': dialogueDraft == null
? null
: {
'lessonId': dialogueDraft!.lessonId,
'stage': dialogueDraft!.stage,
'usedHelp': dialogueDraft!.usedHelp,
'turns': dialogueDraft!.turns
.map(
(turn) => {
'text': turn.text,
'isLearner': turn.isLearner,
'translation': turn.translation,
},
)
.toList(),
},
'dialogueDraft': _dialogueDraftToJson(dialogueDraft),
'sceneDialogueDraft': _dialogueDraftToJson(sceneDialogueDraft),
}),
).catchError((_) => false).whenComplete(() {
_writing = false;
@@ -815,6 +871,46 @@ class AppState extends ChangeNotifier {
}
}
SentenceAnalysisResult? sentenceAnalysisFor(String query) =>
sentenceAnalyses[_normalizeLexiconQuery(query)];
void cacheSentenceAnalysis(SentenceAnalysisResult result) {
final key = _normalizeLexiconQuery(result.originalText);
if (key.isEmpty || result.translation.trim().isEmpty) return;
sentenceAnalyses[key] = result;
notifyListeners();
}
void removeSentenceAnalysis(String query) {
if (sentenceAnalyses.remove(_normalizeLexiconQuery(query)) != null) {
notifyListeners();
}
}
void addPhraseToReview({
required String phrase,
required String meaning,
String? ipa,
String? usageNote,
required String contextSentence,
}) {
final cleanPhrase = phrase.trim();
if (cleanPhrase.isEmpty) return;
final id = 'phrase_${cleanPhrase.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_')}';
final fullMeaning = usageNote != null && usageNote.trim().isNotEmpty
? '$meaning ($usageNote)'
: meaning;
final item = VocabularyItem(
id: id,
word: cleanPhrase,
meaning: fullMeaning,
example: contextSentence.trim(),
exampleMeaning: meaning,
ipa: ipa,
);
addSavedWord(item);
}
void finishOnboarding() {
onboardingComplete = true;
notifyListeners();
@@ -1631,6 +1727,7 @@ class AppState extends ChangeNotifier {
assessments.clear();
assessmentDraft = null;
dialogueDraft = null;
sceneDialogueDraft = null;
lessonStep = LessonStep.preview;
previewIndex = 0;
lessonListeningComplete = false;
+51
View File
@@ -105,6 +105,13 @@ class LocalSnapshotStore {
created_at TEXT NOT NULL
)
''');
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS sentence_analyses (
query_key TEXT PRIMARY KEY NOT NULL, query TEXT NOT NULL,
payload TEXT NOT NULL, provider TEXT NOT NULL, model TEXT NOT NULL,
created_at TEXT NOT NULL
)
''');
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS learner_profiles (
profile_id TEXT PRIMARY KEY NOT NULL, payload TEXT NOT NULL,
@@ -233,6 +240,22 @@ class LocalSnapshotStore {
},
)
.toList();
result['sentenceAnalyses'] =
(await executor.runSelect(
'''SELECT query, payload, provider, model, created_at
FROM sentence_analyses ORDER BY created_at''',
const [],
))
.map(
(row) => <String, dynamic>{
'query': row['query'],
'payload': _decodeObject(row['payload']),
'provider': row['provider'],
'model': row['model'],
'createdAt': row['created_at'],
},
)
.toList();
result['assessments'] = (await executor.runSelect(
'SELECT payload FROM assessment_records ORDER BY completed_at',
const [],
@@ -246,6 +269,8 @@ class LocalSnapshotStore {
result['assessmentDraft'] = _decodeObject(session['payload']);
} else if (session['session_type'] == 'dialogue') {
result['dialogueDraft'] = _decodeObject(session['payload']);
} else if (session['session_type'] == 'sceneDialogue') {
result['sceneDialogueDraft'] = _decodeObject(session['payload']);
}
}
return result;
@@ -262,6 +287,12 @@ class LocalSnapshotStore {
return decoded is Map<String, dynamic> ? decoded : const {};
}
String _encodeObject(Object? value) {
if (value == null) return '{}';
if (value is String) return value;
return jsonEncode(value);
}
Future<void> write(String payload) async {
final executor = _executor ?? await _open();
await _ensureSchema(executor);
@@ -357,6 +388,7 @@ class LocalSnapshotStore {
},
'assessment': data['assessmentDraft'],
'dialogue': data['dialogueDraft'],
'sceneDialogue': data['sceneDialogueDraft'],
};
for (final entry in sessions.entries) {
if (entry.value == null) continue;
@@ -466,6 +498,24 @@ class LocalSnapshotStore {
],
);
}
for (final entry in (data['sentenceAnalyses'] as List? ?? const [])) {
if (entry is! Map) continue;
final query = entry['query'] as String? ?? '';
final key = query.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' ');
final payload = _encodeObject(entry['payload'] ?? {});
if (key.isEmpty || payload == '{}') continue;
await executor.runCustom(
'INSERT INTO sentence_analyses VALUES (?, ?, ?, ?, ?, ?)',
[
key,
query,
payload,
entry['provider'] ?? 'unknown',
entry['model'] ?? '',
entry['createdAt'] ?? DateTime.now().toUtc().toIso8601String(),
],
);
}
}
Future<void> clear() async {
@@ -484,6 +534,7 @@ class LocalSnapshotStore {
'review_items',
'study_sessions',
'temporary_lexicon_entries',
'sentence_analyses',
'learner_profiles',
'lesson_progress',
'assessment_records',
+94 -3
View File
@@ -78,9 +78,9 @@ enum ContentSource { builtInOriginal, aiGenerated, importedReference }
class DialogueAiResponse {
const DialogueAiResponse({
required this.reply,
required this.slots,
required this.evidence,
required this.suggestsComplete,
this.slots = const {},
this.evidence = const [],
this.suggestsComplete = false,
this.translation,
this.feedback,
});
@@ -210,6 +210,92 @@ class TemporaryLexiconEntry {
final DateTime createdAt;
}
/// Represents an extracted key phrase or vocabulary item within a sentence.
class PhraseBreakdownItem {
const PhraseBreakdownItem({
required this.phrase,
required this.meaning,
this.ipa,
this.usageNote,
});
final String phrase;
final String meaning;
final String? ipa;
final String? usageNote;
Map<String, dynamic> toJson() => {
'phrase': phrase,
'meaning': meaning,
if (ipa != null) 'ipa': ipa,
if (usageNote != null) 'usageNote': usageNote,
};
factory PhraseBreakdownItem.fromJson(Map<String, dynamic> json) =>
PhraseBreakdownItem(
phrase: json['phrase'] as String? ?? '',
meaning: json['meaning'] as String? ?? '',
ipa: json['ipa'] as String?,
usageNote: json['usageNote'] as String?,
);
}
/// Structured multi-dimensional analysis for a sentence or phrase, including
/// translation, sentence pattern, grammar note, pronunciation tips, and extracted phrases.
class SentenceAnalysisResult {
const SentenceAnalysisResult({
required this.originalText,
required this.translation,
this.sentencePattern,
this.grammarNote,
this.pronunciationTips,
this.phrases = const [],
this.provider = 'unknown',
this.model = '',
required this.createdAt,
});
final String originalText;
final String translation;
final String? sentencePattern;
final String? grammarNote;
final String? pronunciationTips;
final List<PhraseBreakdownItem> phrases;
final String provider;
final String model;
final DateTime createdAt;
Map<String, dynamic> toJson() => {
'originalText': originalText,
'translation': translation,
if (sentencePattern != null) 'sentencePattern': sentencePattern,
if (grammarNote != null) 'grammarNote': grammarNote,
if (pronunciationTips != null) 'pronunciationTips': pronunciationTips,
'phrases': phrases.map((p) => p.toJson()).toList(),
'provider': provider,
'model': model,
'createdAt': createdAt.toIso8601String(),
};
factory SentenceAnalysisResult.fromJson(Map<String, dynamic> json) =>
SentenceAnalysisResult(
originalText: json['originalText'] as String? ?? '',
translation: json['translation'] as String? ?? '',
sentencePattern: json['sentencePattern'] as String?,
grammarNote: json['grammarNote'] as String?,
pronunciationTips: json['pronunciationTips'] as String?,
phrases: (json['phrases'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(PhraseBreakdownItem.fromJson)
.toList(),
provider: json['provider'] as String? ?? 'unknown',
model: json['model'] as String? ?? '',
createdAt: json['createdAt'] != null
? DateTime.tryParse(json['createdAt'] as String) ?? DateTime.now()
: DateTime.now(),
);
}
class ReviewItem {
const ReviewItem({
required this.id,
@@ -329,9 +415,14 @@ class DialogueSummaryData {
required this.completedTasks,
required this.personalSentence,
required this.usedHelp,
this.improvement,
});
final List<String> completedTasks;
final String personalSentence;
final bool usedHelp;
/// One improvement point collected during the dialogue and shown only at the
/// end, per "对话中不逐句打断" in the conversation spec.
final String? improvement;
}
+306 -58
View File
@@ -1,3 +1,4 @@
import 'a0_core.dart';
import 'models.dart';
/// Stable A0 core identities taught by each seed lesson. Wording can vary in
@@ -533,6 +534,7 @@ class LessonActivity {
required this.reading,
required this.readingQuestion,
required this.readingAnswer,
this.readingOptions = const [],
required this.writingPrompt,
required this.writingExample,
required this.independentPrompt,
@@ -545,12 +547,39 @@ class LessonActivity {
final String reading;
final String readingQuestion;
final String readingAnswer;
final List<String> readingOptions;
final String writingPrompt;
final String writingExample;
final String independentPrompt;
final String independentHelp;
}
int _seedHash(String seed) {
var hash = 0x811c9dc5;
for (final code in seed.codeUnits) {
hash = ((hash ^ code) * 0x01000193) & 0x7fffffff;
}
return hash;
}
/// 选项顺序固定会让学习者养成“永远选第一个”的习惯。[options] 的第一项是标准
/// 答案,这里按题目内容确定性地把它挪到某个位置,并打乱其余干扰项:同一道题
/// 每次进入顺序都一样,但答案在三个位置上分布均匀。
List<String> shuffledOptions(List<String> options, String seed) {
if (options.length < 2) return options;
final distractors = [...options.skip(1)];
var hash = _seedHash(seed);
for (var i = distractors.length - 1; i > 0; i--) {
hash = (hash * 1103515245 + 12345) & 0x7fffffff;
final j = hash % (i + 1);
final swap = distractors[i];
distractors[i] = distractors[j];
distractors[j] = swap;
}
return distractors
..insert(_seedHash('$seed#slot') % options.length, options.first);
}
const a0Activities = <String, LessonActivity>{
'a0-01': LessonActivity(
listening: 'Hello. Im Mia. Nice to meet you.',
@@ -558,9 +587,14 @@ const a0Activities = <String, LessonActivity>{
answers: ['自我介绍', '买东西', '问时间'],
speaking: 'Hello. Im Shen. Nice to meet you.',
reading:
'Mia: Hello. Im Mia.\nShen: Hi. Im Shen.\nMia: Nice to meet you.\nShen: Nice to meet you, too.',
readingQuestion: '谁叫 Mia',
readingAnswer: '第一位说话的人',
'Mia: Hello. Im Mia. Whats your name?\nShen: Hi. Im Shen.\nMia: Nice to meet you.\nShen: Nice to meet you, too.',
readingQuestion: 'Shen 说的最后一句是什么',
readingAnswer: 'Nice to meet you, too.',
readingOptions: [
'Nice to meet you, too.',
'Whats your name?',
'Hi. Im Shen.',
],
writingPrompt: '写一句问候和姓名介绍。',
writingExample: 'Hello. Im Shen.',
independentPrompt: '不看句框,介绍你的名字并回应“Nice to meet you”。',
@@ -572,9 +606,10 @@ const a0Activities = <String, LessonActivity>{
answers: ['你的名字怎么拼?', '你的名字是什么?', '你来自哪里?'],
speaking: 'S H E N',
reading:
'Mia: Whats your name?\nShen: Shen.\nMia: How do you spell that?\nShen: S-H-E-N.',
readingQuestion: 'Shen 的名字怎么拼?',
'Mia: Im Mia. M-I-A.\nShen: Hi, Mia. Im Shen. S-H-E-N.\nMia: How do you spell Sam?\nShen: S-A-M.',
readingQuestion: 'Shen 自己的名字怎么拼',
readingAnswer: 'S-H-E-N',
readingOptions: ['S-H-E-N', 'M-I-A', 'S-A-M'],
writingPrompt: '把名字和拼写写完整。',
writingExample: 'My name is Shen.\nS-H-E-N.',
independentPrompt: '不看句框,用英文介绍名字并拼读它。',
@@ -586,9 +621,10 @@ const a0Activities = <String, LessonActivity>{
answers: ['你的状态', '你的号码', '你的地点'],
speaking: 'How are you? Im good, thanks.',
reading:
'Mia: Hi, Shen. How are you?\nShen: Im good, thanks. How are you?\nMia: Im okay.',
readingQuestion: 'Mia 状态如何',
readingAnswer: 'okay',
'Mia: Hi, Shen. How are you?\nShen: Im good, thanks. How are you?\nMia: Im tired today.',
readingQuestion: 'Mia 说自己今天怎么样',
readingAnswer: 'Im tired today.',
readingOptions: ['Im tired today.', 'Im good, thanks.', 'Im okay.'],
writingPrompt: '写一句今天的状态。',
writingExample: 'Im okay today.',
independentPrompt: '不看句框,问候并说出你的状态。',
@@ -600,9 +636,10 @@ const a0Activities = <String, LessonActivity>{
answers: ['138', '183', '318'],
speaking: 'My number is one-three-eight.',
reading:
'Mia: Whats your phone number?\nShen: One-three-eight.\nMia: Thank you.',
readingQuestion: 'Shen 的号码',
'Mia: Whats your phone number?\nShen: My number is one-three-eight.\nMia: One-eight-three?\nShen: No. One-three-eight.',
readingQuestion: 'Shen 的号码到底是哪一个',
readingAnswer: '138',
readingOptions: ['138', '183', '318'],
writingPrompt: '把虚拟号码 139 写成英文。',
writingExample: 'one-three-nine',
independentPrompt: '不看句框,说出一个三位虚拟号码。',
@@ -613,9 +650,10 @@ const a0Activities = <String, LessonActivity>{
listeningQuestion: '这是什么?',
answers: ['钥匙', '', ''],
speaking: 'Whats this? Its a pen.',
reading: 'Mia: Whats this?\nShen: Its a book.',
readingQuestion: '是什么?',
readingAnswer: 'a book',
reading: 'Mia: Whats this? Is it a pen?\nShen: No. Its a book.',
readingQuestion: 'Shen 说那件东西是什么?',
readingAnswer: 'A book',
readingOptions: ['A book', 'A pen', 'A phone'],
writingPrompt: '写一句“这是一支笔”。',
writingExample: 'Its a pen.',
independentPrompt: '不看句框,说出一个身边物品。',
@@ -626,9 +664,11 @@ const a0Activities = <String, LessonActivity>{
listeningQuestion: '对方在问什么?',
answers: ['来自哪里', '叫什么', '喜欢什么'],
speaking: 'Im from Hong Kong.',
reading: 'Mia: Where are you from?\nShen: Im from Hong Kong.',
reading:
'Mia: Im from London. Where are you from?\nShen: Im from Hong Kong.',
readingQuestion: 'Shen 来自哪里?',
readingAnswer: 'Hong Kong',
readingOptions: ['Hong Kong', 'London', 'Beijing'],
writingPrompt: '写一句你来自哪里。',
writingExample: 'Im from Hong Kong.',
independentPrompt: '不看句框,说来自哪里并反问对方。',
@@ -639,9 +679,11 @@ const a0Activities = <String, LessonActivity>{
listeningQuestion: '这个人是谁?',
answers: ['妈妈', '朋友', '老师'],
speaking: 'This is my family.',
reading: 'Mia: Who is this?\nShen: This is my mother.',
readingQuestion: 'Shen 在介绍谁?',
readingAnswer: 'my mother',
reading:
'Mia: Who is this? Is this your father?\nShen: No. This is my mother.',
readingQuestion: 'Shen 介绍的是哪一位家人?',
readingAnswer: 'My mother',
readingOptions: ['My mother', 'My father', 'My sister'],
writingPrompt: '介绍一位家人或朋友。',
writingExample: 'This is my mother.',
independentPrompt: '不看句框,介绍一位家人或朋友。',
@@ -652,9 +694,11 @@ const a0Activities = <String, LessonActivity>{
listeningQuestion: '是什么时间?',
answers: ['星期一三点', '星期三一点', '星期一一点'],
speaking: 'Its three oclock.',
reading: 'Mia: What day is it?\nShen: Its Monday.',
readingQuestion: '今天星期几?',
reading:
'Mia: What day is it today? Is it Tuesday?\nShen: No. Its Monday.',
readingQuestion: '对话中今天到底是星期几?',
readingAnswer: 'Monday',
readingOptions: ['Monday', 'Tuesday', 'Sunday'],
writingPrompt: '写一句今天和整点。',
writingExample: 'Its Monday. Its three oclock.',
independentPrompt: '不看句框,说今天星期几或现在几点。',
@@ -665,9 +709,11 @@ const a0Activities = <String, LessonActivity>{
listeningQuestion: '对方喜欢茶吗?',
answers: ['喜欢', '不喜欢', '不知道'],
speaking: 'I like tea. Do you like tea?',
reading: 'Mia: I like tea. Do you like tea?\nShen: Yes, I do.',
readingQuestion: 'Shen 的回答是什么?',
readingAnswer: 'Yes, I do.',
reading:
'Mia: I like tea. Do you like tea?\nShen: No, I dont. I like coffee.',
readingQuestion: 'Shen 喜欢喝什么?',
readingAnswer: 'Coffee',
readingOptions: ['Coffee', 'Tea', 'Music'],
writingPrompt: '写一句你的喜好。',
writingExample: 'I like tea.',
independentPrompt: '不看句框,说一个喜好并反问。',
@@ -678,9 +724,11 @@ const a0Activities = <String, LessonActivity>{
listeningQuestion: '对方希望什么?',
answers: ['再说一遍', '说慢一点', '写下来'],
speaking: 'Please speak slowly.',
reading: 'Mia: Please say that again.\nShen: Please speak slowly.',
readingQuestion: 'Shen 希望什么?',
readingAnswer: 'speak slowly',
reading:
'Alex: Hi! Im Alex. Whats your name?\nShen: Hi! Im Shen. Nice to meet you.\nAlex: Nice to meet you, too. Where are you from?\nShen: Im from Hong Kong. Where are you from?\nAlex: Im from London. Do you like coffee?\nShen: No, I dont. I like tea.',
readingQuestion: 'Shen 来自哪里、喜欢喝什么?',
readingAnswer: 'Hong Kong, tea',
readingOptions: ['Hong Kong, tea', 'London, coffee', 'Hong Kong, coffee'],
writingPrompt: '写姓名、地点和喜好三句。',
writingExample: 'Im Shen.\nIm from Hong Kong.\nI like tea.',
independentPrompt: '不看帮助,完成姓名、地点、喜好和反问。',
@@ -699,8 +747,9 @@ const a0SegmentActivities = <String, LessonActivity>{
answers: ['123', '132', '213'],
speaking: 'Zero, one, two, three.',
reading: 'Mia: One, two, three.\nShen: Four, five.',
readingQuestion: 'Shen 说了哪两个数字?',
readingAnswer: 'four, five',
readingQuestion: 'Shen 接着说了哪两个数字?',
readingAnswer: 'Four, five',
readingOptions: ['Four, five', 'One, two', 'Three, four'],
writingPrompt: '把 0、1、2、3 写成英文。',
writingExample: 'zero, one, two, three',
independentPrompt: '不看帮助,说出三个 0 到 5 的数字。',
@@ -711,9 +760,10 @@ const a0SegmentActivities = <String, LessonActivity>{
listeningQuestion: '听到哪一组数字?',
answers: ['678', '687', '768'],
speaking: 'My phone is here.',
reading: 'Mia: What is this?\nShen: It is a phone.',
readingQuestion: '这是什么',
readingAnswer: 'a phone',
reading: 'Mia: Six, seven, eight.\nShen: Nine, ten.',
readingQuestion: 'Shen 最后说了哪两个数字',
readingAnswer: 'Nine, ten',
readingOptions: ['Nine, ten', 'Six, seven', 'Seven, eight'],
writingPrompt: '把 6、7、8 写成英文。',
writingExample: 'six, seven, eight',
independentPrompt: '不看帮助,说出三个 6 到 10 的数字。',
@@ -724,9 +774,11 @@ const a0SegmentActivities = <String, LessonActivity>{
listeningQuestion: '号码是多少?',
answers: ['138', '183', '318'],
speaking: 'My number is one-three-eight.',
reading: 'Mia: Whats your phone number?\nShen: One-three-eight.',
readingQuestion: 'Shen 的号码是?',
readingAnswer: '138',
reading:
'Mia: Whats your phone number?\nShen: My number is five-zero-two.\nMia: Five-two-zero?\nShen: No. Five-zero-two.',
readingQuestion: 'Shen 最后确认的号码是哪一个?',
readingAnswer: '502',
readingOptions: ['502', '520', '205'],
writingPrompt: '把虚拟号码 139 写成英文。',
writingExample: 'one-three-nine',
independentPrompt: '不看句框,说出一个三位虚拟号码。',
@@ -737,9 +789,11 @@ const a0SegmentActivities = <String, LessonActivity>{
listeningQuestion: '今天星期几?',
answers: ['星期一', '星期二', '星期三'],
speaking: 'It is Monday.',
reading: 'Mia: What day is it?\nShen: It is Tuesday.',
readingQuestion: '今天星期几?',
reading:
'Mia: What day is it today? Is it Monday?\nShen: No. It is Tuesday.',
readingQuestion: 'Mia 猜错了,今天其实是星期几?',
readingAnswer: 'Tuesday',
readingOptions: ['Tuesday', 'Monday', 'Wednesday'],
writingPrompt: '写一句今天是星期一、二或三。',
writingExample: 'It is Monday.',
independentPrompt: '不看帮助,说出星期一、二或三。',
@@ -750,9 +804,10 @@ const a0SegmentActivities = <String, LessonActivity>{
listeningQuestion: '今天星期几?',
answers: ['星期五', '星期四', '星期日'],
speaking: 'It is Friday.',
reading: 'Mia: What day is it?\nShen: It is Sunday.',
readingQuestion: '今天星期几',
reading: 'Mia: Is it Saturday today?\nShen: No. It is Sunday.',
readingQuestion: 'Shen 说今天是哪一天',
readingAnswer: 'Sunday',
readingOptions: ['Sunday', 'Saturday', 'Friday'],
writingPrompt: '写一句今天是星期四到日。',
writingExample: 'It is Friday.',
independentPrompt: '不看帮助,说出星期四到日。',
@@ -763,9 +818,11 @@ const a0SegmentActivities = <String, LessonActivity>{
listeningQuestion: '是什么时间?',
answers: ['三点', '一点', '星期三'],
speaking: 'It is three oclock.',
reading: 'Mia: What time is it?\nShen: It is three oclock.',
readingQuestion: '几点?',
readingAnswer: 'three oclock',
reading:
'Mia: What time is it? Is it two oclock?\nShen: No. It is three oclock.',
readingQuestion: 'Shen 说现在是几点?',
readingAnswer: 'Three oclock',
readingOptions: ['Three oclock', 'Two oclock', 'One oclock'],
writingPrompt: '写一句整点时间。',
writingExample: 'It is three oclock.',
independentPrompt: '不看帮助,说一个整点时间。',
@@ -1072,13 +1129,116 @@ class LessonDialogue {
required this.prompts,
required this.hints,
this.translations = const [],
this.requiredTerms = const [],
this.taskLabels = const [],
});
final String goal;
final List<String> prompts;
final List<String> hints;
final List<String> translations;
/// One accepted term group per learner turn. The learner only needs to hit
/// one term in the current group, so natural wording still passes. A term
/// may be a plain word or phrase, an `a + b` conjunction (both parts are
/// required), or a structural token starting with `#`.
final List<List<String>> requiredTerms;
/// Chinese label of what each learner turn has to do. It drives the
/// "还没完成本轮任务" message and the summary of completed tasks, so it must
/// describe the task without giving away the model answer.
final List<String> taskLabels;
}
/// Matches whole words only: the old `text.contains('it')` accepted almost any
/// sentence, including ones that never performed the task.
bool _containsTerm(String text, String term) {
if (term.contains(' + ')) {
return term.split(' + ').every((part) => _containsTerm(text, part.trim()));
}
if (term.startsWith('#')) return _matchesStructure(text, term);
final escaped = RegExp.escape(term.toLowerCase());
return RegExp('(?<![a-z])$escaped(?![a-z])').hasMatch(text);
}
bool _matchesStructure(String text, String token) => switch (token) {
// Three or more letters said one by one, e.g. "S-H-E-N" or "s h e n".
'#spelling' => RegExp(r'(?:^|[^a-z])[a-z](?:[ -][a-z]){2,}').hasMatch(text),
// Any spoken or written digit.
'#digit' => RegExp(
r'(?<![a-z])(zero|one|two|three|four|five|six|seven|eight|nine|ten)(?![a-z])|[0-9]',
).hasMatch(text),
// A real word after the frame, so "I'm" alone is not a name.
'#word' => RegExp(r'[a-z]{2,}').hasMatch(text),
// Any question addressed to the partner.
'#question' =>
text.contains('?') ||
RegExp(
r"(?<![a-z])(what|where|who|when|how|why|do you|are you|can you)(?![a-z])",
).hasMatch(text),
_ => false,
};
String _normalizeDialogueInput(String response) =>
response.toLowerCase().replaceAll('\u2019', "'").replaceAll('\u2018', "'");
/// Whole-lesson and free-scene dialogues validate the same way segment
/// dialogues do: the turn has to contain the language the turn is teaching.
bool matchesDialogueStage(LessonDialogue script, int stage, String response) {
final text = _normalizeDialogueInput(response);
if (stage < 0 || stage >= script.requiredTerms.length) {
// No declared requirement: accept anything that is actually a word.
return RegExp(r'[a-z]{2,}').hasMatch(text);
}
final terms = script.requiredTerms[stage];
if (terms.isEmpty) return RegExp(r'[a-z]{2,}').hasMatch(text);
return terms.any((term) => _containsTerm(text, term));
}
String dialogueTaskLabel(LessonDialogue script, int stage) =>
stage >= 0 && stage < script.taskLabels.length
? script.taskLabels[stage]
: '完成本轮任务';
/// The free "初次见面" scene. It lives next to the lesson dialogues so its
/// tasks are validated and summarised by the same rules.
const a0MeetDialogue = LessonDialogue(
goal: '姓名、地点、状态或喜好,并反问',
prompts: [
'Hi! My name is Mia. What\u2019s your name?',
'Nice to meet you. Where are you from?',
'Great! How are you today? Or what do you like?',
'I\u2019m good, thanks. Now ask me one question!',
],
hints: [
'My name is Alex.',
'I\u2019m from Hong Kong.',
'I\u2019m good, thanks. / I like coffee.',
'What\u2019s your name? / Where are you from?',
],
translations: [
'嗨!我叫 Mia。你叫什么名字?',
'很高兴认识你。你来自哪里?',
'很好!你今天怎么样?或者你喜欢什么?',
'我很好,谢谢。现在请问我一个问题!',
],
requiredTerms: [
["i'm + #word", 'i am + #word', 'my name is', "my name's", 'name is'],
["i'm from", 'i am from', 'from + #word'],
[
'good',
'okay',
'ok',
'fine',
'great',
'tired',
'i like',
'i love',
],
['#question'],
],
taskLabels: ['介绍姓名', '说明来自哪里', '表达状态或喜好', '反问对方'],
);
const a0Dialogues = <String, LessonDialogue>{
'a0-01': LessonDialogue(
goal: '问候、介绍姓名并回应见面问候',
@@ -1100,6 +1260,13 @@ const a0Dialogues = <String, LessonDialogue>{
'太棒了!再打一次招呼吧。',
'现在请问我的名字!',
],
requiredTerms: [
["i'm + #word", 'i am + #word', 'my name is', "my name's", 'name is'],
['nice to meet you'],
['hello', 'hi', 'hey'],
["what's your name", 'what is your name', 'your name', '#question'],
],
taskLabels: ['说出你的名字', '回应 Nice to meet you', '再打一次招呼', '反问对方的名字'],
),
'a0-02': LessonDialogue(
goal: '介绍姓名并完整拼读名字',
@@ -1110,12 +1277,14 @@ const a0Dialogues = <String, LessonDialogue>{
'Now ask me my name!',
],
hints: ['My name is Alex.', 'A-L-E-X.', 'A-L-E-X.', 'Whats your name?'],
translations: [
'嗨!你叫什么名字?',
'那个怎么拼写?',
'谢谢。请再拼写一次。',
'现在请问我的名字!',
translations: ['嗨!你叫什么名字?', '那个怎么拼写?', '谢谢。请再拼写一次。', '现在请问我的名字!'],
requiredTerms: [
["i'm + #word", 'i am + #word', 'my name is', "my name's", 'name is'],
['#spelling'],
['#spelling'],
["what's your name", 'what is your name', 'your name', '#question'],
],
taskLabels: ['说出你的名字', '拼读你的名字', '再拼读一次', '反问对方的名字'],
),
'a0-03': LessonDialogue(
goal: '询问状态、回答并反问',
@@ -1132,6 +1301,13 @@ const a0Dialogues = <String, LessonDialogue>{
'我还好。请再说一次你的状态。',
'现在请说再见!',
],
requiredTerms: [
['good', 'okay', 'ok', 'fine', 'great', 'tired'],
['how are you', '#question'],
['good', 'okay', 'ok', 'fine', 'great', 'tired'],
['bye', 'goodbye', 'see you'],
],
taskLabels: ['说出你今天的状态', '反问对方的状态', '再说一次你的状态', '说再见'],
),
'a0-04': LessonDialogue(
goal: '报告一个虚拟三位号码并确认',
@@ -1153,6 +1329,13 @@ const a0Dialogues = <String, LessonDialogue>{
'请再说一遍这三个数字。',
'现在请问我的电话号码!',
],
requiredTerms: [
['my number is + #digit', "my number's + #digit", 'number is + #digit'],
['yes', 'no', "that's right", 'right', 'correct'],
['#digit'],
["what's your phone number", 'what is your phone number', 'your phone number', 'your number'],
],
taskLabels: ['报出一个三位号码', '确认或纠正听到的号码', '再说一次这三个数字', '反问对方的号码'],
),
'a0-05': LessonDialogue(
goal: '询问并说出一个物品',
@@ -1169,6 +1352,13 @@ const a0Dialogues = <String, LessonDialogue>{
'请问我:What’s this(这是什么)?',
'太棒了!再说一个物品吧。',
],
requiredTerms: [
["it's a", 'it is a', "it's an", 'it is an'],
["it's a", 'it is a', "it's an", 'it is an'],
["what's this", 'what is this'],
["it's a", 'it is a', "it's an", 'it is an'],
],
taskLabels: ['说出这个物品', '完整说出 Its a … 句型', '反问 Whats this', '再说一个物品'],
),
'a0-06': LessonDialogue(
goal: '说来自哪里并反问',
@@ -1190,6 +1380,13 @@ const a0Dialogues = <String, LessonDialogue>{
'请再说一次你来自哪里。',
'请说再见!',
],
requiredTerms: [
["i'm from", 'i am from', 'from + #word'],
['where are you from', "where're you from", 'where are you'],
["i'm from", 'i am from', 'from + #word'],
['bye', 'goodbye', 'see you'],
],
taskLabels: ['说出你来自哪里', '反问对方来自哪里', '再说一次你来自哪里', '说再见'],
),
'a0-07': LessonDialogue(
goal: '介绍一位家人或朋友',
@@ -1211,6 +1408,13 @@ const a0Dialogues = <String, LessonDialogue>{
'请问我:Who is this(这是谁)?',
'请再介绍一个人。',
],
requiredTerms: [
['this is my', 'this is'],
['this is my', 'this is'],
['who is this', "who's this"],
['this is my', 'this is'],
],
taskLabels: ['介绍一位家人或朋友', '完整说出 This is my … 句型', '反问 Who is this', '再介绍一个人'],
),
'a0-08': LessonDialogue(
goal: '说明星期或整点',
@@ -1226,12 +1430,14 @@ const a0Dialogues = <String, LessonDialogue>{
'Its three oclock.',
'What day is it?',
],
translations: [
'今天星期几?',
'现在几点了?',
'请说一个完整的时间句子。',
'现在请问我今天是星期几!',
translations: ['今天星期几?', '现在几点了?', '请说一个完整的时间句子。', '现在请问我今天是星期几!'],
requiredTerms: [
['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'],
["o'clock", 'oclock', "#digit + o'clock"],
["it's + o'clock", "it is + o'clock", "it's + #digit", 'it is + #digit'],
['what day is it', 'what day', '#question'],
],
taskLabels: ['说出今天星期几', '说出现在几点', '说一个完整的时间句子', '反问今天星期几'],
),
'a0-09': LessonDialogue(
goal: '表达喜好、回答和反问',
@@ -1242,12 +1448,14 @@ const a0Dialogues = <String, LessonDialogue>{
'Say your like one more time.',
],
hints: ['I like tea.', 'Yes, I do.', 'Do you like tea?', 'I like tea.'],
translations: [
'你喜欢什么?',
'你喜欢茶吗?',
'现在请问我喜欢什么。',
'请再说一次你的喜好。',
translations: ['你喜欢什么?', '你喜欢茶吗?', '现在请问我喜欢什么。', '请再说一次你的喜好。'],
requiredTerms: [
['i like', 'i love'],
['yes', 'no', 'i do', "i don't", 'i do not'],
['do you like', '#question'],
['i like', 'i love'],
],
taskLabels: ['说出你喜欢什么', '回答是否喜欢', '反问对方的喜好', '再说一次你的喜好'],
),
'a0-10': LessonDialogue(
goal: '请求重复或放慢语速,并完成基础沟通',
@@ -1269,6 +1477,13 @@ const a0Dialogues = <String, LessonDialogue>{
'现在请问我的名字或我来自哪里。',
'请说一件你喜欢的事物。',
],
requiredTerms: [
['say that again', 'again', 'pardon'],
['slowly', 'slow'],
["what's your name", 'what is your name', 'where are you from', 'your name', '#question'],
['i like', 'i love'],
],
taskLabels: ['请求对方重复', '请求对方放慢语速', '反问名字或来自哪里', '说出一件你喜欢的事物'],
),
};
@@ -1277,37 +1492,52 @@ const a0SegmentDialogues = <String, LessonDialogue>{
goal: '听辨并说出 0 到 5',
prompts: ['Say zero, one, two.', 'Now say three, four, five.'],
hints: ['zero, one, two', 'three, four, five'],
translations: ['请说 zero, one, two012)。', '现在请说 three, four, five345)。'],
translations: [
'请说 zero, one, two012)。',
'现在请说 three, four, five345)。',
],
taskLabels: ['说出 zero, one, two', '说出 three, four, five'],
),
'a0-04-b': LessonDialogue(
goal: '听辨 6 到 10 并认识 phone',
prompts: ['Say six, seven, eight.', 'What is this? Say: It is a phone.'],
hints: ['six, seven, eight', 'It is a phone.'],
translations: ['请说 six, seven, eight678)。', '这是什么?请说:It is a phone(这是一部手机)。'],
translations: [
'请说 six, seven, eight678)。',
'这是什么?请说:It is a phone(这是一部手机)。',
],
taskLabels: ['说出 six, seven, eight', '说出 It is a phone.'],
),
'a0-04-c': LessonDialogue(
goal: '询问并报告三位号码',
prompts: ['What is your phone number?', 'Say a three-digit number again.'],
hints: ['My number is one-three-eight.', 'one-three-eight'],
translations: ['你的电话号码是多少?', '请再说一次三位数字。'],
taskLabels: ['报出你的三位号码', '再说一次三位数字'],
),
'a0-08-a': LessonDialogue(
goal: '询问并说出星期一到三',
prompts: ['What day is it?', 'Say Monday, Tuesday, or Wednesday.'],
hints: ['What day is it?', 'It is Monday.'],
translations: ['今天星期几?', '请说 Monday, Tuesday, 或 Wednesday(周一、周二或周三)。'],
taskLabels: ['问今天星期几', '说出周一到周三中的一天'],
),
'a0-08-b': LessonDialogue(
goal: '说出星期四到日',
prompts: ['What day is it?', 'Say Thursday, Friday, Saturday, or Sunday.'],
hints: ['What day is it?', 'It is Friday.'],
translations: ['今天星期几?', '请说 Thursday, Friday, Saturday, 或 Sunday(周四、周五、周六或周日)。'],
translations: [
'今天星期几?',
'请说 Thursday, Friday, Saturday, 或 Sunday(周四、周五、周六或周日)。',
],
taskLabels: ['问今天星期几', '说出周四到周日中的一天'],
),
'a0-08-c': LessonDialogue(
goal: '询问并说出整点',
prompts: ['What time is it?', 'Say one full time sentence.'],
hints: ['What time is it?', 'It is three oclock.'],
translations: ['现在几点了?', '请说一个完整的时间句子。'],
taskLabels: ['问现在几点', '说一个完整的时间句子'],
),
};
@@ -1315,3 +1545,21 @@ LessonDialogue dialogueByLessonId(String id) => a0Dialogues[id]!;
LessonDialogue dialogueBySegmentId(String segmentId, String lessonId) =>
a0SegmentDialogues[segmentId] ?? dialogueByLessonId(lessonId);
/// The English a learner has actually met up to and including [lessonId].
/// The AI partner is told to stay inside this list so a beginner never gets an
/// answer built from words the course has not taught yet.
List<String> taughtLanguageUpTo(String lessonId) {
final result = <String>[];
for (final entry in a0TargetItemIdsByLesson.entries) {
for (final id in entry.value) {
final word = a0CoreItems[id];
if (word != null) result.add(word);
}
if (entry.key == lessonId) break;
}
return result;
}
/// Everything A0 teaches, for the free scene that mixes all topics.
List<String> get allTaughtLanguage => a0CoreItems.values.toList();
+147 -36
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@@ -10,50 +11,137 @@ class SherpaSttService {
sherpa_onnx.OfflineRecognizer? _recognizer;
bool _isInitialized = false;
bool _isInitializing = false;
Future<bool>? _initFuture;
Future<void> _transcribeLock = Future.value();
bool get isReady => _isInitialized && _recognizer != null;
/// Initializes SenseVoice-Small ONNX bindings and unpacks bundled model assets to local disk if needed.
Future<bool> initialize({String? nativeLibDir}) async {
if (_isInitialized) return true;
if (_isInitializing) return false;
_isInitializing = true;
/// Resolves the filesystem path for the offline SenseVoice model files.
/// First checks direct asset filesystem locations (on macOS app bundle, development, tests)
/// to avoid copying 228MB into user Documents.
/// Falls back to unpacking from rootBundle into the app documents directory (on mobile / Android APK).
Future<Map<String, String>?> _resolveModelFiles() async {
const modelFilename = 'model.int8.onnx';
const tokensFilename = 'tokens.txt';
try {
// 1. Direct filesystem candidate paths
final candidateDirs = <String>[];
// Current working directory (unit test runner / local flutter dev)
candidateDirs.add('assets/models/sense_voice');
candidateDirs.add('kouyu_english/assets/models/sense_voice');
// Inside macOS App bundle
if (Platform.isMacOS) {
try {
sherpa_onnx.initBindings(nativeLibDir);
} catch (e) {
debugPrint('[SherpaSttService] initBindings warning: $e');
}
final execDir = File(Platform.resolvedExecutable).parent;
final contentsDir = execDir.parent;
candidateDirs.add('${contentsDir.path}/Frameworks/App.framework/Resources/flutter_assets/assets/models/sense_voice');
candidateDirs.add('${contentsDir.path}/Frameworks/App.framework/Versions/A/Resources/flutter_assets/assets/models/sense_voice');
} catch (_) {}
}
for (final dirPath in candidateDirs) {
final mFile = File('$dirPath/$modelFilename');
final tFile = File('$dirPath/$tokensFilename');
if (mFile.existsSync() && tFile.existsSync() && mFile.lengthSync() > 100000000) {
debugPrint('[SherpaSttService] Using bundled SenseVoice model directly at: $dirPath');
return {
'model': mFile.path,
'tokens': tFile.path,
};
}
}
// 2. Unpack bundled asset to app documents directory (e.g. Android APK)
try {
final docDir = await getApplicationDocumentsDirectory();
final modelDir = Directory('${docDir.path}/sense_voice_models');
if (!await modelDir.exists()) {
await modelDir.create(recursive: true);
}
final modelFiles = [
'model.int8.onnx',
'tokens.txt',
];
final targetModelFile = File('${modelDir.path}/$modelFilename');
final targetTokensFile = File('${modelDir.path}/$tokensFilename');
for (final filename in modelFiles) {
final targetFile = File('${modelDir.path}/$filename');
if (!await targetFile.exists() || (await targetFile.length()) == 0) {
final ByteData data = await rootBundle.load('assets/models/sense_voice/$filename');
final Uint8List bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
await targetFile.writeAsBytes(bytes, flush: true);
final modelValid = targetModelFile.existsSync() && targetModelFile.lengthSync() > 200 * 1024 * 1024;
final tokensValid = targetTokensFile.existsSync() && targetTokensFile.lengthSync() > 100 * 1024;
if (!modelValid) {
debugPrint('[SherpaSttService] Unpacking bundled model to ${targetModelFile.path}...');
final tmpFile = File('${targetModelFile.path}.tmp');
final ByteData data = await rootBundle.load('assets/models/sense_voice/$modelFilename');
final Uint8List bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
await tmpFile.writeAsBytes(bytes, flush: true);
if (await tmpFile.length() > 200 * 1024 * 1024) {
if (await targetModelFile.exists()) await targetModelFile.delete();
await tmpFile.rename(targetModelFile.path);
} else {
throw StateError('Extracted model file is incomplete');
}
}
if (!tokensValid) {
final tmpTokens = File('${targetTokensFile.path}.tmp');
final ByteData data = await rootBundle.load('assets/models/sense_voice/$tokensFilename');
final Uint8List bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
await tmpTokens.writeAsBytes(bytes, flush: true);
if (await targetTokensFile.exists()) await targetTokensFile.delete();
await tmpTokens.rename(targetTokensFile.path);
}
return {
'model': targetModelFile.path,
'tokens': targetTokensFile.path,
};
} catch (e, stack) {
debugPrint('[SherpaSttService] Failed to unpack bundled model: $e\n$stack');
return null;
}
}
/// Initializes SenseVoice-Small ONNX bindings and preloads the recognizer.
/// Deduplicates concurrent callers to wait on the same initialization.
Future<bool> initialize({String? nativeLibDir}) {
if (_isInitialized && _recognizer != null) return Future.value(true);
if (_initFuture != null) return _initFuture!;
_initFuture = _doInitialize(nativeLibDir: nativeLibDir);
return _initFuture!;
}
Future<bool> _doInitialize({String? nativeLibDir}) async {
try {
// Auto-detect native library directory on macOS desktop app bundle
String? resolvedLibDir = nativeLibDir;
if (resolvedLibDir == null && Platform.isMacOS) {
try {
final execDir = File(Platform.resolvedExecutable).parent;
final frameworksDir = Directory('${execDir.parent.path}/Frameworks');
if (frameworksDir.existsSync() && File('${frameworksDir.path}/libsherpa-onnx-c-api.dylib').existsSync()) {
resolvedLibDir = frameworksDir.path;
}
} catch (_) {}
}
try {
sherpa_onnx.initBindings(resolvedLibDir);
} catch (e) {
debugPrint('[SherpaSttService] initBindings notice: $e');
}
final resolved = await _resolveModelFiles();
if (resolved == null) {
debugPrint('[SherpaSttService] Model files could not be located or extracted.');
return false;
}
final modelConfig = sherpa_onnx.OfflineModelConfig(
senseVoice: sherpa_onnx.OfflineSenseVoiceModelConfig(
model: '${modelDir.path}/model.int8.onnx',
language: 'auto',
model: resolved['model']!,
language: 'en',
useInverseTextNormalization: true,
),
tokens: '${modelDir.path}/tokens.txt',
tokens: resolved['tokens']!,
numThreads: 2,
debug: false,
);
@@ -65,19 +153,35 @@ class SherpaSttService {
_recognizer = sherpa_onnx.OfflineRecognizer(recognizerConfig);
_isInitialized = true;
_isInitializing = false;
debugPrint('[SherpaSttService] SenseVoice-Small ONNX ASR engine initialized successfully.');
debugPrint('[SherpaSttService] SenseVoice-Small ONNX ASR engine initialized successfully (language=en).');
return true;
} catch (e, stack) {
debugPrint('[SherpaSttService] Failed to initialize SenseVoice ASR engine: $e\n$stack');
_isInitializing = false;
_isInitialized = false;
return false;
} finally {
if (!_isInitialized) {
_initFuture = null;
}
}
}
/// Transcribes a local 16kHz mono WAV audio file using SenseVoice-Small.
/// Thread-safe: serializes native C++ decoding to prevent concurrency crashes.
Future<String?> transcribeWav(String wavPath) async {
final prevLock = _transcribeLock;
final completer = Completer<void>();
_transcribeLock = completer.future;
try {
await prevLock;
return await _doTranscribeWav(wavPath);
} finally {
completer.complete();
}
}
Future<String?> _doTranscribeWav(String wavPath) async {
try {
if (!_isInitialized) {
final ready = await initialize();
@@ -97,30 +201,36 @@ class SherpaSttService {
}
final stream = _recognizer!.createStream();
stream.acceptWaveform(samples: wave.samples, sampleRate: wave.sampleRate);
_recognizer!.decode(stream);
final result = _recognizer!.getResult(stream);
stream.free();
try {
stream.acceptWaveform(samples: wave.samples, sampleRate: wave.sampleRate);
_recognizer!.decode(stream);
final result = _recognizer!.getResult(stream);
final rawText = result.text.trim();
if (rawText.isEmpty) return null;
final rawText = result.text.trim();
if (rawText.isEmpty) return null;
return _cleanText(rawText);
return _cleanText(rawText);
} finally {
stream.free();
}
} catch (e) {
debugPrint('[SherpaSttService] Transcribe error: $e');
return null;
}
}
/// Cleans and formats raw recognized SenseVoice text (strips emotion/event tags, normalizes casing).
/// Cleans and formats raw recognized SenseVoice text.
String _cleanText(String text) {
if (text.isEmpty) return text;
// Strip SenseVoice special tags like <|zh|>, <|en|>, <|NEUTRAL|>, <|HAPPY|>, <|Speech|>, <|withitn|>, <|woitn|>, etc.
var cleaned = text.replaceAll(RegExp(r'<\|[a-zA-Z0-9_\-\s]+\|>'), '').trim();
if (cleaned.isEmpty) return cleaned;
// Strip leading punctuation often inserted by Whisper/SenseVoice
cleaned = cleaned.replaceFirst(RegExp(r'^[\s,.:;!?~]+'), '').trim();
if (cleaned.isEmpty) return cleaned;
// Normalize consecutive spaces
cleaned = cleaned.replaceAll(RegExp(r'\s+'), ' ').trim();
// Capitalize first character if it's a letter
// Capitalize first character if it's an English letter
if (cleaned.isNotEmpty && cleaned[0].toLowerCase() != cleaned[0].toUpperCase()) {
cleaned = cleaned[0].toUpperCase() + cleaned.substring(1);
}
@@ -133,5 +243,6 @@ class SherpaSttService {
} catch (_) {}
_recognizer = null;
_isInitialized = false;
_initFuture = null;
}
}
+24 -1
View File
@@ -46,6 +46,7 @@ class VoiceService {
Future<void> speak(String text, {bool slow = false}) async {
try {
await stopRecordingPlayback();
await _initTts();
await _tts.stop();
try {
@@ -73,7 +74,22 @@ class VoiceService {
Future<bool> hasRecordPermission() => _recorder.hasPermission();
Future<bool> isRecording() async {
try {
return await _recorder.isRecording();
} catch (_) {
return false;
}
}
Future<bool> startRecording() async {
await stopSpeaking();
await stopRecordingPlayback();
try {
if (await _recorder.isRecording()) {
await _recorder.stop();
}
} catch (_) {}
if (!await _recorder.hasPermission()) return false;
final directory = await getApplicationDocumentsDirectory();
final recordings = Directory('${directory.path}/recordings');
@@ -93,7 +109,14 @@ class VoiceService {
return true;
}
Future<String?> stopRecording() => _recorder.stop();
Future<String?> stopRecording() async {
try {
if (await _recorder.isRecording()) {
return await _recorder.stop();
}
} catch (_) {}
return null;
}
Future<void> playRecording(String path, {VoidCallback? onComplete}) async {
await stopRecordingPlayback();
@@ -158,6 +158,12 @@ class _AssessmentPageState extends State<AssessmentPage> {
@override
void dispose() {
VoiceService.instance.stopSpeaking();
VoiceService.instance.stopListening();
VoiceService.instance.stopRecordingPlayback();
if (listening || aiVoiceRecording) {
VoiceService.instance.stopRecording();
}
controller.dispose();
super.dispose();
}
@@ -209,58 +215,22 @@ class _AssessmentPageState extends State<AssessmentPage> {
return;
}
if (listening) {
await VoiceService.instance.stopListening();
if (mounted) setState(() => listening = false);
return;
}
final ready = await VoiceService.instance.startListening(
(text, _) {
if (mounted) {
setState(() {
controller.text = text;
usedMic = true;
lastTranscript = text;
transcriptEdited = false;
});
}
},
onStatus: (status) {
if (mounted && (status == 'notListening' || status == 'done')) {
setState(() => listening = false);
}
},
onError: (err) {
if (mounted) {
setState(() => listening = false);
}
},
);
if (!ready) {
final recordStarted = await VoiceService.instance.startRecording();
if (mounted) {
setState(() {
aiVoiceRecording = recordStarted;
listening = recordStarted;
speakingUnavailable = !recordStarted;
});
if (recordStarted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已启动麦克风录音,回答后再次点击,AI 将自动转写为英文。')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('无法访问麦克风,口语可稍后补测。')),
);
}
}
return;
}
if (mounted) {
setState(() {
listening = ready;
speakingUnavailable = !ready;
});
VoiceService.instance.stopSpeaking();
final recordStarted = await VoiceService.instance.startRecording();
if (!mounted) return;
setState(() {
aiVoiceRecording = recordStarted;
listening = recordStarted;
speakingUnavailable = !recordStarted;
});
if (recordStarted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已启动麦克风录音,回答后再次点击,将自动转写为英文。')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('无法访问麦克风,口语可稍后补测。')),
);
}
}
@@ -35,7 +35,7 @@ class DialogueScenePage extends StatelessWidget {
const Eyebrow('按当前水平推荐'),
Text('选一个场景,开口练习。', style: Theme.of(context).textTheme.headlineMedium),
Text(
'每次不超过 5 个回答,完成明确任务后结束。',
'轮 4 次回答,完成明确任务后结束。',
style: Theme.of(context).textTheme.bodyMedium,
),
SectionCard(
@@ -60,8 +60,8 @@ class DialogueScenePage extends StatelessWidget {
],
),
),
const _LockedScene(title: '认识新同学', note: '完成当前场景后解锁'),
const _LockedScene(title: '咖啡店', note: 'A1 · 尚未解锁'),
const _LockedScene(title: '认识新同学', note: 'A0 · 后续版本开放'),
const _LockedScene(title: '咖啡店', note: 'A1 · 后续版本开放'),
],
),
);
@@ -107,6 +107,7 @@ class DialoguePage extends StatefulWidget {
class _DialoguePageState extends State<DialoguePage> {
final controller = TextEditingController();
final ScrollController _scrollController = ScrollController();
final List<DialogueTurn> turns = [];
int stage = 0;
bool usedHelp = false;
@@ -123,6 +124,22 @@ class _DialoguePageState extends State<DialoguePage> {
bool waitingForReply = false;
String? validationError;
/// Shown when the reply on screen came from the built-in script instead of
/// the AI, so a canned line is never mistaken for a real answer.
String? aiNotice;
/// The AI's short Chinese comment on the learner's English. It is kept until
/// the dialogue ends: the spec forbids interrupting a beginner turn by turn.
String? latestFeedback;
/// The free scene stores its draft under its own id.
static const _sceneDraftId = 'scene-a0-meet';
/// Closing line for the turn after the last scripted prompt. It stays inside
/// taught A0 language instead of the old "Wonderful — nice meeting you!".
static const _closingLine = 'Bye! Nice to meet you.';
static const _closingTranslation = '再见!很高兴认识你。';
final Set<int> _shownTranslations = <int>{};
LessonDialogue get script => widget.isLessonDialogue
@@ -134,14 +151,12 @@ class _DialoguePageState extends State<DialoguePage> {
.id,
widget.state.activeLessonId,
)
: const LessonDialogue(
goal: '姓名、地点、状态或喜好,并反问',
prompts: prompts,
hints: hints,
translations: translations,
);
: a0MeetDialogue;
String? _resolveTranslationFor(String text, int currentStage) {
/// Only exact sentence matches are trusted. The old positional fallback
/// attached `script.translations[stage]` to whatever the AI happened to say,
/// which produced Chinese that did not match the English on screen.
String? _resolveTranslationFor(String text) {
final cleanText = text.trim();
// 1. Check current script prompts
for (var i = 0; i < script.prompts.length; i++) {
@@ -151,11 +166,12 @@ class _DialoguePageState extends State<DialoguePage> {
}
}
}
// 2. Check standalone prompts
for (var i = 0; i < prompts.length; i++) {
if (prompts[i].trim().toLowerCase() == cleanText.toLowerCase()) {
if (i < translations.length) {
return translations[i];
// 2. Check the free scene script
for (var i = 0; i < a0MeetDialogue.prompts.length; i++) {
if (a0MeetDialogue.prompts[i].trim().toLowerCase() ==
cleanText.toLowerCase()) {
if (i < a0MeetDialogue.translations.length) {
return a0MeetDialogue.translations[i];
}
}
}
@@ -180,6 +196,7 @@ class _DialoguePageState extends State<DialoguePage> {
}
}
// 5. Common fallback phrases
if (cleanText == _closingLine) return _closingTranslation;
if (cleanText.toLowerCase().contains("wonderful") &&
cleanText.toLowerCase().contains("nice meeting you")) {
return "太棒了 — 很高兴认识你!";
@@ -188,40 +205,22 @@ class _DialoguePageState extends State<DialoguePage> {
cleanText.toLowerCase().contains("bye")) {
return "再见!";
}
// 6. If stage index is within script.translations
if (currentStage >= 0 && currentStage < script.translations.length) {
return script.translations[currentStage];
}
return null;
}
static const prompts = [
'Hi! My name is Mia. Whats your name?',
'Nice to meet you. Where are you from?',
'Great! How are you today? Or what do you like?',
'Im good, thanks. Now ask me one question!',
];
static const hints = [
'My name is Alex.',
'Im from Hong Kong.',
'Im good, thanks. / I like coffee.',
'Whats your name? / Where are you from?',
];
static const translations = [
'嗨!我叫 Mia。你叫什么名字?',
'很高兴认识你。你来自哪里?',
'很好!你今天怎么样?或者你喜欢什么?',
'我很好,谢谢。现在请问我一个问题!',
];
@override
void initState() {
super.initState();
final draft = widget.state.dialogueDraft;
final draft = widget.isLessonDialogue
? widget.state.dialogueDraft
: widget.state.sceneDialogueDraft;
final expectedDraftId = widget.isLessonDialogue
? widget.state.activeLessonId
: _sceneDraftId;
final canRestore =
widget.isLessonDialogue &&
draft?.lessonId == widget.state.activeLessonId &&
draft!.stage >= 0 &&
draft != null &&
draft.lessonId == expectedDraftId &&
draft.stage >= 0 &&
draft.stage <= script.prompts.length &&
draft.turns.isNotEmpty;
if (canRestore) {
@@ -230,7 +229,7 @@ class _DialoguePageState extends State<DialoguePage> {
for (var i = 0; i < draft.turns.length; i++) {
final t = draft.turns[i];
if (!t.isLearner && (t.translation == null || t.translation!.isEmpty)) {
final trans = _resolveTranslationFor(t.text, i ~/ 2);
final trans = _resolveTranslationFor(t.text);
turns.add(t.copyWith(translation: trans));
} else {
turns.add(t);
@@ -238,8 +237,9 @@ class _DialoguePageState extends State<DialoguePage> {
}
} else {
final initialPrompt = script.prompts.first;
final initialTranslation = script.translations.firstOrNull ??
_resolveTranslationFor(initialPrompt, 0);
final initialTranslation =
script.translations.firstOrNull ??
_resolveTranslationFor(initialPrompt);
turns.add(
DialogueTurn(
text: initialPrompt,
@@ -251,6 +251,7 @@ class _DialoguePageState extends State<DialoguePage> {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_playLatestAi(slow: false);
_scrollToBottom();
}
});
}
@@ -260,12 +261,28 @@ class _DialoguePageState extends State<DialoguePage> {
VoiceService.instance.stopSpeaking();
VoiceService.instance.stopListening();
VoiceService.instance.stopRecordingPlayback();
if (listening || aiVoiceRecording) {
VoiceService.instance.stopRecording();
}
if (!widget.state.keepRecordings) {
VoiceService.instance.deleteRecording(recordingPath);
}
_scrollController.dispose();
controller.dispose();
super.dispose();
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
Future<void> send() async {
final text = controller.text.trim();
@@ -273,7 +290,10 @@ class _DialoguePageState extends State<DialoguePage> {
return;
}
if (!_matchesCurrentTask(text)) {
setState(() => validationError = '这句还没有完成当前任务。可以查看提示后补充一次。');
setState(
() => validationError = '这一轮要“${_currentTaskLabel()}”,这句还没做到。'
'可以点“提示”看示范,再补充一次。',
);
return;
}
widget.state.recordDialogueAttempt(
@@ -298,13 +318,20 @@ class _DialoguePageState extends State<DialoguePage> {
validationError = null;
});
_saveDraft();
_scrollToBottom();
final aiResponse = await AiService.instance.dialogueReply(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
requiredTask: nextStage < script.prompts.length
aiGoal: nextStage < script.prompts.length
? script.prompts[nextStage]
: 'Say goodbye warmly after the learner asked a question.',
: 'Say goodbye warmly and end the conversation.',
learnerTask: nextStage < script.hints.length
? 'answer with something like "${script.hints[nextStage]}"'
: 'nothing more, the conversation is finished',
allowedLanguage: widget.isLessonDialogue
? taughtLanguageUpTo(widget.state.activeLessonId)
: allTaughtLanguage,
history: turns
.map(
(turn) => <String, String>{
@@ -318,10 +345,10 @@ class _DialoguePageState extends State<DialoguePage> {
final replyText = aiResponse?.reply ??
(nextStage < script.prompts.length
? script.prompts[nextStage]
: 'Wonderful — nice meeting you!');
: _closingLine);
var replyTranslation = aiResponse?.translation;
if (replyTranslation == null || replyTranslation.isEmpty) {
replyTranslation = _resolveTranslationFor(replyText, nextStage);
replyTranslation = _resolveTranslationFor(replyText);
}
setState(() {
turns.add(
@@ -332,86 +359,48 @@ class _DialoguePageState extends State<DialoguePage> {
),
);
waitingForReply = false;
final feedback = aiResponse?.feedback;
if (feedback != null && feedback.trim().isNotEmpty) {
latestFeedback = feedback.trim();
}
aiNotice = aiResponse != null
? null
: (widget.state.aiProvider == AiProviderType.mock
? '当前未连接 AI,正在按示范脚本对话。'
: 'AI 暂时无法连接,这一句来自示范脚本。');
});
_saveDraft();
_scrollToBottom();
VoiceService.instance.speak(replyText);
}
void _saveDraft() {
if (!widget.isLessonDialogue) return;
widget.state.saveDialogueDraft(
DialogueDraft(
lessonId: widget.state.activeLessonId,
stage: stage,
turns: List.unmodifiable(turns),
usedHelp: usedHelp,
),
final draft = DialogueDraft(
lessonId: widget.isLessonDialogue
? widget.state.activeLessonId
: _sceneDraftId,
stage: stage,
turns: List.unmodifiable(turns),
usedHelp: usedHelp,
);
if (widget.isLessonDialogue) {
widget.state.saveDialogueDraft(draft);
} else {
widget.state.saveSceneDialogueDraft(draft);
}
}
String _currentTaskLabel() => dialogueTaskLabel(script, stage);
bool _matchesCurrentTask(String response) {
if (!widget.isLessonDialogue) {
final text = response.toLowerCase();
return switch (stage) {
0 => RegExp(r"\b(i'?m|my name is)\s+[a-z]").hasMatch(text),
1 => RegExp(r"\b(i'?m|i am)\s+from\s+[a-z]").hasMatch(text),
2 => RegExp(
r"\b(i'?m|i am)\s+(good|okay|tired)\b|\bi like\s+[a-z]",
).hasMatch(text),
_ => RegExp(r"\b(what('?s| is)|how are|do you like)\b").hasMatch(text),
};
// Every dialogue now checks the language the turn is teaching. The old
// whole-lesson branch matched bare keywords such as 'it' anywhere in the
// sentence, so an off-task answer passed every stage.
if (widget.isLessonDialogue &&
lessonById(widget.state.activeLessonId).segments.length > 1) {
return matchesSegmentDialogue(_lessonSegmentId, stage, response);
}
final text = response.toLowerCase();
final lessonId = widget.state.activeLessonId;
if (lessonById(lessonId).segments.length > 1) {
return matchesSegmentDialogue(_lessonSegmentId, stage, text);
}
if (stage == 0) {
return response.replaceAll(RegExp(r'[^a-zA-Z]'), '').length >= 2;
}
if (lessonId == 'a0-02' && stage == 1) {
return RegExp(
r'[a-z](?:[ -]?[a-z]){2,}',
caseSensitive: false,
).hasMatch(text);
}
final expected = switch (lessonId) {
'a0-01' => ['nice', 'hello', 'what'],
'a0-03' => ['how', 'good', 'okay', 'tired', 'bye'],
'a0-04' => [
'yes',
'one',
'two',
'three',
'four',
'five',
'six',
'seven',
'eight',
'nine',
'zero',
'what',
],
'a0-05' => ['it', 'what', 'book', 'pen', 'bag', 'key'],
'a0-06' => ['from', 'where', 'bye'],
'a0-07' => ['this', 'who', 'mother', 'father', 'sister', 'brother'],
'a0-08' => [
'it',
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
'clock',
'what',
],
'a0-09' => ['like', 'yes', 'no', 'do'],
'a0-10' => ['please', 'what', 'like'],
_ => ['nice', 'how', 'what', 'from', 'like'],
};
return expected.any(text.contains);
return matchesDialogueStage(script, stage, response);
}
String get _lessonSegmentId => lessonById(widget.state.activeLessonId)
@@ -425,6 +414,7 @@ class _DialoguePageState extends State<DialoguePage> {
widget.onFinished(null);
return;
}
widget.state.clearSceneDialogueDraft();
final learnerTurns = turns.where((turn) => turn.isLearner).toList();
final personalSentence = learnerTurns.isEmpty
? 'My name is …'
@@ -432,13 +422,25 @@ class _DialoguePageState extends State<DialoguePage> {
widget.state.addDialogueRecap(personalSentence);
widget.onFinished(
DialogueSummaryData(
completedTasks: const ['介绍姓名', '说明来自哪里', '表达状态或喜好', '反问对方'],
// Only the turns the learner actually passed are reported.
completedTasks: _completedTaskLabels(),
personalSentence: personalSentence,
usedHelp: usedHelp,
improvement: latestFeedback,
),
);
}
/// A learner turn is only added after it passes [_matchesCurrentTask], so the
/// number of learner turns is the number of tasks actually completed.
List<String> _completedTaskLabels() {
final done = turns.where((turn) => turn.isLearner).length;
return [
for (var i = 0; i < done && i < script.taskLabels.length; i++)
script.taskLabels[i],
];
}
Future<void> _toggleTurnTranslation(int index) async {
if (index < 0 || index >= turns.length) return;
final turn = turns[index];
@@ -453,7 +455,7 @@ class _DialoguePageState extends State<DialoguePage> {
String? trans = turn.translation;
if (trans == null || trans.isEmpty) {
trans = _resolveTranslationFor(turn.text, index ~/ 2);
trans = _resolveTranslationFor(turn.text);
}
if (trans != null && trans.isNotEmpty) {
@@ -462,6 +464,7 @@ class _DialoguePageState extends State<DialoguePage> {
_shownTranslations.add(index);
});
_saveDraft();
_scrollToBottom();
return;
}
@@ -482,6 +485,7 @@ class _DialoguePageState extends State<DialoguePage> {
turns[index] = turn.copyWith(translation: finalTrans);
});
_saveDraft();
_scrollToBottom();
}
Future<void> _showLatestAiTranslation() async {
@@ -491,7 +495,7 @@ class _DialoguePageState extends State<DialoguePage> {
String? trans = latestAi.translation;
if (trans == null || trans.isEmpty) {
trans = _resolveTranslationFor(latestAi.text, stage);
trans = _resolveTranslationFor(latestAi.text);
}
if (trans != null && trans.isNotEmpty) {
@@ -502,6 +506,7 @@ class _DialoguePageState extends State<DialoguePage> {
turns[latestAiIndex] = latestAi.copyWith(translation: trans);
});
_saveDraft();
_scrollToBottom();
return;
}
@@ -525,6 +530,7 @@ class _DialoguePageState extends State<DialoguePage> {
turns[latestAiIndex] = latestAi.copyWith(translation: finalTrans);
});
_saveDraft();
_scrollToBottom();
}
Future<void> _playLatestAi({required bool slow}) async {
@@ -537,7 +543,7 @@ class _DialoguePageState extends State<DialoguePage> {
}
Future<void> _toggleListening() async {
if (aiVoiceRecording) {
if (listening || aiVoiceRecording) {
final path = await VoiceService.instance.stopRecording();
if (!mounted) return;
setState(() {
@@ -564,6 +570,7 @@ class _DialoguePageState extends State<DialoguePage> {
transcriptEdited = false;
}
});
_scrollToBottom();
if (transcribed == null || transcribed.trim().isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('未识别到清晰语音,请再试一次或使用键盘输入。')),
@@ -576,56 +583,19 @@ class _DialoguePageState extends State<DialoguePage> {
return;
}
if (listening) {
await VoiceService.instance.stopListening();
if (mounted) setState(() => listening = false);
return;
}
final available = await VoiceService.instance.startListening(
(text, _) {
if (!mounted) return;
setState(() {
controller.text = text;
usedVoice = true;
lastTranscript = text;
transcriptEdited = false;
});
},
onStatus: (status) {
if (mounted && (status == 'notListening' || status == 'done')) {
setState(() => listening = false);
}
},
onError: (err) {
if (mounted) {
setState(() => listening = false);
}
},
);
if (!available) {
final recordStarted = await VoiceService.instance.startRecording();
if (mounted) {
setState(() {
aiVoiceRecording = recordStarted;
listening = recordStarted;
});
if (recordStarted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已启动麦克风录音,说完后再次点击麦克风,AI 将自动转写英文。')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('无法访问麦克风,请检查手机录音权限。')),
);
}
}
return;
}
VoiceService.instance.stopSpeaking();
final recordStarted = await VoiceService.instance.startRecording();
if (!mounted) return;
setState(() => listening = available);
if (recordStarted) {
setState(() {
aiVoiceRecording = true;
listening = true;
});
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('无法访问麦克风,请检查手机录音权限。')),
);
}
}
Future<void> _toggleRecording() async {
@@ -679,7 +649,9 @@ class _DialoguePageState extends State<DialoguePage> {
@override
Widget build(BuildContext context) {
final finished = stage == script.prompts.length;
final totalStages = script.prompts.length;
return AppPage(
scrollController: _scrollController,
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
@@ -687,7 +659,7 @@ class _DialoguePageState extends State<DialoguePage> {
onPressed: () => widget.onFinished(null),
),
title: Text(
'${widget.isLessonDialogue ? '课程对话' : '初次见面'} · ${finished ? 4 : stage + 1} / 4',
'${widget.isLessonDialogue ? '课程对话' : '初次见面'} · ${finished ? totalStages : stage + 1} / $totalStages',
),
),
child: SpacedColumn(
@@ -796,6 +768,33 @@ class _DialoguePageState extends State<DialoguePage> {
],
),
),
const SizedBox(width: 14),
GestureDetector(
onTap: () => showLexiconLookup(
context,
state: widget.state,
initialText: turn.text,
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.psychology_alt_outlined,
size: 16,
color: AppColors.green,
),
SizedBox(width: 4),
Text(
"句型解析",
style: TextStyle(
fontSize: 12,
color: AppColors.green,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
],
@@ -822,6 +821,7 @@ class _DialoguePageState extends State<DialoguePage> {
hint = script.hints.isNotEmpty ? script.hints[hintIdx] : null;
});
_saveDraft();
_scrollToBottom();
},
),
_AssistChip(
@@ -847,6 +847,29 @@ class _DialoguePageState extends State<DialoguePage> {
style: const TextStyle(color: AppColors.warmInk),
),
),
if (aiNotice != null)
SectionCard(
tint: AppColors.warm,
child: Row(
children: [
const Icon(
Icons.cloud_off_outlined,
size: 18,
color: AppColors.warmInk,
),
const SizedBox(width: 8),
Expanded(
child: Text(
aiNotice!,
style: const TextStyle(
color: AppColors.warmInk,
fontSize: 13,
),
),
),
],
),
),
if (validationError != null)
Text(
validationError!,
@@ -940,9 +963,17 @@ class _DialoguePageState extends State<DialoguePage> {
child: Text(
usedHelp
? '本次使用过提示,课程会把关键表达安排到后续复习。'
: '你完成了 4 个交际任务,接下来试着不看帮助独立表达。',
: '你完成了 ${_completedTaskLabels().length} 个交际任务,接下来试着不看帮助独立表达。',
),
),
if (latestFeedback != null)
SectionCard(
tint: AppColors.warm,
child: Text(
'下次可以注意:${latestFeedback!}',
style: const TextStyle(color: AppColors.warmInk),
),
),
PrimaryButton(
label: widget.isLessonDialogue ? '进入独立尝试' : '查看总结',
onPressed: _finish,
@@ -996,7 +1027,11 @@ class DialogueSummaryPage extends StatelessWidget {
children: [
const Eyebrow('对话完成'),
Text('你完成了自我介绍!', style: Theme.of(context).textTheme.headlineMedium),
Text('你完成了 ${summary.completedTasks.join('')}'),
Text(
summary.completedTasks.isEmpty
? '这次还没有完成完整的交际任务,可以再练一次。'
: '你完成了 ${summary.completedTasks.join('')}',
),
SectionCard(
child: _SummaryLine(
icon: Icons.check_circle_outline,
@@ -1005,6 +1040,16 @@ class DialogueSummaryPage extends StatelessWidget {
tint: AppColors.green,
),
),
if (summary.improvement != null && summary.improvement!.isNotEmpty)
SectionCard(
tint: AppColors.warm,
child: _SummaryLine(
icon: Icons.tips_and_updates_outlined,
title: '下一次说得更好',
sentence: summary.improvement!,
tint: AppColors.warmInk,
),
),
Text(
summary.usedHelp
? '本次使用过提示。下次可以先不看提示,再试一次。'
@@ -89,9 +89,15 @@ class _LessonFlowState extends State<LessonFlow> {
onSkip: widget.state.completePreview,
);
case LessonStep.listening:
final listeningOptions = shuffledOptions(
activity.answers,
'${activity.listening}-listening',
);
content = _ListeningStep(
state: widget.state,
activity: activity,
options: listeningOptions,
correctAnswer: activity.answers.first,
selectedAnswer: selectedAnswer,
audioPlayed: listeningAudioPlayed,
onSelected: (value) => setState(() => selectedAnswer = value),
@@ -101,7 +107,9 @@ class _LessonFlowState extends State<LessonFlow> {
state: widget.state,
initialText: activity.listening,
),
onContinue: selectedAnswer == 0
onContinue:
selectedAnswer >= 0 &&
listeningOptions[selectedAnswer] == activity.answers.first
? widget.state.completeListening
: null,
);
@@ -333,6 +341,8 @@ class _ListeningStep extends StatelessWidget {
const _ListeningStep({
required this.state,
required this.activity,
required this.options,
required this.correctAnswer,
required this.selectedAnswer,
required this.audioPlayed,
required this.onSelected,
@@ -343,6 +353,8 @@ class _ListeningStep extends StatelessWidget {
final LessonActivity activity;
final AppState state;
final List<String> options;
final String correctAnswer;
final int selectedAnswer;
final bool audioPlayed;
@@ -353,7 +365,7 @@ class _ListeningStep extends StatelessWidget {
@override
Widget build(BuildContext context) {
final answers = activity.answers;
final answers = options;
return _LessonScaffold(
step: 2,
child: SpacedColumn(
@@ -409,7 +421,7 @@ class _ListeningStep extends StatelessWidget {
: (audioPlayed ? '请选择答案' : '先播放音频或选择答案'),
onPressed: onContinue,
),
if (selectedAnswer >= 0 && selectedAnswer != 0)
if (selectedAnswer >= 0 && answers[selectedAnswer] != correctAnswer)
const Text(
'再听一次,选择正确答案。',
style: TextStyle(color: AppColors.warmInk),
@@ -645,77 +657,299 @@ class _ReadingStep extends StatefulWidget {
class _ReadingStepState extends State<_ReadingStep> {
final controller = TextEditingController();
int? selectedOptionIndex;
bool showAnswer = false;
/// 打乱后的选项:答案不再固定排在第一位,但同一道题顺序保持稳定。
late final List<String> options = shuffledOptions(
widget.activity.readingOptions,
'${widget.activity.readingQuestion}-reading',
);
@override
void dispose() {
controller.dispose();
super.dispose();
}
bool _isOptionCorrect(int index) {
if (options.isEmpty || index < 0 || index >= options.length) {
return false;
}
final option = options[index].trim();
final answer = widget.activity.readingAnswer.trim();
if (option.toLowerCase() == answer.toLowerCase()) return true;
final normOption = option
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9\u4e00-\u9fa5]'), '');
final normAnswer = answer
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9\u4e00-\u9fa5]'), '');
return normOption.isNotEmpty &&
normAnswer.isNotEmpty &&
(normOption.contains(normAnswer) || normAnswer.contains(normOption));
}
bool get isOptionMode => options.isNotEmpty;
bool get isCorrect {
if (isOptionMode) {
return selectedOptionIndex != null && _isOptionCorrect(selectedOptionIndex!);
}
final answer = widget.activity.readingAnswer.toLowerCase().replaceAll(
RegExp(r'[^a-z0-9]'),
RegExp(r'[^a-z0-9\u4e00-\u9fa5]'),
'',
);
final response = controller.text.toLowerCase().replaceAll(
RegExp(r'[^a-z0-9]'),
RegExp(r'[^a-z0-9\u4e00-\u9fa5]'),
'',
);
return response.isNotEmpty && response.contains(answer);
// 只接受写全了答案的输入:过去反向的 answer.contains(response) 让单个字母
// 也能判对('a' 通过 'A book')。
return response.isNotEmpty && answer.isNotEmpty && response.contains(answer);
}
@override
Widget build(BuildContext context) => _LessonScaffold(
step: 4,
child: SpacedColumn(
children: [
const Eyebrow('读一读'),
Text('在对话里找到答案。', style: Theme.of(context).textTheme.headlineMedium),
SectionCard(
tint: AppColors.softGreen,
child: LexiconText(
widget.activity.reading,
state: widget.state,
style: TextStyle(fontSize: 16, height: 1.6),
),
),
Text(widget.activity.readingQuestion),
TextButton.icon(
onPressed: widget.onLookup,
icon: const Icon(Icons.menu_book_outlined),
label: const Text('查词或短语'),
),
TextField(
controller: controller,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
hintText: '用英文输入答案',
filled: true,
fillColor: AppColors.surface,
border: OutlineInputBorder(),
),
),
if (showAnswer)
Widget build(BuildContext context) {
final hasSelected = selectedOptionIndex != null;
final answeredCorrectly = isCorrect;
return _LessonScaffold(
step: 4,
child: SpacedColumn(
children: [
const Eyebrow('读一读'),
Text('在对话里找到答案。', style: Theme.of(context).textTheme.headlineMedium),
SectionCard(
tint: AppColors.warm,
child: Text(
'答案:${widget.activity.readingAnswer}',
style: const TextStyle(color: AppColors.warmInk),
tint: AppColors.softGreen,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Row(
children: [
Icon(Icons.chat_bubble_outline, size: 16, color: AppColors.green),
SizedBox(width: 6),
Text(
'对话内容',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.green,
),
),
],
),
InkWell(
onTap: () => VoiceService.instance.speak(widget.activity.reading),
borderRadius: BorderRadius.circular(16),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
children: [
Icon(Icons.volume_up_outlined, size: 16, color: AppColors.green),
SizedBox(width: 4),
Text('朗读对话', style: TextStyle(fontSize: 13, color: AppColors.green)),
],
),
),
),
],
),
const SizedBox(height: 8),
LexiconText(
widget.activity.reading,
state: widget.state,
style: const TextStyle(fontSize: 16, height: 1.6),
),
],
),
),
if (!showAnswer && controller.text.isNotEmpty && !isCorrect)
TextButton(
onPressed: () => setState(() => showAnswer = true),
child: const Text('查看答案后继续学习'),
Row(
children: [
TextButton.icon(
onPressed: widget.onLookup,
icon: const Icon(Icons.menu_book_outlined, size: 18),
label: const Text('查词或短语'),
),
const SizedBox(width: 8),
TextButton.icon(
onPressed: () {
final lines = widget.activity.reading.split('\n');
final target = lines.firstWhere(
(l) => l.trim().isNotEmpty,
orElse: () => widget.activity.reading,
).replaceFirst(RegExp(r'^[A-Za-z]+:\s*'), '');
showLexiconLookup(
context,
state: widget.state,
initialText: target,
);
},
icon: const Icon(Icons.auto_stories_outlined, size: 18),
label: const Text('句型深度解析'),
),
],
),
PrimaryButton(
label: showAnswer ? '继续写一写' : '检查并继续',
onPressed: showAnswer || isCorrect ? widget.onContinue : null,
),
],
),
);
SectionCard(
tint: AppColors.surfaceMuted,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.green.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'问题',
style: TextStyle(
color: AppColors.green,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
widget.activity.readingQuestion,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.ink,
),
),
),
],
),
),
if (isOptionMode) ...[
for (var index = 0; index < options.length; index++) ...[
SectionCard(
tint: selectedOptionIndex == index
? (_isOptionCorrect(index) ? AppColors.softGreen : AppColors.warm)
: null,
onTap: () {
setState(() {
selectedOptionIndex = index;
showAnswer = false;
});
},
child: Row(
children: [
Icon(
selectedOptionIndex == index
? (_isOptionCorrect(index) ? Icons.check_circle : Icons.cancel_outlined)
: Icons.radio_button_off,
color: selectedOptionIndex == index
? (_isOptionCorrect(index) ? AppColors.green : AppColors.warmInk)
: AppColors.muted,
),
const SizedBox(width: 12),
Expanded(
child: Text(
options[index],
style: TextStyle(
fontSize: 15,
fontWeight: selectedOptionIndex == index ? FontWeight.w600 : FontWeight.normal,
color: selectedOptionIndex == index
? (_isOptionCorrect(index) ? AppColors.green : AppColors.warmInk)
: AppColors.ink,
),
),
),
],
),
),
],
if (hasSelected && answeredCorrectly)
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppColors.softGreen,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.green.withValues(alpha: 0.3)),
),
child: const Row(
children: [
Icon(Icons.check_circle, color: AppColors.green, size: 20),
SizedBox(width: 8),
Expanded(
child: Text(
'回答正确!点击下方按钮继续',
style: TextStyle(
color: AppColors.green,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
],
),
)
else if (hasSelected && !answeredCorrectly)
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppColors.warm,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.warmInk.withValues(alpha: 0.2)),
),
child: const Row(
children: [
Icon(Icons.help_outline, color: AppColors.warmInk, size: 20),
SizedBox(width: 8),
Expanded(
child: Text(
'不对哦,再仔细观察对话中的关键句子~',
style: TextStyle(color: AppColors.warmInk, fontSize: 13),
),
),
],
),
),
] else ...[
TextField(
controller: controller,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
hintText: '用英文输入答案',
filled: true,
fillColor: AppColors.surface,
border: OutlineInputBorder(),
),
),
],
if (showAnswer)
SectionCard(
tint: AppColors.warm,
child: Text(
'答案:${widget.activity.readingAnswer}',
style: const TextStyle(color: AppColors.warmInk),
),
),
if (!showAnswer &&
((isOptionMode && hasSelected && !answeredCorrectly) ||
(!isOptionMode && controller.text.isNotEmpty && !answeredCorrectly)))
TextButton(
onPressed: () => setState(() => showAnswer = true),
child: const Text('查看答案后继续学习'),
),
PrimaryButton(
label: showAnswer || answeredCorrectly
? '继续写一写'
: (isOptionMode ? '请选择答案' : '检查并继续'),
onPressed: showAnswer || answeredCorrectly ? widget.onContinue : null,
),
],
),
);
}
}
class _WritingStep extends StatefulWidget {
@@ -6,6 +6,7 @@ import '../../core/ai_service.dart';
import '../../core/app_theme.dart';
import '../../core/models.dart';
import '../../core/voice_service.dart';
import '../../core/sherpa_stt_service.dart';
import '../../widgets/app_widgets.dart';
import '../../core/sync/sync_coordinator.dart';
import 'sync_settings_sheet.dart';
@@ -321,6 +322,22 @@ class _SettingsPageState extends State<SettingsPage> {
onTap: () => _confirmDeleteRecordings(context),
),
const Divider(height: 28),
const Text(
'离线语音识别引擎',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const Text(
'已内置 SenseVoice-Small 高精度离线语音识别模型 (INT8)。随 App 安装包直接打包,离线即用,无需额外下载,零网络流量消耗。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
_SettingTile(
title: '离线语音识别:SenseVoice-Small',
subtitle: SherpaSttService.instance.isReady
? '已就绪 · 本地离线识别 (16kHz WAV · INT8)'
: '预加载就绪 · 已内置打包',
trailing: const Icon(Icons.check_circle, color: AppColors.green, size: 20),
),
const Divider(height: 28),
const Text(
'云同步与多端备份',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
@@ -351,7 +368,7 @@ class _SettingsPageState extends State<SettingsPage> {
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
DropdownButtonFormField<AiProviderType>(
value: widget.state.aiProvider,
initialValue: widget.state.aiProvider,
decoration: const InputDecoration(
labelText: '服务类型',
border: OutlineInputBorder(),
@@ -436,6 +453,7 @@ class _SettingsPageState extends State<SettingsPage> {
onPressed: _testingConnection
? null
: () async {
final messenger = ScaffoldMessenger.of(context);
setState(() => _testingConnection = true);
final result = await AiService.instance.testConnection(
provider: widget.state.aiProvider,
@@ -445,7 +463,7 @@ class _SettingsPageState extends State<SettingsPage> {
);
if (!mounted) return;
setState(() => _testingConnection = false);
ScaffoldMessenger.of(context).showSnackBar(
messenger.showSnackBar(
SnackBar(
content: Text(result.message),
backgroundColor:
@@ -458,6 +476,7 @@ class _SettingsPageState extends State<SettingsPage> {
SecondaryButton(
label: '从配置文件重载 (ai_config.json)',
onPressed: () async {
final messenger = ScaffoldMessenger.of(context);
final ok = await widget.state.reloadAiConfigFromAsset();
if (!mounted) return;
if (ok) {
@@ -465,13 +484,13 @@ class _SettingsPageState extends State<SettingsPage> {
endpoint.text = widget.state.aiEndpoint;
model.text = widget.state.aiModel;
});
ScaffoldMessenger.of(context).showSnackBar(
messenger.showSnackBar(
const SnackBar(
content: Text('已从 assets/config/ai_config.json 载入配置。'),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
messenger.showSnackBar(
const SnackBar(content: Text('未找到配置文件或解析失败。')),
);
}
@@ -608,17 +627,20 @@ class _SettingTile extends StatelessWidget {
const _SettingTile({
required this.title,
required this.subtitle,
required this.onTap,
this.onTap,
this.trailing,
});
final String title;
final String subtitle;
final VoidCallback? onTap;
final Widget? trailing;
@override
Widget build(BuildContext context) => ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
title: Text(title),
subtitle: Text(subtitle),
trailing: const Icon(Icons.chevron_right),
trailing: trailing ?? (onTap != null ? const Icon(Icons.chevron_right) : null),
onTap: onTap,
);
}
@@ -1,3 +1,4 @@
import '../../widgets/lexicon_lookup.dart';
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
@@ -257,6 +258,14 @@ class _ReviewPageState extends State<ReviewPage> {
? null
: () => _generateAdaptiveLesson(item),
),
ActionChip(
avatar: const Icon(Icons.search, size: 16),
label: const Text('查词查句'),
onPressed: () => showLexiconLookup(
context,
state: widget.state,
),
),
],
),
if (showHint)
@@ -342,8 +351,12 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
@override
void dispose() {
VoiceService.instance.stopSpeaking();
VoiceService.instance.stopListening();
VoiceService.instance.stopRecordingPlayback();
if (listening || aiVoiceRecording) {
VoiceService.instance.stopRecording();
}
if (!widget.state.keepRecordings) {
VoiceService.instance.deleteRecording(recordingPath);
}
@@ -416,7 +429,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
}
Future<void> _toggleListening() async {
if (aiVoiceRecording) {
if (listening || aiVoiceRecording) {
final path = await VoiceService.instance.stopRecording();
if (!mounted) return;
setState(() {
@@ -457,59 +470,19 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
return;
}
if (listening) {
await VoiceService.instance.stopListening();
if (mounted) setState(() => listening = false);
return;
}
final ready = await VoiceService.instance.startListening(
(text, _) {
if (!mounted) return;
setState(() {
controller.text = text;
usedVoice = true;
transcriptEdited = false;
transcriptConfirmed = false;
lastTranscript = text;
});
final lesson = widget.state.cachedAdaptiveLesson;
if (lesson != null) _saveDraft(lesson);
},
onStatus: (status) {
if (mounted && (status == 'notListening' || status == 'done')) {
setState(() => listening = false);
}
},
onError: (err) {
if (mounted) {
setState(() => listening = false);
}
},
);
if (!ready) {
final recordStarted = await VoiceService.instance.startRecording();
if (mounted) {
setState(() {
aiVoiceRecording = recordStarted;
listening = recordStarted;
});
if (recordStarted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已启动麦克风录音,说完后再次点击,AI 将自动转写为英文。')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('无法访问麦克风,请检查录音权限。你仍可输入英文完成补练。')),
);
}
}
return;
}
VoiceService.instance.stopSpeaking();
final recordStarted = await VoiceService.instance.startRecording();
if (!mounted) return;
setState(() => listening = ready);
if (recordStarted) {
setState(() {
aiVoiceRecording = true;
listening = true;
});
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('无法访问麦克风,请检查录音权限。你仍可输入英文完成补练。')),
);
}
}
Future<void> _toggleRecording() async {
@@ -649,6 +622,16 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
label: const Text('慢放'),
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => showLexiconLookup(
context,
state: widget.state,
initialText: task.stimulus,
),
icon: const Icon(Icons.psychology_alt_outlined),
label: const Text('解析'),
),
],
),
TextField(
+5
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'core/app_state.dart';
import 'core/app_theme.dart';
import 'core/sync/sync_coordinator.dart';
import 'core/sherpa_stt_service.dart';
import 'features/onboarding/onboarding_pages.dart';
import 'features/shell/learning_shell.dart';
@@ -24,6 +25,10 @@ class _KouyuEnglishAppState extends State<KouyuEnglishApp> {
@override
void initState() {
super.initState();
// 异步静默预热打包在本地的 SenseVoice 离线语音模型,加速首次语音交互
SherpaSttService.instance.initialize().then((ok) {
debugPrint('[Main] 离线 SenseVoice 语音引擎预热状态: $ok');
});
SyncCoordinator.instance.init().then((_) {
if (mounted && appState.isLoaded) {
SyncCoordinator.instance.triggerBackgroundSync(appState);
+11 -2
View File
@@ -8,11 +8,13 @@ class AppPage extends StatelessWidget {
required this.child,
this.appBar,
this.bottomNavigationBar,
this.scrollController,
});
final Widget child;
final PreferredSizeWidget? appBar;
final Widget? bottomNavigationBar;
final ScrollController? scrollController;
@override
Widget build(BuildContext context) {
@@ -22,6 +24,7 @@ class AppPage extends StatelessWidget {
body: SafeArea(
top: appBar == null,
child: SingleChildScrollView(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(16, 20, 16, 24),
child: child,
),
@@ -145,14 +148,20 @@ class Eyebrow extends StatelessWidget {
}
class SpacedColumn extends StatelessWidget {
const SpacedColumn({super.key, required this.children, this.spacing = 12});
const SpacedColumn({
super.key,
required this.children,
this.spacing = 12,
this.crossAxisAlignment = CrossAxisAlignment.start,
});
final List<Widget> children;
final double spacing;
final CrossAxisAlignment crossAxisAlignment;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: crossAxisAlignment,
children: [
for (var index = 0; index < children.length; index++) ...[
children[index],
+673 -113
View File
@@ -34,6 +34,43 @@ VocabularyItem? findCourseLexicon(String text) {
}, orElse: () => null);
}
/// Extracts all distinct course lexicon phrases/words that appear within [text].
List<VocabularyItem> extractCourseLexiconPhrases(String text) {
final normalized = text.toLowerCase().replaceAll('', "'").trim();
if (normalized.isEmpty) return const [];
final matched = <VocabularyItem>[];
final seen = <String>{};
for (final item in courseLexiconEntries) {
final word = item.word.toLowerCase().replaceAll('', "'");
if (word.isEmpty || word == normalized) continue;
final pattern = RegExp(
r'(?<![a-zA-Z0-9])' + RegExp.escape(word) + r'(?![a-zA-Z0-9])',
caseSensitive: false,
);
if (pattern.hasMatch(normalized)) {
if (seen.add(word)) {
matched.add(item);
}
}
}
return matched;
}
/// Determines whether the input text looks like a multi-word phrase or complete sentence.
bool isSentenceQuery(String text) {
final trimmed = text.trim();
if (trimmed.isEmpty) return false;
final words = trimmed.split(RegExp(r'\s+'));
return words.length >= 3 ||
trimmed.contains('.') ||
trimmed.contains('?') ||
trimmed.contains('!') ||
trimmed.contains(';') ||
trimmed.contains('') ||
trimmed.contains('') ||
trimmed.length > 25;
}
/// Inline course text that lets a learner tap a known word or phrase without
/// leaving the current task. Longest phrases are matched before their words.
class LexiconText extends StatefulWidget {
@@ -43,12 +80,14 @@ class LexiconText extends StatefulWidget {
required this.state,
this.style,
this.textAlign,
this.showSentenceAction = false,
});
final String text;
final AppState state;
final TextStyle? style;
final TextAlign? textAlign;
final bool showSentenceAction;
@override
State<LexiconText> createState() => _LexiconTextState();
@@ -135,15 +174,34 @@ class _LexiconTextState extends State<LexiconText> {
}
},
),
if (selectedText.isNotEmpty)
TextButton.icon(
onPressed: () => showLexiconLookup(
context,
state: widget.state,
initialText: selectedText,
if (selectedText.isNotEmpty || widget.showSentenceAction)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Wrap(
spacing: 8,
children: [
if (selectedText.isNotEmpty)
TextButton.icon(
onPressed: () => showLexiconLookup(
context,
state: widget.state,
initialText: selectedText,
),
icon: const Icon(Icons.translate_outlined, size: 16),
label: const Text('查询已选文本'),
),
if (widget.showSentenceAction && widget.text.trim().isNotEmpty)
TextButton.icon(
onPressed: () => showLexiconLookup(
context,
state: widget.state,
initialText: widget.text,
),
icon: const Icon(Icons.psychology_alt_outlined, size: 16),
label: const Text('解析整句与短语'),
),
],
),
icon: const Icon(Icons.translate_outlined, size: 16),
label: const Text('查询已选文本'),
),
],
);
@@ -174,17 +232,25 @@ class _LexiconLookupSheet extends StatefulWidget {
class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
late final TextEditingController controller;
VocabularyItem? entry;
List<VocabularyItem> localPhrases = [];
SentenceAnalysisResult? sentenceAnalysis;
bool requestingSentenceAnalysis = false;
String? sentenceAnalysisError;
bool requestingTemporaryDefinition = false;
String? temporaryDefinition;
String? temporaryError;
final Set<String> _addedToReview = {};
@override
void initState() {
super.initState();
controller = TextEditingController(text: widget.initialText);
entry = findCourseLexicon(widget.initialText);
final initial = widget.initialText.trim();
controller = TextEditingController(text: initial);
entry = findCourseLexicon(initial);
localPhrases = extractCourseLexiconPhrases(initial);
sentenceAnalysis = widget.state.sentenceAnalysisFor(initial);
temporaryDefinition = entry == null
? widget.state.temporaryDefinitionFor(widget.initialText)?.definition
? widget.state.temporaryDefinitionFor(initial)?.definition
: null;
}
@@ -194,13 +260,50 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
super.dispose();
}
void _lookup() => setState(() {
entry = findCourseLexicon(controller.text);
temporaryDefinition = widget.state
.temporaryDefinitionFor(controller.text)
?.definition;
temporaryError = null;
});
void _lookup() {
final query = controller.text.trim();
setState(() {
entry = findCourseLexicon(query);
localPhrases = extractCourseLexiconPhrases(query);
sentenceAnalysis = widget.state.sentenceAnalysisFor(query);
temporaryDefinition = widget.state
.temporaryDefinitionFor(query)
?.definition;
sentenceAnalysisError = null;
temporaryError = null;
});
}
Future<void> _requestSentenceAnalysis() async {
final text = controller.text.trim();
if (text.isEmpty) return;
setState(() {
requestingSentenceAnalysis = true;
sentenceAnalysisError = null;
});
final result = await AiService.instance.analyzeSentence(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
text: text,
);
if (!mounted) return;
setState(() {
requestingSentenceAnalysis = false;
sentenceAnalysis = result;
sentenceAnalysisError = result == null
? (widget.state.aiProvider.name == 'mock'
? '未能完成解析,请稍后重试。'
: 'AI 解析暂不可用,请检查网络或 AI 服务配置。')
: null;
});
if (result != null) {
widget.state.cacheSentenceAnalysis(result);
}
}
Future<void> _requestTemporaryDefinition() async {
final text = controller.text.trim();
@@ -232,111 +335,568 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
}
}
@override
Widget build(BuildContext context) => SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(
20,
0,
20,
24 + MediaQuery.viewInsetsOf(context).bottom,
),
child: SpacedColumn(
children: [
const Eyebrow('课程词典 · 短语优先'),
TextField(
controller: controller,
autofocus: true,
textInputAction: TextInputAction.search,
onSubmitted: (_) => _lookup(),
decoration: InputDecoration(
hintText: '输入或粘贴英文词、短语、句子',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: const Icon(Icons.search),
onPressed: _lookup,
void _addPhraseToReview(PhraseBreakdownItem phrase) {
widget.state.addPhraseToReview(
phrase: phrase.phrase,
meaning: phrase.meaning,
ipa: phrase.ipa,
usageNote: phrase.usageNote,
contextSentence: controller.text.trim(),
);
setState(() {
_addedToReview.add(phrase.phrase.toLowerCase());
});
try {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('已将短语 "${phrase.phrase}" 加入复习计划'),
duration: const Duration(seconds: 2),
),
);
} catch (_) {}
}
void _addVocabItemToReview(VocabularyItem item) {
widget.state.addSavedWord(item);
setState(() {
_addedToReview.add(item.word.toLowerCase());
});
try {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('已将 "${item.word}" 加入复习计划'),
duration: const Duration(seconds: 2),
),
);
} catch (_) {}
}
Widget _buildSentenceAnalysisSection(SentenceAnalysisResult analysis) {
return SpacedColumn(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Translation
SectionCard(
tint: AppColors.softGreen,
child: SpacedColumn(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.translate, size: 16, color: AppColors.green),
SizedBox(width: 6),
Text(
'中文整句翻译',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: AppColors.green,
),
),
],
),
Text(
analysis.translation,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.4,
),
),
],
),
),
// Pattern, Pronunciation Tips, Grammar Note
if (analysis.sentencePattern != null ||
analysis.pronunciationTips != null ||
analysis.grammarNote != null)
SectionCard(
tint: AppColors.warm,
child: SpacedColumn(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (analysis.sentencePattern != null &&
analysis.sentencePattern!.isNotEmpty) ...[
const Row(
children: [
Icon(
Icons.lightbulb_outline,
size: 16,
color: Color(0xFFD97706),
),
SizedBox(width: 6),
Text(
'核心句型',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Color(0xFFD97706),
),
),
],
),
Text(
analysis.sentencePattern!,
style: const TextStyle(fontSize: 14, height: 1.3),
),
],
if (analysis.pronunciationTips != null &&
analysis.pronunciationTips!.isNotEmpty) ...[
const SizedBox(height: 6),
const Row(
children: [
Icon(
Icons.record_voice_over_outlined,
size: 16,
color: AppColors.green,
),
SizedBox(width: 6),
Text(
'口语连读与发音',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: AppColors.green,
),
),
],
),
Text(
analysis.pronunciationTips!,
style: const TextStyle(fontSize: 14, height: 1.3),
),
],
if (analysis.grammarNote != null &&
analysis.grammarNote!.isNotEmpty) ...[
const SizedBox(height: 6),
const Row(
children: [
Icon(
Icons.menu_book_outlined,
size: 16,
color: Color(0xFF4B5563),
),
SizedBox(width: 6),
Text(
'语法要点',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Color(0xFF4B5563),
),
),
],
),
Text(
analysis.grammarNote!,
style: const TextStyle(fontSize: 14, height: 1.3),
),
],
],
),
),
if (entry == null) ...[
const SectionCard(
child: Text('本地词典未收录。可以请求临时释义;它会标记为待审核,不能加入复习或影响掌握度。'),
),
OutlinedButton.icon(
onPressed: requestingTemporaryDefinition
? null
: _requestTemporaryDefinition,
icon: const Icon(Icons.auto_awesome_outlined),
label: Text(requestingTemporaryDefinition ? '查询中…' : '生成临时释义'),
),
if (temporaryError != null)
Text(
temporaryError!,
style: const TextStyle(color: AppColors.warmInk),
// Phrases Breakdown
if (analysis.phrases.isNotEmpty) ...[
const SizedBox(height: 4),
Row(
children: [
const Icon(
Icons.auto_stories_outlined,
size: 18,
color: AppColors.green,
),
if (temporaryDefinition != null)
SectionCard(
tint: AppColors.warm,
child: SpacedColumn(
children: [
Text('待审核临时释义\n$temporaryDefinition'),
Text(
'仅保存在本机,不会加入复习或影响掌握度。',
style: Theme.of(context).textTheme.bodySmall,
const SizedBox(width: 6),
Text(
'重点短语与搭配 (${analysis.phrases.length})',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
],
),
for (final phrase in analysis.phrases)
SectionCard(
child: SpacedColumn(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
children: [
Text(
phrase.phrase,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
if (phrase.ipa != null && phrase.ipa!.isNotEmpty)
Text(
phrase.ipa!,
style: const TextStyle(
fontSize: 13,
color: AppColors.muted,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.volume_up_outlined, size: 20),
tooltip: '播放读音',
onPressed: () => VoiceService.instance.speak(phrase.phrase),
),
const SizedBox(width: 4),
_addedToReview.contains(phrase.phrase.toLowerCase())
? const Chip(
label: Text('已在复习', style: TextStyle(fontSize: 12)),
avatar: Icon(Icons.check, size: 14, color: AppColors.green),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
)
: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
),
onPressed: () => _addPhraseToReview(phrase),
icon: const Icon(Icons.bookmark_add_outlined, size: 14),
label: const Text('加复习', style: TextStyle(fontSize: 12)),
),
],
),
Text(
phrase.meaning,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
TextButton.icon(
onPressed: () {
widget.state.removeTemporaryDefinition(controller.text);
setState(() => temporaryDefinition = null);
},
icon: const Icon(Icons.delete_outline, size: 18),
label: const Text('删除此临时释义'),
),
if (phrase.usageNote != null && phrase.usageNote!.isNotEmpty)
Text(
'用法:${phrase.usageNote}',
style: const TextStyle(
fontSize: 12,
color: AppColors.muted,
),
),
],
),
),
],
// Cache source / re-analyze footer
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'解析引擎:${analysis.provider} · 本机已缓存',
style: const TextStyle(fontSize: 12, color: AppColors.muted),
),
TextButton.icon(
onPressed: requestingSentenceAnalysis ? null : _requestSentenceAnalysis,
icon: const Icon(Icons.refresh, size: 14),
label: const Text('重新解析', style: TextStyle(fontSize: 12)),
),
],
),
],
);
}
@override
Widget build(BuildContext context) {
final queryText = controller.text.trim();
final isSentence = isSentenceQuery(queryText);
final hasExactCourseEntry = entry != null && !isSentence;
return SafeArea(
child: Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.85,
),
padding: EdgeInsets.fromLTRB(
20,
0,
20,
24 + MediaQuery.viewInsetsOf(context).bottom,
),
child: SingleChildScrollView(
child: SpacedColumn(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Eyebrow('查词与整句深度解析'),
if (sentenceAnalysis != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColors.softGreen,
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'已解析',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: AppColors.green,
),
),
),
],
),
// Search bar
TextField(
controller: controller,
autofocus: queryText.isEmpty,
textInputAction: TextInputAction.search,
onSubmitted: (_) => _lookup(),
decoration: InputDecoration(
hintText: '输入英文单词、短语或整句',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.search),
suffixIcon: queryText.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 18),
onPressed: () {
controller.clear();
_lookup();
},
)
: null,
),
),
// Audio row for current query
if (queryText.isNotEmpty)
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => VoiceService.instance.speak(queryText),
icon: const Icon(Icons.volume_up_outlined, size: 18),
label: const Text('朗读原文'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: () =>
VoiceService.instance.speak(queryText, slow: true),
icon: const Icon(Icons.slow_motion_video_outlined, size: 18),
label: const Text('慢速朗读'),
),
),
],
),
),
] else ...[
Text(
entry!.word,
style: Theme.of(context).textTheme.headlineMedium,
),
if (entry!.ipa != null)
Text(entry!.ipa!, style: Theme.of(context).textTheme.bodyMedium),
Text(entry!.meaning, style: const TextStyle(fontSize: 18)),
SectionCard(
tint: AppColors.softGreen,
child: Text('${entry!.example}\n${entry!.exampleMeaning}'),
),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => VoiceService.instance.speak(entry!.word),
icon: const Icon(Icons.volume_up_outlined),
label: const Text('播放'),
),
// Mode 1: Exact course lexicon single word card
if (hasExactCourseEntry) ...[
Text(
entry!.word,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: () =>
VoiceService.instance.speak(entry!.word, slow: true),
icon: const Icon(Icons.slow_motion_video_outlined),
label: const Text('慢放'),
),
if (entry!.ipa != null)
Text(entry!.ipa!, style: Theme.of(context).textTheme.bodyMedium),
Text(entry!.meaning, style: const TextStyle(fontSize: 18)),
SectionCard(
tint: AppColors.softGreen,
child: Text('${entry!.example}\n${entry!.exampleMeaning}'),
),
Row(
children: [
Expanded(
child: PrimaryButton(
label: _addedToReview.contains(entry!.word.toLowerCase())
? '已在复习中'
: '加入复习',
onPressed: () {
_addVocabItemToReview(entry!);
},
),
),
],
),
// Provide option to analyze further with AI if user wants deeper context
if (sentenceAnalysis == null)
OutlinedButton.icon(
onPressed: requestingSentenceAnalysis ? null : _requestSentenceAnalysis,
icon: const Icon(Icons.auto_awesome_outlined, size: 16),
label: Text(requestingSentenceAnalysis ? 'AI 解析中…' : '请求 AI 句型与深度解析'),
),
],
),
PrimaryButton(
label: '加入复习',
onPressed: () {
widget.state.addSavedWord(entry!);
Navigator.pop(context);
},
),
],
],
// Mode 2: Sentence / Phrase Analysis Section
if (sentenceAnalysis != null) ...[
_buildSentenceAnalysisSection(sentenceAnalysis!),
],
// Local phrases extracted from sentence (offline fallback/supplement)
if (localPhrases.isNotEmpty && sentenceAnalysis == null) ...[
const SizedBox(height: 4),
Row(
children: [
const Icon(Icons.layers_outlined, size: 18, color: AppColors.green),
const SizedBox(width: 6),
Text(
'本地词典匹配到的短语 (${localPhrases.length})',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
],
),
for (final phraseItem in localPhrases)
SectionCard(
tint: AppColors.softGreen,
child: SpacedColumn(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
phraseItem.word,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.volume_up_outlined, size: 18),
onPressed: () =>
VoiceService.instance.speak(phraseItem.word),
),
const SizedBox(width: 4),
_addedToReview.contains(phraseItem.word.toLowerCase())
? const Icon(Icons.check, size: 18, color: AppColors.green)
: OutlinedButton(
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
),
onPressed: () =>
_addVocabItemToReview(phraseItem),
child: const Text('+ 复习', style: TextStyle(fontSize: 12)),
),
],
),
Text(phraseItem.meaning, style: const TextStyle(fontSize: 14)),
if (phraseItem.example.isNotEmpty)
Text(
'例:${phraseItem.example} (${phraseItem.exampleMeaning})',
style: const TextStyle(
fontSize: 12,
color: AppColors.muted,
),
),
],
),
),
],
// If sentenceAnalysis is not yet available, show AI Trigger Section
if (sentenceAnalysis == null && !hasExactCourseEntry) ...[
SectionCard(
child: SpacedColumn(
children: [
Text(
isSentence
? '需要整个句子的翻译、句型解析、口语连读技巧与重点短语拆解?'
: '本地词典未精确收录。可使用 AI 智能剖析其含义、发音、短语搭配与例句。',
),
if (requestingSentenceAnalysis)
const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 10),
Text('AI 正在深度解析句子结构与短语…'),
],
)
else
PrimaryButton(
label: 'AI 深度解析句子与短语',
icon: Icons.auto_awesome,
onPressed: _requestSentenceAnalysis,
),
if (sentenceAnalysisError != null)
Text(
sentenceAnalysisError!,
style: const TextStyle(color: AppColors.warmInk),
),
],
),
),
// Legacy fallback for quick temporary definition if learner only wants a gloss
if (temporaryDefinition == null)
TextButton.icon(
onPressed: requestingTemporaryDefinition
? null
: _requestTemporaryDefinition,
icon: const Icon(Icons.text_fields_outlined, size: 16),
label: Text(
requestingTemporaryDefinition
? '查询简短释义中…'
: '仅生成简短释义 (待审核)',
),
),
if (temporaryError != null)
Text(
temporaryError!,
style: const TextStyle(color: AppColors.warmInk),
),
if (temporaryDefinition != null)
SectionCard(
tint: AppColors.warm,
child: SpacedColumn(
children: [
Text('待审核临时释义\n$temporaryDefinition'),
Text(
'仅保存在本机,不会加入复习或影响掌握度。',
style: Theme.of(context).textTheme.bodySmall,
),
TextButton.icon(
onPressed: () {
widget.state.removeTemporaryDefinition(controller.text);
setState(() => temporaryDefinition = null);
},
icon: const Icon(Icons.delete_outline, size: 18),
label: const Text('删除此临时释义'),
),
],
),
),
],
],
),
),
),
),
);
);
}
}
@@ -1,3 +1,4 @@
// ignore_for_file: avoid_print, unnecessary_overrides
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/ai_service.dart';
@@ -0,0 +1,97 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/seed_courses.dart';
void main() {
group('对话任务校验', () {
test('每个对话都为每一轮声明了任务标签', () {
for (final entry in a0Dialogues.entries) {
final script = entry.value;
expect(
script.taskLabels.length,
script.prompts.length,
reason: '${entry.key} 的任务标签数量要和回合数一致',
);
expect(
script.requiredTerms.length,
script.prompts.length,
reason: '${entry.key} 的校验词组数量要和回合数一致',
);
for (final group in script.requiredTerms) {
expect(group, isNotEmpty);
}
}
for (final entry in a0SegmentDialogues.entries) {
expect(
entry.value.taskLabels.length,
entry.value.prompts.length,
reason: '${entry.key} 的任务标签数量要和回合数一致',
);
}
expect(a0MeetDialogue.taskLabels.length, a0MeetDialogue.prompts.length);
expect(a0MeetDialogue.requiredTerms.length, a0MeetDialogue.prompts.length);
});
test('示范答案能通过对应回合的校验', () {
for (final entry in {...a0Dialogues, 'scene': a0MeetDialogue}.entries) {
final script = entry.value;
for (var stage = 0; stage < script.hints.length; stage++) {
expect(
matchesDialogueStage(script, stage, script.hints[stage]),
isTrue,
reason: '${entry.key}${stage + 1} 轮的示范答案应当通过',
);
}
}
});
test('跑题回答不再因为关键词沾边而通过', () {
// 旧实现用 text.contains('it'),下面这些句子全部会被判为完成任务。
expect(matchesDialogueStage(a0Dialogues['a0-05']!, 0, 'I did it.'), isFalse);
expect(matchesDialogueStage(a0Dialogues['a0-08']!, 0, 'It is good.'), isFalse);
expect(matchesDialogueStage(a0Dialogues['a0-09']!, 2, 'I like tea.'), isFalse);
expect(matchesDialogueStage(a0MeetDialogue, 0, 'Hello.'), isFalse);
expect(matchesDialogueStage(a0MeetDialogue, 1, 'I am good.'), isFalse);
});
test('自由场景接受同样正确的其他说法', () {
// 旧正则只认 good/okay/tired,也不认 my name's。
expect(matchesDialogueStage(a0MeetDialogue, 0, "My name's Shen."), isTrue);
expect(matchesDialogueStage(a0MeetDialogue, 0, 'I am Shen.'), isTrue);
expect(matchesDialogueStage(a0MeetDialogue, 2, "I'm fine, thanks."), isTrue);
expect(matchesDialogueStage(a0MeetDialogue, 2, 'I am great!'), isTrue);
expect(matchesDialogueStage(a0MeetDialogue, 3, 'Where are you from?'), isTrue);
expect(matchesDialogueStage(a0MeetDialogue, 3, 'Do you like tea?'), isTrue);
});
test('只写半句不算完成任务', () {
expect(matchesDialogueStage(a0MeetDialogue, 0, "I'm"), isFalse);
expect(matchesDialogueStage(a0Dialogues['a0-04']!, 0, 'My number is'), isFalse);
expect(
matchesDialogueStage(a0Dialogues['a0-04']!, 0, 'My number is one-three-eight.'),
isTrue,
);
});
test('拼读和数字这类结构化要求可以识别', () {
expect(matchesDialogueStage(a0Dialogues['a0-02']!, 1, 'S-H-E-N'), isTrue);
expect(matchesDialogueStage(a0Dialogues['a0-02']!, 1, 'Shen'), isFalse);
expect(matchesDialogueStage(a0Dialogues['a0-08']!, 2, "It's three o'clock."), isTrue);
expect(matchesDialogueStage(a0Dialogues['a0-08']!, 2, "It's Monday."), isFalse);
});
test('任务标签可用于提示和总结', () {
expect(dialogueTaskLabel(a0MeetDialogue, 0), '介绍姓名');
expect(dialogueTaskLabel(a0MeetDialogue, 9), '完成本轮任务');
});
test('给 AI 的可用词表随课程递增且不越界', () {
final first = taughtLanguageUpTo('a0-01');
final later = taughtLanguageUpTo('a0-09');
expect(first, isNotEmpty);
expect(later.length, greaterThan(first.length));
expect(first.every(later.contains), isTrue);
expect(later.length, lessThanOrEqualTo(allTaughtLanguage.length));
expect(first.any((word) => word.toLowerCase().contains('coffee')), isFalse);
});
});
}
+150
View File
@@ -0,0 +1,150 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/seed_courses.dart';
const _digitWords = {
'0': 'zero',
'1': 'one',
'2': 'two',
'3': 'three',
'4': 'four',
'5': 'five',
'6': 'six',
'7': 'seven',
'8': 'eight',
'9': 'nine',
};
const _skipWords = {'a', 'an', 'the', 'my', 'is', 'it', 'im', 'to', 'you'};
String _normalize(String text) =>
text.toLowerCase().replaceAll(RegExp(r'[^a-z0-9一-龥]'), '');
/// 选项在对话里是否真的出现过。数字选项按英文读法展开(138 → onethreeeight),
/// 逐字母拼写(S-H-E-N)先去掉连字符。
bool _appearsInReading(String reading, String option) {
final haystack = _normalize(
reading.replaceAllMapped(
RegExp(r'\d'),
(match) => _digitWords[match[0]]!,
),
);
final tokens = option
.replaceAll('-', '')
.replaceAllMapped(RegExp(r'\d'), (match) => _digitWords[match[0]]!)
.toLowerCase()
.split(RegExp(r'[^a-z0-9一-龥]+'))
.where((token) => token.length > 1 && !_skipWords.contains(token));
return tokens.isNotEmpty && tokens.every(haystack.contains);
}
void main() {
final activities = <String, LessonActivity>{
...a0Activities,
...a0SegmentActivities,
};
group('阅读找答案题', () {
test('每题三个选项,第一项是标准答案', () {
for (final entry in activities.entries) {
final activity = entry.value;
expect(activity.answers, hasLength(3), reason: entry.key);
expect(activity.readingOptions, hasLength(3), reason: entry.key);
expect(
activity.readingOptions.first,
activity.readingAnswer,
reason: '${entry.key}readingOptions 第一项应是标准答案',
);
expect(
activity.readingOptions.toSet(),
hasLength(3),
reason: '${entry.key}:选项不能重复',
);
}
});
test('至少两个选项在对话中出现,必须读懂才能选', () {
for (final entry in activities.entries) {
final activity = entry.value;
final present = activity.readingOptions
.where((option) => _appearsInReading(activity.reading, option))
.toList();
expect(
_appearsInReading(activity.reading, activity.readingAnswer),
isTrue,
reason: '${entry.key}:答案必须能在对话里找到',
);
expect(
present.length,
greaterThanOrEqualTo(2),
reason:
'${entry.key}:只有 ${present.length} 个选项出现在对话中,'
'学习者不读对话也能挑出唯一出现过的那个',
);
}
});
test('选项之间不构成子串,避免判分时误判', () {
for (final entry in activities.entries) {
final options = entry.value.readingOptions.map(_normalize).toList();
for (var i = 0; i < options.length; i++) {
for (var j = i + 1; j < options.length; j++) {
expect(
options[i].contains(options[j]) ||
options[j].contains(options[i]),
isFalse,
reason: '${entry.key}:“${entry.value.readingOptions[i]}”与'
'${entry.value.readingOptions[j]}”互为子串,会被判分逻辑当成同一个答案',
);
}
}
}
});
test('打乱后的选项内容不变且顺序稳定', () {
for (final entry in activities.entries) {
final activity = entry.value;
final seed = '${activity.readingQuestion}-reading';
final first = shuffledOptions(activity.readingOptions, seed);
final second = shuffledOptions(activity.readingOptions, seed);
expect(first, second, reason: entry.key);
expect(
first.toSet(),
activity.readingOptions.toSet(),
reason: entry.key,
);
}
});
test('正确答案在三个位置上都出现,不集中在某一位', () {
final readingSlots = <int, int>{};
final listeningSlots = <int, int>{};
for (final activity in activities.values) {
final reading = shuffledOptions(
activity.readingOptions,
'${activity.readingQuestion}-reading',
);
final readingSlot = reading.indexOf(activity.readingAnswer);
readingSlots[readingSlot] = (readingSlots[readingSlot] ?? 0) + 1;
final listening = shuffledOptions(
activity.answers,
'${activity.listening}-listening',
);
final listeningSlot = listening.indexOf(activity.answers.first);
listeningSlots[listeningSlot] = (listeningSlots[listeningSlot] ?? 0) + 1;
}
for (final slots in [readingSlots, listeningSlots]) {
expect(slots.keys.toSet(), {0, 1, 2});
for (final count in slots.values) {
expect(count, lessThan(activities.length ~/ 2));
}
}
});
test('题干不重复,复习时不会撞题', () {
final questions = activities.values
.map((activity) => activity.readingQuestion)
.toList();
expect(questions.toSet(), hasLength(questions.length));
});
});
}
+5 -2
View File
@@ -1,3 +1,4 @@
// ignore_for_file: avoid_print, unnecessary_overrides
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/ai_service.dart';
@@ -44,7 +45,8 @@ void main() {
}
});
test('Live test: /v1/responses dialogueReply', () async {
test('Live test: /v1/responses dialogueReply', timeout: const Timeout(Duration(seconds: 60)), () async {
try {
final reply = await AiService.instance.dialogueReply(
provider: AiProviderType.compatible,
@@ -53,7 +55,8 @@ void main() {
history: [
{'role': 'user', 'content': 'Hello, my name is Alex.'}
],
requiredTask: 'Greet learner and ask what is their name',
aiGoal: 'Greet the learner and ask their name',
learnerTask: 'say their own name',
);
print('Dialogue reply from /v1/responses: reply="${reply?.reply}", slots=${reply?.slots}, suggestsComplete=${reply?.suggestsComplete}');
} catch (e) {
@@ -0,0 +1,231 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/ai_service.dart';
import 'package:kouyu_english/core/app_state.dart';
import 'package:kouyu_english/core/models.dart';
import 'package:kouyu_english/widgets/lexicon_lookup.dart';
void main() {
group('Sentence and Phrase Parsing Logic', () {
test('isSentenceQuery correctly detects sentences vs words', () {
expect(isSentenceQuery('apple'), isFalse);
expect(isSentenceQuery('check in'), isFalse);
expect(isSentenceQuery('Where is the subway?'), isTrue);
expect(isSentenceQuery("I'd like to check in."), isTrue);
expect(isSentenceQuery('This is a longer sentence with more than three words'), isTrue);
});
test('extractCourseLexiconPhrases finds embedded course lexicon items', () {
final matches = extractCourseLexiconPhrases("Hello, nice to meet you, where's the taxi?");
expect(matches, isNotEmpty);
final words = matches.map((m) => m.word.toLowerCase()).toList();
expect(words.any((w) => w.contains('nice to meet you')), isTrue);
});
});
group('Models Serialization', () {
test('PhraseBreakdownItem serialization round-trip', () {
const item = PhraseBreakdownItem(
phrase: 'check in',
meaning: '办理入住/值机',
ipa: '/tʃek ɪn/',
usageNote: '连读发音,酒店或机场常用',
);
final json = item.toJson();
final parsed = PhraseBreakdownItem.fromJson(json);
expect(parsed.phrase, 'check in');
expect(parsed.meaning, '办理入住/值机');
expect(parsed.ipa, '/tʃek ɪn/');
expect(parsed.usageNote, '连读发音,酒店或机场常用');
});
test('SentenceAnalysisResult serialization round-trip', () {
final result = SentenceAnalysisResult(
originalText: "I would like to check in, please.",
translation: '我想办理入住手续,谢谢。',
sentencePattern: "I would like to + 动词原形 (礼貌表达需求)",
grammarNote: 'would like 语气委婉客气,适合服务场景。',
pronunciationTips: 'would like 发音轻柔,check in 发生连读。',
phrases: const [
PhraseBreakdownItem(
phrase: 'would like to',
meaning: '想要做某事(委婉)',
),
PhraseBreakdownItem(
phrase: 'check in',
meaning: '办理入住',
ipa: '/tʃek ɪn/',
),
],
provider: 'mock',
model: 'test-model',
createdAt: DateTime.utc(2026, 9, 16, 10, 0, 0),
);
final json = result.toJson();
final parsed = SentenceAnalysisResult.fromJson(json);
expect(parsed.originalText, result.originalText);
expect(parsed.translation, result.translation);
expect(parsed.sentencePattern, result.sentencePattern);
expect(parsed.grammarNote, result.grammarNote);
expect(parsed.pronunciationTips, result.pronunciationTips);
expect(parsed.phrases.length, 2);
expect(parsed.phrases[0].phrase, 'would like to');
expect(parsed.phrases[1].ipa, '/tʃek ɪn/');
expect(parsed.provider, 'mock');
expect(parsed.model, 'test-model');
});
});
group('AiService Mock Sentence Analysis', () {
test('returns structured breakdown for would like', () async {
final analysis = await AiService.instance.analyzeSentence(
provider: AiProviderType.mock,
endpoint: '',
model: 'mock',
text: 'I would like a cup of tea.',
);
expect(analysis, isNotNull);
expect(analysis!.translation, contains('想要'));
expect(analysis.sentencePattern, contains('would like'));
expect(analysis.pronunciationTips, isNotNull);
expect(analysis.phrases, isNotEmpty);
expect(analysis.phrases.any((p) => p.phrase == 'would like'), isTrue);
});
test('returns structured breakdown for where is', () async {
final analysis = await AiService.instance.analyzeSentence(
provider: AiProviderType.mock,
endpoint: '',
model: 'mock',
text: 'Where is the gate?',
);
expect(analysis, isNotNull);
expect(analysis!.translation, contains('在哪里'));
expect(analysis.sentencePattern, contains('Where is'));
expect(analysis.phrases.any((p) => p.phrase == 'where is'), isTrue);
});
test('returns structured fallback for generic sentence', () async {
final analysis = await AiService.instance.analyzeSentence(
provider: AiProviderType.mock,
endpoint: '',
model: 'mock',
text: 'The weather is very sunny today.',
);
expect(analysis, isNotNull);
expect(analysis!.translation, isNotEmpty);
expect(analysis.sentencePattern, isNotEmpty);
expect(analysis.grammarNote, isNotEmpty);
});
});
group('AppState Sentence Analysis & Review Integration', () {
test('caching and retrieving sentence analyses with query normalization', () {
final state = AppState();
final result = SentenceAnalysisResult(
originalText: ' Where is the bus stop? ',
translation: '请问公交站在哪里?',
sentencePattern: 'Where is + 地点',
createdAt: DateTime.now(),
);
state.cacheSentenceAnalysis(result);
expect(state.sentenceAnalysisFor('where is the bus stop?'), isNotNull);
expect(state.sentenceAnalysisFor('WHERE IS THE BUS STOP? '), isNotNull);
expect(state.sentenceAnalysisFor('where is the bus stop?')?.translation, '请问公交站在哪里?');
state.removeSentenceAnalysis('where is the bus stop?');
expect(state.sentenceAnalysisFor('where is the bus stop?'), isNull);
});
test('addPhraseToReview converts phrase breakdown into vocabulary item in review queue', () {
final state = AppState();
state.addPhraseToReview(
phrase: 'check in',
meaning: '办理登机或入住',
ipa: '/tʃek ɪn/',
usageNote: '酒店机场高频词',
contextSentence: "I'd like to check in please.",
);
expect(state.reviewQueue.any((r) => r.target == 'check in'), isTrue);
final saved = state.reviewQueue.firstWhere((r) => r.target == 'check in');
expect(saved.hint, contains('办理登机或入住'));
expect(saved.hint, contains('酒店机场高频词'));
expect(saved.prompt, "I'd like to check in please.");
});
});
group('UI Lookup Sheet Sentence Analysis View', () {
testWidgets('renders sentence analysis result cards when cached', (tester) async {
final state = AppState();
final analysis = SentenceAnalysisResult(
originalText: 'Where is the departure gate?',
translation: '请问登机口在哪里?',
sentencePattern: 'Where is + 目的地',
grammarNote: 'where 引导的疑问句,注意语调。',
pronunciationTips: 'Where 与 is 自然连读。',
phrases: const [
PhraseBreakdownItem(
phrase: 'departure gate',
ipa: '/dɪˈpɑːrtʃər ɡeɪt/',
meaning: '登机口',
usageNote: '机场核心词汇',
),
],
provider: 'mock',
createdAt: DateTime.now(),
);
state.cacheSentenceAnalysis(analysis);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => ElevatedButton(
onPressed: () => showLexiconLookup(
context,
state: state,
initialText: 'Where is the departure gate?',
),
child: const Text('查句'),
),
),
),
),
);
await tester.tap(find.text('查句'));
await tester.pumpAndSettle();
expect(find.text('查词与整句深度解析'), findsOneWidget);
expect(find.text('中文整句翻译'), findsOneWidget);
expect(find.text('请问登机口在哪里?'), findsOneWidget);
expect(find.text('核心句型'), findsOneWidget);
expect(find.text('Where is + 目的地'), findsOneWidget);
expect(find.text('口语连读与发音'), findsOneWidget);
expect(find.text('重点短语与搭配 (1)'), findsOneWidget);
expect(find.text('departure gate'), findsOneWidget);
expect(find.text('登机口'), findsOneWidget);
expect(find.text('加复习'), findsOneWidget);
// Scroll to '加复习' and tap
await tester.ensureVisible(find.text('加复习'));
await tester.pumpAndSettle();
await tester.tap(find.text('加复习'));
await tester.pumpAndSettle();
expect(state.reviewQueue.any((r) => r.target == 'departure gate'), isTrue);
expect(find.text('已在复习'), findsOneWidget);
});
});
}
+33 -5
View File
@@ -1,20 +1,32 @@
// ignore_for_file: avoid_print
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/sherpa_stt_service.dart';
import 'package:sherpa_onnx/sherpa_onnx.dart' as sherpa_onnx;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final home = Platform.environment['HOME'] ?? '';
final pubCache = Platform.environment['PUB_CACHE'] ?? '$home/.pub-cache';
final candidateDirs = [
'$pubCache/hosted/pub.dev/sherpa_onnx_macos-1.13.8/macos',
'$home/.pub-cache/hosted/pub.dev/sherpa_onnx_macos-1.13.8/macos',
'/Users/shenlei/.pub-cache/hosted/pub.dev/sherpa_onnx_macos-1.13.8/macos',
];
final macosDir = candidateDirs.firstWhere(
(d) => Directory(d).existsSync(),
orElse: () => candidateDirs.first,
);
test('SenseVoice-Small ONNX transcribes WAV audio file accurately', () async {
const macosDir = '/Users/shen/.pub-cache/hosted/pub.dev/sherpa_onnx_macos-1.13.8/macos';
// Test direct Sherpa ASR initialization and decoding with SenseVoice
sherpa_onnx.initBindings(macosDir);
final modelConfig = sherpa_onnx.OfflineModelConfig(
senseVoice: const sherpa_onnx.OfflineSenseVoiceModelConfig(
model: 'assets/models/sense_voice/model.int8.onnx',
language: 'auto',
language: 'en',
useInverseTextNormalization: true,
),
tokens: 'assets/models/sense_voice/tokens.txt',
@@ -38,10 +50,26 @@ void main() {
stream.acceptWaveform(samples: wave.samples, sampleRate: wave.sampleRate);
recognizer.decode(stream);
final result = recognizer.getResult(stream);
print("SenseVoice transcribed result: ${result.text}");
expect(result.text.toLowerCase().contains("nightfall"), isTrue);
print('SenseVoice transcribed result: ${result.text}');
expect(result.text.toLowerCase().contains('nightfall'), isTrue);
stream.free();
}
recognizer.free();
});
test('SherpaSttService singleton initializes and transcribes cleanly', () async {
final ready = await SherpaSttService.instance.initialize(nativeLibDir: macosDir);
expect(ready, isTrue);
expect(SherpaSttService.instance.isReady, isTrue);
const testWave = '/tmp/sherpa_test/sherpa-onnx-zipformer-small-en-2023-06-26/test_wavs/0.wav';
if (File(testWave).existsSync()) {
final transcribed = await SherpaSttService.instance.transcribeWav(testWave);
expect(transcribed, isNotNull);
print('SherpaSttService transcribed: $transcribed');
expect(transcribed!.toLowerCase().contains('nightfall'), isTrue);
// SenseVoice tags should be stripped
expect(transcribed.contains('<|'), isFalse);
}
});
}