feat: improve dialogue speech recognition and AI intervention

This commit is contained in:
shen
2026-09-19 17:45:25 -07:00
parent 5b9cba793e
commit 335d0304b1
6 changed files with 704 additions and 12 deletions
@@ -161,6 +161,8 @@ class _DialoguePageState extends State<DialoguePage>
bool checkingWithAi = false;
WritingAiFeedback? aiCheck;
String? aiCheckError;
bool interveningWithAi = false;
DialogueAiIntervention? aiIntervention;
/// 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.
@@ -308,12 +310,66 @@ class _DialoguePageState extends State<DialoguePage>
});
}
Future<void> send() async {
final text = controller.text.trim();
if (text.isEmpty || stage >= script.prompts.length || waitingForReply) {
Future<void> send({
bool overrideValidation = false,
String? overrideText,
}) async {
final text = (overrideText ?? controller.text).trim();
if (text.isEmpty ||
stage >= script.prompts.length ||
waitingForReply ||
interveningWithAi) {
return;
}
if (!_matchesCurrentTask(text)) {
if (!overrideValidation && !_matchesCurrentTask(text)) {
if (widget.state.aiProvider != AiProviderType.mock) {
setState(() {
interveningWithAi = true;
validationError = null;
aiIntervention = null;
});
_scrollToBottom();
final partnerLine =
turns.where((turn) => !turn.isLearner).lastOrNull?.text ?? '';
final hintText =
stage < script.hints.length ? script.hints[stage] : null;
final intervention =
await AiService.instance.checkDialogueIntervention(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
partnerLine: partnerLine,
taskLabel: _currentTaskLabel(),
learnerText: text,
hint: hintText,
level: _languageLessonId == null
? 'A0'
: lessonLevel(_languageLessonId!),
);
if (!mounted) return;
setState(() => interveningWithAi = false);
if (intervention != null) {
if (intervention.accepted) {
// AI confirmed semantic acceptability: proceed directly!
await _executeSend(text, usedAiIntervention: true);
return;
} else {
// AI detected ASR slip / typo / off-topic: show intervention card
setState(() {
aiIntervention = intervention;
validationError = null;
});
_scrollToBottom();
return;
}
}
}
setState(
() => validationError =
'这一轮要“${_currentTaskLabel()}”,这句还没做到。'
@@ -321,6 +377,17 @@ class _DialoguePageState extends State<DialoguePage>
);
return;
}
await _executeSend(text, usedAiIntervention: overrideValidation);
}
Future<void> _executeSend(
String text, {
bool usedAiIntervention = false,
}) async {
if (usedAiIntervention) {
usedHelp = true;
}
widget.state.recordDialogueAttempt(
taskId: widget.isLessonDialogue
? 'dialogue-$_lessonSegmentId-$stage'
@@ -343,6 +410,7 @@ class _DialoguePageState extends State<DialoguePage>
waitingForReply = true;
hint = null;
validationError = null;
aiIntervention = null;
aiCheck = null;
aiCheckError = null;
});
@@ -750,6 +818,125 @@ class _DialoguePageState extends State<DialoguePage>
validationError!,
style: TextStyle(color: AppColors.warmInk),
),
if (interveningWithAi)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 8),
Text(
'AI 正在理解你的回答…',
style: TextStyle(fontSize: 13, color: AppColors.muted),
),
],
),
),
if (aiIntervention != null)
SectionCard(
tint: AppColors.warm,
child: SpacedColumn(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
Row(
children: [
Icon(
Icons.auto_awesome,
size: 18,
color: AppColors.warmInk,
),
const SizedBox(width: 6),
Text(
'AI 助手干预与建议',
style: TextStyle(
fontWeight: FontWeight.bold,
color: AppColors.warmInk,
),
),
],
),
Text(
aiIntervention!.explanation,
style: TextStyle(
color: AppColors.warmInk,
fontSize: 13,
),
),
if (aiIntervention!.suggestion != null &&
aiIntervention!.suggestion!.isNotEmpty) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: AppColors.line),
),
child: Row(
children: [
const Text(
'建议表达:',
style: TextStyle(fontSize: 12),
),
Expanded(
child: Text(
aiIntervention!.suggestion!,
style: TextStyle(
fontWeight: FontWeight.w600,
color: AppColors.green,
),
),
),
],
),
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.icon(
onPressed: () {
final fix = aiIntervention!.suggestion!;
controller.text = fix;
send(overrideValidation: true, overrideText: fix);
},
icon: const Icon(Icons.check, size: 16),
label: Text(
'修正为 "${aiIntervention!.suggestion}" 并发送',
),
style: FilledButton.styleFrom(
backgroundColor: AppColors.green,
visualDensity: VisualDensity.compact,
),
),
OutlinedButton(
onPressed: () => send(overrideValidation: true),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
child: const Text('仍按原样发送'),
),
],
),
] else ...[
OutlinedButton(
onPressed: () => send(overrideValidation: true),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
child: const Text('仍按原样发送'),
),
],
],
),
),
TextField(
controller: controller,
onChanged: (value) {
@@ -757,13 +944,19 @@ class _DialoguePageState extends State<DialoguePage>
usedVoice &&
value != lastTranscript &&
!transcriptEdited;
final needClearAi = aiCheck != null || aiCheckError != null;
final needClearAi =
aiCheck != null ||
aiCheckError != null ||
aiIntervention != null ||
validationError != null;
if (needResetVoice || needClearAi) {
setState(() {
if (needResetVoice) transcriptEdited = true;
if (needClearAi) {
aiCheck = null;
aiCheckError = null;
aiIntervention = null;
validationError = null;
}
});
}
@@ -790,8 +983,16 @@ class _DialoguePageState extends State<DialoguePage>
onPressed: transcribing ? null : _toggleListening,
),
suffixIcon: IconButton(
icon: const Icon(Icons.send),
onPressed: waitingForReply ? null : send,
icon: interveningWithAi
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
onPressed: (waitingForReply || interveningWithAi)
? null
: send,
),
border: const OutlineInputBorder(),
),
@@ -802,7 +1003,8 @@ class _DialoguePageState extends State<DialoguePage>
final canCheck =
value.text.trim().isNotEmpty &&
!checkingWithAi &&
!waitingForReply;
!waitingForReply &&
!interveningWithAi;
return OutlinedButton.icon(
onPressed: canCheck ? _checkWithAi : null,
icon: checkingWithAi
@@ -828,11 +1030,29 @@ class _DialoguePageState extends State<DialoguePage>
children: [
Text('AI 检查:${aiCheck!.feedback}'),
for (final note in aiCheck!.missing) Text('· $note'),
if (aiCheck!.suggestion != null)
if (aiCheck!.suggestion != null) ...[
Text('参考改正:${aiCheck!.suggestion}'),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () {
controller.text = aiCheck!.suggestion!;
setState(() {
usedHelp = true;
aiCheck = null;
});
},
icon: const Icon(Icons.done, size: 16),
label: const Text('采纳该表达'),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
),
],
Text(
aiCheck!.verdict == 'accepted'
? 'AI 仅作参考;这一轮是否完成仍由本地检查判断'
? 'AI 辅助判定;若词汇超出预设,发送时 AI 会自动进行语义理解与干预'
: '看过改正后再发送,本次对话会记为使用过提示。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),