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
+82
View File
@@ -620,6 +620,85 @@ torch / matplotlib 之类的 dotfile 也落在同一个可写位置。
---
## E14 「卡在第 3 步」——三步说的不是同一步
**日期**:2026-09-26 **区域**:语音转写技能 / 失败恢复
**症状**:用户报「看起来在步骤 3 卡住了,没有完成工作」。界面实况:右栏
「语音转写 · 技能工作流 **4 / 6** 完成」,第 1–4 步全绿,第 5 步「整理段落与重点」
与第 6 步「提炼可交付纪要」都是「未开始」;聊天区最后一条停在
「已记下你确认的说话人身份,正在继续整理段落与生成纪要...」。
**根因**:**用户说的第 3 步、右栏的第 5 步、报错文案里的第 1 步,是三个不同的编号。**
- 真正断掉的是 `audio-transcribe:structure`(右栏第 5 步)。请求发出后 **46ms**
浏览器↔dev server 的连接断开,Gin 的 `c.Request.Context()` 随之取消,上游 LMUAI
回 499 `context canceled`,后端包成 400。
- **不是服务端取消**:全仓 grep `WithCancel|WithTimeout|cancel()` 在 `internal/ai`、
`internal/skills/api`、`internal/middleware`、`audio_transcribe` 中**零命中**;
前端全 `src/` grep `AbortController|CancelToken|signal` 同样零命中。
日志里也没有 401(排除跳登录页杀请求),全文只此 1 次 `context canceled`。
结论:一次客户端断连,不是模型/路由/代码的问题。
- 但**失败文案把它叫「第 1 步」** —— 尾部两步按**自己这一轮**从 1 数,
而右栏按整条链数。用户看到的第一个数字就对不上,于是有了「卡在步骤 3」这个说法。
- 更麻烦的是:断连之后**界面上没有任何续跑入口**。右栏那六个步骤是只读的,
没有 `@click`,任务永远停在 4/6。
**修法**(三处,都在前端):
1. `audioSkill.js` 导出 `STEP_NUMBERS`,报错文案与右栏共用同一份编号 —— 两处各写一遍
就是这次对不上号的成因。
2. 失败的那条助手消息上挂 `🔁 重试`(`SmartAssistantPage.vue`)。复用 `speakerConfirmBusy`
做并发闸,不新造一套。
3. `resumeAudioSkill` 收 `skipSteps`,由页面按 `task_run.action_key` 算出「哪几步跑过了」。
**判据必须是 run 而不是产物类型**:产物类型(`document` / `checklist`)别的技能也在用,
拿它判断会把别人的产物误认成自己的。
4. 待确认那条消息要用 `reactive()` 包。`messages` 是 `ref([])`,push 进去的对象在模板里
读时才被代理,而代码改的是**那个变量** —— 改普通对象只改了原始值,不触发重渲染,
用户会一直看到「正在继续整理...」。
**验证**(临时任务 100,用完即删):
- 用 CDP `Network.setBlockedURLs` 掐掉 `*/api/skills/audio/structure`,复现与线上同形的
断连 → 消息变失败态并挂出重试按钮。截图 `/tmp/shot_retry_a_failed.png`:红字
「第 5 步「整理段落与重点」失败:Network Error」,与右栏那个 5 是同一个。
- 脚本直接对后端补跑一次 `structure`(模拟「后端跑完了,只是响应没回来」)→ 点重试
→ 只跑 `minutes`,跑完 6/6(`/tmp/shot_retry_b_done.png`)。
- 落库核对:`document` 1 份、`checklist` 1 份,没有重复。
- **反证**(先证明断言会失败):再手工 POST 一次 `structure`,同名「结构化纪要」立刻变成
**2 份** —— 这就是 `skipSteps` 缺席时会发生的事。
**怎么早点发现**:
> **同一步在界面上有两个编号,迟早有一天会被当成两步。** 序号这种东西只有一份来源,
> 谁要显示都从那里取。
>
> 而**失败现场必须自带出路**。把「怎么继续」放在一个用户当前看不到、也点不动的地方
> (右栏只读列表),等于没放 —— 用户会做的是刷新,而刷新之后连失败的那条消息都没了。
---
## E15 删除任务只删了 `task_record`,run 与 artifact 全成孤儿
**日期**:2026-09-26 **区域**:后端(`internal/api/my_task.go`)
**症状**:清理验证夹具时走真实接口 `DELETE /api/my/tasks/100`,返回 200。
删前 `task_record` 1 条、`task_run` 7 条、`task_artifact` 6 条;删后
**0 / 7 / 6** —— 只有主记录没了,run 与产物原样留着。
**根因**:`DeleteMyTask`(`my_task.go:46`)只调 `taskRecordDAO.Delete(&task)`,没有级联。
**影响**:产物的 `content_text` 是**完整逐字稿**(真实会议录音,本次夹具那份 1550 字,
线上任务是 26278 字)。也就是说「删掉任务」并不等于「删掉录音内容」。
`DELIVERY.md` 第 3 节手工清单 C 项「清空测试数据」若按这个删法走,
库里会留下一批**没有任何入口能看到、也没人能删**的孤儿逐字稿。
**状态**:**未修**。删除语义(硬删 / 软删留痕)是产品决定,不擅自改。
本次是我自己造的夹具,已手工清掉那 7 条 run 与 6 条产物(先备份
`/tmp/eai_backup_before_retry_test.db`,删后核对为 0 / 0)。
---
## 待沉淀(还没写进规则的)
- [ ] **装完必须导入自检**:带 C 扩展的包(torch / torchaudio / ctranslate2)装完立刻 import 一次(见 E01)。
@@ -634,3 +713,6 @@ torch / matplotlib 之类的 dotfile 也落在同一个可写位置。
ProtectSystem / ReadOnlyPaths)、并发模型(前台独占 vs 后台多请求)。
凡是「原型上验过」的东西,装成服务后必须**照着这三条重验一遍**。
这次的 E13 是账号那条,E12 是并发那条。
- [ ] **E14/E15 的通用形状**:**多步流程的「第 N 步」只能有一个编号来源**(E14);
**有从属数据的聚合根,删除必须级联,或者明确是软删**(E15)。
两条都属「界面/接口说了一件事,数据说另一件事」,候选合并成 P06 的一条。
@@ -0,0 +1,248 @@
{
"agent_routes": {
"embed_gen": "embed_route_llamacpp_nomic",
"image_gen": "image_route_openrouter_gpt_image_2",
"video_gen": "video_route_openrouter_veo_3",
"path_coach": "chat_route_lmuai_deepseek_v4_flash",
"title_gen": "chat_route_lmuai_deepseek_v4_flash",
"audio_transcribe": "audio_route_auto",
"audio_transcribe_llm": "chat_route_lmuai_deepseek_v4_flash_doc"
},
"audio_routes": {
"audio_route_local_whisper": {
"base_url": "http://127.0.0.1:8090/v1",
"description": "语音转写 · 本机 faster-whisper large-v3 + pyannote 3.1(音频不出本机)。由 deploy/eai_agentplatform-asr.service 常驻在 127.0.0.1:8090;服务不在时 audio_route_auto 会改走 fallback_routes 里的云端路由,并在转写产物里标注「音频已出本机」。timeout 定在 840s 而不是一小时:前端 api/audioSkill.js 给这一步的上限是 15 分钟,后端必须在它之前自己收手(否则用户看到的是 axios 超时,而后端还在跑),同时 840s 里还留得下云端那 600s 的回退预算",
"endpoint": "/audio/transcriptions",
"model": "large-v3",
"provider": "local_asr",
"short_model_name": "whisper large-v3",
"short_route_name": "本地",
"supports_speakers": true,
"timeout_seconds": 840
},
"audio_route_siliconflow_diarize": {
"description": "语音转写 · SiliconFlow / XingChen ASR Diarize(云端,音频会出本机;本地服务不可用时的回退,也可由管理员手动指定)",
"endpoint": "/audio/transcriptions",
"model": "XingChenAGI/XingChenASR-Diarize-V3.0",
"provider": "siliconflow",
"short_model_name": "XingChen ASR Diarize",
"short_route_name": "SiliconFlow",
"supports_speakers": true,
"timeout_seconds": 600
},
"audio_route_siliconflow_qwen3": {
"description": "语音转写 · SiliconFlow / Qwen3-ASR(云端,音频会出公网;无说话人分离)",
"endpoint": "/audio/transcriptions",
"model": "Qwen/Qwen3-ASR-1.7B",
"provider": "siliconflow",
"short_model_name": "Qwen3 ASR",
"short_route_name": "SiliconFlow",
"supports_speakers": false,
"timeout_seconds": 600
}
},
"chat_routes": {
"chat_route_lmuai_deepseek_v4_flash": {
"description": "LMUAI / DeepSeek Flash。max_tokens 从 4096 提到 32768(2026-09-26):4096 是当初只按「聊天」估的,但这条路由是 default_route,用户在对话框里选过模型后这个选择会被记进 localStorage 并**一路带到技能内部的加工步骤**(全仓库只有语音转写的第 3、4 步消费 ai_route_id,见 skills/api/audio_handlers.go 的 resolveAudioChatRoute)。于是它会顶掉技能自带的 _doc 路由,让「整理段落」在推理模型思考完之前就把预算耗光、正文 0 字。上限是天花板不是配额,模型写完就停,抬高不会让聊天变慢变贵(实测见 _doc 那条的说明)",
"endpoint": "/chat/completions",
"max_tokens": 32768,
"model": "deepseek-v4-flash",
"provider": "lmuai",
"short_model_name": "DeepSeek V4 Flash",
"short_route_name": "LMUAI",
"temperature": 0.7
},
"chat_route_lmuai_deepseek_v4_flash_doc": {
"description": "LMUAI / DeepSeek Flash(长文输出档,max_tokens=32768)· 供逐字稿整理这类要产出一整篇文档的技能使用。为什么不复用上面那条 4096 的:这是推理模型,思考与正文共用 max_tokens,而思考的长度跟输入几乎不成比例(实测 1200 字的输入,思考照样写到 7169 字)。4096 下正文一个字都写不出来(六次实测只有一次成功)。再说为什么是 32768 而不是 8192:纪要步骤最后要把各段结果**归并**成一份,那是全流程最长的一次输入(实测 8628 字),8192 下 finish_reason=length、正文 0 字,16384 才出稿、32768 又快又稳。上限提高**不会**让逐块调用变慢或变贵——上限是天花板不是配额,模型写完就停:实测同一批 11 段,8192 与 32768 两档的用时(18.6s / 19.2s)与 completion token(均 4928 / 5094)基本一致。调小这个值之前请先看 audio_transcribe/prompts.go 里的实测表",
"endpoint": "/chat/completions",
"max_tokens": 32768,
"model": "deepseek-v4-flash",
"provider": "lmuai",
"short_model_name": "DeepSeek V4 Flash(长文档)",
"short_route_name": "LMUAI",
"temperature": 0.7
},
"chat_route_ollama_qwen": {
"description": "本地对话 · llama.cpp / Qwen3.5 4B",
"endpoint": "/chat/completions",
"max_tokens": 512,
"model": "Qwen3.5-4B-Q4_K_M.gguf",
"provider": "llamacpp",
"short_model_name": "Qwen3.5 4B",
"short_route_name": "本地对话",
"temperature": 0.7,
"timeout_seconds": 90
},
"chat_route_ollama_qwen_fast": {
"description": "本地快聊 · llama.cpp / Qwen3.5 4B",
"endpoint": "/chat/completions",
"max_tokens": 512,
"model": "Qwen3.5-4B-Q4_K_M.gguf",
"provider": "llamacpp",
"short_model_name": "Qwen3.5 4B",
"short_route_name": "本地快聊",
"temperature": 0.3
},
"chat_route_openrouter_deepseek_v3": {
"description": "OpenRouter / DeepSeek V3.2",
"endpoint": "/chat/completions",
"max_tokens": 4096,
"model": "deepseek/deepseek-v3.2",
"provider": "openrouter",
"short_model_name": "DeepSeek V3.2",
"short_route_name": "OpenRouter",
"temperature": 0.6
},
"chat_route_openrouter_deepseek_v4_flash": {
"description": "OpenRouter / DeepSeek V4 Flash 0731",
"endpoint": "/chat/completions",
"max_tokens": 4096,
"model": "deepseek/deepseek-v4-flash-0731",
"provider": "openrouter",
"short_model_name": "DeepSeek V4 Flash 0731",
"short_route_name": "OpenRouter",
"temperature": 0.7
},
"chat_route_openrouter_gpt_5": {
"description": "OpenRouter / GPT 5.4",
"endpoint": "/chat/completions",
"max_tokens": 8192,
"model": "openai/gpt-5.4",
"provider": "openrouter",
"short_model_name": "GPT 5.4",
"short_route_name": "OpenRouter",
"temperature": 0.7
},
"chat_route_openrouter_grok_4": {
"description": "OpenRouter / Grok 4.20",
"endpoint": "/chat/completions",
"max_tokens": 4096,
"model": "x-ai/grok-4.20",
"provider": "openrouter",
"short_model_name": "Grok 4.20",
"short_route_name": "OpenRouter",
"temperature": 0.6
},
"chat_route_siliconflow_deepseek_v4_flash": {
"description": "SiliconFlow / DeepSeek V4 Flash",
"endpoint": "/chat/completions",
"max_tokens": 4096,
"model": "deepseek-ai/DeepSeek-V4-Flash",
"provider": "siliconflow",
"short_model_name": "DeepSeek V4 Flash",
"short_route_name": "SiliconFlow",
"temperature": 0.7
}
},
"default_audio_route": "audio_route_local_whisper",
"default_embed_route": "embed_route_llamacpp_nomic",
"default_route": "chat_route_lmuai_deepseek_v4_flash",
"description": "eai_agentplatform AI 路由配置 — 按场景分类定义多条路由,支持 provider/模型切换",
"embed_routes": {
"embed_route_llamacpp_nomic": {
"base_url": "http://127.0.0.1:8081/v1",
"description": "本地向量 · llama.cpp / Nomic Embed v1.5",
"endpoint": "/embeddings",
"model": "nomic-embed-text-v1.5.Q4_K_M.gguf",
"provider": "llamacpp",
"short_model_name": "Nomic Embed v1.5",
"short_route_name": "本地向量",
"timeout_seconds": 30
},
"embed_route_ollama_bge_m3": {
"description": "本地向量 · Ollama / BGE-M3",
"endpoint": "/embeddings",
"model": "bge-m3",
"provider": "ollama",
"short_model_name": "BGE-M3",
"short_route_name": "本地向量"
},
"embed_route_openrouter_text_v3": {
"description": "OpenRouter / text-embedding-3-small",
"endpoint": "/embeddings",
"model": "openai/text-embedding-3-small",
"provider": "openrouter",
"short_model_name": "text-embedding-3-small",
"short_route_name": "OpenRouter"
}
},
"fallback_routes": {
"chat_route_lmuai_deepseek_flash": [
"chat_route_ollama_qwen_fast",
"chat_route_ollama_qwen"
],
"chat_route_openrouter_deepseek_v3": [
"chat_route_openrouter_grok_4",
"chat_route_ollama_qwen"
],
"chat_route_openrouter_deepseek_v4_flash": [
"chat_route_openrouter_deepseek_v3",
"chat_route_openrouter_grok_4",
"chat_route_ollama_qwen"
],
"chat_route_openrouter_grok_4": [
"chat_route_ollama_qwen"
],
"chat_route_siliconflow_deepseek_v4_flash": [
"chat_route_ollama_qwen_fast",
"chat_route_ollama_qwen"
],
"audio_route_local_whisper": [
"audio_route_siliconflow_diarize"
],
"embed_route_llamacpp_nomic": [
"embed_route_openrouter_text_v3"
],
"embed_route_ollama_bge_m3": [
"embed_route_openrouter_text_v3"
],
"embed_route_openrouter_text_v3": [
"embed_route_ollama_bge_m3"
]
},
"image_routes": {
"image_route_ollama_llava": {
"description": "本地图像 · Ollama / LLaVA",
"endpoint": "/chat/completions",
"max_tokens": 2048,
"model": "llava",
"provider": "ollama",
"short_model_name": "LLaVA",
"short_route_name": "本地图像",
"temperature": 0.7
},
"image_route_openrouter_flux": {
"description": "OpenRouter / FLUX 1.1 Pro",
"endpoint": "/images/generations",
"max_tokens": 1024,
"model": "black-forest-labs/flux-1.1-pro",
"provider": "openrouter",
"short_model_name": "FLUX 1.1 Pro",
"short_route_name": "OpenRouter",
"temperature": 1
},
"image_route_openrouter_gpt_image_2": {
"description": "OpenRouter / GPT 5.4 Image 2",
"endpoint": "/images/generations",
"max_tokens": 4096,
"model": "openai/gpt-5.4-image-2",
"provider": "openrouter",
"short_model_name": "GPT 5.4 Image 2",
"short_route_name": "OpenRouter",
"temperature": 1
}
},
"video_routes": {
"video_route_openrouter_veo_3": {
"description": "OpenRouter / Google Veo 3",
"endpoint": "/videos/generations",
"model": "google/veo-3",
"max_tokens": 4096,
"provider": "openrouter",
"short_model_name": "Veo 3",
"short_route_name": "OpenRouter",
"temperature": 0.7
}
},
"version": "2.1.0"
}
@@ -0,0 +1,303 @@
{
"agent_routes": {
"embed_gen": "embed_route_llamacpp_nomic",
"image_gen": "image_route_openrouter_gpt_image_2",
"video_gen": "video_route_openrouter_veo_3",
"path_coach": "chat_route_lmuai_deepseek_v4_flash",
"title_gen": "chat_route_lmuai_deepseek_v4_flash",
"audio_transcribe": "audio_route_auto",
"audio_transcribe_llm": "chat_route_lmuai_deepseek_v4_flash_doc"
},
"audio_routes": {
"audio_route_local_whisper": {
"base_url": "http://127.0.0.1:8090/v1",
"description": "语音转写 · 本机 faster-whisper large-v3 + pyannote 3.1(音频不出本机)。由 deploy/eai_agentplatform-asr.service 常驻在 127.0.0.1:8090;服务不在时 audio_route_auto 会改走 fallback_routes 里的云端路由,并在转写产物里标注「音频已出本机」。timeout 定在 840s 而不是一小时:前端 api/audioSkill.js 给这一步的上限是 15 分钟,后端必须在它之前自己收手(否则用户看到的是 axios 超时,而后端还在跑),同时 840s 里还留得下云端那 600s 的回退预算",
"endpoint": "/audio/transcriptions",
"model": "large-v3",
"provider": "local_asr",
"short_model_name": "whisper large-v3",
"short_route_name": "本地",
"supports_speakers": true,
"timeout_seconds": 840
},
"audio_route_siliconflow_diarize": {
"description": "语音转写 · SiliconFlow / XingChen ASR Diarize(云端,音频会出本机;本地服务不可用时的回退,也可由管理员手动指定)",
"endpoint": "/audio/transcriptions",
"model": "XingChenAGI/XingChenASR-Diarize-V3.0",
"provider": "siliconflow",
"short_model_name": "XingChen ASR Diarize",
"short_route_name": "SiliconFlow",
"supports_speakers": true,
"timeout_seconds": 600
},
"audio_route_siliconflow_qwen3": {
"description": "语音转写 · SiliconFlow / Qwen3-ASR(云端,音频会出公网;无说话人分离)",
"endpoint": "/audio/transcriptions",
"model": "Qwen/Qwen3-ASR-1.7B",
"provider": "siliconflow",
"short_model_name": "Qwen3 ASR",
"short_route_name": "SiliconFlow",
"supports_speakers": false,
"timeout_seconds": 600
},
"audio_route_openrouter_whisper": {
"description": "语音转写 · OpenRouter / OpenAI whisper-1(云端,音频会出公网;无说话人分离)。此路只作同能力(无分离)路由之间的备份,不能顶替默认的本地路由 —— 回退链按 supports_speakers 过滤,能力不同不回退",
"endpoint": "/audio/transcriptions",
"model": "openai/whisper-1",
"provider": "openrouter",
"base_url": "https://openrouter.ai/api/v1",
"short_model_name": "whisper-1",
"short_route_name": "OpenRouter",
"supports_speakers": false,
"timeout_seconds": 600
},
"audio_route_openrouter_gpt4o_transcribe": {
"description": "语音转写 · OpenRouter / OpenAI gpt-4o-transcribe(云端,音频会出公网;无说话人分离)。实测比 whisper-1 快一倍且断句更整,无分离路由里的首选",
"endpoint": "/audio/transcriptions",
"model": "openai/gpt-4o-transcribe",
"provider": "openrouter",
"base_url": "https://openrouter.ai/api/v1",
"short_model_name": "gpt-4o-transcribe",
"short_route_name": "OpenRouter",
"supports_speakers": false,
"timeout_seconds": 600
},
"audio_route_siliconflow_asr_ultra": {
"description": "语音转写 · SiliconFlow / XingChen ASR V3.2 Ultra(云端,音频会出公网;无说话人分离)。同门的 V3.2(非 Ultra)实测同样可用、约快一倍,但效果略差,没有单独建路由",
"endpoint": "/audio/transcriptions",
"model": "XingChenAGI/XingChenASR-V3.2-Ultra",
"provider": "siliconflow",
"short_model_name": "XingChen V3.2 Ultra",
"short_route_name": "SiliconFlow",
"supports_speakers": false,
"timeout_seconds": 600
}
},
"chat_routes": {
"chat_route_lmuai_deepseek_v4_flash": {
"description": "LMUAI / DeepSeek Flash。max_tokens 从 4096 提到 32768(2026-09-26):4096 是当初只按「聊天」估的,但这条路由是 default_route,用户在对话框里选过模型后这个选择会被记进 localStorage 并**一路带到技能内部的加工步骤**(全仓库只有语音转写的第 3、4 步消费 ai_route_id,见 skills/api/audio_handlers.go 的 resolveAudioChatRoute)。于是它会顶掉技能自带的 _doc 路由,让「整理段落」在推理模型思考完之前就把预算耗光、正文 0 字。上限是天花板不是配额,模型写完就停,抬高不会让聊天变慢变贵(实测见 _doc 那条的说明)",
"endpoint": "/chat/completions",
"max_tokens": 32768,
"model": "deepseek-v4-flash",
"provider": "lmuai",
"short_model_name": "DeepSeek V4 Flash",
"short_route_name": "LMUAI",
"temperature": 0.7
},
"chat_route_lmuai_deepseek_v4_flash_doc": {
"description": "LMUAI / DeepSeek Flash(长文输出档,max_tokens=32768)· 供逐字稿整理这类要产出一整篇文档的技能使用。为什么不复用上面那条 4096 的:这是推理模型,思考与正文共用 max_tokens,而思考的长度跟输入几乎不成比例(实测 1200 字的输入,思考照样写到 7169 字)。4096 下正文一个字都写不出来(六次实测只有一次成功)。再说为什么是 32768 而不是 8192:纪要步骤最后要把各段结果**归并**成一份,那是全流程最长的一次输入(实测 8628 字),8192 下 finish_reason=length、正文 0 字,16384 才出稿、32768 又快又稳。上限提高**不会**让逐块调用变慢或变贵——上限是天花板不是配额,模型写完就停:实测同一批 11 段,8192 与 32768 两档的用时(18.6s / 19.2s)与 completion token(均 4928 / 5094)基本一致。调小这个值之前请先看 audio_transcribe/prompts.go 里的实测表",
"endpoint": "/chat/completions",
"max_tokens": 32768,
"model": "deepseek-v4-flash",
"provider": "lmuai",
"short_model_name": "DeepSeek V4 Flash(长文档)",
"short_route_name": "LMUAI",
"temperature": 0.7
},
"chat_route_ollama_qwen": {
"description": "本地对话 · llama.cpp / Qwen3.5 4B",
"endpoint": "/chat/completions",
"max_tokens": 512,
"model": "Qwen3.5-4B-Q4_K_M.gguf",
"provider": "llamacpp",
"short_model_name": "Qwen3.5 4B",
"short_route_name": "本地对话",
"temperature": 0.7,
"timeout_seconds": 90
},
"chat_route_ollama_qwen_fast": {
"description": "本地快聊 · llama.cpp / Qwen3.5 4B",
"endpoint": "/chat/completions",
"max_tokens": 512,
"model": "Qwen3.5-4B-Q4_K_M.gguf",
"provider": "llamacpp",
"short_model_name": "Qwen3.5 4B",
"short_route_name": "本地快聊",
"temperature": 0.3
},
"chat_route_openrouter_deepseek_v3": {
"description": "OpenRouter / DeepSeek V3.2",
"endpoint": "/chat/completions",
"max_tokens": 4096,
"model": "deepseek/deepseek-v3.2",
"provider": "openrouter",
"short_model_name": "DeepSeek V3.2",
"short_route_name": "OpenRouter",
"temperature": 0.6
},
"chat_route_openrouter_deepseek_v4_flash": {
"description": "OpenRouter / DeepSeek V4 Flash 0731",
"endpoint": "/chat/completions",
"max_tokens": 4096,
"model": "deepseek/deepseek-v4-flash-0731",
"provider": "openrouter",
"short_model_name": "DeepSeek V4 Flash 0731",
"short_route_name": "OpenRouter",
"temperature": 0.7
},
"chat_route_openrouter_gpt_5": {
"description": "OpenRouter / GPT 5.4",
"endpoint": "/chat/completions",
"max_tokens": 8192,
"model": "openai/gpt-5.4",
"provider": "openrouter",
"short_model_name": "GPT 5.4",
"short_route_name": "OpenRouter",
"temperature": 0.7
},
"chat_route_openrouter_grok_4": {
"description": "OpenRouter / Grok 4.20",
"endpoint": "/chat/completions",
"max_tokens": 4096,
"model": "x-ai/grok-4.20",
"provider": "openrouter",
"short_model_name": "Grok 4.20",
"short_route_name": "OpenRouter",
"temperature": 0.6
},
"chat_route_siliconflow_deepseek_v4_flash": {
"description": "SiliconFlow / DeepSeek V4 Flash",
"endpoint": "/chat/completions",
"max_tokens": 4096,
"model": "deepseek-ai/DeepSeek-V4-Flash",
"provider": "siliconflow",
"short_model_name": "DeepSeek V4 Flash",
"short_route_name": "SiliconFlow",
"temperature": 0.7
}
},
"default_audio_route": "audio_route_local_whisper",
"default_embed_route": "embed_route_llamacpp_nomic",
"default_route": "chat_route_lmuai_deepseek_v4_flash",
"description": "eai_agentplatform AI 路由配置 — 按场景分类定义多条路由,支持 provider/模型切换",
"embed_routes": {
"embed_route_llamacpp_nomic": {
"base_url": "http://127.0.0.1:8081/v1",
"description": "本地向量 · llama.cpp / Nomic Embed v1.5",
"endpoint": "/embeddings",
"model": "nomic-embed-text-v1.5.Q4_K_M.gguf",
"provider": "llamacpp",
"short_model_name": "Nomic Embed v1.5",
"short_route_name": "本地向量",
"timeout_seconds": 30
},
"embed_route_ollama_bge_m3": {
"description": "本地向量 · Ollama / BGE-M3",
"endpoint": "/embeddings",
"model": "bge-m3",
"provider": "ollama",
"short_model_name": "BGE-M3",
"short_route_name": "本地向量"
},
"embed_route_openrouter_text_v3": {
"description": "OpenRouter / text-embedding-3-small",
"endpoint": "/embeddings",
"model": "openai/text-embedding-3-small",
"provider": "openrouter",
"short_model_name": "text-embedding-3-small",
"short_route_name": "OpenRouter"
}
},
"fallback_routes": {
"chat_route_lmuai_deepseek_flash": [
"chat_route_ollama_qwen_fast",
"chat_route_ollama_qwen"
],
"chat_route_openrouter_deepseek_v3": [
"chat_route_openrouter_grok_4",
"chat_route_ollama_qwen"
],
"chat_route_openrouter_deepseek_v4_flash": [
"chat_route_openrouter_deepseek_v3",
"chat_route_openrouter_grok_4",
"chat_route_ollama_qwen"
],
"chat_route_openrouter_grok_4": [
"chat_route_ollama_qwen"
],
"chat_route_siliconflow_deepseek_v4_flash": [
"chat_route_ollama_qwen_fast",
"chat_route_ollama_qwen"
],
"audio_route_local_whisper": [
"audio_route_siliconflow_diarize"
],
"embed_route_llamacpp_nomic": [
"embed_route_openrouter_text_v3"
],
"embed_route_ollama_bge_m3": [
"embed_route_openrouter_text_v3"
],
"embed_route_openrouter_text_v3": [
"embed_route_ollama_bge_m3"
],
"audio_route_openrouter_gpt4o_transcribe": [
"audio_route_siliconflow_asr_ultra",
"audio_route_openrouter_whisper",
"audio_route_siliconflow_qwen3"
],
"audio_route_siliconflow_asr_ultra": [
"audio_route_openrouter_gpt4o_transcribe",
"audio_route_openrouter_whisper",
"audio_route_siliconflow_qwen3"
],
"audio_route_openrouter_whisper": [
"audio_route_openrouter_gpt4o_transcribe",
"audio_route_siliconflow_asr_ultra",
"audio_route_siliconflow_qwen3"
],
"audio_route_siliconflow_qwen3": [
"audio_route_openrouter_gpt4o_transcribe",
"audio_route_siliconflow_asr_ultra",
"audio_route_openrouter_whisper"
],
"audio_route_siliconflow_diarize": [
"audio_route_local_whisper"
]
},
"image_routes": {
"image_route_ollama_llava": {
"description": "本地图像 · Ollama / LLaVA",
"endpoint": "/chat/completions",
"max_tokens": 2048,
"model": "llava",
"provider": "ollama",
"short_model_name": "LLaVA",
"short_route_name": "本地图像",
"temperature": 0.7
},
"image_route_openrouter_flux": {
"description": "OpenRouter / FLUX 1.1 Pro",
"endpoint": "/images/generations",
"max_tokens": 1024,
"model": "black-forest-labs/flux-1.1-pro",
"provider": "openrouter",
"short_model_name": "FLUX 1.1 Pro",
"short_route_name": "OpenRouter",
"temperature": 1
},
"image_route_openrouter_gpt_image_2": {
"description": "OpenRouter / GPT 5.4 Image 2",
"endpoint": "/images/generations",
"max_tokens": 4096,
"model": "openai/gpt-5.4-image-2",
"provider": "openrouter",
"short_model_name": "GPT 5.4 Image 2",
"short_route_name": "OpenRouter",
"temperature": 1
}
},
"video_routes": {
"video_route_openrouter_veo_3": {
"description": "OpenRouter / Google Veo 3",
"endpoint": "/videos/generations",
"model": "google/veo-3",
"max_tokens": 4096,
"provider": "openrouter",
"short_model_name": "Veo 3",
"short_route_name": "OpenRouter",
"temperature": 0.7
}
},
"version": "2.1.0"
}
@@ -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;