状态:设计收敛,待实现 日期:2026-04-24 相关系统:
- PPT (
/Users/buoy/Development/gitrepo/PPT) — 前端,对话页面DialogueChatView.vue- cococlass-english-speaking-api (
/Users/buoy/Development/gitrepo/cococlass-english-speaking-api) — 后端,/api/speaking/dialogue/...
当前 POST /api/speaking/dialogue/session 同时做两件事(app/service/speaking/dialogue_service.py:44-97):
DialogueSession DB 记录DialogueMessage前端 DialogueChatView.vue:950-956 在 onMounted 里调 engine.initSession(...),失败只 console.error(useDialogueEngine.ts:39-41),产生三个用户体验问题:
/speakPOST /speak 的语义是"学生音频 → ASR → LLM 回复",必带 audio 字段,开场白没有学生音频可喂。硬塞会污染 /speak 的单一职责。新加 POST /session/{id}/greeting 独立端点。
| 维度 | bundled (现状) | split |
|---|---|---|
| LLM 失败时 DB 状态 | rollback,无孤儿 | 无孤儿(session 早已 commit) |
| LLM 成功但响应丢包 | 真孤儿(session + msg 已 commit,前端无 sessionId) | 同样孤儿 但 有机会触发幂等 retry 自愈 |
| 重试一次开场白的成本 | 1 DB INSERT + 1 LLM 全量调用 | 1 LLM 全量调用 |
| sessionId 生命周期 | 每次重试都变 | 稳定,整个练习期一个 |
| 语义 | "重开一场" | "重新生成开场白" |
| 可靠性耦合 | session 建立被 LLM 失败率拖累 (~1% vs ~0.01%) | 解耦 |
POST /session/{id}/greeting 必须幂等:若该 session round=1 已存在 role='ai' 消息,直接返回已有内容,不重复落库、不重复调 LLM。这让前端重试 100% 安全。
设计里不引入任何自动重试机制(无 setTimeout、无指数退避、无 onError 自动重连)。所有失败的处理一律遵循:
之所以 greeting 失败是"把 loading 消息直接改成 error-card"而不是"完全移除 loading,什么都不显示",是为了让重试入口有明确的点击目标 —— 完全清空会让用户无法感知失败位置。从交互语义上,error-card 可视为 typing-bubble 的等价替代态,仍符合"回到失败前状态" 的原则。
POST /api/speaking/dialogue/session (改)Before: 创建 session + LLM 开场白 + 落 AI 消息 + commit
After: 只建 session,立即 commit 返回
Request: { topic, totalRounds, durationSeconds?, roleId?, userId? } // 不变
Response: {
sessionId: string,
totalRounds: number,
currentRound: 1,
expiresAt: string | null
// 删除 aiMessage 字段
}
失败场景:DB 错误(极低概率) → 500。
POST /api/speaking/dialogue/session/{session_id}/greeting (新)Request: (no body)
Response: { aiMessage: string }
200 正常生成 或 已存在(幂等命中)
404 session 不存在
409 session.status != 'active'
500 LLM 失败
行为:
round=1 && role='ai' 消息,若存在 → 直接返回其 contentcreate_session 步骤 2-3 调 LLM + 落 DialogueMessage + commit幂等性实现重点(MySQL 环境):
dialogue_session 行加行锁:SELECT * FROM dialogue_session WHERE uuid = :uuid FOR UPDATE(SQLAlchemy .with_for_update())。并发请求 serialize 到同一 session,后到者会阻塞等待,拿到锁后查 round=1 AI 消息已存在 → 直接返回。UNIQUE(session_id, round, role) 对历史数据风险太大,放弃 DB 约束路线。POST /speak、WS /speak-stream、GET /report 不变。
useDialogueEngine.ts拆分 initSession 为两个方法:
// 仅设置 session refs + 启动倒计时。不产生网络请求。
function attachSession(info: { sessionId: string; expiresAt?: string | null }) {
sessionId.value = info.sessionId
expiresAt.value = info.expiresAt ?? null
if (info.expiresAt) startCountdown(info.expiresAt)
}
// push loading AI 占位消息 → 请求 /greeting → 填内容或打 error
// greetingInflight ref 锁:期间再次调用直接返回,防止双击派生并发请求
const greetingInflight = ref(false)
let greetingAbortController: AbortController | null = null
async function generateGreeting() {
if (!sessionId.value || greetingInflight.value) return
greetingInflight.value = true
greetingAbortController = new AbortController()
const aiMsg = reactive<PreviewChatMessage>({
id: crypto.randomUUID(),
role: 'ai',
content: '',
timestamp: new Date(),
status: 'loading',
})
messages.value.push(aiMsg)
try {
const { aiMessage } = await api.generateGreeting(sessionId.value, greetingAbortController.signal)
aiMsg.content = aiMessage
aiMsg.status = 'done'
speakTTS(aiMessage)
} catch (err: any) {
if (err?.name === 'AbortError') return // 组件卸载,不改状态
aiMsg.status = 'error'
// 分辨可恢复 vs 不可恢复:404/409 打上 unrecoverable 标记,UI 层换"返回重开"按钮
const code = err?.status as number | undefined
aiMsg.error = friendlyErrorMessage(err?.message)
aiMsg.unrecoverable = code === 404 || code === 409
} finally {
greetingInflight.value = false
greetingAbortController = null
}
}
// 重试开场白:找到失败的首条 AI 消息,移除后重跑 generateGreeting
async function retryGreeting() {
if (greetingInflight.value) return
const firstAi = messages.value.find(m => m.role === 'ai')
if (firstAi?.status !== 'error' || firstAi.unrecoverable) return
messages.value = messages.value.filter(m => m.id !== firstAi.id)
await generateGreeting()
}
PreviewChatMessage 类型扩一个可选 unrecoverable?: boolean 字段(只在 greeting 失败场景用,其它路径不设)。
RealDialogueAPI.generateGreeting 需要抛 带 status 的 error(不能只塞进 message 字符串),见 §4.4。
DialogueAPI 接口新增 generateGreeting,createSession 返回结构改变:
interface DialogueAPI {
createSession(config: SessionConfig): Promise<{ sessionId: string; totalRounds: number; currentRound: number; expiresAt: string | null }>
generateGreeting(sessionId: string, signal?: AbortSignal): Promise<{ aiMessage: string }>
// speak / getReport 不变
}
SessionInfo 类型相应精简(去掉 aiMessage)。
MockDialogueAPI 同步拆:createSession 只返 mock sessionId;generateGreeting 返 mock 开场白。
TopicDiscussionPreview.vue"开始对话"按钮从"纯本地状态切换"改为"先调 createSession":
const sessionCreating = ref(false)
const sessionError = ref<string | null>(null)
const preparedSession = ref<{ sessionId: string; expiresAt: string | null } | null>(null)
async function startDialogue() {
if (sessionCreating.value) return
sessionCreating.value = true
sessionError.value = null
try {
const api = createDialogueApi(props.mode) // §4.4 工厂
const info = await api.createSession({
topic: speakingStore.config.topic || topic,
totalRounds: speakingStore.config.practice.rounds || totalRounds,
roleId: 'tom',
vocabulary: speakingStore.config.learningGoals.vocabulary,
})
preparedSession.value = { sessionId: info.sessionId, expiresAt: info.expiresAt }
dialogueState.value = 'chatting'
} catch (err: any) {
sessionError.value = friendlyErrorMessage(err?.message) || '创建会话失败,请重试'
} finally {
sessionCreating.value = false
}
}
模板改动:
sessionCreating 时变 disabled + 内嵌 spinner,文案可保持"开始对话"(或加省略号)sessionError 文案(非阻塞,按钮保持可点击,用户再次点击即重试)重启流程: resetPreview() 额外清 preparedSession.value = null。
DialogueChatView.vueProps 新增 sessionInfo:
interface Props {
// ... existing
sessionInfo?: { sessionId: string; expiresAt: string | null } | null
}
onMounted 改动:
engine.initSession(...)props.sessionInfo 存在 → engine.attachSession(props.sessionInfo) + engine.generateGreeting()error-card 重试按钮需要三路分支(可恢复 / 不可恢复 / 学生消息或非首条 AI):
当前模板 DialogueChatView.vue:85-88:
<button class="retry-btn" @click="engine.regenerateAiMessage(message.id)">重新生成</button>
改为(在 msg-ai 分支内):
<button
class="retry-btn"
@click="retryAiMessage(message)"
>{{ message.unrecoverable ? '返回重开' : '重新生成' }}</button>
function retryAiMessage(message: PreviewChatMessage) {
const idx = engine.messages.value.indexOf(message)
const hasPrevStudent = engine.messages.value.slice(0, idx).some(m => m.role === 'student')
// 第一条 AI 消息(前面没有学生消息)= greeting
if (!hasPrevStudent) {
// 404/409 等不可恢复:回 ready 页重新创建 session
if (message.unrecoverable) {
emit('restart')
return
}
engine.retryGreeting()
return
}
// 非首条:沿用原 regenerate
engine.regenerateAiMessage(message.id)
}
emit 定义新增 restart 事件:
const emit = defineEmits<{
complete: [report: DialogueReport | null]
restart: []
}>()
handleRestart 改动(路径 A): 不再调 engine.initSession,直接 emit('restart')。父组件 TopicDiscussionPreview 接收后清状态回 ready 页,用户再次点击"开始对话"创建新 session。弹窗里"重新开始"按钮的一键体验降级为"回起点再开始",代码最少,语义最直白。
llmService.ts抽取 API 工厂(供父子组件共用,避免各自 new):
export function createDialogueApi(mode: 'preview' | 'real'): DialogueAPI {
return mode === 'real' ? new RealDialogueAPI() : new MockDialogueAPI()
}
useDialogueEngine 和 TopicDiscussionPreview.startDialogue 都通过工厂拿 API 实例。
自定义 error 类型(让上层能区分 404/409 vs 其它):
export class DialogueApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.status = status
this.name = 'DialogueApiError'
}
}
RealDialogueAPI:
async createSession(config: SessionConfig): Promise<{ sessionId: string; ... }> {
const res = await fetch(`${API_BASE}/session`, { method: 'POST', ... })
if (!res.ok) throw new DialogueApiError(`createSession failed: ${res.status}`, res.status)
return res.json() // 无 aiMessage 字段
}
async generateGreeting(sessionId: string, signal?: AbortSignal): Promise<{ aiMessage: string }> {
const res = await fetch(`${API_BASE}/session/${sessionId}/greeting`, {
method: 'POST',
credentials: 'include',
signal,
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new DialogueApiError(
`greeting failed: ${res.status}${text ? ` (${text.slice(0, 100)})` : ''}`,
res.status,
)
}
return res.json()
}
friendlyErrorMessage 映射表(useDialogueEngine.ts:429-442)文案微调:
'Session not found': '会话已失效,请重新开始', // 已有,文案统一
'Session is not active': '会话已结束,请重新开始', // 已有,文案统一
// LLM 失败等 500 场景回落到通用文案"请求失败,请重试",不新加 key
组件卸载时 abort 在飞的 greeting 请求: useDialogueEngine 的 onUnmounted 新增 greetingAbortController?.abort()(原有 abort() 只 cover speak 路径):
onUnmounted(() => {
abort()
greetingAbortController?.abort()
cancelTTS()
stopCountdown()
})
| 层 | 措施 |
|---|---|
| UI | "开始对话"按钮 sessionCreating 期间 disabled;error-card "重新生成"按钮在 greetingInflight 期间 disabled |
| 前端逻辑 | generateGreeting / retryGreeting 入口处检 greetingInflight ref,in-flight 直接 return |
| 后端 | 在 dialogue_session 行上加行锁 SELECT ... FOR UPDATE(SQLAlchemy .with_for_update()),同一 session 的并发 greeting 请求 serialize;后到者拿锁后查已有 round=1 AI 消息 → 直接返回。MySQL 原生支持,无需 schema 变更 |
dialogue_session / dialogue_message)
dialogue_service.py:create_session 拆成 create_session_only(仅建 session,立即 commit) + generate_greeting(with_for_update() 行锁 → 查 round=1 AI → 有则返回 / 无则调 LLM 落消息 commit)api/dialogue.py:/session 响应去掉 aiMessage;新增 POST /session/{session_id}/greeting 路由llmService.ts:新增 DialogueApiError + createDialogueApi 工厂;DialogueAPI 接口扩展;Real/Mock 都实现 generateGreeting(signal);createSession 返回类型更新useDialogueEngine.ts:拆 initSession → attachSession + generateGreeting(含 greetingInflight 锁 + greetingAbortController) + retryGreeting;onUnmounted 追加 aborttypes/englishSpeaking.ts:PreviewChatMessage 加 unrecoverable?: boolean;SessionInfo 去掉 aiMessageDialogueChatView.vue:接受 sessionInfo prop + 新 restart emit;onMounted 改为 attachSession + generateGreeting;error-card 按钮文案/行为按 unrecoverable 三路分支;handleRestart 改为 emitTopicDiscussionPreview.vue:startDialogue 改为 async,持有 preparedSession/sessionCreating/sessionError;按钮 loading/error UI;监听子组件 @restart 事件清状态回 readyPOST /session 响应体不含 aiMessagePOST /session/{id}/greeting 对同一 session 重复调用返回完全相同的 aiMessage 且 DB 中仅一条 round=1 AI 消息POST /session/{id}/greeting 对非 active session 返 409,对不存在 session 返 404aiMessage,第二个通过行锁等待后走"已存在直接返回"路径cancelled)regenerateAiMessage)和学生消息重试(retryMessage)行为不变setTimeout / 自动重试代码引入;所有重试均由用户点击触发/speak、/speak-stream、/report/greeting 可扩展 query 参数)expires_at 自然过期)