上一篇文章确定了 Monorepo 的整体架构:仓库中包含 server、web、admin 三个应用,以及 shared、ui、config 三个共享包,并使用 Turborepo 和 yarn workspaces 统一管理。
这篇文章继续把架构落实到具体的工程结构中。我们会依次确定目录如何组织、公共逻辑放在哪里、各个子项目分别关注什么,以及日常开发和部署流程如何配合。
2. 目录结构
我们先看完整目录,再逐层说明每个位置承担的职责。
apps/ 与 packages/ 之间有明确的职责边界。apps/ 存放可以独立部署的应用,packages/ 存放被这些应用引用的共享包。Turborepo、Nx 和多数 Monorepo 模板都采用类似约定,开发者可以据此区分最终部署的产物与内部依赖。
每个子项目都拥有独立的 package.json,这是 Yarn workspaces 组织工作区的基础。应用和共享包分别声明自己的依赖与 scripts,再由 workspaces 建立它们之间的链接。
packages/config 不包含运行时代码,只保存 tsconfig、Biome 等配置文件,其他项目通过 extends 引用。公共配置集中后,只需要修改一处,就能让各子项目使用相同的工程规则。
3. packages/shared 公共逻辑
packages/shared 负责保存类型定义、验证规则、业务常量和工具函数。它不包含 UI 组件,也不依赖具体的运行时框架,内部以 TypeScript 为主。只要 Zod、nanoid 等运行时依赖也兼容目标环境,Hono 服务端和两个 Next.js 应用就可以直接引用它。
Zod 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>
定义完成后,server、web 和 admin 可以按照各自的用途引用这些 Schema。
server-usage.ts
// apps/server —— 服务端用 Zod 做请求验证
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { chatRequestSchema } from '@ai-companion/shared'
const app = new Hono()
app.post('/chat', zValidator('json', chatRequestSchema), async (c) => {
const body = c.req.valid('json')
// body 的类型自动推断为 ChatRequest,无需手动标注
return c.json({ reply: `收到: ${body.content}` })
})
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'
export const ChatForm = () => {
const form = useForm<ChatRequest>({
resolver: zodResolver(chatRequestSchema),
})
return <form onSubmit={form.handleSubmit(() => undefined)} />
}
// 表单验证规则与服务端完全一致,用户输入超过 2000 个字符时客户端就会拦截
admin-usage.tsx
// apps/admin —— 后台用类型定义渲染审计表格
import type { ChatResponse } from '@ai-companion/shared'
import { EmotionBadge } from '@ai-companion/ui'
export const AuditTable = ({ records }: { records: ChatResponse[] }) => {
// records 的类型与服务端返回的结构完全一致
return (
<tbody>
{records.map(r => (
<tr key={r.session_id}>
<td>{r.reply}</td>
<td><EmotionBadge emotion={r.emotion} /></td>
<td>{r.memories_used}</td>
</tr>
))}
</tbody>
)
}
同一份 Schema 在服务端负责运行时验证,在客户端负责表单验证,在后台负责类型约束。规则发生变化后,三个应用在同一工作区重新检查或构建时,可以同步看到新的定义。
上一篇文章介绍了 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