Cirray Chat 前后端整理

2026-04-09

今天把 cirray.cn 的 /chat 功能前后端整体实现了一遍,顺手整理成文档。


整体架构

用户 → zh.cirray.cn(Next.js 前端)→ token.cirray.cn(Rust/Axum 后端)→ AI 服务

前端是 Next.js 15 App Router,API Routes 充当代理层,解决 CORS 的同时也避免把后端地址暴露给浏览器。后端是 Rust + Axum,SQLite 做用户存储,对外暴露 OpenAI 兼容接口。

下面这张图是完整调用链路(时序图风格):

Cirray Chat — 全链路调用架构 💻 用户浏览器 zh.cirray.cn Next.js 15 前端 App Router · API Routes · SSE token.cirray.cn → cal.cirray.cn:3000 Rust / Axum 后端 SQLite 用户鉴权 · AI Session 代理 🤖 AI 服务 流式响应 🔐 认证(首次登录 · token 无过期) 输入手机号,点「发送验证码」→ 阿里云短信下发 输入 6 位验证码,点「登录」 POST /api/auth/verify { phone, code } 校验验证码 → HMAC 派生密码 /auth/login 或 /auth/register → 返回 token { token: "3f8a2c…hex 64位,随机,无过期" } localStorage["clawtoken_token"] = token 💬 对话消息流(已登录 · 全程 SSE 流式传输) 输入问题,按 Enter 发送 POST /api/chat Authorization: Bearer <token> { messages:[…], conversation_id? } ① token → 查 SQLite → 取 project_uuid ② 有 conversation_id → 续聊 | 无 → 新建对话 转发到 AI 服务(SSE 请求) SSE 流式响应 data: { "completion": "回答文字" } SSE 转换为 OpenAI 兼容格式 data:{choices:[{delta:{content:"回"}}]} [DONE] Response Header: X-Conversation-Id: <uuid> 逐字渲染 · 存 conversation_id,下次续聊用 请求(实线) 响应(虚线) 组件激活中 关键设计说明 • Token:32字节随机 hex,存 SQLite,无过期时间 — localStorage 有值则刷新后无需重新登录 • 多轮对话:前端存 conversation_id,后端只发最新一条消息,对话历史由 AI 服务内部维护
实线 = 请求,虚线 = 响应,绿色激活框 = 前端处理中,紫色 = 后端处理中

认证模块

登录token

没有用JWT.
现在用户量极小(家庭/团队级),直接用随机 hex token 存 SQLite,简单直接:

  • 登录一次,token 永久有效
  • 要踢人直接删数据库记录
  • 没有任何过期逻辑需要维护

登录流程

用手机号+验证码,用户不需要记密码。后端实际上还是 username/password 体系,前端用 HMAC 从手机号派生一个确定性密码,对用户透明。

手机号 → 阿里云短信发验证码
用户输入验证码 → 前端调 /api/auth/verify
  → 后端校验验证码(内存 Map,5分钟过期)
  → HMAC-SHA256(phone, VERIFY_SECRET) 派生密码
  → 调后端 /auth/login,失败则先 /auth/register 再 login
  → 返回 token
前端 → localStorage.setItem("clawtoken_token", token)

前端接口

发送验证码

POST /api/auth/send-code
Content-Type: application/json

{ "phone": "13800138000" }

→ 200: { "ok": true }
→ 500: { "error": "账户余额不足" }

验证 + 登录/注册

POST /api/auth/verify
Content-Type: application/json

{ "phone": "13800138000", "code": "123456" }

→ 200: { "token": "3f8a2c...64位hex" }
→ 400: { "error": "验证码错误或已过期" }

token 取到后存 localStorage["clawtoken_token"],后续所有请求带上:

Authorization: Bearer <token>

对话模块

前端接口

发起对话(流式)

POST /api/chat
Content-Type: application/json
Authorization: Bearer <token>

{
  "model": "claude-sonnet-4-6",
  "messages": [
    { "role": "user", "content": "你好" }
  ],
  "stream": true,
  "conversation_id": "057c23fe-..."   // 可选,续聊时传
}

响应:SSE 流

data: {"choices":[{"delta":{"role":"assistant","content":"你"},"finish_reason":null}]}
data: {"choices":[{"delta":{"content":"好"},"finish_reason":null}]}
...
data: [DONE]

首次对话响应头里带有:

X-Conversation-Id: 057c23fe-xxxx-xxxx-xxxx-xxxxxxxxxxxx

前端存下这个 id,下次发消息带上,就能续上同一段对话。

SSE 解析关键代码

async function* sseStream(res: Response, abortRef: React.MutableRefObject<boolean>) {
  const reader = res.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  while (!abortRef.current) {
    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) {
      if (!line.startsWith("data: ")) continue;
      const raw = line.slice(6).trim();
      if (raw === "[DONE]") return;
      const json = JSON.parse(raw);
      const text = json.choices?.[0]?.delta?.content;
      if (text) yield text;
    }
  }
}

核心点:buffer 处理 TCP 分包,lines.pop() 保留未完成的行留到下次拼接。

多轮对话设计

后端不存历史消息,历史由 AI 服务那边的 Project/Conversation 机制维护。前端只需要:

  1. 首次对话:传完整 messages 数组,不传 conversation_id
  2. 收到响应头里的 X-Conversation-Id,存起来
  3. 后续对话:只传最新一条 user 消息 + conversation_id

客户端不用存历史,后端也不用存历史,省事。


下一步

这套系统稳定跑起来之后,打算先分享给家人和最亲近的朋友用。

一方面,好东西本来就该先给最近的人。顶级 AI 能力现在还不是人手一个,能帮他们养成习惯、真正用起来,比什么都值。另一方面,这些人也是最真实的测试用户——他们不会客气,遇到问题会直接说,比任何 A/B test 都管用。

让家人先跑起来,就是最好的产品测试。