diff --git a/deploy/.env.prod.example b/deploy/.env.prod.example index c6a746a..16affb9 100644 --- a/deploy/.env.prod.example +++ b/deploy/.env.prod.example @@ -81,3 +81,15 @@ SOCIAL_LLM__PROVIDER_KEYS__MOONSHOT= SOCIAL_LLM__PROVIDER_KEYS__DEEPSEEK= SOCIAL_LLM__PROVIDER_KEYS__ARK= SOCIAL_LLM__PROVIDER_KEYS__ZAI= + +############ +# App 版本更新配置 +############ +# 安装包目录,相对于项目根目录下的 deploy/static/ +SOCIAL_APP_VERSION__RELEASES_DIR=releases +# 当前版本号(语义化版本) +SOCIAL_APP_VERSION__CURRENT_VERSION=0.1.0 +# 当前构建号(整数,每次打包递增) +SOCIAL_APP_VERSION__CURRENT_BUILD=1 +# 下载链接基础域名(生产环境需配置) +SOCIAL_APP_VERSION__DOWNLOAD_BASE_URL= diff --git a/docs/protocols/agent/api-endpoints.md b/docs/protocols/agent/api-endpoints.md index 7cfbd8a..76ec581 100644 --- a/docs/protocols/agent/api-endpoints.md +++ b/docs/protocols/agent/api-endpoints.md @@ -1,6 +1,6 @@ # Agent API Endpoints -本文档列出所有 Agent 相关的 API 端点。 +本文档以当前后端实现为准,描述 `/api/v1/agent` 的 HTTP 接口契约。 Base URL: `/api/v1/agent` @@ -8,353 +8,220 @@ Base URL: `/api/v1/agent` ## 端点清单 -| 方法 | 路径 | 描述 | -|------|------|------| -| POST | `/runs` | 发起 Agent 运行 | -| GET | `/runs/{thread_id}/events` | SSE 事件流 | -| GET | `/history` | 获取对话历史快照 | -| POST | `/attachments` | 上传附件 | -| GET | `/attachments/signed-url` | 生成附件签名 URL | -| POST | `/transcribe` | 语音转文字 (ASR) | +| 方法 | 路径 | 说明 | +|---|---|---| +| POST | `/runs` | 创建一次 agent run(异步入队) | +| GET | `/runs/{thread_id}/events` | 订阅 SSE 事件流 | +| GET | `/history` | 获取历史快照(按天分页) | +| POST | `/attachments` | 上传用户图片附件 | +| GET | `/attachments/signed-url` | 获取附件临时签名链接 | +| POST | `/transcribe` | WAV 音频转写 | --- -## 1. POST /runs +## 1) POST `/runs` -发起一个 Agent 运行任务。 +发起一次运行请求,后端会先持久化用户消息,再将命令放入异步队列。 ### Request -Request Body: `RunAgentInput` - -详细数据结构见 [run-agent-input.md](./run-agent-input.md) +- Body: `RunAgentInput` +- 详细结构见 `docs/protocols/agent/run-agent-input.md` ### Response -```typescript +`202 Accepted` + +```ts { - taskId: string, // 任务 ID - threadId: string, // 会话 ID - runId: string, // 运行 ID - created: string // ISO-8601 时间戳 + taskId: string; + threadId: string; + runId: string; + created: boolean; // 是否新建了会话 } ``` -### Example +### 错误码 -```bash -curl -X POST https://api.example.com/api/v1/agent/runs \ - -H "Content-Type: application/json" \ - -d '{ - "threadId": "550e8400-e29b-41d4-a716-446655440000", - "runId": "run-001", - "state": {}, - "messages": [ - { - "id": "msg-001", - "role": "user", - "content": "帮我查一下北京今天的天气" - } - ], - "tools": [], - "context": [], - "forwardedProps": {} - }' -``` - -### Response Example - -```json -{ - "taskId": "task-abc123", - "threadId": "550e8400-e29b-41d4-a716-446655440000", - "runId": "run-001", - "created": "2026-03-16T10:00:00Z" -} -``` +- `401` 未认证 +- `422` 请求结构校验失败 +- `429` 超过 run 请求速率限制 --- -## 2. GET /runs/{thread_id}/events +## 2) GET `/runs/{thread_id}/events` -获取 SSE 事件流,用于实时接收 Agent 运行过程中的事件。 +订阅指定 thread 的实时事件流。 -### Path Parameters +### Path -| 参数 | 类型 | 描述 | -|------|------|------| -| thread_id | string | 会话 ID | +| 参数 | 类型 | 说明 | +|---|---|---| +| `thread_id` | string | 会话 ID | -### Query Parameters +### Query -| 参数 | 类型 | 默认值 | 描述 | -|------|------|--------|------| -| Last-Event-ID | string | - | 可选,用于断点续传的事件 ID | -| idle_limit | integer | 300 | 最大空闲轮询次数 (1-3600) | +| 参数 | 类型 | 默认 | 说明 | +|---|---|---|---| +| `idle_limit` | integer | `300` | 最大空闲轮询次数(1-3600) | ### Headers -| 参数 | 描述 | -|------|------| -| Last-Event-ID | 可选的事件 ID,用于从指定位置恢复 | +| Header | 必填 | 说明 | +|---|---|---| +| `Accept: text/event-stream` | 否 | 建议显式设置 | +| `Last-Event-ID` | 否 | 从指定游标断点续流 | ### Response -SSE (Server-Sent Events) 流,Content-Type: `text/event-stream` +- `200 OK` +- `Content-Type: text/event-stream` +- 事件类型与字段见 `docs/protocols/agent/sse-events.md` +- 空闲时会发送 keep-alive 注释行 `: keep-alive` -事件类型详情见 [sse-events.md](./sse-events.md) +### 错误码 -### Example - -```javascript -const eventSource = new EventSource( - 'https://api.example.com/api/v1/agent/runs/550e8400-e29b-41d4-a716-446655440000/events' -); - -eventSource.addEventListener('run.started', (e) => { - const data = JSON.parse(e.data); - console.log('Started:', data); -}); - -eventSource.addEventListener('text.delta', (e) => { - const data = JSON.parse(e.data); - console.log('Delta:', data.data.delta); -}); - -eventSource.addEventListener('run.finished', (e) => { - const data = JSON.parse(e.data); - console.log('Finished:', data); -}); -``` +- `401` 未认证 +- `403` 非会话所有者 +- `422` `Last-Event-ID` 非法 +- `429` 超过 SSE 连接数限制 --- -## 3. GET /history +## 3) GET `/history` -获取对话历史快照。 +返回历史快照(`HistorySnapshotResponse`),不是 SSE 包装事件。 -### Query Parameters +### Query -| 参数 | 类型 | 必填 | 描述 | -|------|------|------|------| -| threadId | string | 否 | 会话 ID,不指定则返回最新会话 | -| before | date | 否 | 日期格式 `YYYY-MM-DD`,返回该日期之前的快照 | +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `threadId` | string | 否 | 指定会话,不传则取当前用户最近会话 | +| `before` | `YYYY-MM-DD` | 否 | 返回该日期之前最近一天的快照 | ### Response -```typescript +```ts { - scope: "history_day", - threadId: string | null, - day: string | null, // ISO date format "YYYY-MM-DD" - hasMore: boolean, - messages: HistoryMessage[] + scope: "history_day"; + threadId: string | null; + day: string | null; // YYYY-MM-DD + hasMore: boolean; + messages: Array<{ + id: string; + seq: number; + role: "user" | "assistant" | "tool"; + content: string; + url?: string | null; // user 附件签名链接 + ui_schema?: object | null; // assistant/tool 的编译后 UI + timestamp: string; // ISO-8601 + }>; } ``` -详细数据结构见 [run-agent-input.md](./run-agent-input.md) +### 说明 -### Example - -```bash -curl "https://api.example.com/api/v1/agent/history?threadId=550e8400-e29b-41d4-a716-446655440000&before=2026-03-15" -``` - -### Response Example - -```json -{ - "scope": "history_day", - "threadId": "550e8400-e29b-41d4-a716-446655440000", - "day": "2026-03-15", - "hasMore": false, - "messages": [ - { - "id": "msg-001", - "seq": 1, - "role": "user", - "content": "帮我创建一个日程", - "url": null, - "timestamp": "2026-03-15T10:00:00Z" - }, - { - "id": "msg-002", - "seq": 2, - "role": "assistant", - "content": "好的,我来帮您创建日程。", - "uiSchema": { - "version": "2.0", - "locale": "zh-CN", - "status": "success", - "theme": "default", - "root": { - "type": "stack", - "appearance": "card", - "children": [ - {"type": "text", "content": "日程已创建", "role": "title"}, - {"type": "badge", "label": "SUCCESS", "status": "success"} - ] - } - }, - "timestamp": "2026-03-15T10:00:05Z" - } - ] -} -``` +- 若用户没有任何会话:返回 + - `threadId = null` + - `day = null` + - `hasMore = false` + - `messages = []` --- -## 4. POST /attachments +## 4) POST `/attachments` -上传附件到存储。 +上传图片附件,返回可直接用于 `RunAgentInput.messages[].content[].url` 的签名链接。 -### Form Data +### Request -| 参数 | 类型 | 描述 | -|------|------|------| -| threadId | string | 会话 ID | -| file | file | 要上传的文件 | +- `multipart/form-data` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `threadId` | string | 会话 ID | +| `file` | file | 附件文件 | ### Response -```typescript +```ts { attachment: { - bucket: string, - path: string, - mimeType: string, - size: number, - url: string // 临时访问 URL - } + bucket: string; + path: string; + mimeType: string; + url: string; + }; } ``` -### Example +### 错误码 -```bash -curl -X POST https://api.example.com/api/v1/agent/attachments \ - -F "threadId=550e8400-e29b-41d4-a716-446655440000" \ - -F "file=@/path/to/image.png" -``` - -### Limits - -- 最大文件大小: 5MB -- 支持的文件类型: 见后端配置 +- `401` 未认证 +- `403` 非会话所有者 +- `413` 附件超过大小限制 +- `422` 文件类型不支持/空文件等 +- `503` 存储服务不可用 --- -## 5. GET /attachments/signed-url +## 5) GET `/attachments/signed-url` -生成附件的签名 URL,用于直接访问存储中的文件。 +对已有存储对象重新签名。 -### Query Parameters +### Query -| 参数 | 类型 | 必填 | 描述 | -|------|------|------|------| -| bucket | string | 是 | 存储桶名称 | -| path | string | 是 | 文件路径 | +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `bucket` | string | 是 | 存储桶 | +| `path` | string | 是 | 对象路径 | ### Response -```typescript +```ts { - bucket: string, - path: string, - url: string // 签名 URL -} -``` - -### Example - -```bash -curl "https://api.example.com/api/v1/agent/attachments/signed-url?bucket=agent-inputs&path=user-123/image.png" -``` - -### Response Example - -```json -{ - "bucket": "agent-inputs", - "path": "user-123/image.png", - "url": "https://storage.example.com/agent-inputs/user-123/image.png?signature=abc123..." + bucket: string; + path: string; + url: string; } ``` --- -## 6. POST /transcribe +## 6) POST `/transcribe` -语音转文字 (ASR)。 +WAV 音频转写。 ### Request -Form Data: +- `multipart/form-data` -| 参数 | 类型 | 描述 | -|------|------|------| -| audio | file | 音频文件 (WAV 格式) | - -### Headers - -| 参数 | 描述 | -|------|------| -| content-length | 文件大小 | +| 字段 | 类型 | 说明 | +|---|---|---| +| `audio` | file | WAV 文件 | ### Response -```typescript +```ts { - transcript: string, // 转录文本 - language: string, // 检测到的语言 - duration: number // 音频时长 (秒) + transcript: string; } ``` -### Example +### 限制 -```bash -curl -X POST https://api.example.com/api/v1/agent/transcribe \ - -F "audio=@/path/to/audio.wav" -``` - -### Response Example - -```json -{ - "transcript": "今天天气真不错", - "language": "zh-CN", - "duration": 3.5 -} -``` - -### Limits - -- 支持格式: `audio/wav`, `audio/x-wav`, `audio/wave` -- 最大文件大小: 10MB -- 速率限制: 20 次/分钟/用户 +- 内容类型:`audio/wav` / `audio/x-wav` / `audio/wave` +- 文件大小:最大 10MB +- 速率限制:20 次/分钟/用户 --- -## 错误响应 +## 通用错误 -所有端点可能返回以下错误: - -| 状态码 | 描述 | -|--------|------| -| 400 | 请求参数错误 | -| 401 | 未认证 | -| 403 | 无权限 | -| 404 | 资源不存在 | -| 413 | 请求体过大 | -| 422 | 数据验证失败 | -| 429 | 速率限制 | -| 500 | 服务器内部错误 | - -### Error Response Format +当前实现的错误主体为 FastAPI `detail` 字段: ```json { - "detail": "错误详情信息" + "detail": "..." } ``` diff --git a/docs/protocols/agent/run-agent-input.md b/docs/protocols/agent/run-agent-input.md index 6f7ec33..fed83d7 100644 --- a/docs/protocols/agent/run-agent-input.md +++ b/docs/protocols/agent/run-agent-input.md @@ -307,7 +307,7 @@ interface TaskAcceptedResponse { taskId: string; threadId: string; runId: string; - created: string; // ISO-8601 timestamp + created: boolean; // 是否新建会话 } ``` @@ -359,7 +359,7 @@ interface HistoryMessageTool { seq: number; role: "tool"; content: string; - uiSchema: UiSchemaRenderer | null; // 由 tool_agent_output.ui_hints 编译 + ui_schema: UiSchemaRenderer | null; // 由 tool_agent_output.ui_hints 编译 timestamp: string; } @@ -369,7 +369,7 @@ interface HistoryMessageAssistant { seq: number; role: "assistant"; content: string; - uiSchema: UiSchemaRenderer | null; // 由 worker_agent_output.ui_hints 编译 + ui_schema: UiSchemaRenderer | null; // 由 worker_agent_output.ui_hints 编译 timestamp: string; } @@ -418,7 +418,7 @@ interface UiSchemaRenderer { "seq": 2, "role": "assistant", "content": "好的,我来帮您创建日程。", - "uiSchema": { + "ui_schema": { "version": "2.0", "locale": "zh-CN", "status": "success", @@ -447,3 +447,4 @@ interface UiSchemaRenderer { - binary content 的 url 必须是有效的 signed URL,由 `/api/v1/agent/attachments` 端点生成 - backend 验证通过后,会将 binary url 转换为内部存储路径 - tools 为空数组时,prompt 中不会包含工具说明 +- `RunAgentInput` 同时接受 camelCase 与 snake_case 别名输入(推荐统一使用 camelCase) diff --git a/docs/protocols/agent/sse-events.md b/docs/protocols/agent/sse-events.md index c6558ab..8394b63 100644 --- a/docs/protocols/agent/sse-events.md +++ b/docs/protocols/agent/sse-events.md @@ -1,370 +1,213 @@ # Agent SSE Events -本文档记录 Agent Runtime 产生的所有 Server-Sent Events (SSE) 事件,用于前端实时展示。 +本文档描述 `GET /api/v1/agent/runs/{thread_id}/events` 的事件协议。 -## 事件流转架构 +--- + +## 1) 事件管道 + +后端事件流转如下: + +1. Runtime 直接产出 AG-UI 事件(如 `RUN_STARTED`、`TOOL_CALL_RESULT`) +2. `agui_codec` 仅做协议对齐与字段净化(例如移除仅后端内部统计字段) +3. 事件同时: + - 持久化到数据库(用于 history) + - 发布到 Redis Stream(用于 SSE) +4. `/runs/{thread_id}/events` 从 Redis Stream 读取并输出 SSE + +--- + +## 2) SSE 帧格式 + +每条事件遵循标准 SSE: + +```text +id: +event: +data: -``` -pipeline.emit() - ↓ -AgentScopeEventPipeline.emit() - ├─→ store.persist() → 持久化到数据库 - └─→ bus.publish() → 发布到 Redis Stream - ↓ -前端通过 GET /runs/{thread_id}/events 读取 ``` -## 事件统一格式 +- `id` 可用于断点续流(`Last-Event-ID`) +- `event` 与 JSON 内 `type` 一致(例如 `RUN_STARTED`) +- 空闲时可能出现 keep-alive 注释帧: -所有事件在 Redis 中传输时都包含以下字段: +```text +: keep-alive -```typescript -{ - type: string, // 事件类型 - threadId: string, // 会话 ID - runId: string, // 运行 ID - data: object // 事件数据 -} ``` --- -## 1. Orchestrator 生命周期事件 +## 3) 事件类型(当前实现) -### run.started +### 3.1 Run 生命周期 -Agent 开始运行时触发。 +#### `RUN_STARTED` -```typescript +```json { - type: "run.started", - threadId: "xxx", - runId: "yyy", - data: {} + "type": "RUN_STARTED", + "threadId": "...", + "runId": "..." } ``` -### run.finished +#### `RUN_FINISHED` -Agent 成功完成时触发。 - -```typescript +```json { - type: "run.finished", - threadId: "xxx", - runId: "yyy", - data: {} + "type": "RUN_FINISHED", + "threadId": "...", + "runId": "..." } ``` -### run.error +#### `RUN_ERROR` -Agent 运行出错时触发。 - -```typescript +```json { - type: "run.error", - threadId: "xxx", - runId: "yyy", - data: { - message: "runtime execution failed" - } + "type": "RUN_ERROR", + "threadId": "...", + "runId": "...", + "message": "runtime execution failed", + "code": null } ``` +### 3.2 阶段事件 + +#### `STEP_STARTED` + +```json +{ + "type": "STEP_STARTED", + "threadId": "...", + "runId": "...", + "stepName": "router" | "worker" +} +``` + +#### `STEP_FINISHED` + +```json +{ + "type": "STEP_FINISHED", + "threadId": "...", + "runId": "...", + "stepName": "router" | "worker" +} +``` + +### 3.3 Tool 事件 + +#### `TOOL_CALL_START` + +```json +{ + "type": "TOOL_CALL_START", + "threadId": "...", + "runId": "...", + "messageId": "...", + "toolCallId": "...", + "toolCallName": "...", + "stage": "worker" +} +``` + +#### `TOOL_CALL_ARGS` + +```json +{ + "type": "TOOL_CALL_ARGS", + "threadId": "...", + "runId": "...", + "messageId": "...", + "toolCallId": "...", + "toolCallName": "...", + "args": {}, + "stage": "worker" +} +``` + +#### `TOOL_CALL_END` + +```json +{ + "type": "TOOL_CALL_END", + "threadId": "...", + "runId": "...", + "messageId": "...", + "toolCallId": "...", + "toolCallName": "...", + "stage": "worker" +} +``` + +#### `TOOL_CALL_RESULT` + +```json +{ + "type": "TOOL_CALL_RESULT", + "threadId": "...", + "runId": "...", + "messageId": "...", + "role": "tool", + "stage": "worker", + "tool_name": "...", + "tool_call_id": "...", + "tool_call_args": {}, + "status": "success" | "failed", + "result_summary": "...", + "ui_schema": {}, + "error": null +} +``` + +### 3.4 文本完成事件 + +#### `TEXT_MESSAGE_END` + +当前实现仅在 worker 输出完成后发送完整结果,不发送 token delta 事件。 + +```json +{ + "type": "TEXT_MESSAGE_END", + "threadId": "...", + "runId": "...", + "messageId": "...", + "role": "assistant", + "stage": "worker", + "status": "success" | "partial_success" | "failed", + "answer": "...", + "key_points": [], + "result_type": "execution_report" | "clarification" | "error_report" | "unknown", + "suggested_actions": [], + "error": null, + "ui_schema": {} +} +``` + +`inputTokens`、`outputTokens`、`cost`、`latencyMs`、`model` 属于后端内部统计字段,不在 SSE 对外协议中暴露。 + +### 3.5 快照事件 + +编码器支持以下 AG-UI 类型映射: + +- `STATE_SNAPSHOT` +- `MESSAGES_SNAPSHOT` + +当前 `/runs/{thread_id}/events` 主流程通常不主动产出这两类事件;历史查询请使用 `/history`。 + --- -## 2. Step 阶段事件 +## 4) 字段命名约定 -### step.start +- 事件顶层通用字段使用 AG-UI 风格:`type`、`threadId`、`runId` +- 部分业务字段沿运行时模型历史命名保留下划线: + - `tool_name` + - `tool_call_id` + - `tool_call_args` + - `ui_schema` -阶段开始时触发。 - -```typescript -{ - type: "step.start", - threadId: "xxx", - runId: "yyy", - data: { - stepName: "router" | "worker" - } -} -``` - -### step.finish - -阶段结束时触发。 - -```typescript -{ - type: "step.finish", - threadId: "xxx", - runId: "yyy", - data: { - stepName: "router" | "worker" - } -} -``` - ---- - -## 3. Worker 运行时事件 - -### 3.1 文本消息事件 - -#### text.start - -文本消息开始时触发。 - -```typescript -{ - type: "text.start", - threadId: "xxx", - runId: "yyy", - data: { - messageId: "msg-xxx", - role: "assistant", - stage: "worker" - } -} -``` - -#### text.delta - -文本内容增量更新时触发。 - -```typescript -{ - type: "text.delta", - threadId: "xxx", - runId: "yyy", - data: { - messageId: "msg-xxx", - delta: "这是新增的文本内容", - stage: "worker" - } -} -``` - -#### text.end - -文本消息结束时触发,包含完整输出和使用统计。 - -```typescript -{ - type: "text.end", - threadId: "xxx", - runId: "yyy", - data: { - messageId: "msg-xxx", - role: "assistant", - stage: "worker", - workerAgentOutput: { - status: "success" | "partial_success" | "failed", - answer: "主回复文本", - key_points: ["要点1", "要点2"], - result_type: "execution_report" | "clarification" | "error_report" | "unknown", - suggested_actions: ["建议操作1"], - error: null | { code: string, message: string }, - ui_hints: { ... } | null // 仅在 ui_mode 非空时存在 - }, - model: "gpt-4o", - inputTokens: 1000, - outputTokens: 500, - cost: 0.025, - latencyMs: 1500 - } -} -``` - -**workerAgentOutput 详细结构:** - -```typescript -// WorkerAgentOutputLite -{ - status: "success" | "partial_success" | "failed", - answer: string, - key_points: string[], - result_type: "execution_report" | "clarification" | "error_report" | "unknown", - suggested_actions: string[], - error: { code: string, message: string } | null -} - -// WorkerAgentOutputRich (当 ui.ui_mode 非空时) -{ - // ... WorkerAgentOutputLite 字段 - ui_hints: { - ui_mode: string, - ui_state: object, - actions: UiHintAction[] - } -} -``` - ---- - -### 3.2 工具调用事件 - -#### tool.start - -工具调用开始时触发。 - -```typescript -{ - type: "tool.start", - threadId: "xxx", - runId: "yyy", - data: { - messageId: "msg-xxx", - toolCallId: "call-abc", - toolName: "calendar_read", - stage: "worker" - } -} -``` - -#### tool.args - -工具调用参数时触发。 - -```typescript -{ - type: "tool.args", - threadId: "xxx", - runId: "yyy", - data: { - messageId: "msg-xxx", - toolCallId: "call-abc", - toolName: "calendar_read", - args: { start_date: "2024-01-01", end_date: "2024-01-07" }, - stage: "worker" - } -} -``` - -#### tool.end - -工具调用结束时触发。 - -```typescript -{ - type: "tool.end", - threadId: "xxx", - runId: "yyy", - data: { - messageId: "msg-xxx", - toolCallId: "call-abc", - toolName: "calendar_read", - stage: "worker" - } -} -``` - -#### tool.result - -工具执行结果时触发。 - -```typescript -{ - type: "tool.result", - threadId: "xxx", - runId: "yyy", - data: { - messageId: "msg-xxx", - toolCallId: "call-abc", - toolName: "calendar_read", - stage: "worker", - toolAgentOutput: { - tool_name: "calendar_read", - tool_call_id: "call-abc", - tool_call_args: { start_date: "2024-01-01", end_date: "2024-01-07" }, - status: "success" | "failed", - result_summary: "找到3个事件...", - ui_hints: null, - attachments: null - } - } -} -``` - -**toolAgentOutput 详细结构:** - -```typescript -{ - tool_name: string, - tool_call_id: string, - tool_call_args: object | null, - status: "success" | "failed", - result_summary: string, - ui_hints: object | null, - attachments: Array<{ - bucket: string, - path: string, - mime_type: string, - url: string - }> | null -} -``` - ---- - -## 使用统计字段 - -`text.end` 事件中包含使用统计: - -| 字段 | 类型 | 说明 | -|------|------|------| -| `model` | string | 使用的模型名称 | -| `inputTokens` | number | 输入 token 数量 | -| `outputTokens` | number | 输出 token 数量 | -| `cost` | number | 花费(美元) | -| `latencyMs` | number | 延迟(毫秒) | - ---- - -## 前端接收示例 - -```javascript -const eventSource = new EventSource(`/runs/${threadId}/events`); - -eventSource.addEventListener('run.started', (e) => { - console.log('Agent started:', JSON.parse(e.data)); -}); - -eventSource.addEventListener('step.start', (e) => { - console.log('Step started:', JSON.parse(e.data)); -}); - -eventSource.addEventListener('text.delta', (e) => { - const data = JSON.parse(e.data); - console.log('Text delta:', data.data.delta); -}); - -eventSource.addEventListener('tool.start', (e) => { - const data = JSON.parse(e.data); - console.log('Tool called:', data.data.toolName); -}); - -eventSource.addEventListener('tool.result', (e) => { - const data = JSON.parse(e.data); - console.log('Tool result:', data.data.toolAgentOutput); -}); - -eventSource.addEventListener('text.end', (e) => { - const data = JSON.parse(e.data); - console.log('Worker output:', data.data.workerAgentOutput); - console.log('Usage:', { - inputTokens: data.data.inputTokens, - outputTokens: data.data.outputTokens, - cost: data.data.cost - }); -}); - -eventSource.addEventListener('run.finished', (e) => { - console.log('Agent finished:', JSON.parse(e.data)); -}); - -eventSource.addEventListener('run.error', (e) => { - console.log('Agent error:', JSON.parse(e.data)); -}); -``` +这部分命名属于当前后端实现约束,文档与实现保持一致。 diff --git a/docs/protocols/ui/data-flow.md b/docs/protocols/ui/data-flow.md index a139aaa..3757344 100644 --- a/docs/protocols/ui/data-flow.md +++ b/docs/protocols/ui/data-flow.md @@ -1,432 +1,115 @@ -# 前后端数据流通指南 +# 前后端数据流通指南(Agent Chat) -本文档描述前端如何与后端 Agent 系统交互,以及如何渲染后端返回的 UI 数据。 +本文档仅描述**当前后端实现**的 runs/events/history 数据流,不定义视觉细节。 --- -## 1. 整体交互流程 +## 1) 总体流程 -``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 前端 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────┐ POST /runs ┌──────────────┐ │ -│ │ 构造请求 │ ──────────────────▶│ 后端 │ │ -│ │ (多模态数据) │ │ (任务入队) │ │ -│ └──────────────┘◀────────────────── └──────────────┘ 202 Accepted │ -│ │ taskId │ -│ │ │ -│ │ GET /runs/{threadId}/events │ -│ │──────────────────────────────────────────────────────────▶ │ -│ │ SSE 事件流 ◀───────────────── │ -│ │ │ -│ ┌─────┴──────────────────────────────────────────────────────────┐ │ -│ │ 渲染事件 │ │ -│ │ - text.delta: 追加文本 │ │ -│ │ - tool.result: 渲染 toolAgentOutput.ui_hints → UiSchema │ │ -│ │ - text.end: 渲染 workerAgentOutput.ui_hints → UiSchema │ │ -│ └────────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ - -┌─────────────────────────────────────────────────────────────────────────┐ -│ 后端 │ -├─────────────────────────────────────────────────────────────────────────┤ -│ │ -│ POST /runs │ -│ → 验证请求 │ -│ → 任务入队列 (Taskiq) │ -│ │ -│ Worker 执行 │ -│ → emit 事件到 Redis Stream │ -│ → UiHints → ui_compiler.compile() → UiSchemaRenderer │ -│ │ -│ 持久化 │ -│ → AgentChatMessage (含 uiSchema) │ -│ │ -└─────────────────────────────────────────────────────────────────────────┘ -``` +1. 客户端 `POST /api/v1/agent/runs` 提交 `RunAgentInput` +2. 后端返回 `202` + `taskId/threadId/runId/created` +3. 客户端 `GET /api/v1/agent/runs/{threadId}/events` 订阅 SSE +4. 后端输出 AG-UI 事件(如 `RUN_STARTED`、`TOOL_CALL_RESULT`、`TEXT_MESSAGE_END`) +5. 客户端按需 `GET /api/v1/agent/history` 拉取历史快照(按天) --- -## 2. 多模态数据处理 +## 2) `/runs` 请求与响应 -### 2.1 前端上传附件流程 +### 请求 -```javascript -// 方式一:先上传附件,获取签名 URL -const formData = new FormData(); -formData.append('threadId', threadId); -formData.append('file', imageFile); +- Body: `RunAgentInput` +- user message 可为纯文本,也可为文本+binary(图片 URL) -const uploadResponse = await fetch('/api/v1/agent/attachments', { - method: 'POST', - body: formData, -}); -const { attachment } = await uploadResponse.json(); -// attachment.url 是临时访问 URL +### 响应 -// 方式二:直接使用已有的签名 URL(如果有) -const imageUrl = 'https://storage.example.com/...'; +```json +{ + "taskId": "...", + "threadId": "...", + "runId": "...", + "created": true +} ``` -### 2.2 构造 RunAgentInput - -```javascript -const runInput = { - threadId: threadId, - runId: `run-${Date.now()}`, - state: {}, - messages: [ - { - id: `msg-${Date.now()}`, - role: 'user', - content: [ - { type: 'text', text: '这张图片里有什么?' }, - { - type: 'binary', - mimeType: 'image/png', - url: attachment.url // 签名 URL - } - ] - } - ], - tools: [], // 或传入工具定义 - context: [], - forwardedProps: {} -}; -``` - -### 2.3 发送请求 - -```javascript -const response = await fetch('/api/v1/agent/runs', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(runInput) -}); -// 返回 202 Accepted -const { taskId, threadId, runId, created } = await response.json(); -``` +`created` 语义:是否在本次请求中创建了新会话。 --- -## 3. 事件流渲染 +## 3) `/runs/{threadId}/events` 事件流 -### 3.1 订阅事件 +### SSE 形式 -```javascript -class AgentEventHandler { - constructor(threadId) { - this.eventSource = new EventSource(`/api/v1/agent/runs/${threadId}/events`); - this.setupListeners(); - } +```text +id: +event: +data: - setupListeners() { - this.eventSource.addEventListener('run.started', this.onRunStarted); - this.eventSource.addEventListener('step.start', this.onStepStart); - this.eventSource.addEventListener('text.delta', this.onTextDelta); - this.eventSource.addEventListener('text.message.start', this.onTextStart); - this.eventSource.addEventListener('tool.call.start', this.onToolStart); - this.eventSource.addEventListener('tool.call.args', this.onToolArgs); - this.eventSource.addEventListener('tool.call.result', this.onToolResult); - this.eventSource.addEventListener('text.message.end', this.onTextEnd); - this.eventSource.addEventListener('run.finished', this.onRunFinished); - this.eventSource.addEventListener('run.error', this.onRunError); - } -} ``` -### 3.2 事件处理与渲染 +### 事件类型 -#### text.delta - 文本增量 +以 `docs/protocols/agent/sse-events.md` 为准。当前重点是: -```javascript -onTextDelta(event) { - const data = JSON.parse(event.data); - const { delta, messageId } = data.data; - // 追加到对应消息的文本内容 - appendTextToMessage(messageId, delta); -} -``` +- 运行生命周期:`RUN_STARTED` / `RUN_FINISHED` / `RUN_ERROR` +- 阶段:`STEP_STARTED` / `STEP_FINISHED` +- 工具:`TOOL_CALL_START` / `TOOL_CALL_ARGS` / `TOOL_CALL_END` / `TOOL_CALL_RESULT` +- 文本完成:`TEXT_MESSAGE_END` -#### tool.result - 工具结果 +### 文本流策略 -```javascript -onToolResult(event) { - const data = JSON.parse(event.data); - const { toolAgentOutput, toolCallId } = data.data; - - if (toolAgentOutput?.ui_hints) { - // 后端返回的是 UiHints,需要编译为 UiSchemaRenderer - const uiSchema = compileUiHints(toolAgentOutput.ui_hints); - renderUiSchema(uiSchema, toolCallId); - } else { - // 纯文本结果 - renderText(toolAgentOutput.result_summary, toolCallId); - } -} -``` - -#### text.end - Worker 完成 - -```javascript -onTextEnd(event) { - const data = JSON.parse(event.data); - const { workerAgentOutput, inputTokens, outputTokens, cost } = data.data; - - if (workerAgentOutput?.ui_hints) { - // 渲染 UiHints → UiSchemaRenderer - const uiSchema = compileUiHints(workerAgentOutput.ui_hints); - renderUiSchema(uiSchema, messageId); - } else { - // 纯文本回复 - renderText(workerAgentOutput?.answer, messageId); - } - - // 显示使用统计 - showUsageStats({ inputTokens, outputTokens, cost }); -} -``` +当前后端不提供 token 级 `TEXT_MESSAGE_CONTENT` 增量流作为主路径; +而是在 worker 完成后通过 `TEXT_MESSAGE_END` 一次性携带完整语义结果。 --- -## 4. History 对话历史渲染 +## 4) `/history` 快照 -### 4.1 获取历史 +`GET /api/v1/agent/history` 返回 `HistorySnapshotResponse`: -```javascript -const response = await fetch( - `/api/v1/agent/history?threadId=${threadId}&before=${date}` -); -const { messages } = await response.json(); +```json +{ + "scope": "history_day", + "threadId": "...", + "day": "2026-03-16", + "hasMore": false, + "messages": [] +} ``` -### 4.2 渲染历史消息 +说明: -```javascript -messages.forEach(msg => { - switch (msg.role) { - case 'user': - renderUserMessage(msg); - break; - case 'assistant': - if (msg.uiSchema) { - // 直接渲染 UiSchemaRenderer(后端已编译好) - renderUiSchema(msg.uiSchema); - } else { - // 纯文本 - renderText(msg.content); - } - break; - case 'tool': - // tool 结果也可能有 uiSchema - if (msg.uiSchema) { - renderUiSchema(msg.uiSchema); - } else { - renderText(msg.content); - } - break; - } -}); -``` +- 这是普通 JSON 响应,不是 SSE 事件包装。 +- `messages` 已按 seq 升序组织。 +- `before` 采用 `YYYY-MM-DD`,语义是向更早日期翻页。 --- -## 5. UiSchemaRenderer 渲染 +## 5) events 与 history 的一致性机制 -### 5.1 数据结构 +### 5.1 语义来源一致 -后端 `ui_compiler.compile()` 输出的结构: +两条链路都来自同一运行时输出(worker/tool output)及其持久化元数据。 -```typescript -interface UiSchemaRenderer { - version: "2.0"; - locale: string; - status: "info" | "success" | "warning" | "error" | "pending"; - theme: "default" | "light" | "dark"; - meta?: { - requestId?: string; - toolId?: string; - traceId?: string; - userId?: string; - }; - root: UiLayoutNode; -} +### 5.2 UI 编译器一致 -// 布局节点 -interface UiStackNode { - type: "stack"; - direction: "vertical" | "horizontal"; - gap?: number; - appearance: "plain" | "card" | "section"; - status?: UiStatus; - align?: LayoutAlign; - justify?: LayoutJustify; - wrap?: boolean; - children: UiNode[]; -} +两条链路都使用后端 `ui_compiler.compile(...)` 将 `ui_hints` 编译为可渲染结构: -interface UiGridNode { - type: "grid"; - columns: number; - gap?: number; - appearance: LayoutAppearance; - status?: UiStatus; - children: UiNode[]; -} +- events:在 runtime 发送事件前编译,字段名为 `ui_schema` +- history:在历史转换时编译,字段名为 `ui_schema` -// 基础节点 -type UiNode = - | UiTextNode // 文本 - | UiIconNode // 图标 - | UiBadgeNode // 标签 - | UiButtonNode // 按钮 - | UiKvNode // 键值对 - | UiDividerNode; // 分割线 +### 5.3 当前命名差异(实现现状) -// 文本节点 -interface UiTextNode { - type: "text"; - content: string; - format: "plain" | "markdown"; - role: "title" | "subtitle" | "body" | "caption" | "code"; - status?: UiStatus; - maxLines?: number; - visible?: boolean; -} +两条链路字段命名已统一: -// 按钮节点 -interface UiButtonNode { - type: "button"; - label: string; - style: "primary" | "secondary" | "ghost" | "danger"; - disabled?: boolean; - icon?: UiIconSpec; - action: UiActionPayload; -} - -// 键值对节点 -interface UiKvNode { - type: "kv"; - items: UiKvItem[]; - columns?: number; -} - -interface UiKvItem { - key: string; - label?: string; - value: any; - copyable?: boolean; -} - -// Action payloads -type UiActionPayload = - | { type: "navigation"; path: string; params?: Record } - | { type: "url"; url: string; target?: "_self" | "_blank" } - | { type: "event"; event: string; payload?: Record } - | { type: "tool"; toolId: string; params?: Record } - | { type: "copy"; content: string; successMessage?: string } - | { type: "payload"; payload: Record; submitTo?: string }; -``` - -### 5.2 统一渲染器实现 - -```dart -class UiSchemaRendererWidget extends StatelessWidget { - final UiSchemaRenderer schema; - - @override - Widget build(BuildContext context) { - return renderNode(schema.root); - } - - Widget renderNode(UiNode node) { - switch (node.type) { - case 'text': - return renderTextNode(node as UiTextNode); - case 'icon': - return renderIconNode(node as UiIconNode); - case 'badge': - return renderBadgeNode(node as UiBadgeNode); - case 'button': - return renderButtonNode(node as UiButtonNode); - case 'kv': - return renderKvNode(node as UiKvNode); - case 'divider': - return renderDividerNode(node as UiDividerNode); - case 'stack': - return renderStackNode(node as UiStackNode); - case 'grid': - return renderGridNode(node as UiGridNode); - default: - return SizedBox.shrink(); - } - } -} -``` - -### 5.3 事件渲染 vs History 渲染一致性 - -**关键点**:两者使用同一个渲染器 - -| 数据来源 | 数据格式 | 处理方式 | -|---------|---------|---------| -| events text.end | `workerAgentOutput.ui_hints` | compile → UiSchemaRenderer → 渲染 | -| events tool.result | `toolAgentOutput.ui_hints` | compile → UiSchemaRenderer → 渲染 | -| history assistant | `msg.uiSchema` | 直接渲染 | -| history tool | `msg.uiSchema` | 直接渲染 | - -**编译函数**(前端需要实现): - -```javascript -// 将后端 UiHints 编译为 UiSchemaRenderer -function compileUiHints(hints) { - // 这里可以调用后端的 compile 接口 - // 或者在前端实现相同的编译逻辑 - return fetch('/api/v1/agent/compile-ui-hints', { - method: 'POST', - body: JSON.stringify(hints) - }).then(r => r.json()); -} -``` +- events: `ui_schema`(snake_case) +- history: `ui_schema`(snake_case) --- -## 6. UiHints 介绍(可选) +## 6) 推荐消费顺序(面向客户端重构) -如果前端需要自己处理 UiHints,以下是结构: - -### UiHintsPayload - -```typescript -interface UiHintsPayload { - version: "2.1"; - intent: "message" | "data" | "list" | "status" | "form" | "mixed"; - status: "info" | "success" | "warning" | "error" | "pending"; - - title?: string; - description?: string; - body?: string; - bodyFormat?: "plain" | "markdown"; - - items?: UiHintKvItem[]; - listItems?: UiHintListItem[]; - sections?: UiHintSection[]; - actions?: UiHintAction[]; - icon?: UiHintIcon; - meta?: Record; -} -``` - -**注意**:History API 返回的消息已经包含了编译好的 `uiSchema`,前端不需要自己编译。 - ---- - -## 7. 总结 - -1. **多模态**:前端上传附件 → 获取签名 URL → 构造 RunAgentInput → POST /runs -2. **实时事件**:订阅 /runs/{threadId}/events → 处理 text.delta/tool.result/text.end → 渲染 UiSchema -3. **历史消息**:GET /history → 遍历消息 → 有 uiSchema 则渲染,否则渲染纯文本 -4. **一致性**:events 和 history 使用同一个 UiSchemaRenderer 渲染组件 +1. 先以 `/history` 获取首屏快照 +2. 再接入 `/events` 处理后续增量 +3. 以 `runId` + `messageId/toolCallId` 做去重与合并 +4. 统一消费 `ui_schema`