studio src/lib/sse.ts 已是真实实现(connectAIStream/connectEventStream 双函数 +
fetch/ReadableStream + 按 event: 行分发),但三处正式文档仍停留在虚构契约,故反向对齐(反假绿):
- 前端-01 v6→v7:AI 流改两步(POST /ai/tasks 创建→GET /ai/tasks/{taskId}/stream 建流);
事件流路径 /events/stream→/events;明确按 SSE event: 行分发。
- 后端-05 v8→v9:补 GET /ai/tasks/{taskId}/stream 端点 + SSE 事件契约表
(chunk/quality_check/done/error 及各 payload);记 done 的 taskId/suggestionId
后端 Long 序列化为 JSON 数字、与候选 uuid 字符串契约不一致(待后端统一,前端已在解析边界 String 归一)。
- dev-baseline/muse-studio/CLAUDE.md:SSE 章节由虚构 useAIStream/useEventStream hook
改写为真实双函数 + 线格式 + 约束(AI 流不重连、事件流指数退避 1/2/5/10s、AbortController 关闭、无凭证 fail-closed)。
- sse.ts:onDone 注释补 WHY string(Long→JSON number→String 归一)。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
15 KiB
Muse Studio - 前端工程基线
项目概述
Muse Studio 是自研用户端创作工具,基于 React + Vite + TypeScript 构建的 SPA 应用。 核心能力包括:AI 辅助创作编辑器、智能体工作台、知识库管理、创作市场、个人中心。
- 框架:React 18 + Vite + TypeScript(strict 模式)
- 状态管理:TanStack Query(服务端状态)+ Zustand(客户端状态)+ React Hook Form + Zod(表单)
- 编辑器:Tiptap / ProseMirror
- 样式:Tailwind CSS
- 测试:Vitest + Testing Library + Playwright + MSW
- 开发模式:AI Agent 驱动开发
编码规范
TypeScript 严格模式
// tsconfig.json 关键配置
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}
中文注释要求
- 所有导出函数/组件/Hook 必须有中文 JSDoc
- 关键业务逻辑必须有详细中文行内注释
- Props 接口每个字段必须有中文注释
- 复杂算法、状态流转、副作用必须有中文说明
/**
* 场景卡片组件
* 展示单个创作场景的预览信息,支持点击进入编辑
*/
export function SceneCard({ scene, onSelect }: SceneCardProps) {
// 计算场景字数统计(排除标点和空格)
const wordCount = useMemo(() => countWords(scene.content), [scene.content]);
// ...
}
Props 接口规范
/** 场景卡片属性 */
interface SceneCardProps {
/** 场景数据对象 */
scene: Scene;
/** 选中场景时的回调,传入场景 ID */
onSelect: (sceneId: string) => void;
/** 是否处于只读模式,默认 false */
readonly?: boolean;
/** 自定义样式类名 */
className?: string;
}
类型安全
- 禁止
any,使用unknown+ 类型守卫 - 禁止
as类型断言(除非有充分注释说明安全性) - 禁止
@ts-ignore,使用@ts-expect-error并附带原因注释 - 优先使用 discriminated union 而非可选字段
// 正确:类型守卫
function isApiError(error: unknown): error is ApiError {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
'message' in error
);
}
组件规范
- 函数组件 + Hooks,禁止 class 组件
- 组件文件使用 PascalCase:
SceneCard.tsx - Hook 文件使用 camelCase:
useSceneList.ts - 单个组件文件不超过 300 行,超过则拆分
- 组件职责单一,展示与逻辑分离
目录结构
src/
├── app/ # 应用入口、路由配置、全局 Provider
│ ├── routes/ # 路由定义(按功能模块组织)
│ ├── providers/ # 全局 Context Provider
│ └── App.tsx
├── components/ # 通用 UI 组件(与业务无关)
│ ├── ui/ # 基础 UI 原子组件(Button, Input, Modal...)
│ ├── layout/ # 布局组件(Sidebar, Header, Container...)
│ └── feedback/ # 反馈组件(Toast, Loading, ErrorBoundary...)
├── features/ # 业务功能模块(核心开发区域)
│ ├── editor/ # 编辑器模块(Tiptap 扩展、工具栏、协作)
│ ├── agent/ # 智能体工作台(对话、编排、调试)
│ ├── knowledge/ # 知识库管理(上传、索引、检索)
│ ├── market/ # 创作市场(浏览、购买、发布)
│ └── account/ # 个人中心(设置、订阅、数据)
├── hooks/ # 通用自定义 Hook
├── lib/ # 工具函数库(纯函数、无副作用)
├── stores/ # Zustand 全局状态 store
├── types/ # 全局类型定义
└── api/ # API 层(TanStack Query hooks)
├── client.ts # HTTP 客户端配置(拦截器、错误处理)
├── hooks/ # 按资源组织的 Query/Mutation hooks
└── mocks/ # MSW mock handlers
Feature 模块内部结构
features/editor/
├── components/ # 模块内组件
├── hooks/ # 模块内 Hook
├── stores/ # 模块内局部 store
├── types/ # 模块内类型
├── utils/ # 模块内工具函数
└── index.ts # 模块公开 API(barrel export)
状态管理
三层状态模型
| 层级 | 工具 | 适用场景 |
|---|---|---|
| 服务端状态 | TanStack Query | API 数据、缓存、同步、乐观更新 |
| 客户端状态 | Zustand | UI 状态、用户偏好、编辑器临时状态 |
| 表单状态 | React Hook Form + Zod | 表单输入、校验、提交 |
TanStack Query 规范
/** 获取场景列表 */
export function useSceneList(workId: string) {
return useQuery({
queryKey: ['scenes', workId],
queryFn: () => api.scenes.list(workId),
staleTime: 30_000, // 30 秒内不重新请求
});
}
/** 更新场景内容(乐观更新) */
export function useSceneUpdate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.scenes.update,
onMutate: async (newScene) => {
// 乐观更新:立即更新本地缓存
await queryClient.cancelQueries({ queryKey: ['scenes', newScene.workId] });
const previous = queryClient.getQueryData(['scenes', newScene.workId]);
queryClient.setQueryData(['scenes', newScene.workId], (old) => /* 更新逻辑 */);
return { previous };
},
onError: (_err, _new, context) => {
// 回滚:恢复之前的缓存
queryClient.setQueryData(['scenes', context?.previous]);
},
});
}
Zustand 规范
/** 编辑器 UI 状态 */
interface EditorUIStore {
/** 当前激活的面板 */
activePanel: 'outline' | 'suggestions' | 'knowledge' | null;
/** 是否显示 AI 建议浮层 */
showSuggestions: boolean;
/** 切换面板 */
setActivePanel: (panel: EditorUIStore['activePanel']) => void;
}
export const useEditorUIStore = create<EditorUIStore>((set) => ({
activePanel: null,
showSuggestions: false,
setActivePanel: (panel) => set({ activePanel: panel }),
}));
SSE 实时通信规范
双通道设计
studio 的 SSE 能力集中在 src/lib/sse.ts,对外暴露两个函数(既不是 React Hook,也不用浏览器原生 EventSource):
- AI Stream 通道:
connectAIStream(url, handlers)—— AI 生成内容的流式传输(独立连接,单次请求生命周期)。 - 事件 Stream 通道:
connectEventStream(lastEventId?)—— 系统事件的统一推送(应用级长连接)。
WHY 不用
EventSource:两条通道都要带自定义请求头(X-API-Version,事件流还要Authorization: Bearer),而EventSource无法设置请求头,故统一用fetch+ReadableStream读流。
线格式与事件分发(务必对齐后端)
后端用 Spring SseEmitter.event().name(<事件名>).data(<JSON>) 发送:事件类型在 SSE event: 行,data: 行只是该事件的 payload JSON(不含 type 字段),id: 行承载可续传序号。因此前端必须按 event: 行分发,禁止按 data 里的 type 字段分发。两条通道共用同一套「按 event: 行解析 + id / comment / 多行 data 拼接」的 parser。
AI Stream 的四类事件与 payload:
| event | data payload | 说明 |
|---|---|---|
chunk |
{ content, sequenceNo } |
Token 级增量文本 |
quality_check |
{ dimension, score, passed } |
影子层质检结果 |
done |
{ taskId, suggestionId } |
生成结束;suggestionId 是后续采纳写入 Canonical 的唯一入参 |
error |
{ code, message } |
已脱敏错误,前端展示并关闭连接 |
WHY
done的 ID 归一为 string:后端taskId/suggestionId实为 Long、会序列化为 JSON 数字,而前端全链路(采纳请求体、生成的 OpenAPI 类型)按 string 处理;故在done解析边界统一String()归一后再回调,避免数字流入采纳请求体。
关键约束
- AI Stream:每次请求建立独立连接,用
AbortController支持中止;不自动重试/重连(生成是一次性请求,失败走onError由用户重发)。 - 事件 Stream:应用级长连接,断线按指数退避自动重连(
1s → 2s → 5s → 10s封顶),以收到的id作为lastEventIdcursor 续传;缺登录凭证时 fail-closed(不发空 Bearer)。 - 所有 SSE 连接必须在组件卸载时通过
AbortController正确关闭,避免连接泄漏。 - 解析失败统一走
onError(SSE_PARSE_ERROR),不让异常吞掉整条流。
编辑器规范
架构层级
Block(场景/小节)→ Section(段落)→ Inline(文本/标记)
自动保存策略
- 触发:内容变更后 debounce 2 秒
- 本地缓存:变更立即写入 IndexedDB(防丢失)
- 远程同步:debounce 后调用 API 保存
- 冲突处理:基于版本号的 last-write-wins + 冲突提示
/**
* 编辑器自动保存 Hook
* 实现 IndexedDB 本地缓存 + 远程 debounce 保存
*/
export function useAutoSave(editor: Editor, sceneId: string) {
// 1. 监听 editor transaction
// 2. 每次变更立即写入 IndexedDB
// 3. debounce 2s 后调用远程保存 API
// 4. 保存失败时保留 IndexedDB 数据,下次重试
}
ProseMirror Transaction 规范
- 所有编辑操作通过 Transaction 执行,禁止直接操作 DOM
- 自定义 Node/Mark 必须定义完整的 schema(toDOM、parseDOM)
- 插件(Plugin)按职责拆分,单个插件不超过 200 行
- AI 建议内容使用 Decoration 渲染,不修改文档结构
API 层规范
Hook 命名约定
use{Resource}{Action}
示例:
- useSceneList → 获取场景列表
- useSceneDetail → 获取场景详情
- useSceneCreate → 创建场景
- useSceneUpdate → 更新场景
- useSceneDelete → 删除场景
- useAgentChat → 智能体对话
请求客户端
// api/client.ts
// 统一 HTTP 客户端,基于 fetch 封装
// - 自动附加 Authorization header
// - 统一错误格式转换(后端 ApiResponse → 前端 ApiError)
// - 401 自动跳转登录
// - 请求/响应日志(开发环境)
MSW Mock 规范
- 所有 API Hook 必须有对应的 MSW handler
- Mock 数据使用工厂函数生成(faker + 固定 seed)
- Mock handler 放在
src/api/mocks/handlers/按资源组织 - 开发环境默认启用 MSW,通过环境变量切换真实 API
测试规范
覆盖率要求
| 类型 | 最低覆盖率 |
|---|---|
| 通用组件 | >= 75% |
| 自定义 Hook | >= 80% |
| 新增代码 | >= 85% |
| 工具函数 | >= 90% |
测试分层
| 层级 | 工具 | 关注点 |
|---|---|---|
| 单元测试 | Vitest | 工具函数、纯逻辑 |
| 组件测试 | Vitest + Testing Library | 组件渲染、交互、状态 |
| Hook 测试 | renderHook | 自定义 Hook 行为 |
| E2E 测试 | Playwright | 用户完整流程 |
测试文件组织
src/features/editor/components/
├── SceneCard.tsx
├── SceneCard.test.tsx # 组件测试
└── __tests__/
└── SceneCard.e2e.ts # E2E 测试(可选)
测试要求
- 新增组件/Hook 必须同时提交测试文件
- 测试用例使用中文描述
- 测试遵循 AAA 模式(Arrange-Act-Assert)
- 避免测试实现细节,关注用户行为和输出
describe('SceneCard 场景卡片', () => {
it('应该正确显示场景标题和字数', () => {
// 准备
const scene = createMockScene({ title: '第一章', wordCount: 1500 });
// 执行
render(<SceneCard scene={scene} onSelect={vi.fn()} />);
// 断言
expect(screen.getByText('第一章')).toBeInTheDocument();
expect(screen.getByText('1500 字')).toBeInTheDocument();
});
it('点击卡片时应该触发 onSelect 回调', async () => {
const onSelect = vi.fn();
render(<SceneCard scene={createMockScene()} onSelect={onSelect} />);
await userEvent.click(screen.getByRole('button'));
expect(onSelect).toHaveBeenCalledWith(expect.any(String));
});
});
Lint 与格式化
工具链
- ESLint:代码质量 + TypeScript 规则
- Prettier:代码格式化
- Stylelint:CSS/Tailwind 规则
- husky + lint-staged:提交前自动检查
关键 ESLint 规则
{
"rules": {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/explicit-function-return-type": "warn",
"react-hooks/exhaustive-deps": "error",
"import/no-cycle": "error",
"no-console": ["warn", { "allow": ["warn", "error"] }]
}
}
提交前检查
// lint-staged 配置
{
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
"*.css": ["stylelint --fix", "prettier --write"],
"*.{json,md}": ["prettier --write"]
}
Git 工作流
提交信息格式
{类型}({模块}): {中文描述}
类型:feat | fix | refactor | style | test | docs | chore
模块:editor | agent | knowledge | market | account | common | api
示例:
feat(editor): 实现场景自动保存与 IndexedDB 缓存
fix(agent): 修复 SSE 断线后未自动重连的问题
test(knowledge): 补充知识库上传组件单元测试
分支命名
feature/{模块}-{描述}
fix/{模块}-{描述}
refactor/{模块}-{描述}
示例:
feature/editor-auto-save
fix/agent-sse-reconnect
Agent 行为约束
AI Agent 在本项目中开发时必须遵循以下规则:
- 先读后写:修改任何文件前,必须先读取该文件当前内容
- 复用现有组件:优先使用
src/components/中已有的 UI 组件 - 每次变更运行测试:代码修改后必须运行
pnpm vitest --run确认不破坏现有测试 - UI 变更验证:涉及 UI 的修改必须启动 dev server 进行视觉验证
- 不引入新依赖:除非明确需要且经过确认,不自行添加 npm 包
- 遵循现有模式:新代码必须与同模块已有代码风格一致
- 测试同步提交:新增组件/Hook 必须同时编写并提交测试
- 中文注释:所有新增代码必须包含中文注释(JSDoc + 行内)
关键设计文档引用
开发时应参考以下设计文档(位于仓库根目录 design-docs/):
前端-01-工程结构与核心依赖.md— 工程架构、依赖选型、构建配置前端-02-编辑器与影子层交互.md— 编辑器核心架构、AI 影子层设计前端-03-元引擎与动态表单.md— 元数据驱动的动态 UI 生成前端-04-市场与个人中心交互.md— 市场浏览、交易、个人中心流程前端-05-智能体与知识库工作台交互.md— 智能体对话、知识库管理后端-05-统一API契约-v1.md— 后端 API 接口规范(前端对接依据)