Procházet zdrojové kódy

refactor(speaking): address Task 5 code review feedback

- Extract SessionStartInfo to shared type (reused by Task 6's parent state)
- DialogueChatView onMounted null-path warns via console.warn so misuse is
  observable instead of silently producing an inert chat
- retryAiMessage guards indexOf === -1 to avoid incorrect slice(0, -1)
  semantics in the extreme race where the target message was filtered out
- Document 'first AI = greeting' invariant at the detection site

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jimmylee před 2 týdny
rodič
revize
3e85fb58fa

+ 6 - 0
src/types/englishSpeaking.ts

@@ -258,6 +258,12 @@ export interface GreetingInfo {
   aiMessage: string
 }
 
+// 用于启动 chat view 的最小 session 信息(父组件 createSession 后传给 DialogueChatView)
+export interface SessionStartInfo {
+  sessionId: string
+  expiresAt: string | null
+}
+
 // 对话报告
 export interface DialogueReport {
   evaluation: OverallEvaluation

+ 7 - 2
src/views/Editor/EnglishSpeaking/preview/DialogueChatView.vue

@@ -484,7 +484,7 @@
 <script lang="ts" setup>
 import { ref, computed, watch, onMounted, onUnmounted, nextTick, h, defineComponent } from 'vue'
 import type { PropType } from 'vue'
-import type { PreviewChatMessage, BadgeAchievement, DialogueReport } from '@/types/englishSpeaking'
+import type { PreviewChatMessage, BadgeAchievement, DialogueReport, SessionStartInfo } from '@/types/englishSpeaking'
 import { useDialogueEngine } from '../composables/useDialogueEngine'
 import { useAudioRecorder } from '../composables/useAudioRecorder'
 
@@ -501,7 +501,7 @@ interface Props {
   mode?: 'preview' | 'real'
   showEnglishText?: boolean
   showChineseText?: boolean
-  sessionInfo?: { sessionId: string; expiresAt: string | null } | null
+  sessionInfo?: SessionStartInfo | null
 }
 
 const props = withDefaults(defineProps<Props>(), {
@@ -737,6 +737,9 @@ function handleRetry() {
 
 function retryAiMessage(message: PreviewChatMessage) {
   const idx = engine.messages.value.indexOf(message)
+  if (idx === -1) return  // message already removed (e.g., retryGreeting filtered it out)
+  // Invariant: only generateGreeting pushes an AI message before any student turn,
+  // so the first AI message is always the greeting.
   const hasPrevStudent = engine.messages.value.slice(0, idx).some(m => m.role === 'student')
 
   // 第一条 AI 消息(前面没有学生消息)= greeting
@@ -961,6 +964,8 @@ onMounted(() => {
   if (props.sessionInfo) {
     engine.attachSession(props.sessionInfo)
     engine.generateGreeting()
+  } else {
+    console.warn('[DialogueChatView] mounted without sessionInfo; chat is inert. Parent must createSession before mounting.')
   }
   // 无 sessionInfo 时聊天区保持空(父组件应当先创建 session 再挂载本组件)