Monorepo 公共逻辑

要点

  • 上一篇文章确定了 Monorepo 的整体架构:三个应用(server / web / admin)+ 三个共享包(shared / ui / config)
  • 先看整体目录,再逐层解释设计意图
  • 这是整个 Monorepo 中最重要的共享包
  • web 和 admin 都是 Next.js 应用,在 UI 层面存在可复用的组件
  • 配置包不包含运行时代码,只存放被其他项目 extends 的配置文件

内容

1. 从蓝图到施工图

上一篇文章确定了 Monorepo 的整体架构:三个应用(server / web / admin)+ 三个共享包(shared / ui / config),用 Turborepo + yarn workspaces 管理。

那篇文章回答的是「为什么这样做」。这篇文章要回答**「具体怎么做」**——目录怎么组织、公共逻辑怎么抽取、各子项目各自关注什么、开发和部署的工作流是什么样的。

2. 目录结构设计

先看整体目录,再逐层解释设计意图。

// structure.txt
ai-companion/
 
├── apps/
 
│   ├── server/                  # Hono 服务端
 
│   │   ├── src/
 
│   │   │   ├── routes/          # 路由定义
 
│   │   │   ├── middleware/      # 中间件
 
│   │   │   ├── services/        # 业务逻辑
 
│   │   │   └── index.ts         # 入口
 
│   │   ├── wrangler.toml        # CloudFlare Workers 配置
 
│   │   ├── tsconfig.json
 
│   │   └── package.json
 
│   │
 
│   ├── web/                     # Next.js 客户端
 
│   │   ├── src/
 
│   │   │   ├── app/             # App Router 页面
 
│   │   │   ├── components/      # 客户端专属组件
 
│   │   │   ├── hooks/           # 自定义 Hooks
 
│   │   │   ├── stores/          # Zustand stores
 
│   │   │   └── lib/             # 工具函数
 
│   │   ├── next.config.ts
 
│   │   ├── tsconfig.json
 
│   │   └── package.json
 
│   │
 
│   └── admin/                   # Next.js 后台管理
 
│       ├── src/
 
│       │   ├── app/             # App Router 页面
 
│       │   ├── components/      # 后台专属组件
 
│       │   ├── hooks/
 
│       │   ├── stores/
 
│       │   └── lib/
 
│       ├── next.config.ts
 
│       ├── tsconfig.json
 
│       └── package.json
 

 
├── packages/
 
│   ├── shared/                  # 共享类型、Schema、常量、工具
 
│   │   ├── src/
 
│   │   │   ├── schemas/         # Zod Schema
 
│   │   │   ├── types/           # TypeScript 类型
 
│   │   │   ├── constants/       # 业务常量与枚举
 
│   │   │   ├── utils/           # 工具函数
 
│   │   │   └── index.ts         # 统一导出
 
│   │   ├── tsconfig.json
 
│   │   └── package.json
 
│   │
 
│   ├── ui/                      # 共享 UI 组件
 
│   │   ├── src/
 
│   │   │   ├── components/      # 组件
 
│   │   │   ├── styles/          # 共享样式
 
│   │   │   └── index.ts
 
│   │   ├── tsconfig.json
 
│   │   └── package.json
 
│   │
 
│   └── config/                  # 共享配置
 
│       ├── tsconfig.base.json   # TypeScript 基础配置
 
│       ├── tsconfig.next.json   # Next.js 项目配置
 
│       ├── tsconfig.node.json   # Node/Workers 项目配置
 
│       └── biome.json           # Biome lint/format 配置
 

 
├── turbo.json                   # Turborepo 任务配置
 
├── package.json                 # 根 package.json
 
└── yarn.lock

apps/ 和 packages/ 的分界线。 apps/ 放可独立部署的应用,packages/ 放被应用依赖的共享包。这不是我们发明的约定——Turborepo、Nx、大多数 Monorepo 模板都遵循这个惯例。好处是一眼就能区分"产物"和"依赖"

每个子项目都有独立的 package.json。 这是 yarn workspaces 的要求。每个子项目声明自己的依赖和 scripts,workspaces 负责将它们链接在一起。

packages/config 不包含运行时代码。 它只存放配置文件(tsconfig、biome),被其他项目通过 extends 引用。把配置集中管理,修改一处全局生效。

3. packages/shared——公共逻辑的核心

这是整个 Monorepo 中最重要的共享包。它不包含任何 UI 组件和运行时框架依赖,只包含纯 TypeScript 代码——类型定义、验证规则、常量、工具函数。这意味着它可以同时被 Hono 服务端和两个 Next.js 应用引用,不存在运行时兼容问题。

3.1 Zod Schema 与类型定义

Zod Schema 是 packages/shared 的核心产出。一份 Schema 同时承担两个职责:运行时验证(服务端校验请求参数、客户端校验表单输入)和编译时类型推断(TypeScript 类型直接从 Schema 推导,不需要手动定义 interface)。

// schemas/chat.ts
// packages/shared/src/schemas/chat.ts
 
import { z } from 'zod'
 
import { emotionSchema } from './emotion'
 
// 对话消息 —— 用户发送
 
export const chatRequestSchema = z.object({
 
  content: z.string().min(1, '消息不能为空').max(2000, '消息过长'),
 
  session_id: z.string().uuid('无效的会话 ID'),
 
  emotion_hint: emotionSchema.optional(),
 
})
 
// 对话消息 —— AI 回复
 
export const chatResponseSchema = z.object({
 
  reply: z.string(),
 
  emotion: emotionSchema,
 
  memories_used: z.number().int().min(0),
 
  session_id: z.string().uuid(),
 
  created_at: z.string().datetime(),
 
})
 
// TypeScript 类型——从 Schema 推断,永远与验证规则同步
 
export type ChatRequest = z.infer<typeof chatRequestSchema>
 
export type ChatResponse = z.infer<typeof chatResponseSchema>
// schemas/emotion.ts
// packages/shared/src/schemas/emotion.ts
 
import { z } from 'zod'
 
export const emotionSchema = z.enum([
 
  'happy',      // 开心
 
  'sad',        // 难过
 
  'neutral',    // 平静
 
  'excited',    // 兴奋
 
  'anxious',    // 焦虑
 
  'calm',       // 放松
 
  'curious',    // 好奇
 
  'angry',      // 生气
 
  'tender',     // 温柔
 
  'playful',    // 调皮
 
])
 
export type Emotion = z.infer<typeof emotionSchema>
 
// 情绪转移规则的类型定义
 
export const emotionTransitionSchema = z.object({
 
  from: emotionSchema,
 
  to: emotionSchema,
 
  trigger: z.string(),
 
  probability: z.number().min(0).max(1),
 
})
 
export type EmotionTransition = z.infer<typeof emotionTransitionSchema>
// schemas/memory.ts
// packages/shared/src/schemas/memory.ts
 
import { z } from 'zod'
 
export const memoryTypeSchema = z.enum([
 
  'episodic',    // 情景记忆:具体事件
 
  'semantic',    // 语义记忆:归纳性知识
 
  'preference',  // 偏好记忆:用户喜好
 
])
 
export type MemoryType = z.infer<typeof memoryTypeSchema>
 
export const memorySchema = z.object({
 
  id: z.string(),
 
  user_id: z.string(),
 
  type: memoryTypeSchema,
 
  content: z.string(),
 
  importance: z.number().min(0).max(1),
 
  created_at: z.string().datetime(),
 
  last_accessed: z.string().datetime(),
 
  access_count: z.number().int().min(0),
 
})
 
export type Memory = z.infer<typeof memorySchema>

三端如何消费这些 Schema:

// server-usage.ts
// apps/server —— 服务端用 Zod 做请求验证
 
import { zValidator } from '@hono/zod-validator'
 
import { chatRequestSchema } from '@ai-companion/shared'
 
app.post('/chat', zValidator('json', chatRequestSchema), async (c) => {
 
  const body = c.req.valid('json')
 
  // body 的类型自动推断为 ChatRequest,无需手动标注
 
})
// web-usage.tsx
// apps/web —— 客户端用 Zod 做表单验证(配合 react-hook-form)
 
import { useForm } from 'react-hook-form'
 
import { zodResolver } from '@hookform/resolvers/zod'
 
import { chatRequestSchema, type ChatRequest } from '@ai-companion/shared'
 
const form = useForm<ChatRequest>({
 
  resolver: zodResolver(chatRequestSchema),
 
})
 
// 表单验证规则与服务端完全一致,用户输入超过 2000 字时客户端就会拦截
// admin-usage.tsx
// apps/admin —— 后台用类型定义渲染审计表格
 
import type { ChatResponse } from '@ai-companion/shared'
 
import { EmotionBadge } from '@ai-companion/ui'
 
const AuditTable = ({ records }: { records: ChatResponse[] }) => {
 
  // records 的类型与服务端返回的结构完全一致
 
  return records.map(r => (
 
    <tr key={r.session_id}>
 
      <td>{r.reply}</td>
 
      <td><EmotionBadge emotion={r.emotion} /></td>
 
      <td>{r.memories_used}</td>
 
    </tr>
 
  ))
 
}

一份 Schema,三种用法:服务端做运行时验证、客户端做表单验证、后台做类型约束。改一处,三端同步。

3.2 Hono RPC 类型导出

上一篇提到 Hono RPC 的端到端类型安全。具体实现方式是服务端导出路由类型,客户端通过 hc 创建类型安全的客户端。

// server-routes.ts
// apps/server/src/routes/chat.ts
 
import { Hono } from 'hono'
 
import { zValidator } from '@hono/zod-validator'
 
import { chatRequestSchema } from '@ai-companion/shared'
 
const chat = new Hono()
 
  .post('/', zValidator('json', chatRequestSchema), async (c) => {
 
    const body = c.req.valid('json')
 
    // ... 对话管线处理
 
    return c.json({
 
      reply: '你好呀~',
 
      emotion: 'happy' as const,
 
      memories_used: 3,
 
      session_id: body.session_id,
 
      created_at: new Date().toISOString(),
 
    })
 
  })
 
  .get('/history/:sessionId', async (c) => {
 
    const sessionId = c.req.param('sessionId')
 
    // ... 获取对话历史
 
    return c.json({ messages: [] })
 
  })
 
export { chat }
// server-index.ts
// apps/server/src/index.ts
 
import { Hono } from 'hono'
 
import { chat } from './routes/chat'
 
import { memory } from './routes/memory'
 
import { auth } from './routes/auth'
 
const app = new Hono()
 
  .route('/chat', chat)
 
  .route('/memory', memory)
 
  .route('/auth', auth)
 
export type AppType = typeof app
 
export default app
// web-client.ts
// apps/web/src/lib/api.ts
 
import { hc } from 'hono/client'
 
import type { AppType } from '@ai-companion/server'
 
export const api = hc<AppType>(process.env.NEXT_PUBLIC_API_URL!)
 
// 使用时,所有参数和返回值都有完整的类型提示
 
const res = await api.chat.$post({
 
  json: {
 
    content: '今天过得怎么样?',
 
    session_id: 'xxx-xxx-xxx',
 
  }
 
})
 
const data = await res.json()
 
// data.reply  → string
 
// data.emotion → 'happy' | 'sad' | 'neutral' | ...
 
// data.memories_used → number

这里的关键是 import type &#123; AppType &#125; from '@ai-companion/server'。在 Monorepo 中,这个 import 直接解析到 apps/server/src/index.ts 的源码。类型推导链路是:路由定义 → zValidator 的 Schema → 返回值的 c.json() → 最终的 AppType。整条链路都是编译时推断,零运行时开销。

web 和 admin 都可以用同一个 AppType,但通常 admin 会访问额外的管理 API,所以 server 可能会导出两个类型:

// server-types.ts
// apps/server/src/index.ts
 
export type AppType = typeof app       // 公共 API(web + admin 都能用)
 
export type AdminType = typeof admin   // 管理 API(仅 admin 使用)

3.3 认证与鉴权逻辑

认证相关的类型和工具函数是三端都需要的。

// auth.ts
// packages/shared/src/schemas/auth.ts
 
import { z } from 'zod'
 
export const roleSchema = z.enum(['user', 'admin', 'super_admin'])
 
export type Role = z.infer<typeof roleSchema>
 
// JWT payload 的类型定义
 
export const jwtPayloadSchema = z.object({
 
  sub: z.string(),           // 用户 ID
 
  role: roleSchema,
 
  iat: z.number(),           // 签发时间
 
  exp: z.number(),           // 过期时间
 
})
 
export type JwtPayload = z.infer<typeof jwtPayloadSchema>
 
// 登录请求
 
export const loginRequestSchema = z.object({
 
  email: z.string().email('无效的邮箱格式'),
 
  password: z.string().min(8, '密码至少 8 位'),
 
})
 
export type LoginRequest = z.infer<typeof loginRequestSchema>
 
// 注册请求
 
export const registerRequestSchema = loginRequestSchema.extend({
 
  nickname: z.string().min(2, '昵称至少 2 个字').max(20, '昵称最多 20 个字'),
 
})
 
export type RegisterRequest = z.infer<typeof registerRequestSchema>
// auth-utils.ts
// packages/shared/src/utils/auth.ts
 
import type { Role } from '../schemas/auth'
 
// 权限检查——服务端中间件和客户端路由守卫都会用到
 
const ROLE_HIERARCHY: Record<Role, number> = {
 
  user: 0,
 
  admin: 1,
 
  super_admin: 2,
 
}
 
export function hasPermission(userRole: Role, requiredRole: Role): boolean {
 
  return ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[requiredRole]
 
}
 
// Token 是否过期——客户端判断是否需要刷新 token
 
export function isTokenExpired(exp: number, bufferSeconds = 60): boolean {
 
  return Date.now() / 1000 > exp - bufferSeconds
 
}

服务端用这些类型定义中间件的鉴权逻辑,客户端用 isTokenExpired 判断是否需要静默刷新 token,后台用 hasPermission 做前端路由守卫。同一套权限模型,三端行为一致。

3.4 业务常量与错误码

// constants.ts
// packages/shared/src/constants/index.ts
 
// 记忆系统参数
 
export const MEMORY = {
 
  MAX_EPISODIC: 1000,        // 单用户最大情景记忆数
 
  MAX_SEMANTIC: 200,         // 单用户最大语义记忆数
 
  IMPORTANCE_THRESHOLD: 0.3, // 低于此阈值的记忆可能被遗忘
 
  RETRIEVAL_TOP_K: 5,        // 语义检索返回的最大记忆数
 
  EMBEDDING_DIMENSION: 768,  // 向量维度(BGE base)
 
} as const
 
// 对话参数
 
export const CHAT = {
 
  MAX_HISTORY_TURNS: 20,     // 上下文窗口中保留的最大对话轮数
 
  MAX_MESSAGE_LENGTH: 2000,  // 单条消息最大长度
 
  STREAM_CHUNK_SIZE: 10,     // 流式传输的 chunk 大小(token 数)
 
} as const
 
// 情绪系统参数
 
export const EMOTION = {
 
  DECAY_RATE: 0.1,           // 情绪衰减速率(趋向 neutral 的速度)
 
  TRANSITION_THRESHOLD: 0.6, // 触发情绪转移的最低概率
 
  UPDATE_INTERVAL: 300,      // 情绪状态更新间隔(秒)
 
} as const
// errors.ts
// packages/shared/src/constants/errors.ts
 
export const ERROR_CODES = {
 
  // 认证错误 1xxx
 
  AUTH_INVALID_TOKEN: { code: 1001, message: 'token 无效或已过期' },
 
  AUTH_INSUFFICIENT_ROLE: { code: 1002, message: '权限不足' },
 
  AUTH_EMAIL_EXISTS: { code: 1003, message: '邮箱已注册' },
 
  // 对话错误 2xxx
 
  CHAT_CONTENT_TOO_LONG: { code: 2001, message: '消息内容超过长度限制' },
 
  CHAT_SESSION_NOT_FOUND: { code: 2002, message: '会话不存在' },
 
  CHAT_RATE_LIMITED: { code: 2003, message: '发送过于频繁,请稍后再试' },
 
  CHAT_UNSAFE_CONTENT: { code: 2004, message: '消息内容不合规' },
 
  // 记忆错误 3xxx
 
  MEMORY_NOT_FOUND: { code: 3001, message: '记忆不存在' },
 
  MEMORY_LIMIT_EXCEEDED: { code: 3002, message: '记忆数量已达上限' },
 
  // 系统错误 5xxx
 
  INTERNAL_ERROR: { code: 5001, message: '系统内部错误' },
 
  LLM_TIMEOUT: { code: 5002, message: 'AI 响应超时' },
 
  LLM_UNAVAILABLE: { code: 5003, message: 'AI 服务暂时不可用' },
 
} as const
 
export type ErrorCode = keyof typeof ERROR_CODES

服务端根据错误码返回标准化的错误响应,客户端根据错误码展示对应的用户提示(CHAT_RATE_LIMITED → 显示"发送太快了"),后台根据错误码做错误分类统计和告警规则配置。错误码是三端的公共语言。

3.5 工具函数

// utils.ts
// packages/shared/src/utils/index.ts
 
import { nanoid } from 'nanoid'
 
// ID 生成——统一长度和字符集
 
export function generateId(prefix?: string): string {
 
  const id = nanoid(16)
 
  return prefix ? `${prefix}_${id}` : id
 
}
 
// 日期格式化——对话时间展示
 
export function formatChatTime(isoString: string): string {
 
  const date = new Date(isoString)
 
  const now = new Date()
 
  const diffMs = now.getTime() - date.getTime()
 
  const diffMin = Math.floor(diffMs / 60000)
 
  if (diffMin < 1) return '刚刚'
 
  if (diffMin < 60) return `${diffMin} 分钟前`
 
  const diffHour = Math.floor(diffMin / 60)
 
  if (diffHour < 24) return `${diffHour} 小时前`
 
  const diffDay = Math.floor(diffHour / 24)
 
  if (diffDay < 7) return `${diffDay} 天前`
 
  return date.toLocaleDateString('zh-CN')
 
}
 
// 文本截断——记忆预览、消息摘要
 
export function truncateText(text: string, maxLength: number): string {
 
  if (text.length <= maxLength) return text
 
  return text.slice(0, maxLength - 1) + '…'
 
}
 
// 安全的 JSON 解析——避免 LLM 返回格式异常时崩溃
 
export function safeJsonParse<T>(text: string, fallback: T): T {
 
  try {
 
    return JSON.parse(text) as T
 
  } catch {
 
    return fallback
 
  }
 
}

这些工具函数看似简单,但如果不集中管理,三个子项目会各自实现一套。时间格式不统一、ID 长度不一致、JSON 解析有的做了错误处理有的没做——这些微小的不一致会在后期成为 bug 的温床。

3.6 统一导出

// index.ts
// packages/shared/src/index.ts
 
// Schemas
 
export * from './schemas/chat'
 
export * from './schemas/emotion'
 
export * from './schemas/memory'
 
export * from './schemas/auth'
 
// Constants
 
export * from './constants'
 
export * from './constants/errors'
 
// Utils
 
export * from './utils'
 
export * from './utils/auth'

使用时一行 import 搞定:

// example.ts
import {
 
  chatRequestSchema,
 
  type ChatRequest,
 
  type Emotion,
 
  ERROR_CODES,
 
  MEMORY,
 
  formatChatTime,
 
  hasPermission,
 
} from '@ai-companion/shared'

4. packages/ui——共享 UI 组件

web 和 admin 都是 Next.js 应用,在 UI 层面存在可复用的组件。packages/ui 存放这些共享组件。

设计原则:只放真正共享的组件。 如果一个组件只在 web 中使用,就放在 apps/web/src/components/ 里。只有当 web 和 admin 都需要同一个组件时,才提升到 packages/ui。避免过早抽象。

// emotion-badge.tsx
// packages/ui/src/components/emotion-badge.tsx
 
import type { Emotion } from '@ai-companion/shared'
 
const EMOTION_CONFIG: Record<Emotion, { label: string; color: string }> = {
 
  happy: { label: '开心', color: 'bg-yellow-100 text-yellow-800' },
 
  sad: { label: '难过', color: 'bg-blue-100 text-blue-800' },
 
  neutral: { label: '平静', color: 'bg-gray-100 text-gray-800' },
 
  excited: { label: '兴奋', color: 'bg-orange-100 text-orange-800' },
 
  anxious: { label: '焦虑', color: 'bg-purple-100 text-purple-800' },
 
  calm: { label: '放松', color: 'bg-green-100 text-green-800' },
 
  curious: { label: '好奇', color: 'bg-cyan-100 text-cyan-800' },
 
  angry: { label: '生气', color: 'bg-red-100 text-red-800' },
 
  tender: { label: '温柔', color: 'bg-pink-100 text-pink-800' },
 
  playful: { label: '调皮', color: 'bg-amber-100 text-amber-800' },
 
}
 
export const EmotionBadge = ({ emotion }: { emotion: Emotion }) => {
 
  const config = EMOTION_CONFIG[emotion]
 
  return (
 
    <span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${config.color}`}>
 
      {config.label}
 
    </span>
 
  )
 
}
// chat-bubble.tsx
// packages/ui/src/components/chat-bubble.tsx
 
import { formatChatTime } from '@ai-companion/shared'
 
import { EmotionBadge } from './emotion-badge'
 
import type { Emotion } from '@ai-companion/shared'
 
interface ChatBubbleProps {
 
  content: string
 
  isAI: boolean
 
  emotion?: Emotion
 
  createdAt: string
 
}
 
export const ChatBubble = ({ content, isAI, emotion, createdAt }: ChatBubbleProps) => {
 
  return (
 
    <div className={`flex ${isAI ? 'justify-start' : 'justify-end'} mb-4`}>
 
      <div className={`max-w-100 rounded-2xl px-4 py-3 ${
 
        isAI
 
          ? 'bg-gray-100 dark:bg-gray-800 rounded-tl-none'
 
          : 'bg-blue-500 text-white rounded-tr-none'
 
      }`}>
 
        <p className="text-sm leading-relaxed">{content}</p>
 
        <div className="mt-1.5 flex items-center gap-2">
 
          {isAI && emotion && <EmotionBadge emotion={emotion} />}
 
          <span className="text-xs opacity-50">{formatChatTime(createdAt)}</span>
 
        </div>
 
      </div>
 
    </div>
 
  )
 
}

web 用 ChatBubble 渲染聊天界面,admin 用同一个 ChatBubble 渲染对话审计的详情页。同一条消息在用户端和管理端的展示完全一致,减少"管理员看到的和用户看到的不一样"的认知偏差。

5. packages/config——共享配置

配置包不包含运行时代码,只存放被其他项目 extends 的配置文件。

// tsconfig.base.json
// packages/config/tsconfig.base.json
 
{
 
  "compilerOptions": {
 
    "strict": true,
 
    "target": "ES2022",
 
    "module": "ESNext",
 
    "moduleResolution": "bundler",
 
    "resolveJsonModule": true,
 
    "isolatedModules": true,
 
    "esModuleInterop": true,
 
    "skipLibCheck": true,
 
    "forceConsistentCasingInFileNames": true,
 
    "declaration": true,
 
    "declarationMap": true,
 
    "sourceMap": true
 
  }
 
}
// tsconfig.next.json
// packages/config/tsconfig.next.json
 
{
 
  "extends": "./tsconfig.base.json",
 
  "compilerOptions": {
 
    "lib": ["DOM", "DOM.Iterable", "ES2022"],
 
    "jsx": "preserve",
 
    "plugins": [{ "name": "next" }],
 
    "allowJs": true,
 
    "noEmit": true
 
  }
 
}
// tsconfig.node.json
// packages/config/tsconfig.node.json
 
{
 
  "extends": "./tsconfig.base.json",
 
  "compilerOptions": {
 
    "lib": ["ES2022"],
 
    "types": ["@cloudflare/workers-types"],
 
    "outDir": "./dist"
 
  }
 
}

各子项目的 tsconfig 只需一行 extends:

// tsconfig.json
// apps/web/tsconfig.json
 
{
 
  "extends": "@ai-companion/config/tsconfig.next.json",
 
  "compilerOptions": {
 
    "paths": {
 
      "@/*": ["./src/*"]
 
    }
 
  },
 
  "include": ["src/**/*", "next-env.d.ts"],
 
  "exclude": ["node_modules"]
 
}

升级 TypeScript 版本、修改 strict 规则、添加编译选项——改 packages/config 中的一个文件,所有子项目同步生效。

6. 各子项目的独立关注点

共享逻辑之外,每个子项目还有自己独特的技术关注点。

server 的独立关注点:

  • 中间件编排。 安全检查、认证、限流、日志、耗时统计——中间件的顺序和组合是服务端特有的逻辑
  • CloudFlare Bindings。 wrangler.toml 中配置的 KV / D1 / Vectorize / AI 绑定,以及 c.env 的类型声明
  • 流式响应。 SSE 连接管理、LLM 输出的逐 token 转发、异常断连的处理
  • 后台任务。 记忆归纳(定期将情景记忆归纳为语义记忆)、情绪衰减(定期将情绪趋向 neutral)

web 的独立关注点:

  • SSR / ISR 策略。 落地页用 SSG,对话页用纯客户端渲染,用户设置页用 SSR + 客户端水合
  • 客户端状态管理。 Zustand store 管理对话状态、用户信息、SSE 连接状态
  • 流式消息渲染。 接收 SSE 推送,逐字追加到消息气泡中,流畅的打字机效果
  • 移动端适配。 响应式布局、触摸手势、虚拟键盘弹出时的界面调整
  • 离线体验。 Service Worker 缓存关键资源,弱网环境下的消息队列和重试

admin 的独立关注点:

  • 权限路由守卫。 根据管理员角色动态展示菜单项、拦截无权限的页面访问
  • 数据可视化。 ECharts / Recharts 渲染各类统计图表
  • 表格与表单。 大量数据的分页表格、复杂配置项的表单(Prompt 编辑器、参数调优面板)
  • 操作审计日志。 管理员的每个操作(修改配置、删除记忆、查看对话)都需要记录

7. 开发与部署工作流

7.1 根 package.json 与 workspace 配置

package.json 的核心配置:

// package.json
{
 
  "name": "ai-companion",
 
  "private": true,
 
  "workspaces": [
 
    "apps/*",
 
    "packages/*"
 
  ],
 
  "scripts": {
 
    "dev": "turbo dev",
 
    "build": "turbo build",
 
    "typecheck": "turbo typecheck",
 
    "lint": "turbo lint",
 
    "lint:fix": "turbo lint:fix",
 
    "format": "turbo format"
 
  },
 
  "devDependencies": {
 
    "turbo": "^2.0.0",
 
    "typescript": "^5.5.0"
 
  },
 
  "packageManager": "[email protected]"
 
}

7.2 日常开发流程

// dev.bash
# 克隆仓库后,一条命令安装所有依赖
 
yarn install
 
# 一条命令启动所有子项目的开发服务器
 
yarn dev
 
# Turborepo 并行启动:
 
#   server  → wrangler dev (localhost:8787)
 
#   web     → next dev (localhost:3000)
 
#   admin   → next dev (localhost:3001)
 
# 只启动某个子项目
 
yarn dev --filter=@ai-companion/web
 
# 全仓库类型检查
 
yarn typecheck
 
# 全仓库 lint
 
yarn lint

turbo dev 会并行启动三个开发服务器。修改 packages/shared 中的代码后,依赖它的 server、web、admin 会自动触发热更新。修改一份 Schema,三个终端同时刷新。

7.3 部署策略

三个应用部署到不同的平台,各自有独立的部署流程:

// deploy.txt
┌─────────────────────────────────────────────────┐
 
│                  Git Push / PR Merge             │
 
│                       │                          │
 
│          Turborepo 检测受影响的子项目              │
 
│                       │                          │
 
│     ┌─────────────────┼─────────────────┐        │
 
│     ▼                 ▼                 ▼        │
 
│ ┌────────┐     ┌────────────┐    ┌──────────┐   │
 
│ │ server │     │    web     │    │  admin   │   │
 
│ │        │     │            │    │          │   │
 
│ │wrangler│     │  Vercel /  │    │ Vercel / │   │
 
│ │ deploy │     │  CF Pages  │    │ 内部部署  │   │
 
│ │        │     │            │    │          │   │
 
│ │  ↓     │     │  ↓         │    │  ↓       │   │
 
│ │Workers │     │  CDN Edge  │    │ 内网访问  │   │
 
│ └────────┘     └────────────┘    └──────────┘   │
 
└─────────────────────────────────────────────────┘
  • serverwrangler deploy 部署到 CloudFlare Workers
  • web → Vercel 自动部署(检测到 apps/web 变更时触发)或 CloudFlare Pages
  • admin → 部署到内部环境或带访问控制的 Vercel 项目(vercel --scope internal

Turborepo 的缓存在 CI 中尤其有价值:如果一次 PR 只修改了 apps/web 的代码,apps/serverapps/admin 的构建会直接命中缓存,CI 时间大幅缩短。

8. 总结

这篇文章把上一篇的架构蓝图转化成了具体的工程方案:

目录结构。 apps/ 放三个可独立部署的应用,packages/ 放三个共享包(shared / ui / config)。清晰的分层让每个子项目的职责边界一目了然。

packages/shared 是核心。 Zod Schema 一份定义、三端复用;Hono RPC 类型直接 import、端到端安全;错误码和业务常量统一管理、改一处全局同步。这个包的质量直接决定了整个 Monorepo 的协作效率。

packages/ui 克制共享。 只提升真正被多个应用使用的组件,避免过早抽象。

开发体验优先。 yarn dev 一键启动、修改 Schema 三端热更新、yarn typecheck 全仓库类型校验。开发者的日常体验决定了这套架构能不能被团队真正执行下去。

从下一篇文章开始,我们将离开规划阶段,进入真正的编码实现。第一步是搭建 Hono 服务端的基础骨架——路由结构、中间件管线、以及与 CloudFlare Workers 的部署联调。