跳到主要内容

数据截至 (上游 commit 3dcf4cad0124)

一次对话怎么跑完:CustomChatTransport 与 AI SDK

30 秒导读: 你在 Jan 输入框里敲下回车之后,到屏幕上开始吐字之前,代码干了大约十五件事。 这一章把这十五件事按顺序拆开。它是全书主线——第 1 章讲的骨架、 第 3 章讲的工具、第 4 章讲的本地推理, 都是在这条主线的某一个环节上挂进来的。


1. 先建立直觉:Jan 没有后端,所以"后端"要在前端写

用 Vercel AI SDK 写聊天应用,标准姿势是这样的:

// 示意,非源码
// 浏览器端
const { messages, sendMessage } = useChat() // 默认 POST 到 /api/chat
// 服务器端 /api/chat
export async function POST(req) {
const { messages } = await req.json()
return streamText({ model: openai('gpt-4o'), messages }).toUIMessageStreamResponse()
}

浏览器只管收流,"选哪个模型、带哪些工具、消息怎么整理"这些活全在服务器上

Jan 是一个桌面应用,它没有那台服务器。所以这些活必须搬回前端。搬运的接口是 AI SDK 提供的 ChatTransport——你自己实现 sendMessages,返回一个 UI 消息块流,useChat 就照常工作。

Jan 的实现就是 CustomChatTransport(web-app/src/lib/custom-chat-transport.ts:725)。 它在 reconnectToStream 里把话说得很明白:

// web-app/src/lib/custom-chat-transport.ts:1246-1255,reconnectToStream
// Since this project has no backend, we can't reconnect to a stream, so this is intentionally no-op.
return null

一句话记住本章:CustomChatTransport.sendMessages 就是 Jan 那个"不存在的 /api/chat 路由", 只不过它跑在浏览器进程里,能直接读 zustand 全局 store、能直接 invoke Tauri 命令。


2. 顶层全景:一次发言的六个站点

先看整条链路。怎么读这张图:从上往下是时间顺序,最下面那根回边是工具轮的自动续跑。

用户敲回车


① handleSendMessage ─── 落盘 user 消息 + 组 parts ──► sendMessage()
│ (routes/threads/$threadId.tsx:946)

② Chat 实例(每个线程一个,活在 chat-session-store)


③ CustomChatTransport.sendMessages()
│ a. 造模型 b. 拉工具 c. 规整消息(本章精华)

④ streamText → toUIMessageStream ──► UI 逐字渲染 + 算 tok/s


⑤ onFinish:存盘 / 顺序执行工具 / 每 4 条改一次标题

└─ 还有没闭环的 tool call ─► 自动再发一轮,回到 ③

各站点的职责与落点:

站点干什么在哪个文件
useChat 包装复用 transport、复用 Chat 实例、透传回调web-app/src/hooks/use-chat.ts
会话仓库每线程一个 Chat,切线程不重建web-app/src/stores/chat-session-store.ts
传输层造模型、拉工具、规整消息、发起流web-app/src/lib/custom-chat-transport.ts
模型工厂十几家 provider 收敛成一个 LanguageModelweb-app/src/lib/model-factory.ts
线程路由组件工具轮闭环、消息落盘、标题生成web-app/src/routes/threads/$threadId.tsx

3. 入口:两层"别重建"的复用

这一节讲 Jan 为什么切模型、切线程都不会打断正在跑的流。答案是两个层次的复用。

3.1 换模型不重建 transport

useChat 把 transport 存在 ref 里,而不是 useMemo/useState:

// web-app/src/hooks/use-chat.ts:32
const transportRef = useRef<CustomChatTransport | undefined>(undefined)

注释直说了目的:"so we can update the model used in the transport without having to reload the page or recreate the transport"

它之所以能成立,是因为 transport 根本不持有模型 idsendMessages 每次都现读全局 store:

// web-app/src/lib/custom-chat-transport.ts:866-869
const modelId = useModelProvider.getState().selectedModel?.id
const providerId = useModelProvider.getState().selectedProvider

模型是每轮现取的,不是构造时注入的。所以侧边栏换模型,下一轮自动生效,transport 一个都不用换。

3.2 换线程不重建 Chat

Chat 实例(AI SDK 的会话对象,持有消息数组和流状态)存在 zustand 里,key 是线程 id:

// web-app/src/stores/chat-session-store.ts:65-87,ensureSession
const existing = get().sessions[sessionId]
if (existing) { /* 只同步 transport / title */ return existing.chat }

useChatuseMemo 包住这次调用,依赖只有 [sessionId, ensureSession] (web-app/src/hooks/use-chat.ts:78-88),注释点明是因为 ensureSession 有副作用,不能每次渲染都调。

效果:线程 A 正在流式输出,你切到线程 B 再切回来,A 的流还在跑。 这是 Jan"后台多线程同时生成"的地基。 isSessionBusy(web-app/src/stores/chat-session-store.ts:47)把"在流"和"在跑工具"合成一个忙碌位供 UI 用。

3.3 三个外挂通道

transport 建好之后,useChat 通过三个 effect 往里塞后续变化:

通道触发时机代码
updateSystemMessage线程 assistant 的 instructions 变了web-app/src/hooks/use-chat.ts:64-68
refreshToolsMCP / RAG 工具名集合变了(服务器起停)web-app/src/hooks/use-chat.ts:111-117
setContinueFromContent用户点"继续"续写被截断的回复web-app/src/hooks/use-chat.ts:119-121

4. sendMessages 主流水线

这一节按源码顺序走一遍 sendMessages(web-app/src/lib/custom-chat-transport.ts:1102)。 先给流程,细节在后面几节展开。

① 领号 streamGeneration++ :861
② 现读 model / provider :866
③ 记住最后一条用户文本(给工具路由用) :874
④ llamacpp?探测模型是否已加载 :893
⑤ 三层采样参数合并 → createModel :910-926
⑥ refreshTools(此时模型已在,路由可用) :938
⑦ 消息规整流水线(第 6 节) :946-1099
⑧ streamText + toUIMessageStream :1110

4.1 领号:streamGeneration

// web-app/src/lib/custom-chat-transport.ts:861
const myGeneration = ++this.streamGeneration

transport 实例跨 regenerate 复用,所以旧请求的 onError/onFinish 可能在新请求已经开跑之后才回来。 终态回调里全部先比对号码,不是自己那一轮就不碰全局 loading 状态 (web-app/src/lib/custom-chat-transport.ts:1460:1215)。这是典型的过期回调防护

4.2 「加载模型中」怎么亮起来

只有 llamacpp 走这一步:

// web-app/src/lib/custom-chat-transport.ts:893-905
const loaded = await invoke<string[]>('plugin:llamacpp|get_loaded_models')
if (!loaded.includes(modelId)) {
useAppState.getState().updateLoadingModel(true)
useAppState.getState().updateThreadLoadingModel(threadId, true)
}

先问再亮:模型已经在 router 里就别闪那个提示。探测失败直接吞掉——router 反正会按需加载, 最坏结果只是少显示一个提示,不该因此让整轮失败。

关掉这个标志的地方有两处:一处是 createModel 成功后 (web-app/src/lib/custom-chat-transport.ts:1197-1200),另一处更靠前——llama.cpp 的流式元数据抽取器 一收到第一个 chunk 就关(web-app/src/lib/model-factory.ts:140-145)。第二处才是实际生效的那个, 因为模型加载完到首字之间还有 prompt 预填充的时间。

4.3 三层采样参数,谁压谁

Jan 有三个地方能设温度这类采样参数,合并规则就写在一个对象字面量里:

// web-app/src/lib/custom-chat-transport.ts:914-918
const mergedParams: Record<string, unknown> = {
...modelSamplingDefaults, // ① 模型侧边栏默认
...(inferenceParams ?? {}), // ② assistant 参数
...reasoningParams, // ③ reasoning 开关
}
来源取自覆盖优先级
模型侧边栏默认每个模型自己的 settingsextractModelSamplingDefaults(:100)最低
assistant 参数线程绑定的 assistantgetActiveInferenceParams(:601)
reasoning 开关模型 settings 里的 reasoning 三态buildLlamacppReasoningParams(:495)最高

三个细节值得记:

  • 为什么侧边栏默认要走请求体而不是 CLI 参数? 注释给了答案 (web-app/src/lib/custom-chat-transport.ts:104-106):router 是一个进程服务所有模型, 没法为某个模型单独烤进启动参数,只能每次请求带上。
  • assistant 参数跟着线程走,不跟着全局走。 getActiveInferenceParams 优先读 thread.assistants[0].parameters;id === 'model-only' 的伪 assistant 视为无参数 (web-app/src/lib/custom-chat-transport.ts:773-784)。这样在对话中途换 agent 立即生效。
  • reasoning 必须是 JSON 布尔。 buildLlamacppReasoningParams 的注释指出 llama-server 用 json_value(...).dump() 解析 chat_template_kwargs.enable_thinking,发 "true" 字符串会被拒; 'auto' 则整个 kwarg 都不发,让服务端用自己的 --reasoning-budget 默认值。

内置远程 provider 还有一道额外的剥离:

// web-app/src/lib/custom-chat-transport.ts:919-921
if (isPredefinedRemoteProvider(effectiveProviderName)) {
for (const key of Object.keys(paramsSettings)) delete mergedParams[key]
}

理由写在 web-app/src/lib/providerCaps.ts:187-192:内置远程厂商的采样面是固定的, 放任 UI 里的采样器发过去只会换来一堆看不懂的 400。

4.4 为什么先造模型、后拉工具

顺序是反直觉但有意为之的,注释直接写了 (web-app/src/lib/custom-chat-transport.ts:1174-1175):

Create the model before refreshing tools so the MCP orchestrator can run structured LLM routing.

refreshTools 在连接了多台 MCP server 时会调 mcpOrchestrator.getRelevantTools (web-app/src/lib/mcp-orchestrator/mcp-orchestrator.ts:85)用一个模型去挑该带哪些 server 的工具。 没有模型就没法路由,所以必须先造模型。工具从哪来、怎么挑、怎么审批,是 第 3 章的内容,本章到此为止。


5. 多 provider 适配:ModelFactory.createModel

这一节回答"十几家 provider 怎么收敛成一个对象"。答案是一个分派函数 (web-app/src/lib/model-factory.ts:847),出口统一是 AI SDK 的 LanguageModel

分派前先看线格式,再看名字:

// web-app/src/lib/model-factory.ts:716-718
if (getProviderApiType(provider) === 'anthropic' && providerName !== 'anthropic') {
return this.createAnthropicModel(modelId, provider, parameters)
}

用户自己加的 provider 如果指向 LiteLLM / Bedrock 网关这类 Anthropic 兼容代理,名字可以随便起, 但说的是 Anthropic 的话,就得用 Anthropic SDK。api_type 字段优先于 provider 名 (web-app/src/lib/providerCaps.ts:177-184)。

然后才是按名字的 switch(web-app/src/lib/model-factory.ts:862-896):

分支provider用什么建为什么单独一支
本地llamacppOpenAICompatibleChatLanguageModel + 动态端口要先 startModel,再问 router 要 port/api_key
本地mlx同上,走 plugin:mlx同上,但参数需先过 filterParameters
官方 SDKanthropiccreateAnthropictool_use 配对规则特殊
官方 SDKopenaicreateOpenAI().chat()
官方 SDKgoogle / geminicreateGoogleGenerativeAIGemini 3 的 thought_signature 在 OpenAI 兼容层看不到
官方 SDKmistralcreateMistralmagistral-* 的 delta.content 是数组,通用 schema 会 Zod 报错
官方 SDKxaicreateXai
兼容层azure/groq/together/fireworks/deepseek/cohere/perplexity/moonshot/minimaxcreateOpenAICompatible说标准 OpenAI 话
兜底其它一切createOpenAICompatible用户自定义端点

三条值得单独点出的实现:

① 本地模型的地址是运行期问出来的。 llamacpp 分支先 startModel,再 invoke('plugin:llamacpp|find_session_by_model') 拿到端口和 api_key,才拼出 http://localhost:<port>/v1 (web-app/src/lib/model-factory.ts:929-983)。细节见第 4 章

② 兼容层统一挂 reasoning 抽取中间件。 createOpenAICompatibleModel 出口包了 extractReasoningMiddleware,tag 名按模型 id 猜(web-app/src/lib/model-factory.ts:1342-1348), 把 <think> 这类标签流拆成独立的 reasoning part。

③ 真正改请求体的地方是 createCustomFetch 它拦下每个 POST,把 mergedParams 并进 body、 把 max_output_tokens 改名成 max_tokens、剥掉纯客户端的 key(ctx_len / max_context_tokens / auto_compact),web-app/src/lib/model-factory.ts:366-444。两个只对 llamacpp 生效的小动作在这里:

// web-app/src/lib/model-factory.ts:367-376
if (keepLlamacppOnly && merged.stream === true) merged.return_progress = true
if (keepLlamacppOnly && merged.max_tokens === 0) merged.max_tokens = -1

return_progress 是后面 prompt 进度条的数据源;max_tokens: 0 → -1 是因为 llama-server 的 "不限量"约定是 -1,而用户在 UI 里填 0 想表达的是"别设上限"。


6. 消息规整:本章的精华

这一节是全章最值得读的部分。问题是这样的: AI SDK 的 UIMessage 是给 UI 用的结构 (一条消息 = 一串 parts),各家推理端要的却是各自的 message 数组;更麻烦的是, 真实对话里会出现各种残缺状态——生成中途失败、工具没回结果、用户删了消息、换了个不认图的模型。 把这些直接送给严格的 chat template,换来的是一句看不懂的 Jinja 异常或 400。

Jan 的做法是在 convertToModelMessages 之前串一排纯函数清洗器,每个只治一种病。

6.1 九个清洗器一览

#清洗器治什么病位置
1Anthropic wave 拆分串行 tool-use 导致 tool_use/tool_result 配不上 → 400custom-chat-transport.ts:530-565(splitAssistantToolWaves)
2extractFileMetadataForSystem附件元信息占着用户轮:1324
3buildFilesSystemAddendum把上面抽出来的元信息拼进 system:1359
4裁剪 / 压缩超上下文预算:1026-1065(详见第 5 章)
5hasGenuineUserQuery窗口里没有真正的用户提问:465 / 用在 :1070
6mapUserInlineAttachments内联文件正文没进正文:1378
7stripUnsupportedImageParts换到不认图的模型:353
8encodeAudioAttachments / encodeVideoAttachments兼容层 provider 拒收非图片附件:1262 / :1289
9resolveOrphanToolCalls + coalesceMessagesForAlternation孤儿工具调用 / 连续同角色:405 / :434

最后六个是嵌套调用的,顺序即语义:

convertToModelMessages( :1078
coalesceMessagesForAlternation( ← 最后:合并连续 user
resolveOrphanToolCalls( ← 补上没结果的 tool call
encodeVideoAttachments( ← video → 文本哨兵
encodeAudioAttachments( ← audio → 文本哨兵
stripUnsupportedImageParts( ← 无 vision 能力就删图
mapUserInlineAttachments(...) ← 最先:内联文件正文拼进 text

6.2 Anthropic 的 wave 拆分

病症: Claude API 要求 tool_use 块和它的 tool_result 严格配对。Jan 的 UI 里, 一条 assistant 消息可能长这样:[文本, tool-A, 文本, tool-B]——模型说一句、调一个、再说一句、再调一个。 直接转换会产出配不上的结构,回来一个 400。

思路: 按"工具块之后出现的第一个非工具块"切开,一刀切成多条 assistant 消息。 代码里把每一段叫一个 wave:

// web-app/src/lib/custom-chat-transport.ts:967-971
} else if (!isToolPart(part) && seenToolParts) {
// 非工具 part 出现在工具 part 之后 = 新 wave 的开始
waves.push(currentWave); currentWave = [part]; seenToolParts = false
}

拆出来的消息 id 加后缀 _w0_w1(:983),只影响发给模型的副本,存盘的消息没动。 只有一个 wave 时原样返回,不做无谓改写(:980)。

注意这条与第 9 步的互动: wave 拆分故意制造出连续的 assistant 消息, 所以 coalesceMessagesForAlternation 只合并连续的 user,对连续 assistant 一概不碰—— 这一点写死在函数注释里(web-app/src/lib/custom-chat-transport.ts:363-365)。

6.3 附件元信息上提到 system

病症: Jan 把附件信息以 [ATTACHED_FILES] 文本块的形式持久化在用户消息里(UI 靠它渲染)。 如果原样发给模型,同一批文件会在多轮里重复出现,还占着用户轮的位置。

做法: 抽出来、去重、搬到 system。

// web-app/src/lib/custom-chat-transport.ts:1337-1343,extractFileMetadataForSystem
const { files, cleanPrompt } = extractFilesFromPrompt((part as { text: string }).text)
for (const f of files) { if (!byId.has(f.id)) byId.set(f.id, f) } // 按 id 去重

buildFilesSystemAddendum(:1359)把去重后的结果拼成一段稳定可解析的清单, 每行形如 - file_id: …, name: …, chunks: …,并附一句"相关时用检索工具按这些 file_id 取内容"。 目的是让模型能把 file_id 当参数喂给 RAG 工具,而不是靠猜文件名。

顺带一个小洁癖:system 拼完如果只剩空白,就整个不发——因为有些 chat template 会把空 system 也包成一圈特殊 token(:1003-1008)。

6.4 音视频哨兵:绕过兼容层的类型检查

病症: @ai-sdk/openai-compatible 的转换器只接受图片类 file part,音频视频会被直接拒掉。 但 llama-server 是能吃 input_audio / input_video 的。

做法: 编解码分离。发送侧把 file part 变成一段带哨兵前缀的纯文本:

// web-app/src/lib/custom-chat-transport.ts:1273-1276,encodeAudioAttachments
const parsed = parseAudioDataUrl((part as { url: string }).url)
if (!parsed) return part
return { type: 'text' as const, text: encodeAudioSentinel(parsed.format, parsed.data) }

哨兵格式是 __JAN_AUDIO__<wav|mp3><base64>(web-app/src/lib/audio-sentinel.ts:13)。 到了线上,createCustomFetch 里的 decodeAudioSentinelsInBody / decodeVideoSentinelsInBody (web-app/src/lib/model-factory.ts:655:583)再把它还原成正经的 content part。

这是"在别人的类型系统里偷渡数据"的标准手法:上游库只认文本,那就把二进制编成文本穿过去, 在最后一层出口处还原。代价是 base64 会在内存里多存一份。

6.5 三个"防炸"守卫

这三个都在治同一类病:上一轮失败留下的残骸,会把下一轮一起带炸。

stripUnsupportedImageParts(:353) — 线程中途从 vision 模型换到纯文本模型时, 历史里的图片 part 必须删掉,否则兼容层 provider 400、llama-server 行为随模板而定。 判定依据是模型自己声明的 capability:

// web-app/src/lib/custom-chat-transport.ts:1076-1077
const modelSupportsVision = selectedModel?.capabilities?.includes('vision') ?? false

resolveOrphanToolCalls(:405) — 工具调用被中断(解析错误、abort、断网)时, assistant 消息里会留下 state 还停在 input-available 的 tool part,永远等不到结果。 下一轮发出去就会触发严格模板的 raise_exception。处理办法不是删,而是伪造一个失败结果:

// web-app/src/lib/custom-chat-transport.ts:421-426
return { ...(part as object), state: 'output-error',
errorText:?? 'Tool call did not complete (interrupted by an earlier error).' }

保留了用户意图和模型的推理过程,只是把那个工具标成"失败了"。

coalesceMessagesForAlternation(:434) — 生成中途失败时,AI SDK 会留下一条空的 assistant 占位消息,于是历史变成 [user, user]。这个函数先用 isAssistantMessageEmpty(:224) 把空占位删掉,再把相邻的 user 合并(文本用空行连接,非文本 part 直接追加,mergeMessageParts,:243)。 不丢用户的任何内容,是这个函数的设计约束。

6.6 早失败:没有真正的用户提问

Qwen3.5+ 的模板在窗口里找不到用户提问时,会抛一句 No user query found in messages, 用户完全看不懂。Jan 提前自己拦下来:

// web-app/src/lib/custom-chat-transport.ts:1070-1073
if (!hasGenuineUserQuery(effectiveMessages)) {
throw new Error('This conversation has no user message to respond to. …')
}

"真的用户提问"的判定有讲究(:465-474):user 角色 + 文本非空 + 不能整条都是 <tool_response>…</tool_response>。因为工具结果也是以 user 角色回填的,不排除它就会误判成有提问。

触发场景就两个:用户手动删掉了唯一的真实提问,或者上下文裁剪把它挤掉了。

6.7 续写:assistant prefill

用户点"继续"续写被截断的回复时,Jan 用的是 assistant prefill——把已有的半截回复 作为最后一条 assistant 消息附上去,模型就会接着往下写而不是重新生成:

// web-app/src/lib/custom-chat-transport.ts:1097-1099
const modelMessages = continueContent
? [...baseMessages, { role: 'assistant' as const, content: continueContent }]
: baseMessages

continueFromContent一次性的,取出来立刻置 null(:1095-1096)。

UI 侧还有一手:prependTextDeltaToUIStream(:529)包住输出流,等第一个 text-start 块一到, 就立刻往同一个文本块里塞一个装着旧内容的 text-delta用户看到的是半截文字先出现、然后继续往下长, 而不是一个空框。


7. 工具轮的闭环

前面说过工具从哪来不归本章管,但工具结果怎么变成下一轮是主线的一部分。 闭环在 web-app/src/routes/threads/$threadId.tsx 里,分三步。

streamText 流里出现 tool call


① onToolCall ── 推进 sessionData.tools 队列 :546-548

(流结束)

② onFinish ── 顺序取出:审批 → 执行 → addToolOutput :390-467


③ sendAutomaticallyWhen: followUpMessage 判定 → 自动再发一轮 :549

① 收集。 回调只做一件事,把 toolCall 塞进本线程的队列:

// web-app/src/routes/threads/$threadId.tsx:546-548
onToolCall: ({ toolCall }) => { sessionData.tools.push(toolCall) },

sessionData 来自 chat-session-storegetSessionData(threadId)(:149-150), 所以队列是跟着线程走的,切走再切回来不丢。

② 顺序执行。 onFinish 里起一个 async IIFE,for 循环串行处理——不是并行:

  • 先建一个 AbortController(:377-379),用户点停止就能中断整批。
  • 每个工具先过审批:内置 RAG 工具跳过,MCP 工具要走 useToolApproval.requestApproval(:400-405)。
  • 按工具名分派到 serviceHub.rag().callToolserviceHub.mcp().callTool(:421-439)。
  • 三种结局都调 addToolOutput:拒绝 → output-error(:409),报错 → output-error(:442), 成功 → output(:449)。永远不会静默留空,这正好和 6.5 的 resolveOrphanToolCalls 呼应。
  • 循环期间用 setThreadBusy(threadId, true) 把线程标记为忙(:387),因为流已经结束了, isSessionBusy 读数组长度那一路不是响应式的。

③ 自动续跑。 sendAutomaticallyWhen 收的是一个谓词,Jan 在 AI SDK 的标准判定外面加了一道闸:

// web-app/src/routes/threads/$threadId.tsx:158-169,followUpMessage
if (!toolCallAbortController.current || toolCallAbortController.current?.signal.aborted) return false
return lastAssistantMessageIsCompleteWithToolCalls({ messages })

没有活着的 controller,或者已经被 abort,就不续跑——这是"停止"按钮真正生效的地方。 谓词返回 true,AI SDK 就自动发起新一轮,回到 sendMessages,于是整个第 4~6 节再走一遍。

审批闸门的 UI、MCP 客户端怎么连、工具路由怎么挑,见第 3 章


8. 流式指标与标题生成

8.1 tok/s 是怎么算出来的

Jan 优先用推理端上报的真实速率,拿不到才自己按时间估。数据经过三跳:

llama-server 的 chunk.timings.predicted_per_second
│ MetadataExtractor (model-factory.ts:105)

providerMetadata.tokensPerSecond
│ messageMetadata 里的 finish-step 分支 (custom-chat-transport.ts:1132)

UI 的 tokenSpeed

第二跳的取值路径有一层看着多余的嵌套:

// web-app/src/lib/custom-chat-transport.ts:1133-1136
tokensPerSecond = (part.providerMetadata?.providerMetadata?.tokensPerSecond as number) || 0

外层是 AI SDK 的 provider metadata 容器,内层是抽取器自己返回的那一层 { providerMetadata: {...} } (web-app/src/lib/model-factory.ts:121-128)。

回退逻辑在 finish 分支(:1142-1163):有厂商速率就用厂商的,没有就 outputTokens / durationSec; 计时起点是第一个 text-startreasoning-start(:1124-1130)——不是请求发出那一刻, 所以模型加载和 prompt 预填充的时间不会污染 tok/s。

promptPerSecond(预填充速率)同源,只在有值时才写进 metadata(:1176-1178)。

另外,prompt 处理进度条走的是另一条路:抽取器的 processChunk 直接把 prompt_progress 写进全局 store(web-app/src/lib/model-factory.ts:159-175),不经过 metadata。 数据源就是 4.3 节里那个 return_progress: true

8.2 标题自动刷新

规则简单:第 1 条 assistant 消息之后刷一次,之后每 4 条刷一次

// web-app/src/routes/threads/$threadId.tsx:86
const TITLE_REFRESH_EVERY_N_ASSISTANT_MESSAGES = 4
// :488-491
const isRefreshTick = assistantCount === 1 ||
(assistantCount > 0 && assistantCount % TITLE_REFRESH_EVERY_N_ASSISTANT_MESSAGES === 0)

三道防护:

防护做什么代码
手动改过就不动检查 metadata.titleSetManually:493
只喂最近 8 轮TITLE_TRANSCRIPT_MAX_TURNS 截断转录:494-495
旧请求取消titleAbortRef 每次先 abort 上一个:531-533

llamacpp 下还要多等一步。 生成标题会占用同一个 router 的推理槽,所以先轮询到空闲再发:

// web-app/src/routes/threads/$threadId.tsx:514-529
for (let attempt = 0; attempt < 6; attempt++) {
try { idle = await invoke<boolean>('plugin:llamacpp|router_slots_idle', { modelId }) }
catch { idle = true; break } // 探测失败就当空闲,别卡住
if (idle) break
await new Promise((r) => setTimeout(r, 150))
}
if (!idle) return // 6 次(约 0.9 秒)还忙,这轮就放弃改标题

放弃比排队好——标题只是锦上添花,不值得让用户的下一句话等着。

真正生成标题的是 generateThreadTitle(web-app/src/lib/thread-title-summarizer.ts:61), 一次非流式 generateText;llamacpp 下强制 enable_thinking: false,MLX 则直接放弃 (注释说 MLX 模型的 reasoning 压不干净)。


9. 巧妙之处(可以搬走的)

  • ChatTransport 是个被低估的扩展点。 实现一个接口,就把整套 useChat 的 UI 状态机搬到了 无后端环境里,还顺手拿到了"在发请求前任意改写消息"的钩子。 web-app/src/lib/custom-chat-transport.ts:725

  • 每轮现读 store,而不是构造时注入。 这是 transport 能长期复用、模型能中途热切的根因。 :866-869

  • 单调号码防过期回调。 streamGeneration 只有 4 行,解决的是"regenerate 之后旧流的 onFinish 把新流的 loading 状态关掉"这种极难复现的 bug。:861 / :1189 / :1215

  • 一个清洗器只治一种病,而且都是纯函数。 九个清洗器全部 messages → messages, 单测友好(web-app/src/lib/__tests__/),组合顺序在调用点一眼可见。

  • 给上游的 bug 打客户端补丁,并写清原因。 normalizeToolInputSchemaValue(:136) 为了 llama.cpp 的 json-schema-to-grammar 做了三件事:把 {"foo": "string"} 这种简写展开成 正经 sub-schema、删掉 date/time/date-time 这几个 format、删掉含 \d\w\s 的 pattern (:196-210)。理由都写在注释里:这些东西会让 GBNF 编译失败,而失败是静默的—— 工具调用会直接不工作

  • 不懂的报错就翻译成人话。 extractContextInfoFromError(:313)从 responseBody 原文里捞出被 Zod 剥掉的 n_prompt_tokens / n_ctx,拼成"Used X of Y context tokens"; stripRetryErrorWrapper(:346)剥掉 Failed after N attempts… 的包装层。


10. 边界与局限

  • 不能断线重连。 reconnectToStream 永远返回 null(:1246-1255),useChatresume: false 写死(web-app/src/hooks/use-chat.ts:101)。应用一关,在跑的流就没了。

  • 工具串行执行,不并行。 onFinish 里是 for 循环 await($threadId.tsx:390-467)。 一批工具里有一个慢的,后面全排队。

  • 工具名冲突靠"后者胜"+ 一行警告。 两台 MCP server 暴露同名工具时,只 console.warn, 然后用后来的那个(:766-772)。没有命名空间机制。

  • vision / tools 能力全靠 capability 声明。 声明错了就直接体现为发错请求 (:1076-1077:1103),代码里没有探测兜底。

  • wave 拆分只对 Anthropic 生效。 其它 provider 若也有同样的配对要求,当前没覆盖(:945-948)。

  • 标题生成会抢本地推理槽。 只有 llamacpp 有 router_slots_idle 这道等待 ($threadId.tsx:514),MLX 是直接不生成,其它 provider 不等。


11. 代码地图

主题文件路径符号名
传输层入口web-app/src/lib/custom-chat-transport.tsCustomChatTransport.sendMessages
采样参数三层合并web-app/src/lib/custom-chat-transport.tsextractModelSamplingDefaults / getActiveInferenceParams / buildLlamacppReasoningParams
Anthropic wave 拆分web-app/src/lib/custom-chat-transport.tssendMessages 内的 messagesToConvert IIFE
附件元信息上提web-app/src/lib/custom-chat-transport.tsextractFileMetadataForSystem / buildFilesSystemAddendum
音视频哨兵(编码侧)web-app/src/lib/custom-chat-transport.tsencodeAudioAttachments / encodeVideoAttachments
音视频哨兵(解码侧)web-app/src/lib/model-factory.tsdecodeAudioSentinelsInBody / decodeVideoSentinelsInBody
历史防炸三件套web-app/src/lib/custom-chat-transport.tsstripUnsupportedImageParts / resolveOrphanToolCalls / coalesceMessagesForAlternation
早失败守卫web-app/src/lib/custom-chat-transport.tshasGenuineUserQuery
续写 prefillweb-app/src/lib/custom-chat-transport.tssetContinueFromContent / prependTextDeltaToUIStream
工具 schema 消毒web-app/src/lib/custom-chat-transport.tsnormalizeToolInputSchema / normalizeToolInputSchemaValue
报错翻译web-app/src/lib/custom-chat-transport.tsextractContextInfoFromError / stripRetryErrorWrapper
provider 分派web-app/src/lib/model-factory.tsModelFactory.createModel
请求体改写web-app/src/lib/model-factory.tscreateCustomFetch / filterParameters / stripUnsupportedSamplers
流式指标抽取web-app/src/lib/model-factory.tsproviderMetadataExtractor
线格式判定web-app/src/lib/providerCaps.tsgetProviderApiType / isPredefinedRemoteProvider
useChat 包装web-app/src/hooks/use-chat.tsuseChat(transportRef)
会话复用web-app/src/stores/chat-session-store.tsensureSession / isSessionBusy
工具轮闭环web-app/src/routes/threads/$threadId.tsxonToolCall / onFinish / followUpMessage
标题生成web-app/src/lib/thread-title-summarizer.tsgenerateThreadTitle

下一步该读哪章: 工具怎么来、怎么审批 → 第 3 章; 本地模型怎么起、router 怎么分发 → 第 4 章; 第 6.1 节表里那一步"裁剪 / 压缩"的细节 → 第 5 章