feat: improve dialogue speech recognition and AI intervention
This commit is contained in:
@@ -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),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user