docs(sse): 前端/后端 SSE 契约反向对齐 sse.ts 真实实现

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>
This commit is contained in:
lili 2026-06-16 02:55:24 -07:00
parent 9634842f2f
commit cb57b88866
4 changed files with 45 additions and 40 deletions

View File

@ -1,7 +1,7 @@
# 前端-01工程结构与核心依赖
- 版本v6
- 更新日期2026-05-24
- 版本v7
- 更新日期2026-06-14
- 目标读者:前端 / 架构 / 产品 / 后端
- 阅读时间25-40 分钟
- 边界说明:本文件只定义阶段 7 的前端工程基线、管理后台与用户端拆分、路由组织、状态分层和接口边界。写作台交互看 `前端-02`,元引擎看 `前端-03`,精确 API 看 `后端-05`
@ -192,15 +192,16 @@ muse-studio/
### 6.1 AI 生成 streaming 端点
- 路径:`POST /app-api/muse/ai/generate/stream`
- 生命周期per-request请求发起时建立 SSE 连接,生成完成后连接关闭。
- 端点:分两步——先 `POST /app-api/muse/ai/tasks` 创建任务拿到 `taskId`,再 `GET /app-api/muse/ai/tasks/{taskId}/stream` 建立 SSE 流(生成提示词等参数走创建任务的请求体,不进 URL
- 生命周期per-request建流后流式推送,生成完成或失败后连接关闭。
- 用途AI 正文生成、候选生成等需要流式输出的场景。
- 结束信号:服务端发送 `event: done`payload 包含候选 ID。前端收到后用候选 ID 调用 REST 接口(`GET /app-api/muse/suggestions/{suggestionId}`)获取完整结构化结果(质量评分、来源标注等元数据)。
- 事件分发:按 SSE `event:` 行分发,`data:` 行为该事件 payload JSON。事件类型`chunk`(增量文本)、`quality_check`(质检)、`done`结束payload 含 `taskId` 与候选 `suggestionId`)、`error`(已脱敏错误)。
- 结束信号:服务端发送 `event: done`payload 含候选 ID。前端收到后用候选 ID 调用 REST 接口(`GET /app-api/muse/suggestions/{suggestionId}`)获取完整结构化结果(质量评分、来源标注等元数据)。
- 错误处理:服务端发送 `event: error` 时,前端展示错误原因并关闭连接。
### 6.2 统一事件 stream
- 路径:`GET /app-api/muse/events/stream`
- 路径:`GET /app-api/muse/events`
- 生命周期:长连接,用户登录后建立,页面关闭时断开。
- 用途:接收服务端推送的异步事件通知,前端按 event type 分发到对应处理器。

View File

@ -1,7 +1,7 @@
# 后端-05统一 API(接口) 契约-v1
- 版本v8
- 更新日期2026-05-24
- 版本v9
- 更新日期2026-06-14
- 目标读者:前端 / 后端 / 架构 / 测试
- 阅读时间35-55 分钟
- 边界说明:本文件只定义 Muse 在 Yudao Cloud fork 上的 API 分组、资源语义、关键命令、错误模型与异步交互。底层表结构看 `后端-04`,状态机看 `架构-04`,关键流程看 `后端-03`
@ -449,11 +449,23 @@ Planning Candidate 是 Shadow 对象;确认时目标 owner 必须重验 work
|---|---|---|
| POST | `/app-api/muse/ai/tasks` | 创建生成、续写、扩写、润色、检测、规划任务 |
| GET | `/app-api/muse/ai/tasks/{taskId}` | 查询任务 |
| GET | `/app-api/muse/ai/tasks/{taskId}/stream` | AI 任务 SSE 流式事件(生成增量、质检、结束、错误) |
| GET | `/app-api/muse/works/{workId}/suggestions` | 当前作品候选列表 |
| GET | `/app-api/muse/suggestions/{suggestionId}` | 候选详情 |
| POST | `/app-api/muse/suggestions/{suggestionId}/accept` | 接受候选 |
| POST | `/app-api/muse/suggestions/{suggestionId}/reject` | 丢弃候选 |
AI 任务流(`GET .../ai/tasks/{taskId}/stream`)按 SSE 规范发送:`event:` 行为事件名,`data:` 行为该事件 payload JSON`id:` 行为可续传的事件序号;无事件时发 SSE comment 作为 keepalive。事件契约
| event | data 字段 | 说明 |
|---|---|---|
| `chunk` | `content``sequenceNo` | Token 级增量文本 |
| `quality_check` | `dimension``score``passed` | 影子层质检结果 |
| `done` | `taskId``suggestionId`(可选 `summary` | 终态:生成完成;`suggestionId` 缺失则不发 `done` |
| `error` | `code``message` | 终态:已脱敏错误 |
> 待确认(类型不一致):当前后端实现把 `done``taskId` / `suggestionId` 按 Long 序列化为 JSON **数字**,与本契约中候选/任务 ID 采用的 `uuid` 字符串(见候选详情等资源)不一致。需后端统一为 uuid 字符串或全局改判;统一前,前端已在 SSE 解析边界将其归一为字符串以保证类型稳定。
Accept 请求语义:
| 字段 | 要求 |

View File

@ -203,45 +203,34 @@ export const useEditorUIStore = create<EditorUIStore>((set) => ({
### 双通道设计
1. **AI Stream 通道**AI 生成内容的流式传输(独立连接,按请求生命周期)
2. **事件 Stream 通道**:系统事件的统一推送(长连接,应用生命周期)
studio 的 SSE 能力集中在 `src/lib/sse.ts`,对外暴露两个**函数**(既不是 React Hook也不用浏览器原生 `EventSource`
### AI Stream
1. **AI Stream 通道**`connectAIStream(url, handlers)` —— AI 生成内容的流式传输(独立连接,单次请求生命周期)。
2. **事件 Stream 通道**`connectEventStream(lastEventId?)` —— 系统事件的统一推送(应用级长连接)。
```tsx
/**
* AI 流式生成 Hook
* 管理单次 AI 请求的 SSE 连接生命周期
*/
export function useAIStream(options: AIStreamOptions) {
// 每次请求创建独立 EventSource
// 支持中断AbortController
// 支持 token 级增量渲染
// 错误时自动重试 1 次
}
```
> WHY 不用 `EventSource`:两条通道都要带自定义请求头(`X-API-Version`,事件流还要 `Authorization: Bearer`),而 `EventSource` 无法设置请求头,故统一用 `fetch` + `ReadableStream` 读流。
### 事件 Stream
### 线格式与事件分发(务必对齐后端)
```tsx
/**
* 全局事件流 Hook
* 统一管理系统级实时事件推送
*/
export function useEventStream() {
// 应用启动时建立连接
// 支持 lastEventId 断线续传
// 心跳检测 + 自动重连(指数退避,最大 30s
// 事件类型:任务完成、协作通知、系统消息
}
```
后端用 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()` 归一后再回调,避免数字流入采纳请求体。
### 关键约束
- `lastEventId` 必须持久化到 localStorage确保断线续传
- AI Stream 超时设置 120s事件 Stream 无超时
- 所有 SSE 连接必须在组件卸载时正确关闭
- 网络恢复后自动重连,不丢失中间事件
- **AI Stream**:每次请求建立独立连接,用 `AbortController` 支持中止;**不自动重试/重连**(生成是一次性请求,失败走 `onError` 由用户重发)。
- **事件 Stream**:应用级长连接,断线按指数退避自动重连(`1s → 2s → 5s → 10s` 封顶),以收到的 `id` 作为 `lastEventId` cursor 续传;缺登录凭证时 fail-closed不发空 Bearer
- 所有 SSE 连接必须在组件卸载时通过 `AbortController` 正确关闭,避免连接泄漏。
- 解析失败统一走 `onError``SSE_PARSE_ERROR`),不让异常吞掉整条流。
---

View File

@ -12,6 +12,9 @@ export interface SSEEventHandler {
* AI
* WHY done data { taskId, suggestionId } muse-module-ai doneData
* suggestionId Canonical
* WHY string taskId/suggestionId Long JSON
* OpenAPI src/types/*.ts string connectAIStream done String()
* OpenAPI suggestionId uuid Long
*/
onDone?: (data: { taskId: string; suggestionId: string }) => void;
/** 连接或解析发生错误 */