|
|
@@ -117,10 +117,11 @@
|
|
|
<div v-if="message.status === 'error'" class="error-card">
|
|
|
<span class="error-text">{{ message.error || '生成失败' }}</span>
|
|
|
<button
|
|
|
+ v-if="hasRetryButton(message)"
|
|
|
class="retry-btn"
|
|
|
:disabled="engine.greetingInflight.value"
|
|
|
- @click="retryAiMessage(message)"
|
|
|
- >{{ message.unrecoverable ? '返回重开' : '重新生成' }}</button>
|
|
|
+ @click="handleRetry(message)"
|
|
|
+ >{{ retryButtonLabel(message) }}</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -201,7 +202,16 @@
|
|
|
<!-- 学生错误 -->
|
|
|
<div v-if="message.status === 'error'" class="error-card">
|
|
|
<span class="error-text">{{ message.error || '发送失败' }}</span>
|
|
|
- <button class="retry-btn" @click="engine.retryMessage(message.id)">重试</button>
|
|
|
+ <button
|
|
|
+ v-if="hasRetryButton(message)"
|
|
|
+ class="retry-btn"
|
|
|
+ @click="handleRetry(message)"
|
|
|
+ >{{ retryButtonLabel(message) }}</button>
|
|
|
+ <button
|
|
|
+ v-if="hasRerecordButton(message)"
|
|
|
+ class="rerecord-btn"
|
|
|
+ @click="handleRerecord(message)"
|
|
|
+ >重录</button>
|
|
|
</div>
|
|
|
|
|
|
<!-- L1 评分卡 -->
|
|
|
@@ -330,6 +340,34 @@
|
|
|
</button>
|
|
|
</div>
|
|
|
|
|
|
+ <!-- starting -->
|
|
|
+ <div class="state-layer state-starting" :style="stateStyle('starting')">
|
|
|
+ <div class="record-capsule">
|
|
|
+ <button class="cancel-btn" @click="handleCancelStarting">
|
|
|
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
+ <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
|
|
+ </svg>
|
|
|
+ 取消
|
|
|
+ </button>
|
|
|
+ <div class="record-meter">
|
|
|
+ <svg class="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
+ stroke-width="2" stroke-linecap="round">
|
|
|
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
|
+ </svg>
|
|
|
+ <span class="record-time">准备录音中...</span>
|
|
|
+ </div>
|
|
|
+ <button class="finish-btn" disabled>
|
|
|
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
|
+ <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
|
|
+ <polyline points="22 4 12 14.01 9 11.01" />
|
|
|
+ </svg>
|
|
|
+ 完成
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
<!-- recording -->
|
|
|
<div class="state-layer state-recording" :style="stateStyle('recording')">
|
|
|
<div class="record-capsule">
|
|
|
@@ -390,7 +428,20 @@
|
|
|
<span class="warn-icon">⚠️</span>
|
|
|
<span class="warn-text">{{ lastErrorText }}</span>
|
|
|
</div>
|
|
|
- <button class="retry-pill" @click="handleRetry">重试</button>
|
|
|
+ <button
|
|
|
+ v-if="lastErroredMessage && hasRetryButton(lastErroredMessage)"
|
|
|
+ class="retry-pill"
|
|
|
+ @click="handleRetry(lastErroredMessage)"
|
|
|
+ >{{ retryButtonLabel(lastErroredMessage) }}</button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- finalizing -->
|
|
|
+ <div class="state-layer state-center" :style="stateStyle('finalizing')">
|
|
|
+ <svg class="spinner" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
|
+ stroke-width="2" stroke-linecap="round">
|
|
|
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
|
+ </svg>
|
|
|
+ <span class="center-text">正在生成你的本次对话报告...</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -579,6 +630,10 @@ const silenceHintText = ref('')
|
|
|
const showIdleHint = ref(false)
|
|
|
let idleHintTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
|
|
+const isStarting = ref(false)
|
|
|
+const reportFetchInflight = ref(false)
|
|
|
+let startAbortController: AbortController | null = null
|
|
|
+
|
|
|
// 徽章
|
|
|
const showBadge = ref<BadgeAchievement | null>(null)
|
|
|
const consecutiveFluent = ref(0)
|
|
|
@@ -591,10 +646,13 @@ let badgeTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
|
|
const currentRound = computed(() => engine.currentRound.value)
|
|
|
|
|
|
-// 状态机:recording → stt → ai_thinking → idle / error / done
|
|
|
-const state = computed<'idle' | 'recording' | 'stt' | 'ai_thinking' | 'error' | 'done'>(() => {
|
|
|
+// 状态机:starting → recording → stt → ai_thinking → idle / error / finalizing / done
|
|
|
+const state = computed<
|
|
|
+ 'idle' | 'starting' | 'recording' | 'stt' | 'ai_thinking' | 'finalizing' | 'error' | 'done'
|
|
|
+>(() => {
|
|
|
+ if (isStarting.value) return 'starting'
|
|
|
if (recorder.isRecording.value) return 'recording'
|
|
|
- if (engine.isComplete.value) return 'done'
|
|
|
+ if (engine.isComplete.value) return reportFetchInflight.value ? 'finalizing' : 'done'
|
|
|
|
|
|
const msgs = engine.messages.value
|
|
|
const last = msgs[msgs.length - 1]
|
|
|
@@ -669,14 +727,18 @@ function stateStyle(target: string) {
|
|
|
// ─────────────────────────────────────────────
|
|
|
|
|
|
// 当前流式会话控制器(开始录音时打开 WebSocket,结束时收尾)
|
|
|
-let streamCtl: ReturnType<typeof engine.beginStudentStream> | null = null
|
|
|
+let streamCtl: ReturnType<typeof engine.beginStudentStream> = null
|
|
|
|
|
|
async function handleStartRecording() {
|
|
|
- if (!engine.canRecord.value || recorder.isRecording.value) return
|
|
|
+ if (!engine.canRecord.value || recorder.isRecording.value || isStarting.value) return
|
|
|
player.stop()
|
|
|
+
|
|
|
+ isStarting.value = true
|
|
|
+ startAbortController = new AbortController()
|
|
|
+
|
|
|
try {
|
|
|
- await recorder.startRecording()
|
|
|
- // 启动流式会话(立即 push UI 占位消息 + 打开 WebSocket)
|
|
|
+ await recorder.startRecording(startAbortController.signal)
|
|
|
+ // Mic acquired — open the WS now (no placeholders pushed yet).
|
|
|
streamCtl = engine.beginStudentStream({
|
|
|
sampleRate: recorder.sampleRate.value,
|
|
|
bits: 16,
|
|
|
@@ -685,16 +747,28 @@ async function handleStartRecording() {
|
|
|
if (streamCtl) {
|
|
|
recorder.onChunk.value = streamCtl.pushChunk
|
|
|
}
|
|
|
- } catch (err) {
|
|
|
- console.error('Failed to start recording:', err)
|
|
|
+ } catch (err: any) {
|
|
|
+ if (err.name === 'AbortError') {
|
|
|
+ // User cancelled during getUserMedia. State naturally returns to idle.
|
|
|
+ } else {
|
|
|
+ console.error('Failed to start recording:', err)
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ isStarting.value = false
|
|
|
+ startAbortController = null
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+function handleCancelStarting() {
|
|
|
+ startAbortController?.abort()
|
|
|
+ // recorder.startRecording will reject with AbortError; no further cleanup needed.
|
|
|
+}
|
|
|
+
|
|
|
function handleCancelRecording() {
|
|
|
// 停止录音 + 中止流式会话(丢弃本次录音)
|
|
|
recorder.onChunk.value = null
|
|
|
if (streamCtl) {
|
|
|
- streamCtl.abortStream()
|
|
|
+ streamCtl.abort() // close WS; nothing on the message list to clean
|
|
|
streamCtl = null
|
|
|
}
|
|
|
if (recorder.isRecording.value) {
|
|
|
@@ -711,47 +785,57 @@ async function handleFinishRecording() {
|
|
|
const blob = await recorder.stopRecording()
|
|
|
recorder.onChunk.value = null
|
|
|
if (ctl) {
|
|
|
- // 走 WebSocket 流式路径。WS 失败时,不自动降级 HTTP —— 让错误泡泡的"重试"按钮走人工路径。
|
|
|
- // attachStudentBlob 必须在 ctl.finish() 之前:服务端 done 事件回来前,blob 已经挂到学生消息上,replay 才能用。
|
|
|
- engine.attachStudentBlob(ctl.studentMsgId, blob)
|
|
|
- ctl.finish()
|
|
|
+ ctl.commit(blob) // pushes placeholders, attaches blob, sends 'stop'
|
|
|
} else {
|
|
|
- // 没启动流式(异常情况)→ 直接走旧 HTTP 路径
|
|
|
- await engine.sendStudentMessage(blob)
|
|
|
+ // Fallback: direct HTTP path
|
|
|
+ await engine.sendStudentMessage(blob, crypto.randomUUID())
|
|
|
}
|
|
|
} catch (err) {
|
|
|
console.error('Recording/send failed:', err)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-function handleRetry() {
|
|
|
- const msgs = engine.messages.value
|
|
|
- const last = msgs[msgs.length - 1]
|
|
|
- if (!last) return
|
|
|
- if (last.role === 'student') engine.retryMessage(last.id)
|
|
|
- else engine.regenerateAiMessage(last.id)
|
|
|
-}
|
|
|
-
|
|
|
-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
|
|
|
- if (!hasPrevStudent) {
|
|
|
- if (message.unrecoverable) {
|
|
|
- emit('restart')
|
|
|
- return
|
|
|
- }
|
|
|
- engine.retryGreeting()
|
|
|
+function hasRetryButton(m: PreviewChatMessage): boolean {
|
|
|
+ return m.recovery === 'retry' || m.recovery === 'restart'
|
|
|
+}
|
|
|
+function hasRerecordButton(m: PreviewChatMessage): boolean {
|
|
|
+ return m.role === 'student' && (m.recovery === 'retry' || m.recovery === 'rerecord')
|
|
|
+}
|
|
|
+function retryButtonLabel(m: PreviewChatMessage): string {
|
|
|
+ if (m.recovery === 'restart') return '返回重开'
|
|
|
+ return '重试'
|
|
|
+}
|
|
|
+async function handleRetry(m: PreviewChatMessage) {
|
|
|
+ if (m.recovery === 'restart') {
|
|
|
+ emit('restart')
|
|
|
return
|
|
|
}
|
|
|
-
|
|
|
- // 非首条:沿用原 regenerate
|
|
|
- engine.regenerateAiMessage(message.id)
|
|
|
+ if (m.role === 'student') {
|
|
|
+ await engine.retryMessage(m.id)
|
|
|
+ } else {
|
|
|
+ // greeting case: first AI message with no prior student
|
|
|
+ const idx = engine.messages.value.indexOf(m)
|
|
|
+ const isGreeting = !engine.messages.value
|
|
|
+ .slice(0, idx)
|
|
|
+ .some(x => x.role === 'student')
|
|
|
+ if (isGreeting) {
|
|
|
+ await engine.retryGreeting()
|
|
|
+ } else {
|
|
|
+ await engine.regenerateAiMessage(m.id)
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
+function handleRerecord(m: PreviewChatMessage) {
|
|
|
+ engine.discardCurrentTurn(m.id)
|
|
|
+}
|
|
|
+
|
|
|
+const lastErroredMessage = computed<PreviewChatMessage | null>(() => {
|
|
|
+ const msgs = engine.messages.value
|
|
|
+ for (let i = msgs.length - 1; i >= 0; i--) {
|
|
|
+ if (msgs[i].status === 'error') return msgs[i]
|
|
|
+ }
|
|
|
+ return null
|
|
|
+})
|
|
|
|
|
|
function toggleExpand(id: string) {
|
|
|
expandedMessageId.value = expandedMessageId.value === id ? null : id
|
|
|
@@ -820,12 +904,15 @@ async function handleExitConfirm() {
|
|
|
}
|
|
|
|
|
|
async function fetchReportSafe(): Promise<DialogueReport | null> {
|
|
|
+ reportFetchInflight.value = true
|
|
|
try {
|
|
|
await engine.completeSession()
|
|
|
return await engine.getReport()
|
|
|
} catch (err) {
|
|
|
console.warn('[speaking] getReport failed:', err)
|
|
|
return null
|
|
|
+ } finally {
|
|
|
+ reportFetchInflight.value = false
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -1263,6 +1350,17 @@ onUnmounted(() => {
|
|
|
cursor: pointer;
|
|
|
&:hover { background: #fef2f2; border-color: #f87171; }
|
|
|
}
|
|
|
+.rerecord-btn {
|
|
|
+ padding: 3px 10px;
|
|
|
+ background: transparent;
|
|
|
+ border: 1px solid #d1d5db;
|
|
|
+ border-radius: 999px;
|
|
|
+ font-size: 11px;
|
|
|
+ color: #6b7280;
|
|
|
+ cursor: pointer;
|
|
|
+ margin-left: 4px;
|
|
|
+ &:hover { background: #f9fafb; border-color: #9ca3af; color: #374151; }
|
|
|
+}
|
|
|
|
|
|
// 评分卡
|
|
|
.eval-card {
|