Skip to content

Go back

从零实现AI助手 - 前后端项目搭建《一》

Published:  at 

文章目录

系列导航

本系列拆分为四篇,本篇专注工程骨架。相关篇目:

技术选型速览

决策点选择简要理由
运行时Bun内置 fetchBun.serve、原生 TS;单进程起 API + 流式转发足够快
流式协议SSE(text/event-stream单向推送即可;浏览器 fetch + ReadableStream 比纯 EventSource 更易带 POST body 与 AbortSignal
前端状态zustand(或 Pinia 在 Vue 侧等价)助手场景以「会话 + 消息列表 + 流式指针」为主,zustand 无样板、易与 hooks 组合
数据库PostgreSQL会话/消息/附件元数据关系清晰;JSONB、全文检索、并发写都成熟
大模型DeepSeekOpenAI 兼容 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

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

说明:

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.jsoninclude 中确保覆盖 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:postgres@127.0.0.1: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/healthsrc/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 -dDATABASE_URL 与上文一致即可。


启动与验证

  1. 起数据库 → 跑迁移(Bun 启动时 migrate())→ bun run src/index.ts(或在 package.json 里写 "dev": "bun --watch src/index.ts")。
  2. 前端 pnpm dev(Vite 5173)。
  3. 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/webapps/serverpackage.json 中加 "@ai-chat/shared": "workspace:*" 并在 tsconfig 中引用。


小结



Previous Post
从零实现AI助手 - 消息结构与状态管理《二》
Next Post
基于前端监控架构的设计与实现