数据截至 (上游 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 收敛成一个 LanguageModel | web-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 根本不持有模型 id。sendMessages 每次都现读全局 store:
// web-app/src/lib/custom-chat-transport.ts:866-869
const modelId = useModelProvider.getState().selectedModel?.id
const providerId = useModelProvider.getState().selectedProvider
模型是每轮现取的,不是构造时注入的。所以侧边栏换模型,下一轮自动生效,transport 一个都不用换。