创建时间: 2026-06-06最后更新: 2026-06-06

1. 概述

这一节我们把 Web 首页聊天列表右侧的 AI 对话模块接通 DeepSeek。

页面现在是左右两栏:左边是聊天列表,右边是当前聊天的 AI 对话区。用户输入问题后,前端一边接收模型返回,一边把内容流式展示出来。

这里先把边界放清楚。API 不写在 Next.js API Route 里,而是统一放到 apps/api 子站中处理。Web 子站负责页面、交互和发请求;模型调用、环境变量、请求校验、流式响应,都交给 API 子站。

这块能力会用到几层技术,我们可以先用一张表把关系看清楚:

层级技术作用
Web UIAI Elements官方 AI UI 组件,比如 ConversationMessagePromptInput
Web Chat StateVercel AI SDK useChat管理消息、状态、停止生成、发送消息
Web TransportTextStreamChatTransport调用非 Next.js API 的纯文本流接口
API FrameworkHonoapps/api 子站的 HTTP 路由框架
ContractZod + @repo/contracts前后端共享请求结构校验
Model FrameworkLangChain后端首选 LLM 开发框架
Model ProviderDeepSeek通过 OpenAI-compatible endpoint 接入

整体调用链是这样:

index.txt
01
apps/web Dashboard 首页
02
└─ InboxChat 组件
03
├─ useChat()
04
├─ TextStreamChatTransport
05
└─ POST ${NEXT_PUBLIC_API_BASE_URL}/rpc/chat/inbox
06
07
apps/api Hono 服务
08
└─ /rpc/chat/inbox
09
├─ zValidator(InboxChatRequestSchema)
10
├─ ChatOpenAI({ DeepSeek env })
11
├─ LangChain messages
12
├─ model.stream(messages)
13
└─ text/plain ReadableStream
14
15
DeepSeek API
16
└─ OpenAI-compatible chat model

这套实现会避开 Next.js API Route。Web 只管 UI 和调用 API 子站,LLM 编排和模型配置都在 apps/api 中处理。

2. 环境变量与 Contract

DeepSeek 配置由 API 子站读取。涉及的核心文件有这几个:

index.txt
1
apps/api/src/env.ts
2
apps/api/src/bindings.ts
3
apps/api/wrangler.jsonc
4
apps/api/.dev.vars

非敏感配置可以放在 apps/api/wrangler.jsonc

wrangler.jsonc
1
{
2
"vars": {
3
"DEEPSEEK_BASE_URL": "https://api.deepseek.com/v1",
4
"DEEPSEEK_MODEL": "deepseek-chat"
5
}
6
}

DEEPSEEK_API_KEY 是敏感信息,不要写进仓库。它应该放在本地 apps/api/.dev.vars,线上则放到 Wrangler secret。

.dev.vars
1
DEEPSEEK_API_KEY=你的 DeepSeek Key
2
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
3
DEEPSEEK_MODEL=deepseek-chat

apps/api/.dev.vars 已经被 git ignore,不会提交。

环境变量统一在 apps/api/src/env.ts 里用 Zod 解析:

env.ts
01
const apiEnvSchema = z.object({
02
APP_ENV: z.enum(['development', 'test', 'production']),
03
ADMIN_ORIGIN: z.string().url(),
04
WEB_ORIGIN: z.string().url(),
05
JWT_ACCESS_SECRET: z.string().min(16),
06
JWT_REFRESH_SECRET: z.string().min(16),
07
ACCESS_TOKEN_TTL_SEC: z.coerce.number().int().positive(),
08
REFRESH_TOKEN_TTL_SEC: z.coerce.number().int().positive(),
09
DEEPSEEK_API_KEY: z.string().min(1).optional(),
10
DEEPSEEK_BASE_URL: z.string().url().optional(),
11
DEEPSEEK_MODEL: z.string().min(1).optional(),
12
})

解析函数从 bindings 中取值,再交给 schema 校验:

env.ts
01
export function getApiEnv(bindings: ApiBindings): ApiEnv {
02
return apiEnvSchema.parse({
03
APP_ENV: bindings.APP_ENV,
04
ADMIN_ORIGIN: bindings.ADMIN_ORIGIN,
05
WEB_ORIGIN: bindings.WEB_ORIGIN,
06
JWT_ACCESS_SECRET: bindings.JWT_ACCESS_SECRET,
07
JWT_REFRESH_SECRET: bindings.JWT_REFRESH_SECRET,
08
ACCESS_TOKEN_TTL_SEC: bindings.ACCESS_TOKEN_TTL_SEC,
09
REFRESH_TOKEN_TTL_SEC: bindings.REFRESH_TOKEN_TTL_SEC,
10
DEEPSEEK_API_KEY: bindings.DEEPSEEK_API_KEY,
11
DEEPSEEK_BASE_URL: bindings.DEEPSEEK_BASE_URL,
12
DEEPSEEK_MODEL: bindings.DEEPSEEK_MODEL,
13
})
14
}

前后端请求结构放在 contract 里:

index.txt
1
packages/contracts/src/chat/inbox-chat.contract.ts

这里有个容易忽略的地方。前端用了 Vercel AI SDK 的 useChat,它传给后端的消息不是简单的 { role, content },而是 UIMessage,大概长这样:

index.ts
1
{
2
id: string
3
role: 'user' | 'assistant'
4
parts: Array<UIMessagePart>
5
}

parts 不一定只有文本,还可能有 step-start、tool part、data part、file part。后端 contract 如果只认 { type: 'text', text: string },后面很容易因为 AI SDK 附带的 part 类型变化而校验失败。

所以当前 schema 只要求每个 part 至少有 type,其他字段允许透传:

inbox-chat.contract.ts
1
const InboxChatPartSchema = z.object({
2
type: z.string().min(1),
3
}).passthrough()
4
5
export const InboxChatMessageSchema = z.object({
6
id: z.string().optional(),
7
role: z.enum(['user', 'assistant']),
8
parts: z.array(InboxChatPartSchema).min(1).max(50),
9
})

完整请求结构里除了 messages,还带上了当前聊天上下文 mail。这样后端调用模型时,才能把当前聊天主题、发送方、摘要一起交给 DeepSeek。

inbox-chat.contract.ts
1
export const InboxChatRequestSchema = z.object({
2
messages: z.array(InboxChatMessageSchema).min(1).max(20),
3
mail: z.object({
4
subject: z.string().min(1).max(200),
5
sender: z.string().min(1).max(120),
6
senderEmail: z.string().email(),
7
teaser: z.string().min(1).max(2000),
8
}),
9
})

3. API 子站实现

API 核心文件是:

index.txt
1
apps/api/src/routes/chat/inbox.route.ts

路由挂载在:

index.txt
1
POST /rpc/chat/inbox

挂载文件是:

index.txt
1
apps/api/src/routes/index.ts
index.ts
1
.route('/rpc/chat/inbox', inboxChatRoute)

请求体先用 zValidator 校验:

inbox.route.ts
01
inboxChatRoute.post(
02
'/',
03
zValidator(
04
'json',
05
InboxChatRequestSchema,
06
buildValidationErrorHandler('Invalid chat payload'),
07
),
08
async (c) => {
09
// handler
10
},
11
)

这样 Web 和 API 共用同一份 schema。AI SDK 的消息结构以后有变化,也先改 contract。校验失败时,也会走项目已有的 API failure 结构。

因为 UIMessage.parts 里可能混着非文本 part,真正进入 LangChain 前,只提取文本内容:

inbox.route.ts
1
function extractText(message: { parts: Array<{ type: string; text?: unknown }> }) {
2
return message.parts
3
.filter((part) => part.type === 'text' && typeof part.text === 'string')
4
.map((part) => part.text)
5
.join('\n')
6
.trim()
7
}

这样可以跳过 step-start 等 part,避免 LangChain 收到空消息或非法消息。

DeepSeek 这里通过 LangChain 的 ChatOpenAI 接入。DeepSeek 提供 OpenAI-compatible API,所以我们只要把 baseURL 指到 DeepSeek 即可。

inbox.route.ts
1
const model = new ChatOpenAI({
2
apiKey: env.DEEPSEEK_API_KEY,
3
model: env.DEEPSEEK_MODEL ?? 'deepseek-chat',
4
configuration: {
5
baseURL: env.DEEPSEEK_BASE_URL ?? 'https://api.deepseek.com/v1',
6
},
7
})

如果没有配置 DEEPSEEK_API_KEY,不要让请求继续往下走,而是直接返回明确错误:

inbox.route.ts
1
if (!env.DEEPSEEK_API_KEY) {
2
throw new AppError(
3
BizCode.SYSTEM_INTERNAL_ERROR,
4
'DeepSeek API key is not configured',
5
500,
6
)
7
}

调用模型前,API 会先构造系统提示词和当前聊天上下文:

inbox.route.ts
01
const messages: BaseMessage[] = [
02
new SystemMessage([
03
'你是 AI Agent Web 控制台里的聊天助手。',
04
'请基于当前聊天上下文,用简洁、自然的中文回答用户。',
05
'如果用户要求起草回复,请直接给出可发送的回复内容。',
06
].join('\n')),
07
new HumanMessage([
08
`聊天主题:${payload.mail.subject}`,
09
`发送方:${payload.mail.sender} <${payload.mail.senderEmail}>`,
10
`聊天摘要:${payload.mail.teaser}`,
11
].join('\n')),
12
]

随后把前端传来的历史消息转换为 LangChain 消息。用户消息变成 HumanMessage,助手消息变成 AIMessage

inbox.route.ts
01
for (const message of payload.messages) {
02
const text = extractText(message)
03
04
if (!text) {
05
continue
06
}
07
08
messages.push(
09
message.role === 'user'
10
? new HumanMessage(text)
11
: new AIMessage(text),
12
)
13
}

模型调用使用 LangChain 的流式接口:

inbox.route.ts
1
const stream = await model.stream(messages)

接下来把 LangChain stream 转成 Web 标准 ReadableStream<Uint8Array>

inbox.route.ts
01
const textStream = new ReadableStream<Uint8Array>({
02
async start(controller) {
03
const encoder = new TextEncoder()
04
05
try {
06
for await (const chunk of stream) {
07
const content = typeof chunk.content === 'string'
08
? chunk.content
09
: chunk.content.map((part) => {
10
if (typeof part === 'string') {
11
return part
12
}
13
14
if ('text' in part && typeof part.text === 'string') {
15
return part.text
16
}
17
18
return ''
19
}).join('')
20
21
if (content) {
22
controller.enqueue(encoder.encode(content))
23
}
24
}
25
26
controller.close()
27
} catch (error) {
28
controller.error(error)
29
}
30
},
31
})

最后返回纯文本流:

inbox.route.ts
1
return new Response(textStream, {
2
headers: {
3
'content-type': 'text/plain; charset=utf-8',
4
'cache-control': 'no-cache',
5
},
6
})

这里用 text/plain,是为了配合前端的 TextStreamChatTransport

4. Web 侧实现

Web 聊天入口文件是:

index.txt
1
apps/web/app/(dashboard)/_components/inbox-chat.tsx

首页把当前选中的聊天传进去:

page.tsx
1
<InboxChat mail={selectedMail} />

当前首页文件是:

index.txt
1
apps/web/app/(dashboard)/page.tsx

Web 侧使用 useChat 管理对话状态:

inbox-chat.tsx
1
const { messages, sendMessage, status, error, stop } = useChat({
2
transport,
3
messages: initialMessages,
4
})

useChat 会帮我们保存消息列表、发送用户消息、接收并合并流式 assistant 回复。它还会给出 submittedstreaming 等状态,并提供 stop() 用来中断生成。

因为 API 不在 Next.js API Route,而是在 apps/api 子站,所以这里不能走默认 /api/chat。我们要用 TextStreamChatTransport 指向 API 子站:

inbox-chat.tsx
1
const transport = useMemo(
2
() => new TextStreamChatTransport<UIMessage>({
3
api: `${getWebClientEnv().NEXT_PUBLIC_API_BASE_URL}/rpc/chat/inbox`,
4
body: {
5
mail,
6
},
7
}),
8
[mail],
9
)

这里的 NEXT_PUBLIC_API_BASE_URL 是 Web 子站调用 API 子站的 base URL。body.mail 会被附加到每次聊天请求中,messages 则由 AI SDK 自动放进请求体。

实际发出的请求体大致是这样:

index.json
01
{
02
"mail": {
03
"subject": "Meeting Tomorrow",
04
"sender": "William Smith",
05
"senderEmail": "williamsmith@example.com",
06
"teaser": "Hi team..."
07
},
08
"id": "chat-id",
09
"messages": [
10
{
11
"id": "...",
12
"role": "user",
13
"parts": [
14
{ "type": "text", "text": "请帮我起草回复" }
15
]
16
}
17
],
18
"trigger": "submit-message"
19
}

后端 schema 使用 passthrough 和宽松 part 结构,所以可以兼容 AI SDK 附带的额外字段。

UI 组件使用 vercel 提供的 AI Elements 组件库

index.txt
1
apps/web/src/components/ai-elements/conversation.tsx
2
apps/web/src/components/ai-elements/message.tsx
3
apps/web/src/components/ai-elements/prompt-input.tsx

安装命令是:

index.bash
1
pnpm dlx shadcn@latest add @ai-elements/conversation @ai-elements/message @ai-elements/prompt-input -c apps/web -y

同时新增 web 子站本地配置:

index.txt
1
apps/web/components.json

registry 配置为:

components.json
1
{
2
"registries": {
3
"@ai-elements": "https://ai-sdk.dev/elements/api/registry/{name}.json"
4
}
5
}

Conversation 渲染部分使用这些组件:ConversationConversationContentConversationScrollButtonMessageMessageContentMessageResponse

inbox-chat.tsx
01
<Conversation>
02
<ConversationContent>
03
{messages.map((message) => (
04
<Message from={message.role} key={message.id}>
05
<MessageContent>
06
<MessageResponse>{getMessageText(message)}</MessageResponse>
07
</MessageContent>
08
</Message>
09
))}
10
{status === 'submitted' ? (
11
<Message from="assistant">
12
<MessageContent>
13
<MessageResponse>正在连接模型...</MessageResponse>
14
</MessageContent>
15
</Message>
16
) : null}
17
{error ? <p className="text-sm text-destructive">{error.message}</p> : null}
18
</ConversationContent>
19
<ConversationScrollButton />
20
</Conversation>

输入区使用 PromptInputPromptInputTextareaPromptInputSubmit

inbox-chat.tsx
01
<PromptInput
02
onSubmit={(message) => {
03
sendMessage({ text: message.text })
04
}}
05
>
06
<PromptInputTextarea
07
defaultValue="请帮我起草一段简洁专业的回复。"
08
disabled={isSending}
09
placeholder="输入回复内容..."
10
/>
11
<PromptInputSubmit
12
disabled={isSending}
13
onStop={stop}
14
status={status}
15
/>
16
</PromptInput>

5. 样式、依赖与踩坑

AI Elements 官方 Message 组件依赖 streamdown,所以 web 全局样式里要加 source 配置。

文件是:

index.txt
1
apps/web/app/globals.css
globals.css
1
@source "../node_modules/streamdown/dist/*.js";

Web 子站新增依赖包括:

依赖说明
@ai-sdk/reactReact 侧 AI SDK 能力
aiAI SDK 核心包
streamdown流式 Markdown/文本渲染相关依赖
@streamdown/cjkCJK 支持
@streamdown/code代码渲染支持
@streamdown/math数学公式支持
@streamdown/mermaidMermaid 支持
use-stick-to-bottom对话滚动到底部
cmdk命令面板相关依赖
nanoidID 生成
shadcn/ui 相关依赖AI Elements 组件依赖的基础 UI

API 子站新增依赖包括:

依赖说明
@langchain/coreLangChain 核心能力
@langchain/openai通过 ChatOpenAI 接 DeepSeek OpenAI-compatible API

接入时有几个地方要提前处理好。

不要把 API 写在 Next.js API Route。 本项目要求 API 服务统一写在 apps/api 子站中,所以 Web 不能依赖默认 /api/chat

inbox-chat.tsx
1
new TextStreamChatTransport({
2
api: `${NEXT_PUBLIC_API_BASE_URL}/rpc/chat/inbox`,
3
})

DeepSeek Key 不能写入仓库。 DEEPSEEK_API_KEY 只能放在本地 apps/api/.dev.vars 或线上 Wrangler secret。不要写进 wrangler.jsonc、源码、Markdown 文档,也不要写进前端环境变量。

AI SDK 的 parts 不是只有 text。 如果 schema 一开始只允许 { type: 'text', text: string },可能会遇到这样的错误:

index.json
1
{
2
"message": "Invalid chat payload",
3
"details": [
4
{
5
"path": ["messages", 2, "parts", 0, "type"],
6
"message": "Invalid input: expected \"text\""
7
}
8
]
9
}

原因是 AI SDK 可能发送 step-start 等非文本 part。最终修复是允许 part 透传:

inbox-chat.contract.ts
1
const InboxChatPartSchema = z.object({
2
type: z.string().min(1),
3
}).passthrough()

然后后端只在进入 LangChain 前提取文本:

inbox.route.ts
1
.filter((part) => part.type === 'text' && typeof part.text === 'string')

API 返回协议要匹配 Transport。 当前前端使用的是 TextStreamChatTransport,所以 API 返回 text/plain; charset=utf-8

index.http
1
content-type: text/plain; charset=utf-8

如果以后改用默认 DefaultChatTransport,后端就需要返回 AI SDK UI message stream,而不是纯文本流。

总结

接通 DeepSeek 不能只看模型 API 是否能返回内容,还要把 Web、API、Contract、LangChain、DeepSeek 和流式 UI 的边界放清楚。

Web 负责聊天 UI 和请求入口,API 子站负责参数校验、聊天上下文拼接、LangChain 调用和纯文本流返回。AI SDK 用 useChat 管消息和状态,AI Elements 负责对话组件,TextStreamChatTransport 把 Web 请求接到 API 子站。

这样接入后,项目原有边界不会被打乱。后面继续做动态聊天、会话持久化、工具调用和鉴权,也都有位置可以往下加。