系列导航
本系列拆分为四篇,本篇专注工程骨架。相关篇目:
技术选型速览
| 决策点 | 选择 | 简要理由 |
|---|---|---|
| 运行时 | Bun | 内置 fetch、Bun.serve、原生 TS;单进程起 API + 流式转发足够快 |
| 流式协议 | SSE(text/event-stream) | 单向推送即可;浏览器 fetch + ReadableStream 比纯 EventSource 更易带 POST body 与 AbortSignal |
| 前端状态 | zustand(或 Pinia 在 Vue 侧等价) | 助手场景以「会话 + 消息列表 + 流式指针」为主,zustand 无样板、易与 hooks 组合 |
| 数据库 | PostgreSQL | 会话/消息/附件元数据关系清晰;JSONB、全文检索、并发写都成熟 |
| 大模型 | DeepSeek | OpenAI 兼容 API,便于在服务端用同一套 messages 结构转发 |
为何不用 WebSocket:多数场景只需服务端→客户端推送 token;双向实时协作再考虑 WS。
推荐目录结构(monorepo)
ai-chat/
├── apps/
│ ├── web/ # Vite + React + TS
│ └── server/ # Bun HTTP + SSE
├── packages/
│ └── shared/ # 共享类型(Message、ChatRequest 等)
├── docker-compose.yml # 本地 Postgres
├── pnpm-workspace.yaml
└── package.json
apps/web:前端应用,使用 Vite + React + TypeScript。apps/server:后端服务,使用 Bun 提供 HTTP 接口与 SSE 流式输出。packages/shared:前后端共享类型定义(如消息结构、请求体结构)。apps/web依赖packages/shared,保证前端类型与后端协议一致。apps/server也依赖packages/shared,避免重复维护类型。apps/server连接PostgreSQL,用于持久化会话、消息和附件信息。apps/server调用DeepSeek API,并将结果以 SSE 方式回传给前端。
pnpm-workspace.yaml 示例:
packages:
- "apps/*"
- "packages/*"
根目录 package.json 可挂脚本并行启动:
{
"scripts": {
"dev": "pnpm -r --parallel run dev"
}
}
前端:apps/web
项目搭建
cd apps
pnpm create vite web --template react-ts
cd web
pnpm add zustand axios marked highlight.js dompurify uuid
pnpm add -D @types/uuid
# dompurify 自带类型;若旧版本再补 @types/dompurify
说明:
- axios:统一配置 baseURL、拦截器(将来挂 token);流式仍建议用
fetch(axios 对 SSE 流支持不如原生直观)。 - marked + highlight.js:Markdown 与代码高亮(渲染细节见第三篇)。
- dompurify:防止模型输出中的 HTML 注入。
- uuid:生成
requestId、消息 id。
vite.config.ts:代理与依赖预构建
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
server: {
port: 5173,
proxy: {
"/api": {
target: "http://127.0.0.1:3000",
changeOrigin: true,
},
},
},
optimizeDeps: {
include: ["marked", "highlight.js", "dompurify"],
},
});
tsconfig.json 路径别名
在 compilerOptions 中增加:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
并在 tsconfig.app.json 的 include 中确保覆盖 src。
最小入口验证
src/App.tsx 仅测代理与健康检查即可:
export default function App() {
return (
<button
type="button"
onClick={async () => {
const r = await fetch("/api/health");
console.log(await r.json());
}}
>
ping /api/health
</button>
);
}
后端:apps/server
初始化与依赖
mkdir -p apps/server && cd apps/server
bun init -y
bun add pg
# DeepSeek 兼容 OpenAI:用原生 fetch 即可,也可 bun add openai
若用
postgres(postgres.js)替代pg亦可,本文以pg为例。
环境变量
.env(勿提交仓库):
DATABASE_URL=postgresql://postgres:[email protected]:5432/aichat
DEEPSEEK_API_KEY=sk-...
DEEPSEEK_BASE_URL=https://api.deepseek.com
PORT=3000
入口:src/index.ts
import { migrate } from "./migrate";
import { handleChat } from "./routes/chat";
import { handleHealth } from "./routes/health";
await migrate();
Bun.serve({
port: Number(Bun.env.PORT) || 3000,
async fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/api/health") return handleHealth();
if (url.pathname === "/api/chat" && req.method === "POST") {
return handleChat(req);
}
return new Response("Not Found", { status: 404 });
},
});
console.log("Bun server listening");
极简迁移:src/migrate.ts
import { readdir } from "node:fs/promises";
import path from "node:path";
import pg from "pg";
export async function migrate() {
const pool = new pg.Pool({ connectionString: Bun.env.DATABASE_URL });
const dir = path.join(import.meta.dir, "migrations");
const files = (await readdir(dir)).filter((f) => f.endsWith(".sql")).sort();
for (const file of files) {
const sql = await Bun.file(path.join(dir, file)).text();
await pool.query(sql);
}
await pool.end();
}
在 migrations/001_init.sql 写入 schema(与第二篇对齐,此处给完整初版):
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT 'deepseek-chat',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
content TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'completed',
request_id TEXT UNIQUE,
token_usage INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_messages_session_created ON messages(session_id, created_at);
CREATE TABLE IF NOT EXISTS attachments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
filename TEXT NOT NULL,
mime TEXT NOT NULL,
size_bytes INT NOT NULL,
storage_path TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
DeepSeek 封装:src/llm/deepseek.ts
const BASE = Bun.env.DEEPSEEK_BASE_URL ?? "https://api.deepseek.com";
const KEY = Bun.env.DEEPSEEK_API_KEY;
export type ChatMessage = { role: "system" | "user" | "assistant"; content: string };
export async function deepseekChat(
messages: ChatMessage[],
options: { model?: string; temperature?: number; max_tokens?: number; signal?: AbortSignal }
) {
const res = await fetch(`${BASE}/v1/chat/completions`, {
method: "POST",
signal: options.signal,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${KEY}`,
},
body: JSON.stringify({
model: options.model ?? "deepseek-chat",
messages,
temperature: options.temperature ?? 0.7,
max_tokens: options.max_tokens ?? 4096,
stream: false,
}),
});
if (!res.ok) {
const t = await res.text();
throw new Error(`DeepSeek ${res.status}: ${t}`);
}
const data = (await res.json()) as {
choices: Array<{ message: { content: string } }>;
usage?: { total_tokens: number };
};
return {
content: data.choices[0]?.message?.content ?? "",
usage: data.usage?.total_tokens,
};
}
/** 返回 AsyncIterable<string>,每个元素为一段 delta 纯文本 */
export async function* deepseekChatStream(
messages: ChatMessage[],
options: { model?: string; temperature?: number; max_tokens?: number; signal?: AbortSignal }
): AsyncGenerator<string> {
const res = await fetch(`${BASE}/v1/chat/completions`, {
method: "POST",
signal: options.signal,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${KEY}`,
},
body: JSON.stringify({
model: options.model ?? "deepseek-chat",
messages,
temperature: options.temperature ?? 0.7,
max_tokens: options.max_tokens ?? 4096,
stream: true,
}),
});
if (!res.ok) {
const t = await res.text();
throw new Error(`DeepSeek ${res.status}: ${t}`);
}
if (!res.body) throw new Error("No response body");
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const s = line.trim();
if (!s.startsWith("data:")) continue;
const payload = s.slice(5).trim();
if (payload === "[DONE]") return;
try {
const json = JSON.parse(payload) as {
choices?: Array<{ delta?: { content?: string } }>;
};
const piece = json.choices?.[0]?.delta?.content;
if (piece) yield piece;
} catch {
/* 忽略非 JSON 行 */
}
}
}
}
流式解析的健壮版(跨包 UTF-8、SSE 帧)在第三篇展开;此处保证能跑通最小链路。
POST /api/chat 骨架:src/routes/chat.ts
import { deepseekChatStream } from "../llm/deepseek";
const SSE_HEADERS = {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
} as const;
export async function handleChat(req: Request): Promise<Response> {
const body = (await req.json()) as {
messages: Array<{ role: string; content: string }>;
};
const messages = body.messages as Array<{ role: "system" | "user" | "assistant"; content: string }>;
const stream = new ReadableStream({
async start(controller) {
const enc = new TextEncoder();
const send = (obj: unknown) => {
controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
};
try {
for await (const delta of deepseekChatStream(messages, { signal: req.signal })) {
send({ type: "delta", text: delta });
}
send({ type: "done" });
controller.close();
} catch (e) {
send({ type: "error", message: (e as Error).message });
controller.close();
}
},
});
return new Response(stream, { headers: SSE_HEADERS });
}
GET /api/health:src/routes/health.ts
export function handleHealth() {
return Response.json({ ok: true, ts: Date.now() });
}
Docker:本地 PostgreSQL
docker-compose.yml(仓库根或 infra/):
services:
db:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: aichat
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
启动:docker compose up -d,DATABASE_URL 与上文一致即可。
启动与验证
- 起数据库 → 跑迁移(Bun 启动时
migrate())→bun run src/index.ts(或在package.json里写"dev": "bun --watch src/index.ts")。 - 前端
pnpm dev(Vite 5173)。 - curl 测 SSE(注意 POST 与 body):
curl -N -X POST http://127.0.0.1:3000/api/chat \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"用一句话介绍 SSE"}]}'
应看到多行 data: {"type":"delta",...} 与最后的 data: {"type":"done"}。
packages/shared(可选但推荐)
packages/shared/package.json:
{
"name": "@ai-chat/shared",
"version": "0.0.1",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts"
}
src/index.ts 先导出占位类型,第二篇会填满:
export type MessageRole = "user" | "assistant" | "system";
export type MessageStatus = "pending" | "streaming" | "completed" | "error" | "aborted";
在 apps/web 与 apps/server 的 package.json 中加 "@ai-chat/shared": "workspace:*" 并在 tsconfig 中引用。
小结
- 本篇搭好 Vite 代理 + Bun 路由 + Postgres schema + DeepSeek 最小流式,不涉及完整业务状态机。
- 下一篇 ai_assistant_state.md:消息模型、zustand 单一数据源、请求/响应协议与入库节奏。