fix(audio): 断连后能接着跑完 —— 失败消息上的重试 + 步骤编号统一

用户报「卡在步骤 3」,实际断在第 5 步 structure:请求发出 46ms 后浏览器
连接断开,Gin 的 Request.Context() 被取消,上游回 499,后端包成 400。
服务端与前端应用层都没有任何主动取消(全仓 grep cancel/AbortController
零命中),是一次客户端断连。

断连本身无法在代码里杜绝,但它暴露了三个真 bug:

1. 报错文案说「第 1 步」,右栏说第 5 步 —— 尾部两步按自己这一轮从 1 数。
   新增 STEP_NUMBERS,报错与右栏共用同一份编号(这就是「步骤 3」这个
   说法的来源:用户看到的第一个数字就对不上)。
2. 断连后界面上没有任何续跑入口,任务永远停在 4/6(右栏六步是只读的)。
   失败的那条助手消息上挂「🔁 重试」。
3. 无脑重跑会在同一任务里留第二份「结构化纪要」,两份差别用户看不出来。
   resumeAudioSkill 收 skipSteps,按 task_run.action_key 判「跑过没有」
   —— 不用产物类型判,document/checklist 别的技能也在用。

另修:待确认那条消息改用 reactive()。messages 是 ref([]),push 进去的对象
模板读时才被代理,而代码改的是那个变量,改普通对象不触发重渲染,
用户会一直看到「正在继续整理...」。

验证(临时任务 100,已删,先备份 db):CDP setBlockedURLs 掐掉 structure
复现同形断连 → 消息挂出重试按钮,文案与右栏同为第 5 步;后端补跑一次
structure(模拟响应丢失但已落库)→ 点重试只跑 minutes,落库 document 1 份、
checklist 1 份。反证:再手工跑一次 structure,同名产物立刻变 2 份。
详见 bugs_and_errors.md E14。

E15(未修,记录在案):DeleteMyTask 只删 task_record,run 与 artifact 全成
孤儿,产物 content_text 是完整逐字稿。删除语义务必先定硬删还是软删。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-27 00:04:19 +08:00
co-authored by Claude Code
parent b4c8ea38d1
commit 81366ca273
5 changed files with 784 additions and 10 deletions
@@ -45,8 +45,13 @@ export function skillConsumesAudio(skillKey) {
*/
const STEP_ORDER = ['scope', 'transcribe', 'speakers']
/** 确认之后才跑的两步。中间隔着一次人工确认,所以不能和上面合成一个循环。 */
const RESUME_STEP_ORDER = ['structure', 'minutes']
/**
* 确认之后才跑的两步。中间隔着一次人工确认,所以不能和上面合成一个循环。
*
* 导出是给页面用的:失败后「重试」要先算出哪几步已经有产物了(见
* audioTailDoneSteps),跳过的判断得和这里的顺序是同一份。
*/
export const RESUME_STEP_ORDER = ['structure', 'minutes']
const STEP_LABELS = {
scope: '确认音频范围与语言',
@@ -57,6 +62,22 @@ const STEP_LABELS = {
minutes: '提炼可交付纪要',
}
/**
* 每一步在右栏工作流里的序号。
*
* 报错文案里说的「第 N 步」必须和右栏那个数字是同一个 —— 原先尾部两步按
* **自己这一轮**从 1 数(「第 1 步『整理段落与重点』失败」),而右栏把它叫第 5 步,
* 于是用户看到「卡住了」时根本对不上是哪一步(2026-09-26 实际发生过一次)。
*/
const STEP_NUMBERS = {
scope: 1,
transcribe: 2,
speakers: 3,
'speakers-confirm': 4,
structure: 5,
minutes: 6,
}
/** 从已挂载的附件里挑出第一个音频(挂多个时只处理第一个,不做隐式合并)。 */
export function pickAudioResource(resources) {
const list = Array.isArray(resources) ? resources : []
@@ -143,7 +164,7 @@ export async function executeAudioSkill(context = {}) {
} catch (error) {
const done = results.length
const tail = done > 0 ? `(前 ${done} 步的产物已保存,可在右侧产物区查看)` : ''
throw new Error(`第 ${index + 1} 步「${STEP_LABELS[step]}」失败:${error?.message || '请稍后重试'}${tail}`)
throw new Error(`第 ${STEP_NUMBERS[step]} 步「${STEP_LABELS[step]}」失败:${error?.message || '请稍后重试'}${tail}`)
}
}
@@ -165,11 +186,17 @@ export async function executeAudioSkill(context = {}) {
*
* 为什么确认这件事要在这里再做一次调用、而不是让后端一路跑到底:确认是**人的动作**,
* 中间可能隔几分钟、隔一次刷新、甚至隔一天。后端不为此挂一个等待中的请求。
*
* `skipSteps` 是给「重试」用的:请求可能**已经在后端跑完并落库了,只是响应没回来**
* (浏览器连接断掉、代理掉线 —— 2026-09-26 第 5 步就这么失败过一次)。那时无脑重跑
* 会在同一个任务里留下第二份「结构化纪要」,而两份的差别用户看不出来,
* 只会疑惑产物区为什么多了一条。调用方按任务里已有的 run 算好传进来。
*/
export async function resumeAudioSkill(context = {}) {
const definition = context?.definition || {}
const taskId = Number(context?.taskId || 0)
const aiRouteId = String(context?.aiRouteId || '').trim()
const skipSteps = new Set(Array.isArray(context?.skipSteps) ? context.skipSteps : [])
if (!taskId) {
throw new Error('找不到当前任务,无法继续。请刷新后重试。')
}
@@ -177,6 +204,7 @@ export async function resumeAudioSkill(context = {}) {
const results = []
for (let index = 0; index < RESUME_STEP_ORDER.length; index += 1) {
const step = RESUME_STEP_ORDER[index]
if (skipSteps.has(step)) continue
try {
const res = await runAudioSkillStep(step, { task_id: taskId, ai_route_id: aiRouteId })
if (!res?.data) throw new Error('服务没有返回结果')
@@ -185,7 +213,7 @@ export async function resumeAudioSkill(context = {}) {
} catch (error) {
const done = results.length
const tail = done > 0 ? '(「整理段落与重点」的产物已保存,可在右侧产物区查看)' : ''
throw new Error(`第 ${index + 1} 步「${STEP_LABELS[step]}」失败:${error?.message || '请稍后重试'}${tail}`)
throw new Error(`第 ${STEP_NUMBERS[step]} 步「${STEP_LABELS[step]}」失败:${error?.message || '请稍后重试'}${tail}`)
}
}
@@ -149,6 +149,16 @@
<div class="msg-actions">
<button @click="copyMessage(msg.content)">📋 复制</button>
<button v-if="msg.role === 'assistant'" @click="handleFollowUp(msg)">💬 追问</button>
<!-- 失败消息上的续跑入口。它挂在**消息**上而不是右栏:失败那一刻
用户的眼睛在这里,右栏那六个步骤是只读的,点不了。 -->
<button
v-if="msg.retry"
class="msg-retry"
:disabled="speakerConfirmBusy"
@click="handleMessageRetry(msg)"
>
{{ speakerConfirmBusy ? '⏳ 重试中...' : '🔁 重试' }}
</button>
</div>
<div class="msg-time">{{ msg.timestamp }}</div>
</div>
@@ -208,7 +218,7 @@
</template>
<script setup>
import { ref, computed, nextTick, watch, onMounted, markRaw } from 'vue'
import { ref, reactive, computed, nextTick, watch, onMounted, markRaw } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import { ElMessage } from 'element-plus'
@@ -238,6 +248,7 @@ import { getSkillByKey } from '@/skills/registry'
import { buildComposerBlocks, blocksToMountedResources, hasSendableContent } from '@/skills/shared/runtime'
import {
AUDIO_SKILL_KEY,
RESUME_STEP_ORDER as AUDIO_TAIL_STEPS,
pickAudioResource,
resumeAudioSkill,
runAudioSkillStep,
@@ -1701,15 +1712,68 @@ async function handleSpeakerConfirm(speakers) {
}
// 确认已经落库了,从这里往后就是「继续跑」—— 失败也不能把上面那次确认说成失败。
const pendingMessage = {
//
// 这条消息用 reactive() 包了一层,不是多此一举:messages 是 ref([]),
// push 进去的对象在模板里读时才会被代理,而下面改的是**这个变量**。
// 直接改普通对象只改了它的原始值,不会触发重新渲染 —— 文案要等到下一次
// 别的原因引起的重渲染才会变,用户在那之前一直看到「正在继续整理...」。
const pendingMessage = reactive({
role: 'assistant',
content: '已记下你确认的说话人身份,正在继续整理段落与生成纪要...',
timestamp: new Date().toLocaleTimeString(),
}
retry: null,
})
messages.value.push(pendingMessage)
await nextTick()
scrollToEnd()
try {
await runAudioTail(taskId, pendingMessage, {
failureLead: '已确认说话人身份,但继续执行失败:',
})
} finally {
speakerConfirmBusy.value = false
await refreshTaskRail()
await nextTick()
scrollToEnd()
}
}
/** 尾部两步里已经跑完的那些步骤。 */
function audioTailDoneSteps() {
// 判据取 task_run 而不是「有没有 document 产物」:后端每一步的 run 与产物是在
// 同一个事务里写的(audio_handlers.go persistAudioStep),所以有 run 必有产物;
// 而产物类型(document / checklist)别的技能也在用,拿它判断会误判成「跑过了」。
const runs = currentTaskDetail.value?.runs
const done = new Set()
for (const run of Array.isArray(runs) ? runs : []) {
const key = String(run?.action_key || run?.actionKey || '').trim()
if (!key.startsWith(`${AUDIO_SKILL_KEY}:`)) continue
done.add(key.slice(AUDIO_SKILL_KEY.length + 1))
}
return done
}
/**
* 跑尾部的「整理段落与重点」与「提炼可交付纪要」,结果写回传入的那条消息。
*
* 失败不抛,改成把消息标成失败态并挂上 `retry`:调用它的两处(确认那一下、
* 以及重试按钮)都不该各自再写一遍 try/catch,否则迟早有一处忘了挂重试入口 ——
* 而那正是这一次要修的问题。
*
* `failureLead` 由调用方给:确认失败和重试失败要说的第一句话不一样,但
* 「后半句怎么办」是同一句。
*/
async function runAudioTail(taskId, message, { failureLead = '继续执行失败:' } = {}) {
const done = audioTailDoneSteps()
const skipSteps = AUDIO_TAIL_STEPS.filter((step) => done.has(step))
if (skipSteps.length === AUDIO_TAIL_STEPS.length) {
message.content = '「整理段落与重点」和「提炼可交付纪要」的产物都已经在任务里了,不用重跑,右侧产物区可以查看。'
message.retry = null
message.timestamp = new Date().toLocaleTimeString()
return
}
try {
const result = await resumeAudioSkill({
// 按 key 从注册表取,不用 mountedRegistrySkill:刷新页面之后
@@ -1721,13 +1785,44 @@ async function handleSpeakerConfirm(speakers) {
// scope 那一步的产物不在这一轮的 results 里(它是确认之前跑的),
// 用它生成文件名下载才不至于退回默认的「转写稿」。
fileName: audioFileNameOf(currentTaskDetail.value),
skipSteps,
onStepDone: refreshTaskRail,
})
message.retry = null
messages.value.push(buildLocalSkillReply(result, mountedRegistrySkill.value))
} catch (error) {
pendingMessage.content = `已确认说话人身份,但继续执行失败:${error?.message || '请稍后重试'}`
pendingMessage.timestamp = new Date().toLocaleTimeString()
ElMessage.error(error.message || '继续执行失败')
message.content =
`${failureLead}${error?.message || '请稍后重试'}` +
'。可以点下面的「重试」接着跑,已经出过产物的步骤会跳过。'
// 重试入口只挂在这条消息上,刷新页面就没了 —— 这是选「消息上的按钮」时
// 就接受的代价:此刻用户就在这条消息旁边,而右栏那六个步骤是只读的。
message.retry = { kind: 'audio-tail' }
message.timestamp = new Date().toLocaleTimeString()
ElMessage.error(error?.message || '继续执行失败')
}
}
/**
* 失败消息上的「重试」:接着跑尾部那两步。
*
* 为什么不必重跑整条链:确认(第 4 步)已经落库,逐字稿与说话人名单也都在,
* 断掉的只是最后两次加工。而后端每一步都是独立端点,本来就能单独跑
* (services 的六个 /skills/audio/* 各是一步),缺的只是界面上这个入口。
*/
async function handleMessageRetry(message) {
if (speakerConfirmBusy.value) return
if (message?.retry?.kind !== 'audio-tail') return
const taskId = Number(currentTaskId.value) || 0
if (!taskId) {
ElMessage.error('找不到当前任务,请刷新后重试')
return
}
speakerConfirmBusy.value = true
try {
// 先刷一次再判「哪几步跑过了」:这份 detail 可能是几分钟前拉的,
// 而断连那次很可能其实已经在后端跑完并落库了。
await refreshTaskRail()
await runAudioTail(taskId, message, { failureLead: '重试仍未成功:' })
} finally {
speakerConfirmBusy.value = false
await refreshTaskRail()
@@ -2596,6 +2691,24 @@ onMounted(async () => {
background: #eef1f6;
}
/* 「重试」是这个平台上唯一一个「接着把活干完」的按钮,其余按钮都只是复制/追问。
给它一个区别于灰按钮的颜色,免得用户把它当成第三个装饰按钮划过去。 */
.msg-actions button.msg-retry {
background: #eef2ff;
border-color: #c7d2fe;
color: #4f46e5;
font-weight: 600;
}
.msg-actions button.msg-retry:hover {
background: #e0e7ff;
}
.msg-actions button.msg-retry:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.msg-time {
font-size: 11px;
color: #909399;