diff --git a/.gitignore b/.gitignore index b22d5f03..e22a4ef0 100644 --- a/.gitignore +++ b/.gitignore @@ -93,6 +93,12 @@ offline-backend-20260512/backend/tests/* # Keep the model configuration regression coverage versioned even though # this deployment repository otherwise ignores newly added backend tests. !offline-backend-20260512/backend/tests/test_model_management.py +# Deep-research reliability regression coverage. +!offline-backend-20260512/backend/tests/test_deep_research_persistence.py +!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py +!offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py +!offline-backend-20260512/backend/tests/test_deep_research_start_job.py +!offline-backend-20260512/backend/tests/test_summarization_human_message.py deploy .history diff --git a/frontend-web/docs/angular-智能体对话对接文档.md b/frontend-web/docs/angular-智能体对话对接文档.md new file mode 100644 index 00000000..34f0b92b --- /dev/null +++ b/frontend-web/docs/angular-智能体对话对接文档.md @@ -0,0 +1,1261 @@ +# Angular 对接 DeerFlow 智能体对话页 + +本文说明如何在 **其它 Angular 项目** 中对接 DeerFlow 智能体对话能力,对标页面: + +``` +http://localhost:5173/#/page/workspace/agents/roundtable-structure/chats/new +``` + +对应前端路由:`#/page/workspace/agents/{agent_id}/chats/{thread_id}` +其中 `agent_id = roundtable-structure`(展示名「结构化抽取」),`thread_id = new` 表示新建会话。 + +本文覆盖:**登录拿 Token → 列模型/切模型 → 建会话/存聊天记录 → 流式对话 → 思考内容 → 步骤条(含创建脚本/执行脚本/写入文件及全部工具)→ 用户协助卡片 → 消息列表渲染 → 停止/重连/上传/产物**。 +**不包含**智能体配置(创建/编辑智能体、技能绑定等)。 + +--- + +## 0. 先看这一页 + +对接本质不是「调一个聊天 HTTP」,而是复用本系统的 **LangGraph 兼容协议**: + +| 能力 | 走哪条线 | Base URL | +|------|----------|----------| +| 登录、模型列表、上传、产物、追问建议 | Gateway REST | `{GATEWAY}`,例如 `http://host:8001` | +| 会话 / 流式对话 / 取消 | LangGraph Platform 兼容 API | `{GATEWAY}/api` | + +本仓库前端环境变量: + +- `VITE_BACKEND_BASE_URL` → `{GATEWAY}` +- `VITE_LANGGRAPH_BASE_URL` → `{GATEWAY}/api` + +开发机前端常见端口是 **5173 或 5174**(Vite 被占用会换端口),Gateway 默认 **8001**。 +下文用: + +```text +GATEWAY = http://localhost:8001 +LG = http://localhost:8001/api +``` + +所有对话接口都要带: + +```http +Authorization: Bearer +Content-Type: application/json +``` + +用 Bearer **不必**再带 CSRF。不要用 cookie 登录(`/login/local` 只把 JWT 写进 HttpOnly cookie,不适合 Angular 跨域 API 客户端)。 + +--- + +## 1. 架构与端到端流程 + +```mermaid +sequenceDiagram + participant A as Angular + participant G as Gateway :8001 + participant R as RunManager / Lead Agent + + A->>G: POST /api/v1/auth/login/username + G-->>A: access_token (JWT, ~7 天) + A->>G: GET /api/models + G-->>A: models[] + A->>G: POST /api/threads metadata.agent_id=roundtable-structure + G-->>A: thread_id + A->>G: POST /api/threads/{id}/runs/stream (SSE) + G->>R: start_run(assistant_id, context.model_name, ...) + loop 流式 + R-->>A: event: metadata / messages / values / custom / end + end + Note over A: 按消息分组渲染气泡 + 步骤条 + 协助卡片 + A->>G: GET /api/threads/{id}/state (刷新后还原聊天记录) +``` + +`roundtable-structure` 与其它自定义智能体共用 **同一套 Lead Agent 图**。 +真正选中哪个智能体靠三层同时带上(缺一不可,建议都传): + +1. 请求体 `assistant_id = "roundtable-structure"`(文件系统/库表里的 **id**,不是中文名) +2. `context.agent_id = "roundtable-structure"` +3. 会话 `metadata.agent_id = "roundtable-structure"`(历史列表按此过滤) + +--- + +## 2. CORS / 跨域(Angular 必读) + +Gateway 默认放行的浏览器 Origin 只有: + +- `http://localhost:5173` / `5174` / `3000` / `8080`(及对应 `127.0.0.1`) + +**Angular CLI 默认是 `http://localhost:4200`,不在白名单里。** +部署或本地对接时必须把 Angular 源加进环境变量,例如: + +```bash +GATEWAY_CORS_ORIGINS=http://localhost:4200,http://127.0.0.1:4200,https://your-angular-host +``` + +或开发期临时 `GATEWAY_CORS_ALLOW_ALL=1`(勿用于生产)。 + +浏览器跨域读 SSE 还需要暴露这些响应头(Gateway 已配): + +- `Content-Location`(从中解析 `run_id`,取消/重连要用) +- `Location`、`Content-Disposition` + +请求请带 `credentials: include` 也可以,但 **Bearer 才是主认证**。 + +--- + +## 3. 登录:如何拿到 Token + +登录接口 **不需要** JWT。前缀:`/api/v1/auth`。 + +推荐 Angular 用 **用户名登录** 或 **上游 Token 换票**。不要用 `/login/local`。 + +### 3.1 用户名登录(推荐) + +```http +POST {GATEWAY}/api/v1/auth/login/username?password=OPTIONAL +Content-Type: application/json + +{ "username": "alice" } +``` + +- 若该账号在后台配了「登录口令」,**必须**带匹配的 `?password=` +- 若开了全局共享口令门(`config.yaml → auth_login.username_login.require_password`),未配个人口令的账号也要带共享口令 +- 未开密码门且无个人口令时,可省略 `password` + +成功响应: + +```json +{ + "access_token": "", + "token_type": "bearer", + "expires_in": 604800, + "user_id": "", + "email": "alice@deerflow.local", + "system_role": "user", + "needs_setup": false, + "created": false, + "username": "alice" +} +``` + +`expires_in` 默认 **7 天(604800 秒)**。系统 **没有 refresh 接口**,过期后重新登录。 +JWT payload:`{ sub: user_id, exp, iat, ver }`,HS256。改密码会 bump `token_version`,旧票立刻失效。 + +后续所有请求: + +```http +Authorization: Bearer +``` + +即 ``${token_type} ${access_token}``。 + +校验当前用户: + +```http +GET {GATEWAY}/api/v1/auth/me +Authorization: Bearer +``` + +```json +{ "id": "...", "email": "...", "system_role": "user", "needs_setup": false } +``` + +401 → 清本地 token,回到登录。 + +### 3.2 上游 Token 换票 + +适用于已有统一认证、URL 里带 `authToken` 的场景: + +```http +POST {GATEWAY}/api/v1/auth/login/token?token= +Content-Type: application/json +``` + +响应形状与用户名登录相同。上游时钟偏差可能导致短暂 401,本系统前端会重试最多 3 次、间隔 3 秒;Angular 建议同样处理。 + +开关:`config.yaml → auth_login.token_login.enabled`。 + +### 3.3 登录失败 + +`detail` 可能是字符串、`{ code, message }`、或校验数组。请按字符串抽取,不要直接 `String(object)`。 + +常见: + +| HTTP | 含义 | +|------|------| +| 401 | 口令错误 / 上游 token 无效 | +| 403 `{ code: "PENDING_APPROVAL" }` | 管理员取消了该账号放行 | +| 429 | 限流 | + +### 3.4 Angular 存储建议 + +```ts +const AUTH_KEY = 'deerflow.auth'; + +interface LoginResponse { + access_token: string; + token_type: string; + expires_in: number; + user_id: string; + email: string; + system_role: string; + username?: string; +} + +function authHeader(): HttpHeaders { + const auth = JSON.parse(localStorage.getItem(AUTH_KEY) || 'null') as LoginResponse | null; + return new HttpHeaders({ + Authorization: `${auth?.token_type || 'Bearer'} ${auth?.access_token || ''}`, + }); +} +``` + +--- + +## 4. 模型列表与切换 + +智能体配置不用做;**对话时选哪个大模型**要做。 + +### 4.1 拉模型 + +```http +GET {GATEWAY}/api/models +Authorization: Bearer +``` + +```json +{ + "models": [ + { + "name": "deepseek-chat", + "model": "deepseek-chat", + "display_name": "DeepSeek Chat", + "supports_thinking": true, + "supports_reasoning_effort": false, + "supports_vision": false, + "supports_tool_calling": true, + "is_local": false, + "provider": "openai" + } + ], + "token_usage": { "enabled": false } +} +``` + +下拉框展示 `display_name`,提交时传 **`name`(不是 `model`)** 到 `context.model_name`。 + +### 4.2 思考档位(本系统输入框的 flash / thinking / pro / ultra) + +每轮 `runs/stream` 的 `context` 里带这些开关(后端白名单字段,未列入的会被丢掉): + +| 档位 | `thinking_enabled` | `is_plan_mode` | `subagent_enabled` | `reasoning_effort` | 效果 | +|------|--------------------|----------------|--------------------|--------------------|------| +| flash | false | false | false | — | 快答,尽量不思考 | +| thinking | true | false | false | `low` | 出思考块 | +| pro | true | true | false | `medium` | 思考 + 计划 Todo | +| ultra | true | true | true | `high` | 再加子任务委派 | + +若所选模型 `supports_thinking === false`,请强制 flash。 +内网部分 vLLM/Qwen 只声明了 `supports_thinking`,关思考时建议额外传 `thinking_force_disabled: true`,否则可能关不干净。 + +模型解析顺序(后端): + +1. 本轮 `context.model_name` +2. 智能体自己的 `config.yaml → model` +3. 系统配置的第一个模型 + +每轮都可以换模型;换了只影响 **下一轮**,不会改写历史。 + +--- + +## 5. 会话(Thread)= 聊天记录的存储单位 + +聊天记录 **不用 Angular 自己建库**。Gateway 用 LangGraph checkpointer 存消息,用 `threads_meta` 存索引(标题、owner、metadata)。 + +物理目录(了解即可):`.deer-flow/users/{user_id}/threads/{thread_id}/user-data/{workspace,uploads,outputs}`。 + +### 5.1 新建会话 + +```http +POST {LG}/threads +Authorization: Bearer +Content-Type: application/json + +{ + "assistant_id": "roundtable-structure", + "metadata": { "agent_id": "roundtable-structure" } +} +``` + +```json +{ + "thread_id": "3f2a…", + "status": "idle", + "created_at": "2026-08-18T09:00:00+08:00", + "updated_at": "…", + "metadata": { "agent_id": "roundtable-structure" }, + "values": {}, + "interrupts": {} +} +``` + +也可以不先 create:`runs/stream` 带 `if_not_exists: "create"` 时会自动建。本系统前端就是 **第一次发消息时** 由 SDK 建 thread,再 `PATCH` 写入 `metadata.agent_id`。建议 Angular **显式 create**,方便先上传文件、再发问。 + +### 5.2 补 metadata(必须,否则历史列表滤不出来) + +```http +PATCH {LG}/threads/{thread_id} +{ "metadata": { "agent_id": "roundtable-structure" } } +``` + +为 merge,不会整表覆盖。 + +### 5.3 历史列表(按智能体过滤) + +```http +POST {LG}/threads/search +{ + "metadata": { "agent_id": "roundtable-structure" }, + "limit": 50, + "offset": 0, + "query": null, + "status": null +} +``` + +返回 `ThreadResponse[]`。标题在 `values.title`(来自库里的 `display_name`)。 +`query` 按标题模糊搜。系统会话(调度、会商工人线程)默认排除。 + +条数: + +```http +POST {LG}/threads/count +(body 同上,limit/offset 忽略) +→ { "total": 12 } +``` + +列表项建议展示: + +1. `values.title`;没有则用该会话第一条用户消息截断 50 字 +2. `updated_at` + +打开某条:跳到自己的路由并 `GET …/state` 拉消息。 + +### 5.4 打开已有会话(还原聊天记录) + +优先: + +```http +GET {LG}/threads/{thread_id}/state +``` + +`values` 里至少有: + +```json +{ + "title": "抽取方案结构", + "title_provisional": false, + "messages": [ /* 见第 8 节 */ ], + "artifacts": ["/mnt/user-data/outputs/flow.json"], + "todos": [{ "content": "…", "status": "in_progress" }] +} +``` + +也可: + +```http +GET {LG}/threads/{thread_id} +``` + +带 metadata + 当前 `values`(含 messages)。 + +checkpoint 历史(一般不需要): + +```http +POST {LG}/threads/{thread_id}/history +{ "limit": 10, "before": null } +``` + +只在最新一条 history 上带 `messages`,避免重复。 + +### 5.5 删除会话 + +两步都做才干净: + +```http +DELETE {LG}/threads/{thread_id} +``` + +这会删 checkpoint / meta / 磁盘目录。本系统前端 SDK 删除后再打一次 Gateway 同路径,确保本地文件清掉。 + +### 5.6 会话状态 + +`status`:`idle` | `busy` | `interrupted` | `error` +正在跑时为 `busy`。同一会话再发会被 `multitask_strategy: "reject"` 打回 **HTTP 409**。 + +--- + +## 6. 发起智能体对话(核心) + +```http +POST {LG}/threads/{thread_id}/runs/stream +Authorization: Bearer +Content-Type: application/json +Accept: text/event-stream +``` + +### 6.1 请求体(与本系统 AgentChatPage 对齐) + +```json +{ + "assistant_id": "roundtable-structure", + "input": { + "messages": [ + { + "type": "human", + "content": [{ "type": "text", "text": "请根据这份方案抽取流程图" }], + "additional_kwargs": { + "client_ts": "2026-08-18T09:00:00.000Z" + } + } + ] + }, + "context": { + "agent_id": "roundtable-structure", + "model_name": "deepseek-chat", + "thinking_enabled": true, + "thinking_force_disabled": false, + "is_plan_mode": false, + "subagent_enabled": false, + "reasoning_effort": "low", + "memory_injection_enabled": true, + "rag_mode_enabled": false, + "summarization_enabled": false, + "thread_id": "" + }, + "config": { "recursion_limit": 1000 }, + "stream_mode": ["values", "messages-tuple", "updates", "custom"], + "stream_subgraphs": true, + "stream_resumable": true, + "on_disconnect": "cancel", + "multitask_strategy": "reject", + "if_not_exists": "create" +} +``` + +要点: + +- `input.messages` **只传本轮新的 human**,不要把历史整包再塞一遍(服务端 checkpointer 会拼) +- `content` 可以是纯字符串 `"你好"`,也可以是上面的 text block 数组 +- `client_ts` 可选,前端用来显示提问时间 +- `stream_mode` 里写 `messages-tuple`(LangGraph Platform 名);服务端会转成内部 `messages` 再以 SSE `event: messages` 吐出 +- `on_disconnect: "cancel"`:浏览器关 SSE 就停服务端;若希望关页后台继续跑,改 `"continue"` +- `multitask_strategy: "reject"`:会话已有进行中的 run → **409**,文案类似「当前对话还在生成回答…」 + +响应头: + +```http +Content-Type: text/event-stream +Content-Location: /api/threads/{thread_id}/runs/{run_id} +``` + +**立刻把 `run_id` 存下来**(停止、重连都靠它)。SSE 第一帧 `event: metadata` 里也会再给一次。 + +### 6.2 纯文本以外的用户消息 + +图片(模型 `supports_vision` 时): + +```json +{ + "type": "human", + "content": [ + { "type": "text", "text": "看看这张图" }, + { "type": "image_url", "image_url": { "url": "https://…/a.png" } } + ] +} +``` + +附件:先上传(第 15 节),再在 `additional_kwargs.files` 里带虚拟路径。 + +协助卡片的回复:**不要**调 LangGraph `Command(resume)`,当成 **普通新一轮 human 消息** 再 `runs/stream`(见第 12 节)。 + +### 6.3 停止生成 + +只关 EventSource **不够**,服务端可能还在跑。 + +```http +POST {LG}/threads/{thread_id}/runs/{run_id}/cancel?action=interrupt&wait=false +``` + +- 202:已受理;`wait=true` 等到停完返回 204 +- `action=interrupt`:停下,保留当前 checkpoint(可继续聊) +- `action=rollback`:回滚到本轮开始前 + +列出该会话的 run: + +```http +GET {LG}/threads/{thread_id}/runs +GET {LG}/threads/{thread_id}/runs/{run_id} +``` + +### 6.4 断线重连 + +SSE 帧带 `id:`。重连时: + +```http +GET {LG}/threads/{thread_id}/runs/{run_id}/join +Last-Event-ID: <上一帧 id> +``` + +或 POST stream 时带 `stream_resumable: true` 后由 SDK 自动 join。Gateway 重启后 join 会立刻 `event: end`,此时改拉 `/state` 渲染历史即可。 + +心跳是注释行:`: heartbeat`,忽略即可。 + +--- + +## 7. SSE 事件协议(Angular 必须自己解析 POST 流) + +**`EventSource` 只支持 GET,不能用来 POST `/runs/stream`。** +Angular 请用 `fetch` + `ReadableStream`(或封装好的 SSE parser)。不要用 `HttpClient` 的默认 JSON 解析。 + +### 7.1 线格式 + +``` +event: metadata +data: {"run_id":"…","thread_id":"…"} +id: 1 + +event: messages +data: [{...chunk...}, {...meta...}] +id: 2 + +event: values +data: {"title":"…","messages":[...],"artifacts":[],"todos":[]} +id: 3 + +event: custom +data: {"type":"task_running","task_id":"…","message":"…"} +id: 4 + +event: end +data: null +id: 5 +``` + +字段顺序:`event:` → `data:` → `id:` → 空行。`data` 是一行 JSON(`ensure_ascii=false`,中文直接出)。 + +### 7.2 各事件怎么用 + +| `event` | `data` | UI 该做什么 | +|---------|--------|-------------| +| `metadata` | `{ run_id, thread_id }` | 保存 run_id | +| `messages` | `[chunk, meta]` | **token 级增量**:拼 AI 正文、思考、tool_call 参数 | +| `values` | 完整 state | **权威快照**:用 `messages[]` 替换本地列表(标题、todos、artifacts 也在这) | +| `updates` | `{ nodeName: writes }` | 可忽略;或用来知道刚跑完哪个节点 | +| `custom` | 见下表 | toast / 子任务状态 | +| `end` | `null` | 结束本轮,关 loading | +| `error` | 错误对象 | 展示错误 | + +`custom` 常见: + +```json +{ "type": "task_running", "task_id": "…", "message": "正在检索…" } +{ "type": "llm_retry", "message": "模型调用失败,正在重试…" } +{ "type": "context_compacting", "message": "正在压缩上下文…" } +``` + +后两个弹 toast 即可,不要当消息气泡。 + +### 7.3 `event: messages` 的 chunk(最重要) + +`data` 是二元组: + +```json +[ + { + "type": "AIMessageChunk", + "id": "run-abc-0", + "content": "正在", + "additional_kwargs": { + "reasoning_content": "用户想抽取结构,我先读文件…" + }, + "tool_call_chunks": [ + { + "index": 0, + "id": "call_xxx", + "name": "bash", + "args": "{\"description\": \"创建脚本\"" + } + ] + }, + { + "langgraph_node": "model", + "ls_provider": "…", + "ls_model_name": "…" + } +] +``` + +合并规则(按 `chunk.id` 分桶): + +1. **正文**:把每次 `content`(字符串)往后拼;如果是 block 数组,只拼 `type==="text"` 的 `text` +2. **思考**:`additional_kwargs.reasoning_content` 一般是 **累计全文**(以最新一帧为准);若是 delta 则拼接。同时要从正文里剥 `…`(见第 10 节) +3. **工具调用**:按 `tool_call_chunks[].index` 拼 `name` / `args`(args 是 JSON **字符串碎片**,拼完再 `JSON.parse`) +4. **工具结果**:会出现 `type: "tool"` / `ToolMessage`,`tool_call_id` 对上上面的 `id`,`name` 为工具名,`content` 为结果 + +`values.messages` 到来后,用快照覆盖增量状态,避免拼错。 + +### 7.4 Angular 解析骨架 + +```ts +async function streamRun( + lgBase: string, + threadId: string, + body: unknown, + token: string, + onEvent: (event: string, data: unknown) => void, +): Promise { + const res = await fetch(`${lgBase}/threads/${threadId}/runs/stream`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + Accept: 'text/event-stream', + }, + body: JSON.stringify(body), + }); + if (res.status === 409) throw new Error('当前对话还在生成回答,请稍候再发送'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + const location = res.headers.get('Content-Location') || ''; + const runId = location.split('/runs/')[1]; + + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const parts = buf.split('\n\n'); + buf = parts.pop() || ''; + for (const block of parts) { + if (block.startsWith(':')) continue; // heartbeat + let event = 'message'; + let data = ''; + for (const line of block.split('\n')) { + if (line.startsWith('event:')) event = line.slice(6).trim(); + else if (line.startsWith('data:')) data += line.slice(5).trim(); + } + if (!data) continue; + onEvent(event, data === 'null' ? null : JSON.parse(data)); + } + } + return runId; +} +``` + +--- + +## 8. 消息数据结构(历史 + 实时共用) + +`values.messages[]` 每条大致如下(LangChain 序列化后): + +```json +{ + "type": "human | ai | tool", + "id": "uuid-or-run-id", + "name": "ask_clarification | bash | …", + "content": "字符串 或 content block 数组", + "tool_calls": [ + { "id": "call_xxx", "name": "bash", "args": { "description": "执行脚本", "command": "python3 a.py" } } + ], + "tool_call_id": "call_xxx", + "additional_kwargs": { + "reasoning_content": "…", + "clarification": { }, + "files": [ ], + "client_ts": "…", + "hide_from_ui": false + } +} +``` + +### 8.1 `content` 形态 + +| 形态 | 含义 | +|------|------| +| `"一段文本"` | 最常见 | +| `[{ "type": "text", "text": "…" }]` | 多段文本 | +| `[{ "type": "image_url", "image_url": { "url": "…" } }]` | 用户图 / 模型回图 | +| `[{ "type": "thinking", "thinking": "…" }]` | Anthropic 风格思考 | +| `[{ "type": "json", "json": { } }]` | 工具结构化结果 | + +### 8.2 必须隐藏、不要渲染的消息 + +- `additional_kwargs.hide_from_ui === true`(摘要压缩等) +- `name === "summary"` +- `name === "todo_reminder"` +- `type === "system"`(一般不会出现在 UI 列表) + +### 8.3 角色怎么画 + +| `type` | 默认 UI | +|--------|---------| +| `human` | 右侧用户气泡,Markdown 可关,展示附件 chips | +| `ai` 且无 `tool_calls`、有正文 | 左侧助手最终回答(Markdown) | +| `ai` 有 `tool_calls` 或仅有思考 | **不单独当最终气泡**,进入步骤条 | +| `tool` | **几乎不单独成气泡**,并进步骤条 / 协助卡片 | + +--- + +## 9. 消息列表如何分组渲染 + +本系统 `groupMessages()` 的规则,Angular 请原样移植,否则步骤条和协助卡片会对不齐。 + +从前往后扫 `messages`(已过滤隐藏消息): + +1. `human` → 一组 `human` +2. `ai` 且 `tool_calls` 含 `present_files` → `assistant:present-files`(正文 + 文件卡片) +3. `ai` 且 `tool_calls` 含 `task` → `assistant:subagent`(子任务卡) +4. `ai` 有思考或有其它 tool_calls → 并入(或新建)`assistant:processing`(**步骤条**) +5. 同一条 `ai` **既有思考又有最终正文、且没有 tool_calls** → processing 一组 **再** 出一组 `assistant` 气泡 +6. `tool` 且 `name === ask_clarification` → 先塞进当前 processing,再单独开 `assistant:clarification` +7. 其它 `tool` → 塞进当前 processing(用 `tool_call_id` 填步骤的 result) + +渲染顺序示例(一轮典型技能执行): + +```text +[用户] 请抽取结构 +[步骤条] + 思考 + 读取文件 SKILL.md + 创建脚本 + 执行脚本 + 写入文件 /mnt/user-data/outputs/flow.json + 展示文件 +[助手] 已生成 flow-json,可在右侧预览。 +``` + +若中途问用户: + +```text +[步骤条] … 需要你的协助 +[协助卡片] 需要你的确认 +(等待用户下一轮 human) +``` + +### 9.1 用户气泡 + +- 文本:`extractContentFromMessage`,去掉 `…` 这类内部标记 +- 附件:`additional_kwargs.files[]` → `{ filename, size, path, status }` +- 图片 block → `` +- 可选:编辑/删除(删除要改 checkpoint,对接初期可不做) + +### 9.2 助手最终回答 + +- Markdown(GFM 表格/代码块) +- 流式时按 token 追加;`values` 快照到达后用完整 `content` 替换 +- 正文里的 `` 必须剥掉(见第 10 节) +- 引用:技能检索结果会在步骤里展开,正文里可能有 `[n]` 引用角标 +- 复制 / 导出 Word 可选 + +### 9.3 流式指示 + +`busy` 且还没有最终 `assistant` 气泡时,在列表底部显示跳动的「正在生成」。 +上传文件未完成时可插一条 mock AI:`additional_kwargs.element === "task"`,文案「文件上传中,请稍候...」。 + +--- + +## 10. 思考内容如何对接 + +思考 **没有** 独立 SSE 事件名,混在 AI 消息里。按优先级取: + +1. `additional_kwargs.reasoning_content`(字符串,多数内网模型走这条) +2. content block `{ type: "thinking", thinking: "…" }` +3. 正文里的 `…`(含流式 **未闭合** 的 ``) + +### 10.1 剥离算法(请直接用) + +```ts +const THINK_RE = /\s*([\s\S]*?)\s*<\/think>/g; +const THINK_OPEN = ''; + +function splitThink(content: string): { answer: string; thinking: string | null } { + const parts: string[] = []; + let working = content.replace(THINK_RE, (_, t: string) => { + const s = t.trim(); + if (s) parts.push(s); + return ''; + }); + const i = working.indexOf(THINK_OPEN); + if (i !== -1 && !working.slice(i).includes('')) { + const partial = working.slice(i + THINK_OPEN.length).trim(); + if (partial) parts.push(partial); + working = working.slice(0, i); + } + return { answer: working.trim(), thinking: parts.length ? parts.join('\n\n') : null }; +} +``` + +展示用 `answer`,思考区用 `reasoning_content || thinking`。 + +### 10.2 UI 约定(与现网一致) + +- 步骤条里:思考是一种 step,标题 **「思考」**,可折叠,灯泡图标 +- 只有思考、还没有工具、还没有最终正文时:单独一块可折叠「思考」,流式时默认展开,结束后可自动收起 +- **历史刷新后思考仍在**(写在 checkpoint 的 AI 消息上),不要丢 +- 不要把思考当作用户可见的最终答案 + +`thinking_enabled: false` 时模型不应再吐思考;若仍出现 ``,继续剥掉以免露标签。 + +--- + +## 11. 步骤条(Chain of Thought)— 请做全 + +步骤条只渲染 `assistant:processing` 组。内部只有两类 step: + +- `reasoning`:思考文本 +- `toolCall`:一次工具调用(`task` 子任务除外,走独立卡片) + +### 11.1 从消息抽出 steps + +对组内每条 `ai`: + +1. 若有思考 → push `{ type: "reasoning", reasoning }` +2. 遍历 `tool_calls`,跳过 `name === "task"` +3. 用 `tool_call.id` 在组内找对应 `type==="tool"` 的消息,作为 `result` +4. `result.content` 若是 JSON 字符串就 parse + +UI: + +- 默认只展示 **最后一个工具步骤** + 其后的思考 +- 上方折叠:「查看其他 N 个步骤」/「隐藏步骤」 +- 进行中:该步 status=`active`(转圈);有 result 后 `complete`(对勾) +- 工具参数还在流式拼接时,label 用已有 `args.description` 或占位「正在调用工具…」 + +### 11.2 步骤标题怎么来(「创建脚本 / 执行脚本 / 写入文件」在这里) + +**系统没有名为 `create_script` / `execute_script` 的工具。** +模型调用沙箱工具时 **必须先填 `args.description`**(短中文说明),步骤条 **优先展示 description**。所以你会看到: + +| 用户看到的步骤 | 实际 `tool_calls[].name` | `args` | +|----------------|--------------------------|--------| +| 创建脚本 | `bash` | `{ description: "创建脚本", command: "cat > /tmp/a.py <<'EOF' …" }` | +| 执行脚本 | `bash` | `{ description: "执行脚本", command: "python3 /tmp/a.py" }` | +| 写入文件 | `write_file` 或 `str_replace` | `{ description: "写入文件", path: "/mnt/user-data/outputs/flow.json", content: "…" }` | +| 读取技能说明 | `read_file` | `{ description: "读取技能说明", path: "/mnt/skills/public/…/SKILL.md" }` | + +`description` 是模型写的,文案不固定。没有 description 时再用下表中文底稿。 + +### 11.3 全量工具 → 步骤条映射 + +与现网 `message-group.tsx` + `zh-CN.ts toolCalls` 对齐。未知 MCP/技能脚本一律走最后一行。 + +| `name` | 默认中文标题 | 图标建议 | 展开区 | +|--------|--------------|----------|--------| +| (reasoning step) | 思考 | 灯泡 | Markdown/纯文本思考 | +| `web_search` | `知识库检索 "{query}"`;无 query 则为「知识库检索」 | 搜索 | 结果列表(title/snippet/url),可折叠「N 条检索结果」 | +| 任意工具但 result 长得像检索 `{ results: [...] }` | `搜索 “{query}”` 或「搜索相关信息」 | 搜索 | 同上 | +| 技能展示/RAG 引用模式 | 「检索参考来源」或「获取 N 条结果」 | 搜索 | 来源卡片(title/snippet/原文) | +| `image_search` | `搜索相关图片 “{query}”` | 搜索 | 缩略图网格,点开 `source_url` | +| `web_fetch` | 查看网页 | 地球 | 链接,标题从返回 Markdown 的 `# title` 抽 | +| `ls` | `args.description` 或 **列出文件夹** | 文件夹 | path chip | +| `read_file` | `args.description` 或 **读取文件** | 书本 | path chip | +| `write_file` | `args.description` 或 **写入文件** | 笔记本 | path;点击打开产物预览 | +| `str_replace` | 同上(默认也叫写入文件) | 笔记本 | path | +| `bash` | **必须用 `args.description`**;没有则 **执行命令** | 终端 | 展示 `command` 的 bash 代码块(不一定展示 stdout,stdout 在 tool result 里) | +| `glob` | `args.description` 或 `使用 “glob” 工具` | 扳手 | 可选 path/pattern | +| `grep` | `args.description` 或 `使用 “grep” 工具` | 扳手 | pattern / path | +| `browser_fetch_page` | `args.description` 或 `使用 “browser_fetch_page” 工具` | 扳手 | url | +| `browser_act` | `args.description` 或 `使用 “browser_act” 工具` | 扳手 | 动作说明 | +| `ask_clarification` | **需要你的协助** | 问号 | 空(真正交互在卡片) | +| `write_todos` | **更新 To-do 列表** | 清单 | 空;完整列表见输入框上方 Todo 面板 | +| `present_files` | 一般不进步骤条,进「展示文件」组 | 文件 | 见 13.1 | +| `update_artifact` | `args.title` 或「生成代码」/「生成文档」 | 笔 | 「代码文件」或「Markdown 文档」 | +| `view_image` | `args.description` 或 `使用 “view_image” 工具` | 扳手 | 图片路径 | +| `skill_list` | `args.description` 或 `使用 “skill_list” 工具` | 扳手 | — | +| `skill_view` | 读取某个技能 | 扳手 | 技能名 | +| `search_skills` | 检索可用技能 | 扳手 | 关键词 | +| `memory` / `hindsight_*` | 记忆读写 | 扳手 | — | +| `deep_research_progress` | `args.label` 或「正在撰写研究报告」 | 转圈/对勾 | detail + 可折叠「模型思考」;`status!=="complete"` 为进行中 | +| `task` | **不要**画在步骤条 | — | 见 13.2 子任务卡 | +| **其它 MCP / 自定义工具** | `args.description` **或** `使用 “{name}” 工具` | 扳手 | 有 path/url 可展示 | + +`roundtable-structure` 常走技能 `knowledge-base-ingest`,因此一轮里会密集出现:`read_file`(SKILL.md)→ `bash`(创建/执行脚本)→ `write_file`(产物)→ `present_files`。把 `bash.description` 原样显示,就能覆盖「创建脚本」「执行脚本」。 + +### 11.4 工具参数字段(沙箱类) + +所有沙箱工具的 **第一个参数都是 `description`**: + +```ts +interface BashArgs { description: string; command: string } +interface LsArgs { description: string; path: string } +interface ReadFileArgs { description: string; path: string } +interface WriteFileArgs { description: string; path: string; content: string } +interface StrReplaceArgs { description: string; path: string; old_str: string; new_str: string } +interface GlobArgs { description: string; pattern: string; path?: string } +interface GrepArgs { description: string; pattern: string; path?: string } +interface WebSearchArgs { query: string } +interface WebFetchArgs { url: string } +``` + +`write_file` 流式时 `content` 会变长,可做实时预览(现网点击步骤打开右侧产物面板)。 + +### 11.5 检索结果 result 形状(web_search / 技能) + +常见: + +```json +{ + "success": true, + "query": "台湾滨海防卫", + "count": 10, + "results": [ + { + "title": "…", + "content_preview": "…", + "url": "https://…", + "recUuid": "…", + "score": 0.8 + } + ] +} +``` + +字段名不统一(`content` / `page_content` / `snippet` / `m_title` / `name`),展示时做并集兜底。 + +--- + +## 12. 用户协助卡片(ask_clarification) + +当模型信息不够、有多种做法、或要确认风险时,会调用工具 `ask_clarification`。 +**ClarificationMiddleware 会截住该工具,写入 ToolMessage,并 `goto=END` 结束本轮。** +这 **不是** LangGraph `interrupt()` / `Command(resume)`。用户点选项后,发 **新的 human 消息** 即可,智能体会带着选择继续。 + +### 12.1 如何识别 + +`message.type === "tool" && message.name === "ask_clarification"` + +结构化数据在: + +```json +{ + "additional_kwargs": { + "clarification": { + "question": "流程图层级按哪套业务?", + "clarification_type": "approach_choice", + "context": "报告里同时出现了任务链和目的链。", + "options": [ + { "id": "option-0", "label": "任务→目的→行为体", "description": "可选" }, + { "id": "option-1", "label": "按报告原标题层级" } + ], + "allow_custom": true, + "allow_multiple": false + } + }, + "content": "(Markdown 兜底文案,无结构化数据时用这个渲染)" +} +``` + +`clarification_type`: + +- `missing_info` 缺信息 +- `ambiguous_requirement` 需求有歧义 +- `approach_choice` 方案选择 +- `risk_confirmation` 风险确认 +- `suggestion` 建议确认 + +同一轮可以有 **多张** 独立卡片(多个 tool call)。只统计 **上一条 human 之后** 的 clarification 为「待答」。 + +### 12.2 UI(与现网文案) + +- 标题:**需要你的确认**;多张时显示 `1/N` +- 展示 `context`、`question` +- 选项做成按钮 +- `allow_multiple !== true`:单选。**仅一张待答卡时,点击即提交**;多张时只记录选择,点最后一张上的「全部发送」 +- `allow_multiple === true`:多选 + 必须点提交 +- `allow_custom !== false`(后端恒为 true):额外文本框 +- 按钮:「发送回答」/ 多张时「全部发送」 +- 历史里已答过的卡:禁用,只读回显 + +步骤条里对应一步标题仍是「需要你的协助」。 + +### 12.3 提交内容格式(必须按这个拼,模型才认) + +单张: + +```text +我选择:任务→目的→行为体 +我选择:A、B;补充:用户打的字 +(只有自定义)用户打的字 +``` + +多张: + +```text +问题1「……」:我选择:A +问题2「……」:补充:…… +``` + +然后: + +```http +POST …/runs/stream +input.messages = [{ type: "human", content: "<上面拼好的文本>" }] +``` + +不要调 `/state` 的 resume,也不要自己造 ToolMessage。 + +--- + +## 13. 其它卡片与面板 + +### 13.1 展示文件 `present_files` + +AI `tool_calls` 含 `name: "present_files"`,`args.filepaths: string[]`(必须是 `/mnt/user-data/outputs/…`)。 + +UI:Markdown 摘要 + 文件卡片。点击后: + +```http +GET {GATEWAY}/api/threads/{thread_id}/artifacts/{path} +``` + +`path` 例:`mnt/user-data/outputs/flow.json`(虚拟前缀,不要用 Windows 盘符)。 +`?download=true` 强制下载。HTML/SVG 会当附件,降低 XSS。 + +列表: + +```http +GET {GATEWAY}/api/threads/{thread_id}/artifacts +→ { "thread_id", "files": [{ path, name, size_bytes, mime_type, modified_at }] } +``` + +state 里的 `artifacts: string[]` 是虚拟路径列表,可与卡片同步。 + +### 13.2 子任务 `task`(ultra 档) + +`tool_calls[].name === "task"`,`args` 含 `description` / `prompt` / `subagent_type`。 +组类型 `assistant:subagent`,标题「执行 N 个子任务」(N>1 时「并行执行」)。 +`custom` 事件 `task_running` 更新进行中文案。终态看对应 tool 结果。 + +`roundtable-structure` 默认不必开 ultra;对接时可先不做,遇到 `task` 当普通扳手步骤也行。 + +### 13.3 Todo 面板(pro / ultra) + +`write_todos` 出现在步骤条;完整列表在 `values.todos`: + +```ts +interface Todo { content?: string; status?: 'pending' | 'in_progress' | 'completed' } +``` + +现网浮在输入框上方。一轮只应有一个 `in_progress`。 + +### 13.4 创建智能体 / 写作审批卡 + +`setup_agent` / `setup_writing` 带 approval kwargs。普通 `roundtable-structure` 对话 **不会出现**,可忽略。 +Deep Research 的 `setup_research_approval` 同理。 + +--- + +## 14. 标题、追问、错误态 + +### 14.1 自动标题 + +第一轮用户消息到达后,TitleMiddleware 先用问题字面做 **临时标题**(`title_provisional: true`),再异步 LLM 生成最终标题。 +听 `event: values` 的 `title` 即可,刷新列表。 + +### 14.2 追问建议(一轮结束后) + +```http +POST {GATEWAY}/api/threads/{thread_id}/suggestions +{ + "messages": [ + { "role": "user", "content": "…" }, + { "role": "assistant", "content": "…" } + ], + "n": 3, + "model_name": "deepseek-chat" +} +``` + +```json +{ "suggestions": ["是否导出 JSON?", "按另一套层级再抽一次"] } +``` + +点建议 = 再发一条 human。失败时返回空数组,不要报错挡 UI。 + +### 14.3 错误与限流 + +| 情况 | 表现 | UI | +|------|------|----| +| 会话已在生成 | HTTP 409 | toast:「当前对话还在生成回答…」,不要红框 | +| 全局并发打满 | HTTP 429 | toast 提示稍后再试 | +| 模型失败重试 | `custom.llm_retry` | 轻提示 | +| 上下文压缩 | `custom.context_compacting` | 「正在压缩上下文…」 | +| 会话不属于当前用户 | 403/404 | 回到新建 | + +--- + +## 15. 文件上传(用户侧) + +先有 `thread_id`(可先 create 空会话)。 + +```http +POST {GATEWAY}/api/threads/{thread_id}/uploads +Authorization: Bearer +Content-Type: multipart/form-data +``` + +字段名 `files`,可多文件。默认上限约 10 个、单文件 50MB、合计 100MB。 +PDF/PPT/Excel/Word 会转成 Markdown 一并放入 uploads。 + +```json +{ + "success": true, + "message": "Successfully uploaded 1 file(s)", + "files": [ + { + "filename": "report.docx", + "virtual_path": "/mnt/user-data/uploads/report.docx", + "markdown_virtual_path": "/mnt/user-data/uploads/report.md" + } + ], + "skipped_files": [] +} +``` + +发消息时: + +```json +"additional_kwargs": { + "files": [ + { "filename": "report.md", "path": "/mnt/user-data/uploads/report.md", "status": "uploaded" } + ] +} +``` + +限额:`GET /api/threads/{thread_id}/uploads/limits`。 +列表/删除:`GET …/uploads/list`,`DELETE …/uploads/{filename}`。 + +--- + +## 16. 推荐的页面结构(对标 AgentChatPage) + +不必做智能体配置侧栏。最小工作台: + +``` +┌─ 左:会话列表(threads/search by agent_id)─┬─ 中:消息列表 ─┬─ 右:产物预览 ─┐ +│ 新建对话 │ 标题(values.title)│ artifacts │ +│ 每条 title + 时间 │ 用户/步骤条/卡片/回答│ │ +│ │ Todo(可选) │ │ +│ │ 输入框:模型+档位+停止│ │ +└────────────────────────────────────────────────┴─────────────────────┴────────────────┘ +``` + +输入框建议: + +- 模型 Select(`GET /api/models`) +- 档位:快速 / 思考 / 规划(对应 flash / thinking / pro);ultra 可隐藏 +- 发送、停止(streaming 时) +- 可选:上传、追问 chips + +`roundtable-structure` 是「结构化抽取」智能体:给它方案报告或席位交付,产出 flow-json 流程图数据,常伴随写文件 + 展示文件。不要当成多智能体会商编排页(`/roundtable` 是另一套 API)。 + +--- + +## 17. 最小对接顺序(验收清单) + +1. `POST /api/v1/auth/login/username` → 存 JWT +2. CORS 已包含 Angular Origin +3. `GET /api/models` → 下拉,选中项的 `name` 写入 `context.model_name` +4. `POST /api/threads` + `metadata.agent_id=roundtable-structure` +5. `POST /api/threads/{id}/runs/stream`,`assistant_id` 与 `context.agent_id` 都等于 `roundtable-structure` +6. 解析 SSE:`metadata` → `messages` 增量 → `values` 覆盖 → `end` +7. 消息分组:用户气泡 / 步骤条 / 协助卡片 / 最终 Markdown +8. 步骤条至少覆盖:`bash`(description=创建脚本/执行脚本)、`write_file`(写入文件)、`read_file`、`ls`、`web_search`、未知工具 +9. 思考:`reasoning_content` + `` 剥离 +10. 协助卡片:选项 → 拼「我选择:」→ 新一轮 human +11. `POST /threads/search` 还原列表;`GET /state` 还原消息(含思考与步骤) +12. 停止:`POST …/runs/{run_id}/cancel` +13. 409 toast +14. `present_files` → `GET …/artifacts/{path}` + +--- + +## 18. 接口速查 + +Base:`{GATEWAY} = http://host:8001`,LangGraph `{LG} = {GATEWAY}/api`。 +除登录外均需 `Authorization: Bearer`。 + +| 方法 | 路径 | 用途 | +|------|------|------| +| POST | `/api/v1/auth/login/username` | 用户名登录 | +| POST | `/api/v1/auth/login/token?token=` | 上游 token 换票 | +| GET | `/api/v1/auth/me` | 当前用户 | +| GET | `/api/models` | 模型列表 | +| POST | `/api/threads` | 建会话 | +| POST | `/api/threads/search` | 历史列表 | +| POST | `/api/threads/count` | 历史条数 | +| GET | `/api/threads/{id}` | 会话摘要 + values | +| GET | `/api/threads/{id}/state` | 最新 checkpoint | +| PATCH | `/api/threads/{id}` | 改 metadata | +| DELETE | `/api/threads/{id}` | 删除会话 | +| POST | `/api/threads/{id}/history` | checkpoint 历史 | +| POST | `/api/threads/{id}/runs/stream` | **流式对话** | +| POST | `/api/threads/{id}/runs/{rid}/cancel` | 停止 | +| GET | `/api/threads/{id}/runs` | run 列表 | +| GET | `/api/threads/{id}/runs/{rid}/join` | 重连 SSE | +| POST | `/api/threads/{id}/uploads` | 上传 | +| GET | `/api/threads/{id}/artifacts` | 产物列表 | +| GET | `/api/threads/{id}/artifacts/{path}` | 读产物 | +| POST | `/api/threads/{id}/suggestions` | 追问 | + +可选简化入口(**无登录、不落聊天记录、没有步骤条协议**,不建议用来复刻本页): + +- `POST /api/open/chat` / `/api/open/chat/stream`(`agent_id` 或中文 `agent_name`) + +可选 iframe 嵌现网 UI(不自己画步骤条):见 `frontend-web/docs/embed-iframe-chat.md`。那是通用工作台 `/embed/chats`,不是 `agents/roundtable-structure` 路由;若必须嵌本页,可用: + +``` +{FRONTEND}/#/page/workspace/agents/roundtable-structure/chats/new?embed=1 +``` + +仍要先登录;iframe 跨域时走 `?embed=1` 可隐藏外壳。要完全自绘 UI,请走本文 REST + SSE。 + +--- + +## 19. Angular 工程注意 + +1. **POST SSE 用 fetch**,不要 `HttpClient.get` + EventSource +2. `run_id` 从 `Content-Location` 或 `event: metadata` 取;取消必须打 cancel +3. `assistant_id` 用 **`roundtable-structure`**,不要传「结构化抽取」 +4. 历史过滤靠 `metadata.agent_id`,第一轮后务必 PATCH +5. 同一 thread 未 `end` 不要再发,否则 409 +6. JWT 约 7 天,无 refresh +7. 把 `localhost:4200` 加入 `GATEWAY_CORS_ORIGINS` +8. 可用 npm 包 `@langchain/langgraph-sdk` 的 `Client`(`apiUrl: LG, defaultHeaders: { Authorization }`)少写 SSE 细节;UI 仍要自己做分组/步骤条/卡片 +9. 不要把思考、tool 结果、协助卡当成三条普通聊天记录平铺,观感会和现网差很多 +10. 步骤条 label **永远优先 `args.description`**,这样才能显示「创建脚本」「执行脚本」「写入文件」 + +--- + +## 20. 参考实现位置(本仓库) + +| 主题 | 文件 | +|------|------| +| 对话页 | `frontend-web/src/pages/AgentChatPage.tsx` | +| 发消息 / SSE | `frontend-web/src/core/threads/hooks.ts` | +| 消息分组 | `frontend-web/src/core/messages/utils.ts` | +| 步骤条 | `frontend-web/src/components/workspace/messages/message-group.tsx` | +| 协助卡片 | `frontend-web/src/components/workspace/messages/message-list.tsx` | +| 中文步骤文案 | `frontend-web/src/core/i18n/locales/zh-CN.ts` → `toolCalls` | +| 登录 | `frontend-web/src/core/auth/api.ts` | +| 模型 | `GET /api/models` ← `app/gateway/routers/models.py` | +| 会话 / 状态 | `app/gateway/routers/threads.py` | +| 流式 | `app/gateway/routers/thread_runs.py` | +| SSE 帧 | `app/gateway/services.py` `format_sse` | +| 协助卡 payload | `deerflow/agents/middlewares/clarification_middleware.py` | +| 沙箱工具 description | `deerflow/sandbox/tools.py`(bash / write_file / …) | + +智能体种子:`offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-structure/`(`id: roundtable-structure`,`name: 结构化抽取`)。 diff --git a/frontend-web/public/runtime-config.js b/frontend-web/public/runtime-config.js index 66470d66..e24ea0c7 100644 --- a/frontend-web/public/runtime-config.js +++ b/frontend-web/public/runtime-config.js @@ -32,10 +32,10 @@ window.__CM_RUNTIME_CONFIG__ = { VITE_BRAND_WELCOME_DARK_URL: "", // DeerFlow Gateway 地址:前端自有后端接口,例如 /api/agents、/api/auth、/api/threads 等。 - // VITE_BACKEND_BASE_URL: "http://10.19.200.5:2026", + // VITE_BACKEND_BASE_URL: "https://ch1.b.uat.4.cn/deerflow", // LangGraph 流式对话接口地址:普通问答、智能体对话等流式能力使用。 - // VITE_LANGGRAPH_BASE_URL: "http://10.19.200.5:2026/api", + // VITE_LANGGRAPH_BASE_URL: "https://ch1.b.uat.4.cn/deerflow/api", // Canvas/AI 写作独立 LangGraph 地址:为空时前端会回退使用 VITE_LANGGRAPH_BASE_URL。 VITE_CANVAS_LANGGRAPH_BASE_URL: "", @@ -50,6 +50,10 @@ window.__CM_RUNTIME_CONFIG__ = { // CMZS 后端接口:任务管理、任务分析、三情分析、行动分析等业务接口。 VITE_API_BASE_URL: "https://ch1.b.uat.4.cn/zncm", + // 8BF 嵌入页专用任务系统接口。部署到其他系统时只需修改这一项, + // 不影响原多智能体会商使用的任务详情接口。 + VITE_8BF_TASK_API_BASE_URL: "https://ch1.b.uat.4.cn/zncm", + // 佳来模型对话接口。 VITE_AIPI_CHAT_URL: "https://ch1.b.uat.4.cn/gpt", @@ -90,7 +94,7 @@ window.__CM_RUNTIME_CONFIG__ = { VITE_AIPI_FRONTEND_URL: "http://47.88.25.99:3000", // Consumer 接口:深链任务详情 cop-task-detail、研判情报报告 selectSqReport 等使用。 - VITE_AIPI_CONSUMER_URL: "http://10.19.200.5:8002/consumer", + VITE_AIPI_CONSUMER_URL: "https://ch1.b.uat.4.cn/consumer", // A 系统跳转地址:人物、组织、事件等列表项打开链接使用。 VITE_AIPI_PRESENT_URL: "http://present.a.uat.4.cn/present/domain", diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index 3a11ded3..9af34f0b 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -19,8 +19,9 @@ import { useQueryClient } from "@tanstack/react-query"; import { useParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react"; -import { toast } from "sonner"; -import { Streamdown } from "streamdown"; +import { toast } from "sonner"; +import { defaultUrlTransform } from "react-markdown"; +import { Streamdown } from "streamdown"; import { Artifact, @@ -42,6 +43,7 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { CodeEditor } from "@/components/workspace/code-editor"; import { MarkdownAiEditor } from "@/open-canvas/components/artifact/MarkdownAiEditor"; import { useDocumentRewrite } from "@/open-canvas/hooks/useDocumentRewrite"; +import { useCapabilities as useResearchCapabilities } from "@/strategy-components/api/deep-research"; import { listDurableDocumentRewrites, streamDurableDocumentRewriteJob, @@ -149,9 +151,11 @@ export function ArtifactFileDetail({ onClose, allowVirtualMarkdownEdit = false, disableDesensitize = false, - hideFileName = false, - onDownloadClick, -}: { + hideFileName = false, + onDownloadClick, + toolbarActions, + researchSessionId, +}: { className?: string; filepath: string; threadId: string; @@ -177,8 +181,15 @@ export function ArtifactFileDetail({ * (深度研究沙箱用它弹「Markdown / Word」格式选择弹窗),并隐藏工具栏中重复的 * 「另存为 Word」。默认不传 —— 其它调用方行为不变。 */ - onDownloadClick?: () => void; -}) { + onDownloadClick?: () => void; + /** Deep-research reports use protected `deep-research://` image references. */ + researchSessionId?: string; + /** + * 由宿主注入到右上角 ArtifactActions 图标组的动作。深度研究报告使用它 + * 放置全文改写入口,避免再在沙箱内容区叠一个文字按钮。 + */ + toolbarActions?: ReactNode; +}) { const { t } = useI18n(); const { artifacts, setOpen, select } = useArtifacts(); const { isMock, thread, publicShareCode } = useThread(); @@ -291,13 +302,20 @@ export function ArtifactFileDetail({ const updateArtifact = useUpdateArtifactContent(); const queryClient = useQueryClient(); const documentRewrite = useDocumentRewrite(); + const imageCapabilities = useResearchCapabilities(); const rewritePresentation = useDocumentRewritePresentation(); const hydratedRewriteScopeRef = useRef(null); const mirroredRequirementsPlanRef = useRef<{ presentationId: string | null; plan: string }>({ presentationId: null, plan: "", }); + const mirroredThinkingTextRef = useRef<{ presentationId: string | null; thinking: string }>({ + presentationId: null, + thinking: "", + }); const documentRewriteActive = documentRewrite.isActive; + const imageGenerationAvailable = imageCapabilities.data?.imageGeneration?.available === true; + const maxGeneratedImages = imageCapabilities.data?.imageGeneration?.maxImages ?? 4; const canUseFullDocumentRewrite = canEditArtifact && !hasLocalEdits; const displayContent = documentRewriteActive ? documentRewrite.run.draft @@ -405,6 +423,29 @@ export function ArtifactFileDetail({ writtenCharCount: event.charCount, }); break; + case "illustration_started": + rewritePresentation.update(presentationId, { + stage: "illustrating", + illustrationTarget: event.section, + maxGeneratedImages: event.total || 1, + }); + break; + case "illustration_generated": + rewritePresentation.update(presentationId, { + stage: "illustrating", + illustrationTarget: event.section, + generatedImageCount: event.generatedCount ?? event.position, + maxGeneratedImages: event.total || 1, + }); + break; + case "illustration_completed": + rewritePresentation.update(presentationId, { + stage: "illustrating", + generatedImageCount: event.generatedCount, + maxGeneratedImages: event.requestedCount || 1, + warnings: event.warnings, + }); + break; case "validation_started": rewritePresentation.update(presentationId, { stage: "validation" }); break; @@ -463,6 +504,8 @@ export function ArtifactFileDetail({ instruction: historical.request.instruction, style: historical.request.style, modelName: historical.request.modelName, + generateImages: historical.request.generateImages, + maxGeneratedImages: historical.request.maxGeneratedImages, originalContent: historical.request.originalContent, rewrittenContent: typeof historicalResult.content === "string" ? historicalResult.content : null, requirementsPlan: typeof historicalResult.requirementsPlan === "string" @@ -497,6 +540,8 @@ export function ArtifactFileDetail({ instruction: latest.request.instruction, style: latest.request.style, modelName: latest.request.modelName, + generateImages: latest.request.generateImages, + maxGeneratedImages: latest.request.maxGeneratedImages, originalContent, rewrittenContent, requirementsPlan: typeof result.requirementsPlan === "string" ? result.requirementsPlan : "", @@ -537,6 +582,8 @@ export function ArtifactFileDetail({ instruction: latest.request.instruction, style: latest.request.style, modelName: latest.request.modelName ?? undefined, + generateImages: latest.request.generateImages, + maxGeneratedImages: latest.request.maxGeneratedImages, }, { jobId: latest.id, @@ -605,6 +652,19 @@ export function ArtifactFileDetail({ rewritePresentation, ]); + // Thoughts use the same rAF coalescing as the draft, then are mirrored once + // per browser frame into the regular message-list card. They never touch + // the artifact Markdown or the durable rewrite checkpoint. + useEffect(() => { + const presentationId = documentRewritePresentationId; + const thinking = documentRewrite.run.thinkingText; + if (!presentationId || !rewritePresentation || !thinking) return; + const previous = mirroredThinkingTextRef.current; + if (previous.presentationId === presentationId && previous.thinking === thinking) return; + mirroredThinkingTextRef.current = { presentationId, thinking }; + rewritePresentation.update(presentationId, { thinkingText: thinking }); + }, [documentRewrite.run.thinkingText, documentRewritePresentationId, rewritePresentation]); + useEffect(() => { const handleRewriteUndo = (event: Event) => { const detail = (event as CustomEvent<{ @@ -851,6 +911,8 @@ export function ArtifactFileDetail({ instruction: string; style: "formal_analysis" | "professional" | "concise" | "readable"; modelName: string | null; + generateImages: boolean; + maxGeneratedImages: number; }) => { if (!canUseFullDocumentRewrite) { toast.error( @@ -872,6 +934,8 @@ export function ArtifactFileDetail({ instruction: input.instruction, style: input.style, modelName: input.modelName, + generateImages: input.generateImages, + maxGeneratedImages: input.maxGeneratedImages, originalContent: draftContent, }); setDocumentRewritePresentationId(presentationId ?? null); @@ -882,6 +946,8 @@ export function ArtifactFileDetail({ instruction: input.instruction, style: input.style, modelName: input.modelName ?? undefined, + generateImages: input.generateImages, + maxGeneratedImages: input.maxGeneratedImages, expectedHash, }, { @@ -921,6 +987,29 @@ export function ArtifactFileDetail({ writtenCharCount: event.charCount, }); break; + case "illustration_started": + rewritePresentation.update(presentationId, { + stage: "illustrating", + illustrationTarget: event.section, + maxGeneratedImages: event.total || 1, + }); + break; + case "illustration_generated": + rewritePresentation.update(presentationId, { + stage: "illustrating", + illustrationTarget: event.section, + generatedImageCount: event.generatedCount ?? event.position, + maxGeneratedImages: event.total || 1, + }); + break; + case "illustration_completed": + rewritePresentation.update(presentationId, { + stage: "illustrating", + generatedImageCount: event.generatedCount, + maxGeneratedImages: event.requestedCount || 1, + warnings: event.warnings, + }); + break; case "validation_started": rewritePresentation.update(presentationId, { stage: "validation" }); break; @@ -1217,6 +1306,7 @@ export function ArtifactFileDetail({ onClick={handleSaveArtifact} /> )} + {toolbarActions} {canEditArtifact && ( )} @@ -1454,25 +1545,40 @@ export function ArtifactFileDetail({ onOpenChange={setDocumentRewriteDialogOpen} fileName={getFileName(filepath)} charCount={(displayContent ?? "").replace(/\s/g, "").length} + imageGenerationAvailable={imageGenerationAvailable} + maxGeneratedImages={maxGeneratedImages} onStart={handleStartDocumentRewrite} /> ); } -function SandboxPreviewImage({ - src, - alt, - threadId, - publicShareCode, - ...props -}: React.ImgHTMLAttributes & { - threadId: string; - publicShareCode?: string; -}) { - if (!src || typeof src !== "string") { - return null; - } +function SandboxPreviewImage({ + src, + alt, + threadId, + publicShareCode, + researchSessionId, + ...props +}: React.ImgHTMLAttributes & { + threadId: string; + publicShareCode?: string; + researchSessionId?: string; +}) { + if (!src || typeof src !== "string") { + return null; + } + + if (src.startsWith("deep-research://")) { + return researchSessionId ? ( + + ) : null; + } let url = src; if (src.startsWith("/mnt/")) { @@ -1496,21 +1602,108 @@ function SandboxPreviewImage({ src={url} alt={alt} /> - ); -} + ); +} + +/** Fetches a private deep-research image with the normal auth client. */ +function ProtectedResearchReportImage({ + sessionId, + src, + alt, + ...props +}: React.ImgHTMLAttributes & { + sessionId: string; +}) { + const [objectUrl, setObjectUrl] = useState(null); + const [loadFailed, setLoadFailed] = useState(false); + const [retryKey, setRetryKey] = useState(0); + + useEffect(() => { + setObjectUrl(null); + setLoadFailed(false); + if (!src?.startsWith("deep-research://")) { + return; + } + const name = src.slice("deep-research://".length); + if (!name) { + setLoadFailed(true); + return; + } + const controller = new AbortController(); + let nextUrl: string | null = null; + let disposed = false; + void (async () => { + try { + const response = await apiFetch( + `${getBackendBaseURL()}/api/deep-research/sessions/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(name)}`, + { method: "GET", signal: controller.signal }, + ); + if (!response.ok) { + throw new Error(`Unable to load report image (${response.status})`); + } + const blob = await response.blob(); + if (!blob.size) { + throw new Error("Report image is empty"); + } + nextUrl = URL.createObjectURL(blob); + if (disposed) return; + setObjectUrl(nextUrl); + } catch (error) { + if (!disposed && !(error instanceof DOMException && error.name === "AbortError")) { + // Never let an optional illustration break Markdown rendering, but don't + // leave the user with a misleading infinite loading label either. + setLoadFailed(true); + } + } + })(); + return () => { + disposed = true; + controller.abort(); + if (nextUrl) URL.revokeObjectURL(nextUrl); + }; + }, [retryKey, sessionId, src]); + + if (!objectUrl) { + if (loadFailed) { + return ( + + 报告配图加载失败 + + + ); + } + return 正在加载报告配图…; + } + return ( + {alt + ); +} export function ArtifactFilePreview({ content, language, - threadId, - publicShareCode, - animated = false, + threadId, + publicShareCode, + researchSessionId, + animated = false, activeCursor = false, }: { content: string; language: string; - threadId: string; - publicShareCode?: string; + threadId: string; + publicShareCode?: string; + researchSessionId?: string; animated?: boolean; activeCursor?: boolean; }) { @@ -1574,7 +1767,7 @@ export function ArtifactFilePreview({ ); }, }; - }, [content]); + }, [content, publicShareCode, researchSessionId, threadId]); useEffect(() => { if (language !== "html") { @@ -1639,16 +1832,26 @@ export function ArtifactFilePreview({ animated && "animate-[sandbox-preview-enter_320ms_ease-out]", )} > - + url.startsWith("deep-research://") + ? url + : defaultUrlTransform(url) + } + components={{ ...markdownComponents, img: (props) => ( ), }} diff --git a/frontend-web/src/components/workspace/artifacts/document-rewrite-dialog.tsx b/frontend-web/src/components/workspace/artifacts/document-rewrite-dialog.tsx index 765ceca1..ee696af3 100644 --- a/frontend-web/src/components/workspace/artifacts/document-rewrite-dialog.tsx +++ b/frontend-web/src/components/workspace/artifacts/document-rewrite-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { SparklesIcon } from "lucide-react"; +import { ImagePlusIcon, SparklesIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; @@ -20,6 +20,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; import { ModelNameSelect } from "@/components/workspace/model-name-select"; import type { DocumentRewriteStyle } from "@/open-canvas/api/rewrite"; @@ -51,16 +52,22 @@ export function DocumentRewriteDialog({ onOpenChange, fileName, charCount, + imageGenerationAvailable = false, + maxGeneratedImages = 4, onStart, }: { open: boolean; onOpenChange: (open: boolean) => void; fileName: string; charCount: number; + imageGenerationAvailable?: boolean; + maxGeneratedImages?: number; onStart: (input: { instruction: string; style: DocumentRewriteStyle; modelName: string | null; + generateImages: boolean; + maxGeneratedImages: number; }) => void; }) { const [instruction, setInstruction] = useState( @@ -68,11 +75,15 @@ export function DocumentRewriteDialog({ ); const [style, setStyle] = useState("formal_analysis"); const [modelName, setModelName] = useState(null); + const [generateImages, setGenerateImages] = useState(false); + const [imageCount, setImageCount] = useState("2"); useEffect(() => { if (!open) return; setStyle("formal_analysis"); setModelName(null); + setGenerateImages(false); + setImageCount("2"); }, [open]); const selectedStyle = STYLE_OPTIONS.find((item) => item.value === style); @@ -136,6 +147,48 @@ export function DocumentRewriteDialog({ + {imageGenerationAvailable && ( +
+
+ + +
+ {generateImages && ( +
+ 每次会额外消耗图片模型额度 + +
+ )} +
+ )} + + + +

+ {active ? "模型思考内容正在更新" : "模型思考内容已完成"} +

+
+          {text}
+        
+
+ + ); +} + export function DocumentRewriteTimeline({ item, }: { @@ -64,6 +110,9 @@ export function DocumentRewriteTimeline({ const current = stepPosition(item.stage); const failed = ["conflict", "error", "cancelled"].includes(item.stage); const completed = item.stage === "completed"; + const thinkingStep = ["snapshot", "requirements"].includes(item.stage) + ? "requirements" + : "writing"; const presentation = useDocumentRewritePresentation(); const [undoing, setUndoing] = useState(false); const [diffOpen, setDiffOpen] = useState(false); @@ -148,7 +197,13 @@ export function DocumentRewriteTimeline({
{STEPS.map((step, index) => { const active = !isTerminal(item.stage) && index === current; - const done = completed || index < current; + const illustrationSkipped = step.key === "illustrations" && !item.generateImages; + const illustrationFailed = + step.key === "illustrations" && + item.generateImages && + completed && + item.generatedImageCount === 0; + const done = !illustrationSkipped && !illustrationFailed && (completed || index < current); return (
)} + {step.key === "illustrations" && ( + illustrationSkipped ? ( +

+ 本次未启用配图,保留原文已有图片与链接。 +

+ ) : active ? ( +

+ {item.illustrationTarget + ? `正在为「${item.illustrationTarget}」生成配图,已完成 ${item.generatedImageCount}/${item.maxGeneratedImages} 张。` + : `正在准备 ${item.maxGeneratedImages} 张文档配图…`} +

+ ) : item.generatedImageCount > 0 ? ( +

+ 已生成并保存 {item.generatedImageCount} 张本地配图。 +

+ ) : ( +

+ 未成功生成配图,文字改写结果仍已保留。 +

+ ) + )} + {step.key === thinkingStep && ( + + )} {step.key === "validation" && item.warnings.length > 0 && (

{item.warnings.join(";")} @@ -228,6 +307,11 @@ export function DocumentRewriteTimeline({ 本次优化:{item.summary.requestedOptimizations.join("、")}。

) : null} + {item.summary.requestedImageCount ? ( +

+ 文档配图:请求 {item.summary.requestedImageCount} 张,已保存 {item.summary.generatedImageCount ?? 0} 张本地图片。 +

+ ) : null} {item.summary.headingChanges && ( item.summary.headingChanges.reordered || item.summary.headingChanges.added.length > 0 || diff --git a/frontend-web/src/components/workspace/messages/message-group.tsx b/frontend-web/src/components/workspace/messages/message-group.tsx index bae97491..634deb28 100644 --- a/frontend-web/src/components/workspace/messages/message-group.tsx +++ b/frontend-web/src/components/workspace/messages/message-group.tsx @@ -523,7 +523,13 @@ function DeepResearchProgressStep({ ? args.label : "正在撰写研究报告"; const detail = typeof args.detail === "string" ? args.detail.trim() : ""; + const thinking = typeof args.thinking === "string" ? args.thinking.trim() : ""; const active = args.status !== "complete"; + const [thinkingOpen, setThinkingOpen] = useState(false); + + useEffect(() => { + if (active && thinking) setThinkingOpen(true); + }, [active, thinking]); return ( )} + {thinking && ( + + + + + +

+ {active ? "模型思考内容正在更新" : "模型思考内容已完成"} +

+
+              {thinking}
+            
+
+
+ )}
); } diff --git a/frontend-web/src/components/workspace/messages/message-list-item.tsx b/frontend-web/src/components/workspace/messages/message-list-item.tsx index 66ab3a16..b6945e1c 100644 --- a/frontend-web/src/components/workspace/messages/message-list-item.tsx +++ b/frontend-web/src/components/workspace/messages/message-list-item.tsx @@ -6,9 +6,10 @@ import { ExternalLinkIcon, FileIcon, Loader2Icon, - PencilIcon, - SearchIcon, -} from 'lucide-react'; + PencilIcon, + SearchIcon, + SparklesIcon, +} from 'lucide-react'; import { memo, useCallback, @@ -16,9 +17,10 @@ import { useEffect, useMemo, useState, - type AnchorHTMLAttributes, - type ImgHTMLAttributes, -} from 'react'; + type AnchorHTMLAttributes, + type ImgHTMLAttributes, + type ReactNode, +} from 'react'; import rehypeKatex from 'rehype-katex'; import { Loader } from '@/components/ai-elements/loader'; @@ -838,7 +840,7 @@ function FrontendRagSourcesPanel({ const [open, setOpen] = useState(false); if (sources.length === 0) return null; return ( - +
- {open && ( -
- {trace.rounds.map((round, index) => ( - - ))} -
+ {open && ( +
+ {trace.rounds.map((round, index) => ( +
+ {index < trace.rounds.length - 1 && ( + + )} + + +
+ ))} +
)}
@@ -1014,7 +1030,7 @@ function MultiRoundRagContent({ ); } -function MultiRoundRagRoundCard({ +function MultiRoundRagRoundCard({ index, round, isLoading, @@ -1026,78 +1042,228 @@ function MultiRoundRagRoundCard({ isLoading: boolean; rehypePlugins: MarkdownContentProps['rehypePlugins']; components: MarkdownContentProps['components']; -}) { - return ( -
-
- - {index} - - {round.title} - {round.isSearching && } -
- {round.thought && ( -
-
思考内容
- -
- )} - {round.body && ( -
-
本轮正文
- -
- )} - {round.query && ( -
- - {round.query} -
- )} - {round.retrievals.length > 0 && ( -
-
- 检索内容 · {round.retrievals.length} 条 -
-
- {round.retrievals.map((item, resultIndex) => ( -
- - {resultIndex + 1}. - {item.title} - -
- {item.content || '暂无资料摘要'} -
- {(item.source || item.time) && ( -
- {[item.source, item.time].filter(Boolean).join(' · ')} -
- )} -
- ))} -
-
- )} -
- ); -} +}) { + const [open, setOpen] = useState(!round.query || round.isSearching); + useEffect(() => { + // A newly created round stays expanded to expose its thought and loading + // query. Once that query has returned, retain only the round header until + // the user asks to inspect the completed detail again. + setOpen(!round.query || round.isSearching); + }, [round.id, round.isSearching, round.query]); + + return ( + +
+ + + + +
+ {round.thought && ( + } + > + + + )} + {round.body && ( +
+
本轮正文
+ +
+ )} + {(round.query || round.isSearching) && ( +
+ {round.isSearching ? ( + + ) : ( + + )} + 检索词 + + {round.query || '正在生成检索词…'} + +
+ )} + {(round.query || round.isSearching || round.retrievals.length > 0) && ( + } + suffix={{round.retrievals.length} 条} + > + {round.isSearching ? ( +
+ + 正在召回相关内容… +
+ ) : ( +
+ {round.retrievals.length > 0 ? ( + round.retrievals.map((item, resultIndex) => ( + + )) + ) : ( +

+ 本轮未召回相关资料。 +

+ )} +
+ )} +
+ )} +
+
+
+
+ ); +} + +function MultiRoundRagDetailSection({ + title, + icon, + suffix, + defaultOpen = true, + children, +}: { + title: string; + icon: ReactNode; + suffix?: ReactNode; + defaultOpen?: boolean; + children: ReactNode; +}) { + const [open, setOpen] = useState(defaultOpen); + useEffect(() => setOpen(defaultOpen), [defaultOpen]); + + return ( + +
+ + + + + {children} + +
+
+ ); +} + +function MultiRoundRagResultCard({ + item, + index, +}: { + item: MultiRoundRagRound['retrievals'][number]; + index: number; +}) { + const [open, setOpen] = useState(index === 1); + const sourceMeta = [item.source, item.time].filter(Boolean).join(' · '); + + return ( + +
+ + + + +

+ {item.content || '暂无资料摘要'} +

+ {(sourceMeta || item.url) && ( +
+ {sourceMeta && {sourceMeta}} + {item.url && ( + + 查看原文 + + + )} +
+ )} +
+
+
+ ); +} function resolveSingleRoundRagSourceUrl( source: RuntimeDisplaySource | null, diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index 488fbec6..016168d8 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -255,15 +255,15 @@ export function MessageList({ const [settings] = useLocalSettings(); const ragModeEnabled = settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); - const hasSingleRoundRag = useMemo( + const hasSingleRoundRag = useMemo( () => messages.some( (message) => isSingleRoundRagMessage(message) || isMultiRoundRagMessage(message), ), - [messages], - ); - const referencesEnabled = ragModeEnabled || hasSingleRoundRag; + [messages], + ); + const referencesEnabled = ragModeEnabled || hasSingleRoundRag; const { skills } = useSkills(); const skillDisplayMap = useMemo( () => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()), @@ -436,11 +436,12 @@ export function MessageList({ className={cn("flex size-full flex-col justify-center", className)} > - {groupMessages(messages, (group) => { diff --git a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx index d3320001..8d16f7e2 100644 --- a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx @@ -54,6 +54,7 @@ import type { CompactShortcutRouteParam, } from "@/core/system-settings/api"; import { cn } from "@/lib/utils"; +import { createClientId } from "@/lib/client-id"; import { APP_THEME_COLORS, LOGIN_SOURCE_OPTIONS, @@ -77,10 +78,7 @@ const languageOptions: { value: Locale; label: string }[] = [ const DEFAULT_SHORTCUT_THEME_MAP = "red=red;green=green;blue=blue;dark-blue=dark-blue"; function makeShortcutId() { - if (typeof crypto !== "undefined" && "randomUUID" in crypto) { - return crypto.randomUUID(); - } - return `shortcut-${Date.now()}-${Math.random().toString(16).slice(2)}`; + return createClientId("shortcut"); } function newShortcutParam(): CompactShortcutRouteParam { diff --git a/frontend-web/src/components/workspace/settings/custom-theme-library-settings.tsx b/frontend-web/src/components/workspace/settings/custom-theme-library-settings.tsx index e7f30e0d..ce84813d 100644 --- a/frontend-web/src/components/workspace/settings/custom-theme-library-settings.tsx +++ b/frontend-web/src/components/workspace/settings/custom-theme-library-settings.tsx @@ -43,6 +43,7 @@ import { customThemeTextContrast, type CustomThemeAdvancedKey, } from "@/core/theme/custom-theme"; +import { createClientId } from "@/lib/client-id"; import { cn } from "@/lib/utils"; const MAX_CUSTOM_THEMES = 12; @@ -127,10 +128,7 @@ const ADVANCED_COLOR_FIELDS: Array<{ ]; function makeThemeId(): `custom-theme-${string}` { - const suffix = - typeof crypto !== "undefined" && "randomUUID" in crypto - ? crypto.randomUUID().replace(/[^a-z0-9-]/gi, "").toLowerCase() - : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`; + const suffix = createClientId().replace(/[^a-z0-9-]/gi, "").toLowerCase(); return `custom-theme-${suffix}`; } diff --git a/frontend-web/src/core/document-rewrite/context.tsx b/frontend-web/src/core/document-rewrite/context.tsx index 22424354..1e7dbcbb 100644 --- a/frontend-web/src/core/document-rewrite/context.tsx +++ b/frontend-web/src/core/document-rewrite/context.tsx @@ -6,6 +6,7 @@ import type { DocumentRewriteStage, } from "@/open-canvas/hooks/useDocumentRewrite"; import type { DocumentRewriteSummary } from "@/open-canvas/api/rewrite"; +import { createClientId } from "@/lib/client-id"; export type DocumentRewritePresentation = { id: string; @@ -19,11 +20,17 @@ export type DocumentRewritePresentation = { instruction: string; style: string; modelName: string | null; + generateImages: boolean; + maxGeneratedImages: number; + generatedImageCount: number; + illustrationTarget: string; stage: DocumentRewriteStage; originalCharCount: number; writtenCharCount: number; /** Short model-generated plan, streamed while it understands the request. */ requirementsPlan: string; + /** Ephemeral thought trace. Never included in the Markdown draft or persisted job state. */ + thinkingText: string; warnings: string[]; summary: DocumentRewriteSummary | null; /** Kept in-memory for the line-level compare dialog; backend job hydration refills it. */ @@ -42,9 +49,14 @@ type CreatePresentationInput = Omit< DocumentRewritePresentation, | "id" | "stage" + | "generateImages" + | "maxGeneratedImages" + | "generatedImageCount" + | "illustrationTarget" | "originalCharCount" | "writtenCharCount" | "requirementsPlan" + | "thinkingText" | "warnings" | "summary" | "originalContent" @@ -59,6 +71,8 @@ type CreatePresentationInput = Omit< originalContent?: string; rewrittenContent?: string | null; requirementsPlan?: string; + generateImages?: boolean; + maxGeneratedImages?: number; }; type DocumentRewritePresentationContextValue = { @@ -74,10 +88,7 @@ const DocumentRewritePresentationContext = createContext(null); function createId() { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - return "drw-ui-" + crypto.randomUUID(); - } - return "drw-ui-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2); + return createClientId("drw-ui"); } export function DocumentRewritePresentationProvider({ @@ -96,9 +107,14 @@ export function DocumentRewritePresentationProvider({ ...input, id, stage: "snapshot", + generateImages: input.generateImages === true, + maxGeneratedImages: Math.min(Math.max(input.maxGeneratedImages ?? 2, 1), 4), + generatedImageCount: 0, + illustrationTarget: "", originalCharCount: 0, writtenCharCount: 0, requirementsPlan: input.requirementsPlan ?? "", + thinkingText: "", warnings: [], summary: null, originalContent: input.originalContent ?? "", diff --git a/frontend-web/src/core/sentiment-agent/use-sentiment-agent-chat.ts b/frontend-web/src/core/sentiment-agent/use-sentiment-agent-chat.ts index f38b04df..a9417ba1 100644 --- a/frontend-web/src/core/sentiment-agent/use-sentiment-agent-chat.ts +++ b/frontend-web/src/core/sentiment-agent/use-sentiment-agent-chat.ts @@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk"; import { useCallback, useEffect, useRef, useState } from "react"; import { runSentimentAgent, toAgUiMessages } from "./api"; +import { createClientId } from "@/lib/client-id"; const STORAGE_PREFIX = "workspace.sentiment-agent."; @@ -53,9 +54,9 @@ export function useSentimentAgentChat(threadId: string) { const question = query.trim(); if (!question) return; - const assistantId = `sentiment-ai-${crypto.randomUUID()}`; + const assistantId = createClientId("sentiment-ai"); const userMessage: Message = { - id: `sentiment-human-${crypto.randomUUID()}`, + id: createClientId("sentiment-human"), type: "human", content: [{ type: "text", text: question }], additional_kwargs: { @@ -115,7 +116,7 @@ export function useSentimentAgentChat(threadId: string) { try { await runSentimentAgent({ threadId, - runId: `run-${crypto.randomUUID()}`, + runId: createClientId("run"), messages: history, signal: controller.signal, handlers: { diff --git a/frontend-web/src/core/streamdown/inline-think.ts b/frontend-web/src/core/streamdown/inline-think.ts new file mode 100644 index 00000000..b8bdc27e --- /dev/null +++ b/frontend-web/src/core/streamdown/inline-think.ts @@ -0,0 +1,90 @@ +/** + * Some OpenAI-compatible reasoning models write ``...`` into + * the ordinary text stream rather than a dedicated reasoning field. Markdown + * sandboxes must never render those fragments as document content. Callers + * can separately render the returned thinking part in a labelled UI trace. + * + * The filter is stateful because either tag can be split across arbitrary SSE + * deltas. Hold only a possible tag suffix, so confirmed Markdown continues + * to appear with the normal token-streaming cadence. + */ +export class InlineThinkTagFilter { + private static readonly OPEN_TAG = ""; + private static readonly CLOSE_TAG = ""; + + private insideThink = false; + private pending = ""; + + private possibleTagPrefixSize(content: string, tag: string) { + const lowered = content.toLowerCase(); + for (let size = Math.min(content.length, tag.length - 1); size > 0; size -= 1) { + if (lowered.slice(-size) === tag.slice(0, size)) return size; + } + return 0; + } + + pushParts(chunk: string): { text: string; thinking: string } { + if (!chunk) return { text: "", thinking: "" }; + + let remaining = this.pending + chunk; + this.pending = ""; + let visible = ""; + let thinking = ""; + + while (remaining) { + const tag = this.insideThink + ? InlineThinkTagFilter.CLOSE_TAG + : InlineThinkTagFilter.OPEN_TAG; + const tagIndex = remaining.toLowerCase().indexOf(tag); + if (tagIndex >= 0) { + if (this.insideThink) thinking += remaining.slice(0, tagIndex); + else visible += remaining.slice(0, tagIndex); + remaining = remaining.slice(tagIndex + tag.length); + this.insideThink = !this.insideThink; + continue; + } + + const suffixSize = this.possibleTagPrefixSize(remaining, tag); + const confirmed = suffixSize ? remaining.slice(0, -suffixSize) : remaining; + if (this.insideThink) thinking += confirmed; + else visible += confirmed; + this.pending = suffixSize ? remaining.slice(-suffixSize) : ""; + break; + } + + return { text: visible, thinking }; + } + + push(chunk: string) { + return this.pushParts(chunk).text; + } + + /** Flush a completed stream into its visible/thinking channels. */ + finishParts(): { text: string; thinking: string } { + if (this.insideThink) { + const thinking = this.pending; + this.insideThink = false; + this.pending = ""; + return { text: "", thinking }; + } + const tail = this.pending; + this.pending = ""; + return { text: tail, thinking: "" }; + } + + finish() { + return this.finishParts().text; + } + + /** Replace a rendered checkpoint while retaining the correct tag state. */ + replace(content: string) { + this.insideThink = false; + this.pending = ""; + return this.push(content); + } +} + +export function stripInlineThinkTags(content: string) { + const filter = new InlineThinkTagFilter(); + return filter.push(content) + filter.finish(); +} diff --git a/frontend-web/src/core/threads/use-multi-round-rag.ts b/frontend-web/src/core/threads/use-multi-round-rag.ts index ee8ec224..805bee50 100644 --- a/frontend-web/src/core/threads/use-multi-round-rag.ts +++ b/frontend-web/src/core/threads/use-multi-round-rag.ts @@ -2,6 +2,7 @@ import type { Message } from "@langchain/langgraph-sdk"; import { useCallback, useEffect, useRef, useState } from "react"; import { readRuntimeEnv } from "@/env"; +import { createClientId } from "@/lib/client-id"; const STORAGE_PREFIX = "workspace.multi-round-rag."; @@ -13,6 +14,9 @@ export type MultiRoundRagRetrieval = { time?: string; recUuid?: string; url?: string; + /** Original tool payload, retained so a future renderer can add more fields + * without losing data received from the multi-round service. */ + raw?: Record; }; export type MultiRoundRagRound = { @@ -74,7 +78,7 @@ function writeStoredMessages(threadId: string, messages: Message[]) { } function createId(prefix: string) { - return `${prefix}-${crypto.randomUUID()}`; + return createClientId(prefix); } function isAbortError(error: unknown) { @@ -87,8 +91,11 @@ function asRecord(value: unknown): Record | null { : null; } -function asString(value: unknown): string { - return typeof value === "string" ? value : ""; +/** Values in the deployed tools are occasionally numeric IDs, not strings. */ +function asText(value: unknown): string { + if (typeof value === "string") return value; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return ""; } function parseJson(value: unknown): unknown { @@ -103,8 +110,11 @@ function parseJson(value: unknown): unknown { function parseEventLine(line: string): MultiRoundEvent | null { const trimmed = line.trim(); if (!trimmed.startsWith("data:")) return null; + const payload = trimmed.slice("data:".length).trim(); + if (!payload || payload === "[DONE]") return { event: "done" }; try { - return JSON.parse(trimmed.slice("data:".length).trim()) as MultiRoundEvent; + const parsed = parseJson(payload); + return asRecord(parsed) as MultiRoundEvent | null; } catch { return null; } @@ -141,10 +151,10 @@ function addThinkingRound(trace: MultiRoundRagTrace, thought: string) { if (current.title === "正在准备检索" && !current.thought && !current.query) { current.title = "思考过程"; current.thought = thought; - return; + return current; } current.isSearching = false; - trace.rounds.push({ + const next = { id: createId("multi-rag-round"), title: "思考过程", thought, @@ -152,7 +162,9 @@ function addThinkingRound(trace: MultiRoundRagTrace, thought: string) { query: "", isSearching: true, retrievals: [], - }); + }; + trace.rounds.push(next); + return next; } function appendRoundBody(trace: MultiRoundRagTrace, body: string) { @@ -162,49 +174,94 @@ function appendRoundBody(trace: MultiRoundRagTrace, body: string) { current.body = `${current.body ?? ""}${body}`; } -function mapRetrievals(value: unknown): MultiRoundRagRetrieval[] { - const root = asRecord(parseJson(value)); - const list = - (Array.isArray(root?.results) && root.results) || - (Array.isArray(root?.organic) && root.organic) || - []; - return list.flatMap((item, index) => { +function firstArray(values: unknown[]): unknown[] { + for (const value of values) { + const parsed = parseJson(value); + if (Array.isArray(parsed)) return parsed; + } + return []; +} + +function toRecordList(value: unknown): Record[] { + const parsed = parseJson(value); + const root = asRecord(parsed); + const list = Array.isArray(parsed) + ? parsed + : firstArray([ + root?.results, + root?.organic, + root?.data, + root?.items, + root?.list, + root?.documents, + ]); + return list.flatMap((item) => { + if (typeof item === "string") return [{ content: item }]; const record = asRecord(item); - if (!record) return []; - const recUuid = asString(record.recUuid ?? record.rec_uuid); - const url = asString(record.url ?? record.link ?? record.href); + return record ? [record] : []; + }); +} + +/** + * GeneralQA's service fans out to several business search tools. Normalising + * all of their result shapes here prevents a successful tool call from being + * silently absent just because it did not use `results` or `organic`. + */ +function mapRetrievals(value: unknown): MultiRoundRagRetrieval[] { + const list = toRecordList(value); + return list.flatMap((item, index) => { + const recUuid = asText(item.recUuid ?? item.rec_uuid ?? item.uuid); + const url = asText(item.url ?? item.link ?? item.href); + const title = asText( + item.title ?? item.m_title ?? item.taskName ?? item.user_name ?? item.name ?? item.user_id, + ); + const content = asText( + item.content ?? + item.m_content ?? + item.m_conent ?? + item.snippet ?? + item.text ?? + item.overview ?? + item.page_content ?? + item.description ?? + item.summary, + ); return [ { - id: asString(record.id) || recUuid || undefined, - title: asString(record.title) || `检索资料 ${index + 1}`, - content: asString(record.content ?? record.snippet ?? record.text), - source: asString(record.source ?? record.source1 ?? url) || undefined, - time: asString(record.publish_time ?? record.m_publish ?? record.date) || undefined, + id: asText(item.id) || recUuid || undefined, + title: title || `检索资料 ${index + 1}`, + content: content || (url && !title ? url : ""), + source: asText(item.source ?? item.source1 ?? item.m_source ?? item.platform) || undefined, + time: asText(item.publish_time ?? item.m_publish ?? item.m_publicsh ?? item.date ?? item.time) || undefined, recUuid: recUuid || undefined, url: /^https?:\/\//i.test(url) ? url : undefined, + raw: item, }, ]; }); } function mapReferences(value: unknown): MultiRoundRagSource[] { - const record = asRecord(value); - const list = Array.isArray(record?.references) ? record.references : []; + const parsed = parseJson(value); + const record = asRecord(parsed); + const list = Array.isArray(parsed) + ? parsed + : firstArray([record?.references, record?.results, record?.sources, record?.documents]); return list.flatMap((item, index) => { const source = asRecord(item); if (!source) return []; - const content = asString(source.content ?? source.snippet); - const rawUrl = asString(source.url ?? source.link ?? source.href); - const recUuid = asString(source.recUuid ?? source.rec_uuid ?? source.uuid); + const content = asText(source.content ?? source.m_content ?? source.m_conent ?? source.snippet ?? source.text); + const rawUrl = asText(source.url ?? source.link ?? source.href); + const recUuid = asText(source.recUuid ?? source.rec_uuid ?? source.uuid); return [ { - id: asString(source.id) || recUuid || undefined, - title: asString(source.title) || `参考资料 ${index + 1}`, - type: asString(source.type) || "参考资料", - snippet: asString(source.snippet) || content, + id: asText(source.id) || recUuid || undefined, + title: asText(source.title ?? source.m_title ?? source.taskName ?? source.user_name) || `参考资料 ${index + 1}`, + type: asText(source.type) || "参考资料", + snippet: asText(source.snippet) || content, content, - source: asString(source.source ?? source.source1) || undefined, - time: asString(source.time ?? source.m_publish ?? source.publish_time) || undefined, + source: asText(source.source ?? source.source1 ?? source.m_source ?? source.platform) || undefined, + time: asText(source.time ?? source.m_publish ?? source.m_publicsh ?? source.publish_time ?? source.date) || undefined, url: /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined, raw: source, }, @@ -222,6 +279,71 @@ function cloneTrace(trace: MultiRoundRagTrace): MultiRoundRagTrace { }; } +function toolTitle(toolName: string): string { + switch (toolName) { + case "local_search": + return "本地知识检索"; + case "tavily_search": + return "联网检索"; + case "google_search": + case "getTaskLikeTop": + return "思考过程"; + case "search_user_profile": + return "靶标平台画像库"; + case "get_taiwan_news": + case "search_keyword_taiwan_news": + return "台湾热点新闻"; + case "get_world_news": + return "世界热点新闻"; + case "get_root_messaage_comment": + return "获取该消息的全部评论"; + case "search_user_behavior": + return "查询该用户的历史行为"; + case "get_america_news": + return "美国热点新闻"; + case "get_time_data": + return "态势新闻搜索"; + case "get_textid_content": + return "获取新闻详细内容"; + default: + return toolName || "检索过程"; + } +} + +function isSearchTool(toolName: string): boolean { + return [ + "local_search", + "tavily_search", + "google_search", + "getTaskLikeTop", + "search_user_profile", + "get_taiwan_news", + "search_keyword_taiwan_news", + "get_world_news", + "get_root_messaage_comment", + "search_user_behavior", + "get_america_news", + "get_time_data", + ].includes(toolName); +} + +function queryFromToolInput(input: Record, result: unknown): string { + const resultRecord = asRecord(parseJson(result)); + const direct = asText(input.q ?? input.query ?? input.keyword ?? input.user_fig); + if (direct) return direct; + if (input.s || input.e) { + return [asText(input.s), asText(input.e)].filter(Boolean).join(" - "); + } + return asText(resultRecord?.query ?? resultRecord?.q); +} + +function textFromEventData(value: unknown): string { + const parsed = parseJson(value); + if (typeof parsed === "string") return parsed; + const record = asRecord(parsed); + return asText(record?.text ?? record?.content ?? record?.message ?? record?.answer ?? record?.output); +} + function isSingleRoundRagMessage(message: Message) { return message.additional_kwargs?.single_round_rag === true; } @@ -370,92 +492,204 @@ export function useMultiRoundRagMessages(threadId: string) { const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; + let activeThoughtRound: MultiRoundRagRound | null = null; + let activeThoughtTailIsAnswer = false; + let answerFromShowText = false; + + const appendAnswer = (text: string, allowCompleteSnapshot = false) => { + if (!text) return; + // Some gateways emit one complete `message` after a `show_text` + // answer. Restrict snapshot detection to that case and to a + // meaningful length: adjacent streaming chunks can legitimately + // contain the same character, and must never be discarded. + if ( + allowCompleteSnapshot && + trace.answer.length >= 16 && + text.startsWith(trace.answer) + ) { + trace.answer = text; + return; + } + trace.answer += text; + }; + + const consumeShowText = (text: string, tailIsAnswer = false) => { + if (!text) return; + if (activeThoughtRound) { + const endIndex = text.indexOf(""); + if (endIndex < 0) { + activeThoughtRound.thought += text; + return; + } + activeThoughtRound.thought += text.slice(0, endIndex); + const roundBody = text.slice(endIndex + "".length).trim(); + if (roundBody) { + if (activeThoughtTailIsAnswer) { + appendAnswer(roundBody); + answerFromShowText = false; + trace.isComplete = true; + } else { + appendRoundBody(trace, roundBody); + } + } + activeThoughtRound = null; + activeThoughtTailIsAnswer = false; + return; + } + + const thinkStart = text.indexOf(""); + if (thinkStart < 0) { + const current = latestRound(trace); + current.isSearching = false; + appendAnswer(text); + answerFromShowText = true; + trace.isComplete = true; + return; + } + + const prefix = text.slice(0, thinkStart).trim(); + if (prefix) appendRoundBody(trace, prefix); + + // A full … envelope is normally delivered as one + // tool call. Keep the unfinished variant open as well: production + // proxies are allowed to split an SSE frame at any byte boundary. + const thinkEnd = text.indexOf("", thinkStart + "".length); + const thought = text + .slice(thinkStart + "".length, thinkEnd < 0 ? undefined : thinkEnd) + .trim(); + const thoughtRound = addThinkingRound(trace, thought); + if (thinkEnd < 0) { + activeThoughtRound = thoughtRound; + activeThoughtTailIsAnswer = tailIsAnswer; + return; + } + const roundBody = text.slice(thinkEnd + "".length).trim(); + if (roundBody) { + if (tailIsAnswer) { + appendAnswer(roundBody); + answerFromShowText = false; + trace.isComplete = true; + } else { + appendRoundBody(trace, roundBody); + } + } + }; + + const consumeToolCall = (payload: unknown) => { + const parsedPayload = parseJson(payload); + const tools = Array.isArray(parsedPayload) ? parsedPayload : [parsedPayload]; + for (const toolValue of tools) { + const tool = asRecord(toolValue); + if (!tool) continue; + const toolName = asText(tool.tool_name ?? tool.name); + const input = asRecord(parseJson(tool.tool_input ?? tool.input ?? tool.arguments)) ?? {}; + const current = latestRound(trace); + + if (toolName === "show_text") { + consumeShowText(asText(input.text ?? input.content)); + continue; + } + + if (toolName === "get_textid_content") { + current.title = toolTitle(toolName); + const article = asRecord(parseJson(input.result)); + const body = asText(article?.content ?? article?.text ?? input.content ?? input.result); + if (body) appendRoundBody(trace, body); + current.isSearching = false; + continue; + } + + const hasResult = input.result !== undefined; + const query = queryFromToolInput(input, input.result); + if (isSearchTool(toolName) || hasResult) { + current.title = toolTitle(toolName); + current.query = query || current.query; + if (!hasResult && isSearchTool(toolName)) { + // GeneralQA can receive a model text event before the search + // tool announces its query. The query starts a new visible + // loading phase, so restore the round/trace state here and + // keep the query badge spinning until its result arrives. + current.isSearching = true; + trace.isComplete = false; + } else if (hasResult) { + const retrievals = mapRetrievals(input.result); + // An empty search response is still a completed response; it + // must replace a previous list instead of leaving stale cards. + current.retrievals = retrievals; + if (retrievals.length === 0) { + const fallbackBody = textFromEventData(input.result); + if (fallbackBody && fallbackBody !== current.query) { + appendRoundBody(trace, fallbackBody); + } + } + current.isSearching = false; + } + } + } + }; + + const finishTrace = () => { + trace.isComplete = true; + activeThoughtRound = null; + activeThoughtTailIsAnswer = false; + for (const round of trace.rounds) round.isSearching = false; + }; + + const consumeEvent = (event: MultiRoundEvent) => { + const eventType = asText(event.event); + const data = event.data; + if (eventType === "tool_call") { + consumeToolCall(data); + } else if (eventType === "message") { + const text = textFromEventData(data); + if (text) { + if (activeThoughtRound || /<\/?think>/i.test(text)) { + // Most deployments put thoughts in `show_text`, but this + // fallback also preserves a compatible gateway that emits + // `` in normal message chunks. + consumeShowText(text, true); + } else { + latestRound(trace).isSearching = false; + appendAnswer(text, answerFromShowText); + answerFromShowText = false; + trace.isComplete = true; + } + } + } else if (eventType === "references") { + sources = mapReferences(data); + } else if (eventType === "error") { + const message = textFromEventData(data) || "多轮 RAG 服务返回错误。"; + setError(message); + if (!trace.answer) trace.answer = message; + finishTrace(); + } else if (eventType === "done") { + // A few compatible gateway versions include the whole final + // answer in `done`; render it when no `message` chunk was sent. + const finalText = textFromEventData(data); + if (finalText && !trace.answer) appendAnswer(finalText); + finishTrace(); + } + refreshAssistant(); + }; + + const consumeLines = (flush = false) => { + const lines = buffer.split(/\r?\n/); + buffer = flush ? "" : (lines.pop() ?? ""); + for (const line of lines) { + const event = parseEventLine(line); + if (event) consumeEvent(event); + } + }; + while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split(/\r?\n/); - buffer = lines.pop() ?? ""; - for (const line of lines) { - const event = parseEventLine(line); - if (!event) continue; - const eventType = asString(event.event); - const data = event.data; - if (eventType === "tool_call") { - const tool = asRecord(data); - const toolName = asString(tool?.tool_name); - const input = asRecord(tool?.tool_input) ?? {}; - const current = latestRound(trace); - if (toolName === "show_text") { - const text = asString(input.text); - if (text.includes("")) { - const thinkEnd = text.indexOf(""); - addThinkingRound( - trace, - (thinkEnd >= 0 - ? text.slice(0, thinkEnd + "".length) - : text - ) - .replace(/<\/?think>/g, "") - .trim(), - ); - if (thinkEnd >= 0) { - const roundBody = text - .slice(thinkEnd + "".length) - .trim(); - if (roundBody) { - appendRoundBody(trace, roundBody); - } - } - } else { - // A non-thinking `show_text` immediately after a thought is - // this round's narrative; the terminal `message` event is - // still rendered only as the final answer below all rounds. - current.isSearching = false; - trace.answer += text; - trace.isComplete = true; - } - } else if ( - toolName === "local_search" || - toolName === "tavily_search" || - toolName === "google_search" - ) { - const queryData = asRecord(parseJson(input.result)); - current.title = - toolName === "local_search" ? "本地知识检索" : "联网检索"; - current.query = - asString(input.q ?? input.query) || - asString(queryData?.query) || - current.query; - const retrievals = mapRetrievals(input.result); - if (retrievals.length > 0) current.retrievals = retrievals; - current.isSearching = false; - } else if (toolName === "get_textid_content") { - const article = asRecord(parseJson(input.result)); - const body = - asString(article?.content ?? article?.text) || - asString(input.content); - if (body) appendRoundBody(trace, body); - current.isSearching = false; - } - refreshAssistant(); - } else if (eventType === "message" && typeof data === "string") { - latestRound(trace).isSearching = false; - trace.answer += data; - trace.isComplete = true; - refreshAssistant(); - } else if (eventType === "references") { - sources = mapReferences(data); - refreshAssistant(); - } else if (eventType === "done") { - trace.isComplete = true; - for (const round of trace.rounds) round.isSearching = false; - refreshAssistant(); - } - } + consumeLines(); } - trace.isComplete = true; - for (const round of trace.rounds) round.isSearching = false; + buffer += decoder.decode(); + consumeLines(true); + finishTrace(); refreshAssistant(); } catch (caught) { trace.isComplete = true; diff --git a/frontend-web/src/core/threads/use-single-round-rag.ts b/frontend-web/src/core/threads/use-single-round-rag.ts index e90d5fc8..39c29326 100644 --- a/frontend-web/src/core/threads/use-single-round-rag.ts +++ b/frontend-web/src/core/threads/use-single-round-rag.ts @@ -2,10 +2,11 @@ import type { Message } from "@langchain/langgraph-sdk"; import { useCallback, useEffect, useRef, useState } from "react"; import { readRuntimeEnv } from "@/env"; +import { createClientId } from "@/lib/client-id"; const STORAGE_PREFIX = "workspace.single-round-rag."; -type KnowledgeBaseChunk = { +type KnowledgeBaseChunk = Record & { answer?: unknown; docs?: unknown; }; @@ -168,38 +169,116 @@ function parseKnowledgeBaseChunk(jsonText: string): KnowledgeBaseChunk { } } -function flattenDocs(value: unknown): Record[] { - if (Array.isArray(value)) { - return value.flatMap((item) => flattenDocs(item)); +function readString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value; } - if (!value || typeof value !== "object") return []; - const record = value as Record; - if ("docs" in record) return flattenDocs(record.docs); - return [record]; + return undefined; } -function normalizeSources(value: unknown): RagSource[] { - return flattenDocs(value).map((item, index) => { - const title = String(item.m_title ?? item.title ?? item.source ?? `参考资料 ${index + 1}`); - const content = String(item.m_content ?? item.content ?? item.snippet ?? ""); - const source = item.source1 ?? item.source; - const rawUrl = item.url ?? item.link ?? item.href; - return { - id: String(item.recUuid ?? item.rec_uuid ?? item.uuid ?? item.id ?? "") || undefined, - title, - type: String(item.source1 ?? item.type ?? "知识库"), - snippet: content, - content, - source: typeof source === "string" ? source : undefined, - time: typeof item.m_publish === "string" ? item.m_publish : undefined, - url: typeof rawUrl === "string" && /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined, - raw: item, - }; +function docsFallbackContent(docs: unknown): string | undefined { + const candidates = Array.isArray(docs) ? docs : [docs]; + return readString(...candidates); +} + +function buildKnowledgeDetailUrl(recUuid: string | undefined): string | undefined { + const knowledgeUrl = readRuntimeEnv("VITE_AIPI_KNOWLEDGE_URL")?.replace(/\/$/, ""); + if (!knowledgeUrl || !recUuid) return undefined; + return `${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${encodeURIComponent(recUuid)}`; +} + +function toRagSource( + record: Record, + index: number, + fallbackContent?: string, +): RagSource { + const title = + readString(record.m_title, record.title, record.source, record.source1) ?? + `参考资料 ${index + 1}`; + const content = + readString( + record.m_content, + // Some deployed responses spell this field incorrectly; keep it as a + // read-only compatibility alias rather than dropping the document. + record.m_conent, + record.content, + record.snippet, + fallbackContent, + record.url, + ) ?? ""; + const source = readString(record.source1, record.source, record.m_source); + const rawUrl = readString(record.url, record.link, record.href); + const recUuid = readString(record.recUuid, record.rec_uuid, record.uuid, record.id); + return { + id: recUuid, + title, + type: readString(record.type, record.source1) ?? "知识库", + snippet: content, + content, + source, + time: readString(record.m_publish, record.m_publicsh, record.time), + // GeneralQA uses recUuid to open the knowledge-detail page. Prefer that + // stable internal record link, then fall back to a backend-provided URL. + url: + buildKnowledgeDetailUrl(recUuid) ?? + (rawUrl && /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined), + raw: record, + }; +} + +/** + * A single-RAG stream is a concatenation of source envelopes, for example: + * `{docs:["..."], recUuid:"...", m_title:"...", m_content:"..."}`. + * The root-level metadata is the source; `docs` merely marks the envelope and + * supplies a fallback body. This mirrors GeneralQA's per-chunk processing. + */ +function normalizeSources(chunk: KnowledgeBaseChunk): RagSource[] { + const hasRootMetadata = [ + chunk.recUuid, + chunk.rec_uuid, + chunk.m_title, + chunk.m_content, + chunk.m_conent, + chunk.source1, + chunk.m_publish, + chunk.m_publicsh, + ].some((value) => typeof value === "string" && value.trim()); + if (hasRootMetadata) { + return [toRagSource(chunk, 0, docsFallbackContent(chunk.docs))]; + } + + const docs = Array.isArray(chunk.docs) ? chunk.docs : chunk.docs ? [chunk.docs] : []; + return docs.flatMap((item, index): RagSource[] => { + if (typeof item === "string") { + return [toRagSource({}, index, item)]; + } + if (!item || typeof item !== "object") return []; + return [toRagSource(item as Record, index)]; }); } +function mergeSources(current: RagSource[], incoming: RagSource[]): RagSource[] { + const result = [...current]; + const keys = new Set( + current.map((source) => + source.id + ? `id:${source.id}` + : `content:${source.title}\u0000${source.content}\u0000${source.source ?? ""}`, + ), + ); + for (const source of incoming) { + const key = source.id + ? `id:${source.id}` + : `content:${source.title}\u0000${source.content}\u0000${source.source ?? ""}`; + if (keys.has(key)) continue; + keys.add(key); + result.push(source); + } + return result; +} + function createId(prefix: string) { - return `${prefix}-${crypto.randomUUID()}`; + return createClientId(prefix); } function isAbortError(error: unknown) { @@ -397,7 +476,9 @@ export function useSingleRoundRagMessages(threadId: string) { for (const jsonText of extracted.objects) { const chunk = parseKnowledgeBaseChunk(jsonText); if (typeof chunk.answer === "string") answerChunks.push({ answer: chunk.answer }); - if (chunk.docs !== undefined) sources = normalizeSources(chunk.docs); + if (chunk.docs !== undefined) { + sources = mergeSources(sources, normalizeSources(chunk)); + } refreshAssistant(); } } diff --git a/frontend-web/src/eightbf-roundtable/api/task-context.ts b/frontend-web/src/eightbf-roundtable/api/task-context.ts new file mode 100644 index 00000000..280414a0 --- /dev/null +++ b/frontend-web/src/eightbf-roundtable/api/task-context.ts @@ -0,0 +1,310 @@ +import { getBackendBaseURL } from "@/core/config"; +import { readRuntimeEnv } from "@/env"; + +export interface EightBfTask { + id: string; + externalTaskId: string; + title: string; + description: string; + direction: string; + overview: string; + priority: string; + taskPriority: string; + targetDirection: string; + taskStatus: string; + source: string; + raw: Record | null; +} + +export interface EightBfDictionaryOption { + label: string; + value: string; + alias?: string; + statusTone?: string; +} + +export interface EightBfTaskDictionaries { + priority: EightBfDictionaryOption[]; + direction: EightBfDictionaryOption[]; + status: EightBfDictionaryOption[]; +} + +export interface EightBfTaskContextPayload { + kind: "eightbf_roundtable_task"; + usage: string; + task_id: string; + task: { + id: string; + title: string; + description: string; + direction: string; + overview: string; + priority: string; + task_priority: string; + target_direction: string; + task_status: string; + }; + task_tags: { + priority: string; + direction: string; + status: string; + }; +} + +/** + * 外部任务系统在离线部署或本地联调时可能不可用。此回退仅用于页面展示与 + * 意图上下文,保持与岗位会商示例一致,避免左上任务卡退化成接口错误文本。 + */ +export function createEightBfFallbackTask(taskId: string): EightBfTask { + return { + id: taskId, + externalTaskId: taskId, + title: "暑期活动", + description: "围绕暑期活动的游泳夏令营开展宣传,结合线上线下渠道吸引目标人群参与,持续提升活动影响力。", + direction: "暑期活动", + overview: "面向家长群体传播暑期游泳夏令营活动信息,提升品牌曝光与触达声量。", + priority: "urgent", + taskPriority: "urgent", + targetDirection: "summer_activity", + taskStatus: "system_created", + source: "本系统创建", + raw: null, + }; +} + +const TASK_DETAIL_PATH = "/task/{taskId}"; +const DICTIONARY_TYPES = { + priority: "task_priority", + direction: "task_direction", + status: "task_status", +} as const; + +function externalApiBase(): string { + // The position-roundtable Vue app calls this task system through + // VITE_API_BASE_URL (usually the /zncm gateway), not DeerFlow's own API. + return ( + readRuntimeEnv("VITE_8BF_TASK_API_BASE_URL")?.replace(/\/+$/, "") || + readRuntimeEnv("VITE_API_BASE_URL")?.replace(/\/+$/, "") || + getBackendBaseURL().replace(/\/+$/, "") + ); +} + +function externalUrl(path: string): string { + if (/^https?:\/\//i.test(path)) return path; + return `${externalApiBase()}${path.startsWith("/") ? path : `/${path}`}`; +} + +/** + * 当前 DeerFlow 的外部业务请求适配器沿用任务系统的默认约定:使用 + * `Authorization: Bearer admin`,而不是 DeerFlow 登录接口返回的 token。 + * 两套 token 的用途不同,不能直接复用 `apiFetch` 的默认鉴权头;否则 + * 任务详情/字典接口可能返回 401,页面只会看到空任务卡。 + * + * 这里仍保留 include credentials,与原 Vue 请求保持一致;同时不走 + * `apiFetch` 的 401 自动清理逻辑,避免外部任务系统的鉴权失败误登出 + * DeerFlow 当前用户。 + */ +async function fetchExternalTaskSystem( + path: string, + init: RequestInit = {}, +): Promise { + const headers = new Headers(init.headers); + if (!headers.has("Authorization")) { + headers.set("Authorization", "Bearer admin"); + } + if (!headers.has("Accept")) { + headers.set("Accept", "application/json"); + } + return fetch(externalUrl(path), { + ...init, + credentials: init.credentials ?? "include", + headers, + }); +} + +function firstText(...values: unknown[]): string { + for (const value of values) { + if (value === undefined || value === null) continue; + const text = String(value).trim(); + if (text) return text; + } + return ""; +} + +function unwrapData(payload: unknown): Record { + let current: unknown = payload; + // Task services in different deployments wrap the object as either + // { data: task } or { data: { data: task } }. + for (let index = 0; index < 3; index += 1) { + if (!current || typeof current !== "object" || Array.isArray(current)) break; + const next = (current as { data?: unknown }).data; + if (!next || typeof next !== "object" || Array.isArray(next)) break; + current = next; + } + return current && typeof current === "object" && !Array.isArray(current) + ? (current as Record) + : {}; +} + +export function normalizeEightBfTask( + raw: Record | null | undefined, + fallbackTaskId = "", +): EightBfTask { + const source = raw ?? {}; + const id = firstText( + source.id, + source.taskId, + source.externalTaskId, + source.external_task_id, + fallbackTaskId, + ); + return { + id, + externalTaskId: firstText(source.externalTaskId, source.external_task_id, id), + title: firstText(source.title, source.taskName, source.task_name, source.name), + description: firstText( + source.description, + source.taskDescription, + source.task_description, + source.taskContent, + source.task_content, + source.content, + ), + direction: firstText( + source.direction, + source.taskDirection, + source.task_direction, + ), + overview: firstText(source.overview, source.summary), + priority: firstText(source.priority, source.urgency), + taskPriority: firstText( + source.taskPriority, + source.task_priority, + source.priority, + source.urgency, + ), + targetDirection: firstText( + source.targetDirection, + source.target_direction, + source.direction, + source.taskDirection, + source.task_direction, + ), + taskStatus: firstText(source.taskStatus, source.task_status, source.status), + source: "测试分析岗任务详情接口", + raw: source, + }; +} + +function dictionaryRows(payload: unknown): unknown[] { + if (Array.isArray(payload)) return payload; + if (payload && typeof payload === "object") { + const directData = (payload as { data?: unknown }).data; + if (Array.isArray(directData)) return directData; + if (directData && typeof directData === "object") { + const nested = directData as { rows?: unknown; list?: unknown }; + if (Array.isArray(nested.rows)) return nested.rows; + if (Array.isArray(nested.list)) return nested.list; + } + } + const data = unwrapData(payload); + if (Array.isArray(data.rows)) return data.rows; + if (Array.isArray(data.list)) return data.list; + return []; +} + +function normalizeDictionary(payload: unknown): EightBfDictionaryOption[] { + return dictionaryRows(payload) + .map((item) => { + if (!item || typeof item !== "object") return null; + const row = item as Record; + const label = firstText(row.dictLabel, row.label); + const value = firstText(row.dictValue, row.dict_value, row.value); + if (!label || !value) return null; + return { + label, + value, + alias: firstText(row.alias) || undefined, + statusTone: firstText(row.statusTone, row.status_tone) || undefined, + } as EightBfDictionaryOption; + }) + .filter((item): item is EightBfDictionaryOption => item !== null); +} + +export async function fetchEightBfTask(taskId: string): Promise { + const encodedId = encodeURIComponent(taskId); + const path = TASK_DETAIL_PATH.replaceAll("{taskId}", encodedId); + const response = await fetchExternalTaskSystem(path, { method: "GET" }); + if (!response.ok) { + throw new Error(`任务详情接口请求失败(${response.status})`); + } + const payload = (await response.json()) as unknown; + const task = normalizeEightBfTask(unwrapData(payload), taskId); + // Keep the external route id as the stable scope even if a deployment's + // response includes a legacy/internal numeric id. + return { ...task, id: taskId, externalTaskId: taskId }; +} + +export async function fetchEightBfTaskDictionaries(): Promise { + const entries = await Promise.all( + (Object.entries(DICTIONARY_TYPES) as Array<[ + keyof EightBfTaskDictionaries, + string, + ]>).map(async ([key, path]) => { + try { + const response = await fetchExternalTaskSystem( + `/system/dict/data/type/${path}`, + { method: "GET" }, + ); + if (!response.ok) throw new Error(`字典接口请求失败(${response.status})`); + return [key, normalizeDictionary(await response.json())] as const; + } catch (error) { + // A dictionary failure must not hide the task card; the card falls back + // to the raw task codes and the detail request remains usable. + console.warn(`[8bf] ${key} 字典加载失败`, error); + return [key, []] as const; + } + }), + ); + return Object.fromEntries(entries) as EightBfTaskDictionaries; +} + +export function dictionaryText( + options: EightBfDictionaryOption[] | undefined, + value: unknown, + preferredField: "label" | "alias" = "label", +): string { + const raw = firstText(value); + if (!raw) return ""; + const match = options?.find((option) => option.value === raw); + return firstText(match?.[preferredField], match?.label, raw); +} + +export function buildEightBfTaskContext( + task: EightBfTask | null, + dictionaries: EightBfTaskDictionaries, +): EightBfTaskContextPayload | null { + if (!task?.id) return null; + return { + kind: "eightbf_roundtable_task", + usage: + "这是 8BF 页面左上角任务卡片中的任务信息。请直接基于这些信息理解用户问题,不要要求用户重复提供已经存在的任务名称、任务描述、任务方向或任务状态。", + task_id: task.id, + task: { + id: task.id, + title: task.title, + description: task.description, + direction: task.direction, + overview: task.overview, + priority: task.priority, + task_priority: task.taskPriority, + target_direction: task.targetDirection, + task_status: task.taskStatus, + }, + task_tags: { + priority: dictionaryText(dictionaries.priority, task.taskPriority), + direction: dictionaryText(dictionaries.direction, task.targetDirection, "alias"), + status: dictionaryText(dictionaries.status, task.taskStatus), + }, + }; +} diff --git a/frontend-web/src/eightbf-roundtable/components/EightBfStep1Workspace.tsx b/frontend-web/src/eightbf-roundtable/components/EightBfStep1Workspace.tsx new file mode 100644 index 00000000..ec06e6f9 --- /dev/null +++ b/frontend-web/src/eightbf-roundtable/components/EightBfStep1Workspace.tsx @@ -0,0 +1,451 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { + Bot, + ChevronDown, + ChevronLeft, + Loader2, + Search, + Settings2, + UserRoundCog, +} from "lucide-react"; +import { toast } from "sonner"; + +import { AgentDetailPanel } from "@/components/workspace/agents/agent-detail-panel"; +import { useAgent, useAgents, type Agent } from "@/core/agents"; +import agentConfigLogo from "@/assets/position-roundtable/agent-config-logo.svg"; +import collapsePanelIcon from "@/assets/position-roundtable/collapse-panel.svg"; +import doubleArrowDecoration from "@/assets/position-roundtable/double-arrow-decoration.svg"; +import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput"; +import "@/position-roundtable/styles/intent-understanding.css"; +import type { Step1PanelProps } from "@/roundtable-planning/components/Step1Panel"; +import { Step1Panel } from "@/roundtable-planning/components/Step1Panel"; +import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain"; +import { cn } from "@/lib/utils"; + +type IntentRecord = Record; + +export interface EightBfStep1PanelProps extends Step1PanelProps { + chain: BusinessChain | null; + canConfigureChain: boolean; + onConfigureChain: () => void; + selectedModel: string; + modelOptions: Array<{ label: string; value: string }>; + onSelectedModelChange: (model: string) => void; +} + +export function EightBfStep1Panel({ + chain, + canConfigureChain, + onConfigureChain, + selectedModel, + modelOptions, + onSelectedModelChange, + intentReady, + ...step1Props +}: EightBfStep1PanelProps) { + const [rightPanelOpen, setRightPanelOpen] = useState(true); + const [rightTab, setRightTab] = useState<"agents" | "results">("agents"); + + const openPanel = (tab: "agents" | "results") => { + if (rightPanelOpen && rightTab === tab) { + setRightPanelOpen(false); + return; + } + setRightTab(tab); + setRightPanelOpen(true); + }; + + return ( +
+ +
+ } + headerRightSlot={ +
+ + +
+ } + rightSidebar={ + + } + hideQuestionCount + inputArea={(inputProps) => ( + + )} + intentReady={intentReady} + /> +
+ ); +} + +export function EightBfStep1Sidebar({ + taskCard, + businessChain, + intentReady, +}: { + taskCard?: ReactNode; + businessChain?: ReactNode; + intentReady: object | null; +}) { + return ( +
+ {taskCard} + +
+ ); +} + +/** 第一、二步共用的意图结果面板,保持完全相同的展示结构和样式。 */ +export function EightBfIntentDelivery({ + intentReady, + businessChain, +}: { + intentReady: object | null; + businessChain?: ReactNode; +}) { + const sections = intentSections(intentReady); + return ( +
+ {sections.length > 0 && ( +
+
+
+
+ {sections.map((section) => ( +
+

{section.label}

+
    + {section.items.map((item) =>
  • {item}
  • )} +
+
+ ))} +
+
+ )} + {businessChain} +
+ ); +} + +/** 第二步不需要第一步的整列占位,只复用其任务卡和意图面板。 */ +export function EightBfStep2SidebarHeader({ + taskCard, + intentReady, +}: { + taskCard?: ReactNode; + intentReady: object | null; +}) { + return ( +
+ {taskCard} + +
+ ); +} + +export function EightBfHeaderBrand({ chainTitle }: { chainTitle?: string }) { + return ( +
+ +
+

8BF 多智能体会商

+

按业务链承接任务、对话与分析产物

+
+ + + {chainTitle ? "业务链已启动" : "等待业务链配置"} + +
+ ); +} + +export function EightBfHeaderChain({ chainTitle }: { chainTitle?: string }) { + return ( + + 当前业务链 + {chainTitle || "暂未配置"} + + ); +} + +function EightBfAgentWorkspace({ + tab, + chain, + intentReady, + canConfigureChain, + onConfigureChain, + onCollapse, +}: { + tab: "agents" | "results"; + chain: BusinessChain | null; + intentReady: object | null; + canConfigureChain: boolean; + onConfigureChain: () => void; + onCollapse: () => void; +}) { + const { agents, isLoading } = useAgents(); + const seats = chain?.seats ?? []; + const [selectedAgentId, setSelectedAgentId] = useState(null); + const { agent: selectedAgent, isLoading: selectedAgentLoading } = useAgent(selectedAgentId); + + useEffect(() => { + if (selectedAgentId && !seats.some((seat) => seat.agent_id === selectedAgentId)) { + setSelectedAgentId(null); + } + }, [seats, selectedAgentId]); + + const seatAgents = useMemo(() => { + const agentById = new Map(agents.map((agent) => [agent.id, agent])); + return seats.map((seat) => ({ seat, agent: agentById.get(seat.agent_id) ?? null })); + }, [agents, seats]); + const selectedSeat = seats.find((seat) => seat.agent_id === selectedAgentId) ?? null; + + if (tab === "results") { + return ( +
+
+ 分析结果 + +
+
+ +
+
+ ); + } + + if (selectedAgentId) { + return ( +
+
+ + +
+
+ {selectedAgentLoading ? ( +
+ 正在读取智能体配置… +
+ ) : ( + toast.info("请在中间对话区向任务理解智能体发起问题")} + showRecentChats={false} + avatarSrc={agentConfigLogo} + positionName={chain?.title || "8BF 业务链"} + skillsIcon={} + skillsTitle="技能列表" + showCommonQuestions + panelTitle="智能体配置" + embedded + hideStatusBadges + /> + )} +
+
+ ); + } + + return ( +
+
+
+

智能体配置

+

{chain?.title ? `业务链「${chain.title}」已配置的智能体` : "请先配置 8BF 业务链"}

+
+
+ {canConfigureChain && ( + + )} + +
+
+
+ + 业务链智能体 + {seatAgents.length} 个 +
+
+ {isLoading && seats.length > 0 ? ( +
正在加载智能体…
+ ) : seats.length === 0 ? ( +
当前业务链未配置智能体
+ ) : ( + seatAgents.map(({ seat, agent }, index) => ( + setSelectedAgentId(seat.agent_id)} + /> + )) + )} +
+
+ ); +} + +function EightBfAgentRow({ + seat, + agent, + index, + onSelect, +}: { + seat: ChainSeat; + agent: Agent | null; + index: number; + onSelect: () => void; +}) { + const name = agent?.name || seat.name || "未命名智能体"; + const description = agent?.description || seat.description || "查看该智能体的模型、技能与常用问题配置"; + return ( + + ); +} + +function EightBfIntentResults({ intentReady }: { intentReady: object | null }) { + const sections = intentSections(intentReady); + if (sections.length === 0) { + return

完成意图识别后,将在这里展示任务意图与关键约束。

; + } + return ( +
+ {sections.map((section) => ( +
+

{section.label}

+
    {section.items.map((item) =>
  • {item}
  • )}
+
+ ))} +
+ ); +} + +function intentSections(intentReady: object | null): Array<{ label: string; items: string[] }> { + const source = intentReady && typeof intentReady === "object" ? intentReady as IntentRecord : null; + if (!source) return []; + const entries = [ + ["核心目标", arrayValue(source.coreGoals, source.objective)], + ["风险提示", arrayValue(source.riskWarnings, source.constraints)], + ["关键要点", arrayValue(source.keyPoints, source.assumptions)], + ["战略意义", arrayValue(source.strategicSignificance)], + ] as const; + return entries + .filter(([, items]) => items.length > 0) + .map(([label, items]) => ({ label, items })); +} + +function intentSectionTone(label: string) { + switch (label) { + case "核心目标": + return "position-roundtable-intent-delivery-section--goals"; + case "风险提示": + return "position-roundtable-intent-delivery-section--constraints"; + case "关键要点": + return "position-roundtable-intent-delivery-section--purpose"; + case "战略意义": + return "position-roundtable-intent-delivery-section--significance"; + default: + return "position-roundtable-intent-delivery-section--significance"; + } +} + +function arrayValue(...values: unknown[]): string[] { + for (const value of values) { + if (Array.isArray(value)) { + const items = value.map((item) => String(item).trim()).filter(Boolean); + if (items.length > 0) return items; + } + if (typeof value === "string" && value.trim()) return [value.trim()]; + } + return []; +} diff --git a/frontend-web/src/eightbf-roundtable/components/EightBfTaskCard.tsx b/frontend-web/src/eightbf-roundtable/components/EightBfTaskCard.tsx new file mode 100644 index 00000000..f3d0a09f --- /dev/null +++ b/frontend-web/src/eightbf-roundtable/components/EightBfTaskCard.tsx @@ -0,0 +1,97 @@ +import { cn } from "@/lib/utils"; +import collapsePanelIcon from "@/assets/position-roundtable/collapse-panel.svg"; +import { + dictionaryText, + type EightBfDictionaryOption, + type EightBfTask, +} from "@/eightbf-roundtable/api/task-context"; + +interface EightBfTaskCardProps { + task: EightBfTask | null; + dictionaries: { + priority: EightBfDictionaryOption[]; + direction: EightBfDictionaryOption[]; + status: EightBfDictionaryOption[]; + }; + loading?: boolean; + error?: string | null; + /** 第二步左栏使用,与“智能体配置”同款图标收起整列。 */ + onCollapse?: () => void; +} + +export function EightBfTaskCard({ + task, + dictionaries, + loading = false, + error, + onCollapse, +}: EightBfTaskCardProps) { + const priority = dictionaryText(dictionaries.priority, task?.taskPriority); + const direction = dictionaryText(dictionaries.direction, task?.targetDirection, "alias"); + const status = dictionaryText(dictionaries.status, task?.taskStatus); + // 外部任务接口不可用时,卡片使用与岗位会商相同的示例任务;标签也必须 + // 保持可读,而不能暴露内部状态码。 + const tags = task?.raw === null + ? ["紧急", "本系统创建", "暑期活动"] + : [priority, status, direction].filter(Boolean); + + return ( +
+
+ {onCollapse && ( + + )} +

+ {task?.title || (task?.id ? `任务 ${task.id}` : "等待任务信息")} +

+ + {tags.length > 0 && ( +
+ {tags.map((tag, index) => ( + 1 && "border-[#a2e1f9] bg-[rgba(162,225,249,0.3)] leading-[18px] text-[#a2e1f9]", + ].filter(Boolean).join(" ")} + > + {tag} + + ))} +
+ )} +

+ {task?.description || task?.overview || (loading ? "正在读取任务详情…" : "暂无任务描述")} +

+ {error &&

{error}

} +
+
+ ); +} diff --git a/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx b/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx index a5ba9789..e66a0331 100644 --- a/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx +++ b/frontend-web/src/eightbf-roundtable/pages/EightBfRoundtablePage.tsx @@ -1,7 +1,23 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { Loader2, Save, Settings2, X } from "lucide-react"; +import { useSearchParams } from "react-router-dom"; +import { Loader2, Save, X } from "lucide-react"; import { getAccountDisplayName, getStoredAuth } from "@/core/auth"; +import { EightBfTaskCard } from "@/eightbf-roundtable/components/EightBfTaskCard"; +import { + EightBfStep1Panel, + EightBfStep1Sidebar, + EightBfStep2SidebarHeader, +} from "@/eightbf-roundtable/components/EightBfStep1Workspace"; +import { + buildEightBfTaskContext, + createEightBfFallbackTask, + fetchEightBfTask, + fetchEightBfTaskDictionaries, + normalizeEightBfTask, + type EightBfTask, + type EightBfTaskDictionaries, +} from "@/eightbf-roundtable/api/task-context"; import { PositionBusinessChainFlow } from "@/position-roundtable/components/PositionBusinessChainFlow"; import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage"; import { listChains } from "@/roundtable-planning/api/chains"; @@ -26,10 +42,28 @@ const BUSINESS_CODE = "8BF"; */ export default function EightBfRoundtablePage({ compactChrome = false }: { compactChrome?: boolean }) { const auth = getStoredAuth(); + const [searchParams] = useSearchParams(); + const taskId = useMemo( + () => searchParams.get("taskId")?.trim() || "", + [searchParams], + ); // 8BF 公共业务链由指定账号维护,不依赖外部透传的管理员角色。 // 与敏感词管理使用同一可信账户展示名规则(登录邮箱 @ 前缀)。 const canConfigureChain = getAccountDisplayName(auth).toLowerCase() === "lqq"; - const storageTaskId = useMemo(() => `8bf:${auth?.user_id || "anonymous"}`, [auth?.user_id]); + const storageTaskId = useMemo( + () => `8bf:${auth?.user_id || "anonymous"}:${taskId || "anonymous"}`, + [auth?.user_id, taskId], + ); + const [task, setTask] = useState(() => ( + taskId ? normalizeEightBfTask({}, taskId) : null + )); + const [taskDictionaries, setTaskDictionaries] = useState({ + priority: [], + direction: [], + status: [], + }); + const [taskLoading, setTaskLoading] = useState(Boolean(taskId)); + const [taskError, setTaskError] = useState(null); const [mapping, setMapping] = useState(null); const [chains, setChains] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -41,6 +75,55 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa () => chains.find((chain) => chain.id === mapping?.chainId) ?? null, [chains, mapping?.chainId], ); + const taskContext = useMemo( + () => buildEightBfTaskContext(task, taskDictionaries), + [task, taskDictionaries], + ); + + useEffect(() => { + if (!taskId) { + setTask(null); + setTaskError(null); + setTaskLoading(false); + setTaskDictionaries({ priority: [], direction: [], status: [] }); + return; + } + + let cancelled = false; + setTaskLoading(true); + setTaskError(null); + setTask(normalizeEightBfTask({}, taskId)); + let pendingRequests = 2; + const markRequestFinished = () => { + pendingRequests -= 1; + if (!cancelled && pendingRequests === 0) setTaskLoading(false); + }; + // Keep detail and dictionary requests independent: a missing dictionary + // must not prevent the task card or task context from being available. + void fetchEightBfTask(taskId) + .then((nextTask) => { + if (!cancelled) setTask(nextTask); + }) + .catch((error) => { + if (cancelled) return; + console.warn("[8bf] 任务详情请求失败,使用页面示例任务", error); + setTask(createEightBfFallbackTask(taskId)); + setTaskError(null); + }) + .finally(markRequestFinished); + void fetchEightBfTaskDictionaries() + .then((nextDictionaries) => { + if (!cancelled) setTaskDictionaries(nextDictionaries); + }) + .catch(() => { + // fetchEightBfTaskDictionaries already degrades each dictionary to an + // empty list; this guard protects future adapter changes. + }) + .finally(markRequestFinished); + return () => { + cancelled = true; + }; + }, [taskId]); const loadConfiguration = useCallback(async () => { setIsLoading(true); @@ -94,18 +177,12 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa } }, [canConfigureChain, chains, selectedChainId]); + const openChainSettings = useCallback(() => { + if (canConfigureChain) setIsSettingsOpen(true); + }, [canConfigureChain]); + const toolbarSlot = ( <> - {canConfigureChain && ( - - )} {canConfigureChain && isSettingsOpen && (
| null} + entryRailTaskId={taskId || null} + entryRailTaskCardSlot={taskId ? ( + + ) : undefined} toolbarSlot={toolbarSlot} + hideHeaderSaveDraft + hideHeaderSettings + newTaskLabel="新建会话" + hideDefaultStep1RightSidebar + renderStep1={(props) => ( + + )} + renderStep1LeftSidebar={({ intentReady }) => ( + + ) : undefined} + businessChain={ + + } + /> + )} + renderStep2LeftSidebarHeader={({ intentReady, onCollapse }) => ( + + ) : undefined} + /> + )} entryRailBusinessChainSlot={ div:first-child, +.eightbf-roundtable .position-roundtable-intent-composer-frame, +.eightbf-roundtable .position-roundtable-intent-config-pane { border-radius: 4px; } +.eightbf-roundtable .position-roundtable-intent-panel > div:first-child { border-radius: 4px 4px 0 0; } + +.eightbf-roundtable .eightbf-roundtable__step1-sidebar { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: 12px; + overflow: hidden; + padding-right: 0; +} + +/* 8BF 首步左栏不允许横向滚动;流程图的横向超出由画布自身裁切。 */ +.eightbf-roundtable[data-roundtable-step="1"] #left-sidebar, +.eightbf-roundtable .eightbf-roundtable__step1-sidebar, +.eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar { + overflow-x: hidden !important; +} + +.eightbf-roundtable[data-roundtable-step="1"] #left-sidebar::-webkit-scrollbar:horizontal, +.eightbf-roundtable .eightbf-roundtable__step1-sidebar::-webkit-scrollbar:horizontal, +.eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar::-webkit-scrollbar:horizontal { + display: none; + height: 0; +} + +.eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar { + min-height: 0; + overflow-y: auto; +} + +.eightbf-roundtable .eightbf-roundtable__intent-sidebar-empty { + margin: 0; + padding: 12px 16px 18px; + color: #aaceff; + font-size: 14px; + line-height: 22px; +} + +/* The flow is embedded into the intent surface: only its own header line and + dotted canvas are visible. The surrounding intent panel owns the border. */ +.eightbf-roundtable .position-roundtable-intent-delivery-flow { + /* Reference flow has horizontal gutters. Reduce the width by those gutters + so the right-side magnifier is never clipped by the left-rail crop. */ + width: calc(100% - 24px) !important; + height: 580px !important; + min-height: 580px !important; + flex-basis: 580px !important; +} +.eightbf-roundtable .position-roundtable-intent-delivery-flow > div:first-child { + margin: 0 0 12px !important; +} + +.eightbf-roundtable .eightbf-roundtable__agent-list-workspace { + display: flex; + height: 100%; + min-height: 0; + flex-direction: column; + background: #032650; +} +.eightbf-roundtable .eightbf-roundtable__agent-list-workspace > header { + display: flex; + min-height: 64px; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 12px; + border-bottom: 1px solid rgb(43 143 254 / 35%); +} +.eightbf-roundtable .eightbf-roundtable__agent-list-workspace h3 { margin: 0; color: #fff; font-size: 17px; font-weight: 500; } +.eightbf-roundtable .eightbf-roundtable__agent-list-workspace p { margin: 4px 0 0; color: #aaceff; font-size: 11px; line-height: 1.45; } +.eightbf-roundtable .eightbf-roundtable__configure-chain-button { + display: inline-flex; + height: 28px; + align-items: center; + gap: 4px; + border: 1px solid rgb(115 200 255 / 52%); + border-radius: 4px; + background: rgb(0 98 217 / 22%); + color: #dceaff; + padding: 0 8px; + font-size: 11px; +} +.eightbf-roundtable .eightbf-roundtable__configure-chain-button:hover { background: rgb(0 98 217 / 40%); } +.eightbf-roundtable .eightbf-roundtable__agent-list-search { + display: flex; + align-items: center; + gap: 7px; + margin: 12px; + border: 1px solid rgb(43 143 254 / 36%); + border-radius: 4px; + background: rgb(0 12 28 / 20%); + color: #aaceff; + padding: 8px 9px; + font-size: 12px; +} +.eightbf-roundtable .eightbf-roundtable__agent-list-search span:last-child { margin-left: auto; border-radius: 999px; background: #0b386d; padding: 1px 6px; color: #83c1ff; font-size: 10px; } +.eightbf-roundtable .eightbf-roundtable__agent-list-scroll { min-height: 0; flex: 1; overflow-y: auto; padding: 0 12px 12px; } +.eightbf-roundtable .eightbf-roundtable__agent-list-loading, +.eightbf-roundtable .eightbf-roundtable__agent-list-empty { display: flex; min-height: 100px; align-items: center; justify-content: center; gap: 7px; color: #8aa0c4; font-size: 12px; text-align: center; } +.eightbf-roundtable .eightbf-roundtable__agent-list-row { + display: flex; + width: 100%; + align-items: center; + gap: 10px; + border-bottom: 1px solid rgb(43 143 254 / 21%); + padding: 11px 2px; + color: inherit; + text-align: left; + transition: background-color .16s ease, transform .16s ease; +} +.eightbf-roundtable .eightbf-roundtable__agent-list-row:hover { background: rgb(0 98 217 / 13%); transform: translateX(2px); } +.eightbf-roundtable .eightbf-roundtable__agent-list-avatar { display: inline-flex; width: 34px; height: 34px; flex: 0 0 auto; align-items: center; justify-content: center; border: 1px solid #007e82; border-radius: 7px; background: #00306a; color: #73c8ff; } +.eightbf-roundtable .eightbf-roundtable__agent-list-avatar svg { width: 18px; height: 18px; } +.eightbf-roundtable .eightbf-roundtable__agent-list-row strong, +.eightbf-roundtable .eightbf-roundtable__agent-list-row small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.eightbf-roundtable .eightbf-roundtable__agent-list-row strong { color: #fff; font-size: 13px; font-weight: 600; } +.eightbf-roundtable .eightbf-roundtable__agent-list-row small { margin-top: 3px; color: #aaceff; font-size: 11px; } +.eightbf-roundtable .eightbf-roundtable__agent-list-index { color: #75b7f5; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; } +.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar { display: flex; height: 48px; align-items: center; justify-content: space-between; border-bottom: 1px solid rgb(43 143 254 / 35%); padding: 0 12px; } +.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar > button:first-child { display: inline-flex; align-items: center; gap: 2px; color: #aaceff; font-size: 12px; } +.eightbf-roundtable .eightbf-roundtable__agent-detail-topbar > button:first-child:hover { color: #fff; } +.eightbf-roundtable .eightbf-roundtable__intent-results-scroll { padding: 12px; } +.eightbf-roundtable .eightbf-roundtable__intent-results-empty { margin: 0; color: #aaceff; font-size: 13px; line-height: 1.8; } +.eightbf-roundtable .eightbf-roundtable__intent-results-list { display: grid; gap: 10px; } +.eightbf-roundtable .eightbf-roundtable__intent-results-list section { border: 1px solid rgb(43 143 254 / 32%); border-radius: 4px; background: rgb(0 12 28 / 20%); padding: 12px; } +.eightbf-roundtable .eightbf-roundtable__intent-results-list h3 { margin: 0 0 8px; color: #a2e1f9; font-size: 14px; font-weight: 500; } +.eightbf-roundtable .eightbf-roundtable__intent-results-list ul { margin: 0; padding-left: 17px; color: #dceaff; font-size: 12px; line-height: 1.8; } + +/* Screenshot-equivalent dark workbench shell. */ +.dark .eightbf-roundtable { background: #061b3b; color: #e7f0ff; } +.dark .eightbf-roundtable > header { min-height: 64px; border-color: rgb(43 143 254 / 45%); background: #032650; box-shadow: none; } +.dark .eightbf-roundtable .eightbf-roundtable__header-brand h1 { color: #e7f0ff; } +.dark .eightbf-roundtable .eightbf-roundtable__header-brand p { color: #8aa0c4; } +.dark .eightbf-roundtable .eightbf-roundtable__header-status { border-color: rgb(101 214 198 / 45%); background: rgb(15 118 110 / 24%); color: #9ef0df; } +.dark .eightbf-roundtable .eightbf-roundtable__header-chain { border-color: rgb(143 196 255 / 44%); background: #082b57; color: #aaceff; } +.dark .eightbf-roundtable .eightbf-roundtable__header-chain strong { color: #e7f0ff; } +.dark .eightbf-roundtable #new-task-btn { border: 1px solid rgb(115 200 255 / 45%); border-radius: 4px; background: transparent; color: #e7f0ff; box-shadow: none; } +.dark .eightbf-roundtable #new-task-btn:hover { background: rgb(0 98 217 / 24%); } +.dark .eightbf-roundtable[data-roundtable-step="1"] > main { gap: 0; padding: 12px 0; } +.dark .eightbf-roundtable[data-roundtable-step="1"] #middle-viewport { border-color: rgb(43 143 254 / 50%); border-radius: 4px; background: #032650; box-shadow: none; } +.dark .eightbf-roundtable[data-roundtable-step="1"] #left-sidebar { + width: 374px; + overflow: hidden; + border: 0; + border-radius: 0; + background: transparent; + padding: 0 12px; +} +.dark .eightbf-roundtable .eightbf-roundtable__intent-delivery-sidebar { + border-color: rgb(0 110 254 / 50%); + background: #032650; +} + +@media (max-width: 1280px) { + .eightbf-roundtable .eightbf-roundtable__header-chain { min-width: 180px; max-width: 220px; } + .dark .eightbf-roundtable[data-roundtable-step="1"] #left-sidebar { width: 326px; } + .eightbf-roundtable .position-roundtable-intent-config-pane { --prt-intent-config-width: 320px; } +} + +@media (max-width: 1024px) { + .dark .eightbf-roundtable[data-roundtable-step="1"] #left-sidebar { width: 294px; } + .eightbf-roundtable .eightbf-roundtable__header-status, + .eightbf-roundtable .eightbf-roundtable__header-brand p { display: none; } + .eightbf-roundtable .eightbf-roundtable__header-chain { display: none; } + .eightbf-roundtable .position-roundtable-intent-config-pane { --prt-intent-config-width: min(360px, 42vw); } +} diff --git a/frontend-web/src/eightbf-roundtable/styles/step2.css b/frontend-web/src/eightbf-roundtable/styles/step2.css index 0abc75de..a9481942 100644 --- a/frontend-web/src/eightbf-roundtable/styles/step2.css +++ b/frontend-web/src/eightbf-roundtable/styles/step2.css @@ -14,4 +14,67 @@ .eightbf-roundtable .eightbf-roundtable__chain-settings-button:hover { background: #115e59; } -/* 可在此继续覆盖会商消息、席位卡片、流程节点及展开按钮的样式。 */ +/* 展开时工作区紧贴左栏;收起时恢复通用的 16px 间距,避免窄栏贴着内容区。 */ +.dark .eightbf-roundtable[data-roundtable-step="2"] > main[data-step2-sidebar-expanded="true"] { + gap: 0; +} + +.dark .eightbf-roundtable[data-roundtable-step="2"] > main[data-step2-sidebar-expanded="false"] { + gap: 10px; +} + +/* 第二步左栏顶部直接复用第一步任务卡和意图理解区,但不复用第一步整列的 + flex: 1 行为;内容高度随意图实际内容收拢,剩余内容交给左栏统一滚动。 */ +.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable__step2-sidebar-header { + display: flex; + flex: 0 0 auto; + flex-direction: column; + gap: 12px; + min-height: 0; + overflow: visible; +} + +.eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable__step2-sidebar-header + .eightbf-roundtable__intent-delivery-sidebar { + flex: 0 0 auto; + overflow: visible; +} + +/* 角色面板与列表均依内容撑开,再交由左栏的单一滚动容器承载;否则取消 + 内层滚动后,角色项会从固定高度的父面板溢出并覆盖下方内容。 */ +.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar > .eightbf-roundtable__step2-sidebar-header + div { + flex: 0 0 auto !important; + min-height: 0 !important; +} + +.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar .custom-scrollbar { + flex: 0 0 auto; + overflow: visible; + padding-right: 0; +} + +/* 8BF 第二步沿用第一步的 4px 面板语言,避免通用会商的 rounded-2xl 混入。 */ +.eightbf-roundtable[data-roundtable-step="2"] #left-sidebar > .rounded-2xl { + border-radius: 4px !important; +} + +/* 展开态与第一步左栏保持同一宽度和内边距。注意不能无条件锁定宽度,否则会 + 覆盖收起态的 w-[56px],使顶部图标按钮看起来无效。 */ +.dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="true"] { + width: 374px; + padding: 0 12px; +} + +/* 第二步输入区直接复用第一步 PositionIntentInput;共识动作也使用其 + #enter-roundtable-btn 规则,保证和「确认意图并启动业务链」完全同款。 */ +.dark .eightbf-roundtable[data-roundtable-step="2"] .eightbf-roundtable-step2-input { + flex: 0 0 auto; +} + +@media (max-width: 1280px) { + .dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="true"] { width: 326px; } +} + +@media (max-width: 1024px) { + .dark .eightbf-roundtable[data-roundtable-step="2"] #left-sidebar[data-sidebar-expanded="true"] { width: 294px; } +} diff --git a/frontend-web/src/lib/client-id.ts b/frontend-web/src/lib/client-id.ts new file mode 100644 index 00000000..77aff53b --- /dev/null +++ b/frontend-web/src/lib/client-id.ts @@ -0,0 +1,27 @@ +/** + * Generate a client-only id without assuming a secure browser context. + * + * `crypto.randomUUID()` is unavailable in several browsers when the app is + * opened directly through an `http://:` address. Request and + * UI ids need uniqueness, not a security token, so a timestamp/random fallback + * keeps those flows usable until the deployment is served over HTTPS. + */ +export function createClientId(prefix?: string): string { + const cryptoApi = typeof globalThis === "object" ? globalThis.crypto : undefined; + let id: string | undefined; + + if (typeof cryptoApi?.randomUUID === "function") { + id = cryptoApi.randomUUID(); + } else if (typeof cryptoApi?.getRandomValues === "function") { + try { + const values = new Uint32Array(4); + cryptoApi.getRandomValues(values); + id = Array.from(values, (value) => value.toString(16).padStart(8, "0")).join(""); + } catch { + // Some restricted browser contexts expose crypto but reject random data. + } + } + + id ??= `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`; + return prefix ? `${prefix}-${id}` : id; +} diff --git a/frontend-web/src/open-canvas/api/rewrite.ts b/frontend-web/src/open-canvas/api/rewrite.ts index 18ab5900..4d471fed 100644 --- a/frontend-web/src/open-canvas/api/rewrite.ts +++ b/frontend-web/src/open-canvas/api/rewrite.ts @@ -119,6 +119,10 @@ export interface DocumentRewriteRequest { instruction: string; style?: DocumentRewriteStyle; modelName?: string; + /** Generate locally saved illustration assets after the prose rewrite. */ + generateImages?: boolean; + /** Bounded server-side to 1–4. Ignored unless generateImages is true. */ + maxGeneratedImages?: number; expectedHash?: string; } @@ -140,6 +144,8 @@ export interface DocumentRewriteSummary { }; requestedOptimizations?: string[]; validationWarnings?: string[]; + generatedImageCount?: number; + requestedImageCount?: number; } export type DocumentRewriteStreamEvent = @@ -156,6 +162,15 @@ export type DocumentRewriteStreamEvent = | { type: "rewrite_delta"; delta: string; charCount: number } /** Durable full-draft recovery point used after an SSE reconnect. */ | { type: "rewrite_checkpoint"; content: string; charCount: number } + | { type: "illustration_started"; section: string; position: number; total: number } + | { + type: "illustration_generated"; + section: string; + position: number; + total: number; + generatedCount?: number; + } + | { type: "illustration_completed"; generatedCount: number; requestedCount: number; warnings: string[] } | { type: "validation_started" } | { type: "validation_completed"; passed: boolean; warnings: string[] } | { type: "comparison_ready"; summary: DocumentRewriteSummary } @@ -177,6 +192,8 @@ export interface DurableDocumentRewriteJob { instruction: string; style: DocumentRewriteStyle; modelName: string | null; + generateImages: boolean; + maxGeneratedImages: number; originalContent: string; }; } @@ -229,6 +246,30 @@ async function readDocumentRewriteEvent( content: String(parsed.content ?? ""), charCount: Number(parsed.charCount ?? 0), }; + case "illustration_started": + return { + type: "illustration_started", + section: String(parsed.section ?? ""), + position: Number(parsed.position ?? 0), + total: Number(parsed.total ?? 0), + }; + case "illustration_generated": + return { + type: "illustration_generated", + section: String(parsed.section ?? ""), + position: Number(parsed.position ?? 0), + total: Number(parsed.total ?? 0), + generatedCount: Number(parsed.generatedCount ?? parsed.position ?? 0), + }; + case "illustration_completed": + return { + type: "illustration_completed", + generatedCount: Number(parsed.generatedCount ?? 0), + requestedCount: Number(parsed.requestedCount ?? 0), + warnings: Array.isArray(parsed.warnings) + ? parsed.warnings.filter((item): item is string => typeof item === "string") + : [], + }; case "validation_started": return { type: "validation_started" }; case "validation_completed": @@ -362,6 +403,8 @@ export async function listDurableDocumentRewrites( instruction: String(request.instruction ?? ""), style: (request.style ?? "professional") as DocumentRewriteStyle, modelName: typeof request.modelName === "string" ? request.modelName : null, + generateImages: request.generateImages === true, + maxGeneratedImages: Number(request.maxGeneratedImages ?? 2), originalContent: String(request.originalContent ?? ""), }, }]; diff --git a/frontend-web/src/open-canvas/hooks/useDocumentRewrite.ts b/frontend-web/src/open-canvas/hooks/useDocumentRewrite.ts index 2612d535..3bde9ac4 100644 --- a/frontend-web/src/open-canvas/hooks/useDocumentRewrite.ts +++ b/frontend-web/src/open-canvas/hooks/useDocumentRewrite.ts @@ -9,12 +9,14 @@ import { cancelDurableDocumentRewrite, streamDocumentRewrite, } from "../api/rewrite"; +import { InlineThinkTagFilter, stripInlineThinkTags } from "@/core/streamdown/inline-think"; export type DocumentRewriteStage = | "idle" | "snapshot" | "requirements" | "writing" + | "illustrating" | "validation" | "committing" | "completed" @@ -27,6 +29,8 @@ export interface DocumentRewriteRun { draft: string; /** Live, compact execution plan produced before Markdown generation starts. */ requirementsPlan: string; + /** Ephemeral model reasoning, deliberately kept out of the Markdown draft. */ + thinkingText: string; originalCharCount: number; writtenCharCount: number; fileName: string; @@ -41,6 +45,7 @@ const INITIAL_RUN: DocumentRewriteRun = { stage: "idle", draft: "", requirementsPlan: "", + thinkingText: "", originalCharCount: 0, writtenCharCount: 0, fileName: "", @@ -67,6 +72,10 @@ export function useDocumentRewrite() { const frameRef = useRef(null); const pendingRequirementsDeltaRef = useRef(""); const requirementsFrameRef = useRef(null); + const pendingThinkingRef = useRef(""); + const thinkingFrameRef = useRef(null); + const draftThinkFilterRef = useRef(new InlineThinkTagFilter()); + const requirementsThinkFilterRef = useRef(new InlineThinkTagFilter()); const flushDelta = useCallback(() => { if (frameRef.current !== null) { @@ -86,27 +95,6 @@ export function useDocumentRewrite() { })); }, []); - const enqueueDelta = useCallback((delta: string, writtenCharCount: number) => { - if (!delta) return; - pendingDeltaRef.current += delta; - pendingWrittenCharCountRef.current = writtenCharCount; - if (frameRef.current !== null) return; - frameRef.current = requestAnimationFrame(() => { - frameRef.current = null; - const pending = pendingDeltaRef.current; - const pendingWrittenCharCount = pendingWrittenCharCountRef.current; - pendingDeltaRef.current = ""; - pendingWrittenCharCountRef.current = null; - if (!pending) return; - setRun((previous) => ({ - ...previous, - stage: "writing", - draft: previous.draft + pending, - ...(pendingWrittenCharCount !== null ? { writtenCharCount: pendingWrittenCharCount } : {}), - })); - }); - }, []); - const flushRequirements = useCallback(() => { if (requirementsFrameRef.current !== null) { cancelAnimationFrame(requirementsFrameRef.current); @@ -124,9 +112,35 @@ export function useDocumentRewrite() { })); }, []); - const enqueueRequirementsDelta = useCallback((delta: string) => { + const flushThinking = useCallback(() => { + if (thinkingFrameRef.current !== null) { + cancelAnimationFrame(thinkingFrameRef.current); + thinkingFrameRef.current = null; + } + const delta = pendingThinkingRef.current; + pendingThinkingRef.current = ""; if (!delta) return; - pendingRequirementsDeltaRef.current += delta; + setRun((previous) => ({ ...previous, thinkingText: previous.thinkingText + delta })); + }, []); + + const enqueueThinking = useCallback((delta: string) => { + if (!delta) return; + pendingThinkingRef.current += delta; + if (thinkingFrameRef.current !== null) return; + thinkingFrameRef.current = requestAnimationFrame(() => { + thinkingFrameRef.current = null; + const pending = pendingThinkingRef.current; + pendingThinkingRef.current = ""; + if (!pending) return; + setRun((previous) => ({ ...previous, thinkingText: previous.thinkingText + pending })); + }); + }, []); + + const enqueueRequirementsDelta = useCallback((delta: string) => { + const { text: visibleDelta, thinking } = requirementsThinkFilterRef.current.pushParts(delta); + enqueueThinking(thinking); + if (!visibleDelta) return; + pendingRequirementsDeltaRef.current += visibleDelta; if (requirementsFrameRef.current !== null) return; requirementsFrameRef.current = requestAnimationFrame(() => { requirementsFrameRef.current = null; @@ -141,7 +155,30 @@ export function useDocumentRewrite() { requirementsPlan: previous.requirementsPlan + pending, })); }); - }, []); + }, [enqueueThinking]); + + const enqueueDelta = useCallback((delta: string, writtenCharCount: number) => { + const { text: visibleDelta, thinking } = draftThinkFilterRef.current.pushParts(delta); + enqueueThinking(thinking); + if (!visibleDelta) return; + pendingDeltaRef.current += visibleDelta; + pendingWrittenCharCountRef.current = writtenCharCount; + if (frameRef.current !== null) return; + frameRef.current = requestAnimationFrame(() => { + frameRef.current = null; + const pending = pendingDeltaRef.current; + const pendingWrittenCharCount = pendingWrittenCharCountRef.current; + pendingDeltaRef.current = ""; + pendingWrittenCharCountRef.current = null; + if (!pending) return; + setRun((previous) => ({ + ...previous, + stage: "writing", + draft: previous.draft + pending, + ...(pendingWrittenCharCount !== null ? { writtenCharCount: pendingWrittenCharCount } : {}), + })); + }); + }, [enqueueThinking]); const cancel = useCallback(() => { controllerRef.current?.abort(); @@ -155,12 +192,13 @@ export function useDocumentRewrite() { } flushDelta(); flushRequirements(); + flushThinking(); setRun((previous) => previous.stage === "completed" ? previous : { ...previous, stage: "cancelled", error: "已停止全文改写,原文件未被修改。" }, ); - }, [flushDelta, flushRequirements]); + }, [flushDelta, flushRequirements, flushThinking]); const reset = useCallback(() => { controllerRef.current?.abort(); @@ -169,6 +207,9 @@ export function useDocumentRewrite() { pendingDeltaRef.current = ""; pendingWrittenCharCountRef.current = null; pendingRequirementsDeltaRef.current = ""; + pendingThinkingRef.current = ""; + draftThinkFilterRef.current = new InlineThinkTagFilter(); + requirementsThinkFilterRef.current = new InlineThinkTagFilter(); if (frameRef.current !== null) { cancelAnimationFrame(frameRef.current); frameRef.current = null; @@ -177,6 +218,10 @@ export function useDocumentRewrite() { cancelAnimationFrame(requirementsFrameRef.current); requirementsFrameRef.current = null; } + if (thinkingFrameRef.current !== null) { + cancelAnimationFrame(thinkingFrameRef.current); + thinkingFrameRef.current = null; + } setRun(INITIAL_RUN); }, []); @@ -202,6 +247,9 @@ export function useDocumentRewrite() { pendingDeltaRef.current = ""; pendingWrittenCharCountRef.current = null; pendingRequirementsDeltaRef.current = ""; + pendingThinkingRef.current = ""; + draftThinkFilterRef.current = new InlineThinkTagFilter(); + requirementsThinkFilterRef.current = new InlineThinkTagFilter(); setRun({ ...INITIAL_RUN, stage: "snapshot", @@ -236,28 +284,36 @@ export function useDocumentRewrite() { case "requirements_delta": enqueueRequirementsDelta(event.delta); break; + case "thinking": + enqueueThinking(event.chunk); + break; case "requirements_checkpoint": flushRequirements(); setRun((previous) => ({ ...previous, stage: "requirements", - requirementsPlan: event.content, + requirementsPlan: requirementsThinkFilterRef.current.replace(event.content), })); break; case "requirements_completed": flushRequirements(); - setRun((previous) => ({ - ...previous, - stage: "requirements", - requirementsPlan: event.content, - })); + { + const requirementsPlan = + requirementsThinkFilterRef.current.replace(event.content) + + requirementsThinkFilterRef.current.finish(); + setRun((previous) => ({ + ...previous, + stage: "requirements", + requirementsPlan, + })); + } break; case "requirements_resolved": flushRequirements(); setRun((previous) => ({ ...previous, stage: "writing", - ...(event.plan ? { requirementsPlan: event.plan } : {}), + ...(event.plan ? { requirementsPlan: stripInlineThinkTags(event.plan) } : {}), })); break; case "rewrite_delta": @@ -276,13 +332,20 @@ export function useDocumentRewrite() { setRun((previous) => ({ ...previous, stage: "writing", - draft: event.content, - writtenCharCount: event.charCount, + draft: draftThinkFilterRef.current.replace(event.content), + writtenCharCount: stripInlineThinkTags(event.content).replace(/\s/g, "").length, })); break; + case "illustration_started": + case "illustration_generated": + case "illustration_completed": + flushDelta(); + setRun((previous) => ({ ...previous, stage: "illustrating" })); + break; case "validation_started": flushDelta(); flushRequirements(); + flushThinking(); setRun((previous) => ({ ...previous, stage: "validation" })); break; case "validation_completed": @@ -300,17 +363,23 @@ export function useDocumentRewrite() { case "committed": flushDelta(); flushRequirements(); - setRun((previous) => ({ - ...previous, - draft: event.content, - writtenCharCount: event.content.replace(/\s/g, "").length, - versionId: event.versionId ?? previous.versionId, - })); - options?.onCommitted?.(event.content); + flushThinking(); + { + const committedContent = + draftThinkFilterRef.current.replace(event.content) + draftThinkFilterRef.current.finish(); + setRun((previous) => ({ + ...previous, + draft: committedContent, + writtenCharCount: committedContent.replace(/\s/g, "").length, + versionId: event.versionId ?? previous.versionId, + })); + options?.onCommitted?.(committedContent); + } break; case "done": flushDelta(); flushRequirements(); + flushThinking(); receivedTerminalEvent = true; setRun((previous) => ({ ...previous, @@ -321,6 +390,7 @@ export function useDocumentRewrite() { case "conflict": flushDelta(); flushRequirements(); + flushThinking(); receivedTerminalEvent = true; setRun((previous) => ({ ...previous, @@ -331,12 +401,14 @@ export function useDocumentRewrite() { case "cancelled": flushDelta(); flushRequirements(); + flushThinking(); receivedTerminalEvent = true; setRun((previous) => ({ ...previous, stage: "cancelled", error: event.message })); return; case "error": flushDelta(); flushRequirements(); + flushThinking(); receivedTerminalEvent = true; setRun((previous) => ({ ...previous, @@ -351,6 +423,7 @@ export function useDocumentRewrite() { if (!controller.signal.aborted && !receivedTerminalEvent) { flushDelta(); flushRequirements(); + flushThinking(); setRun((previous) => ({ ...previous, stage: "error", @@ -361,6 +434,7 @@ export function useDocumentRewrite() { if (controller.signal.aborted) return; flushDelta(); flushRequirements(); + flushThinking(); setRun((previous) => ({ ...previous, stage: "error", @@ -372,7 +446,7 @@ export function useDocumentRewrite() { } } }, - [enqueueDelta, enqueueRequirementsDelta, flushDelta, flushRequirements], + [enqueueDelta, enqueueRequirementsDelta, enqueueThinking, flushDelta, flushRequirements, flushThinking], ); useEffect( @@ -380,13 +454,14 @@ export function useDocumentRewrite() { controllerRef.current?.abort(); if (frameRef.current !== null) cancelAnimationFrame(frameRef.current); if (requirementsFrameRef.current !== null) cancelAnimationFrame(requirementsFrameRef.current); + if (thinkingFrameRef.current !== null) cancelAnimationFrame(thinkingFrameRef.current); }, [], ); return { run, - isActive: ["snapshot", "requirements", "writing", "validation", "committing"].includes(run.stage), + isActive: ["snapshot", "requirements", "writing", "illustrating", "validation", "committing"].includes(run.stage), start, cancel, reset, diff --git a/frontend-web/src/pages/PluginManagementPage.tsx b/frontend-web/src/pages/PluginManagementPage.tsx index 0faf29b9..e1d9cb0b 100644 --- a/frontend-web/src/pages/PluginManagementPage.tsx +++ b/frontend-web/src/pages/PluginManagementPage.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { useAgentSelector } from "@/core/agents"; +import { createClientId } from "@/lib/client-id"; import { type DomainAgentMapping, normalizeDomainInput, @@ -20,10 +21,7 @@ import { function makeRow(): DomainAgentMapping { return { - id: - typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" - ? crypto.randomUUID() - : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, + id: createClientId(), domain: "", agentId: "", enabled: true, diff --git a/frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx b/frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx index 5723d416..bf85a03f 100644 --- a/frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx +++ b/frontend-web/src/pages/deep-research/DeepResearchWorkbench.tsx @@ -123,6 +123,7 @@ import { advancedOptionsFromConfig, buildReportCardConfig, ReportConfigCard, + ReportOutlineEditor, } from "./ReportConfigCard"; import { ResearchSandboxPanel } from "./ResearchSandboxPanel"; import { REPORT_OUTPUT_PATH, useReportSandbox } from "./useReportSandbox"; @@ -183,6 +184,8 @@ type WorkbenchRun = { phase: string; planText: string; planReasoning: string; + /** Live-only model thinking while report Markdown is being streamed. */ + reportThinking: string; report: string; reportTargets: Record; reportVersion: number; @@ -201,6 +204,7 @@ const EMPTY_RUN: WorkbenchRun = { phase: "initializing", planText: "", planReasoning: "", + reportThinking: "", report: "", reportTargets: {}, reportVersion: 1, @@ -418,6 +422,10 @@ function DeepResearchWorkbenchInner() { presentationId: null, plan: "", }); + const mirroredReportRewriteThinkingRef = useRef<{ presentationId: string | null; thinking: string }>({ + presentationId: null, + thinking: "", + }); const sessions = useResearchSessions(); const sessionQuery = useResearchSession(selectedId); @@ -428,6 +436,10 @@ function DeepResearchWorkbenchInner() { const [localSettings] = useLocalSettings(); const { models, isLoading: modelsLoading } = useModels(); const seenEventKeys = useRef(new Set()); + // Live provider deltas can legitimately repeat the same text (including a + // single space). They have no durable sequence, so never deduplicate them + // by payload or a thought token could disappear from the visible trace. + const liveEventSerial = useRef(0); const replayedTraceJobs = useRef(new Set()); const pendingReportEvents = useRef([]); const reportAnimationFrame = useRef(null); @@ -679,6 +691,16 @@ function DeepResearchWorkbenchInner() { }); }, [reportDocumentRewrite.run.requirementsPlan, rewritePresentation]); + useEffect(() => { + const presentationId = reportRewritePresentationId.current; + const thinking = reportDocumentRewrite.run.thinkingText; + if (!presentationId || !rewritePresentation || !thinking) return; + const previous = mirroredReportRewriteThinkingRef.current; + if (previous.presentationId === presentationId && previous.thinking === thinking) return; + mirroredReportRewriteThinkingRef.current = { presentationId, thinking }; + rewritePresentation.update(presentationId, { thinkingText: thinking }); + }, [reportDocumentRewrite.run.thinkingText, rewritePresentation]); + useEffect(() => { const handleRewriteUndo = (event: Event) => { const detail = (event as CustomEvent<{ @@ -854,6 +876,9 @@ function DeepResearchWorkbenchInner() { phase: session?.status === "completed" ? "done" : run.phase, materialCount, error: run.error, + thinking: run.reportThinking, + events: run.events, + requestedImageCount: requestedReportImageCount(session?.config), }), } as Injected); } @@ -870,6 +895,7 @@ function DeepResearchWorkbenchInner() { ? buildCompletedReportMessage({ sourceCount: materialCount, summaryText: run.summaryText, + imageCount: countEmbeddedReportImages(session?.reportMarkdown), }) : buildStreamingSummaryMessage(run.summaryText), // MessageList 会将该标记转为既有的 assistant 流式渲染状态,避免摘要只在结束后才显示。 @@ -950,7 +976,9 @@ function DeepResearchWorkbenchInner() { pendingFollowUp, followUpStream, run.error, + run.events, run.phase, + run.reportThinking, run.summaryText, session, streamJobId, @@ -980,6 +1008,7 @@ function DeepResearchWorkbenchInner() { if (replayingTerminalTrace && replayedTraceJobs.current.has(streamJobId)) return; if (replayingTerminalTrace) replayedTraceJobs.current.add(streamJobId); seenEventKeys.current = new Set(); + liveEventSerial.current = 0; pendingReportEvents.current = []; setRun({ ...EMPTY_RUN, jobId: streamJobId }); // 进行中的写作仍自动打开沙箱;历史完成记录只回放步骤与摘要,等待用户点击 @@ -997,6 +1026,7 @@ function DeepResearchWorkbenchInner() { let reportTargets = current.reportTargets; let planText = current.planText; let planReasoning = current.planReasoning; + let reportThinking = current.reportThinking; let summaryText = current.summaryText; let phase = current.phase; for (const event of queued) { @@ -1009,6 +1039,10 @@ function DeepResearchWorkbenchInner() { planReasoning += typeof event.payload.delta === "string" ? event.payload.delta : ""; continue; } + if (event.type === "report_thinking") { + reportThinking += typeof event.payload.delta === "string" ? event.payload.delta : ""; + continue; + } if (event.type === "summary_delta") { summaryText += typeof event.payload.delta === "string" ? event.payload.delta : ""; continue; @@ -1020,6 +1054,7 @@ function DeepResearchWorkbenchInner() { if (event.type === "report_reset") { report = ""; reportTargets = {}; + reportThinking = ""; reportVersion = typeof event.payload.reportVersion === "number" ? event.payload.reportVersion : reportVersion + 1; continue; } @@ -1035,7 +1070,7 @@ function DeepResearchWorkbenchInner() { reportVersion = Math.max(reportVersion, version); } } - return { ...current, report, reportTargets, reportVersion, phase, planText, planReasoning, summaryText }; + return { ...current, report, reportTargets, reportVersion, phase, planText, planReasoning, reportThinking, summaryText }; }); }; @@ -1043,12 +1078,15 @@ function DeepResearchWorkbenchInner() { streamJobId, { onEvent: (event) => { - const key = eventKey(event); + const key = event.seq === 0 && !event.liveId + ? `live-arrival:${++liveEventSerial.current}` + : eventKey(event); if (seenEventKeys.current.has(key)) return; seenEventKeys.current.add(key); if ( event.type === "plan_delta" || event.type === "plan_reasoning" || + event.type === "report_thinking" || event.type === "report_delta" || event.type === "report_chunk" || event.type === "report_reset" || @@ -1521,6 +1559,10 @@ function DeepResearchWorkbenchInner() { case "config": return ( 0 || imageSnapshot.imageTotal > 0 || imageSnapshot.images.length > 0; + const imageTotal = imageSnapshot.imageTotal || requestedImageCount; + const generatedImages = imageSnapshot.images.filter((image) => image.status === "completed").length; + const failedImages = imageSnapshot.images.filter((image) => image.status === "failed").length; const stages = [ { id: "harvest", @@ -1879,6 +1933,18 @@ function buildWritingProgressToolCalls({ boundary: chatPhaseRank("reviewing"), detail: "正在逐字写入右侧沙箱中的 report.md", }, + ...(imageStageVisible ? [{ + id: "illustrate", + label: "生成报告配图", + boundary: chatPhaseRank("exporting"), + detail: generatedImages > 0 + ? `已生成 ${generatedImages} 张报告配图并嵌入 report.md${failedImages ? `;另有 ${failedImages} 张未生成,文字报告已保留` : ""}` + : failedImages > 0 + ? `配图未生成,已保留完整文字报告${imageTotal ? `(已处理 ${failedImages}/${imageTotal} 张)` : ""}` + : imageTotal > 0 + ? `正在生成报告配图,已处理 ${generatedImages + failedImages}/${imageTotal} 张` + : "正在为报告关键章节生成配图", + }] : []), { id: "summary", label: "生成报告摘要", @@ -1901,6 +1967,7 @@ function buildWritingProgressToolCalls({ label: stage.label, detail, status: complete ? "complete" : "active", + ...(stage.id === "write" && thinking.trim() ? { thinking } : {}), }, }; }); @@ -1910,14 +1977,19 @@ function buildWritingProgressToolCalls({ function buildCompletedReportMessage({ sourceCount, summaryText, + imageCount, }: { sourceCount: number; summaryText: string; + imageCount: number; }): string { const parts = [ "## 研究报告已完成", `已基于 ${sourceCount} 条资料完成报告,正文已在右侧沙箱中打开;可通过沙箱工具栏下载 Markdown 或 Word。`, ]; + if (imageCount > 0) { + parts.push(`已生成 ${imageCount} 张报告配图,并嵌入正文对应章节。`); + } if (summaryText.trim()) { parts.push(`### 报告摘要\n\n${summaryText.trim()}`); } @@ -2104,6 +2176,7 @@ function ResearchConversation({ const hasTrace = active || events.length > 0; const streamedSourceCount = events.reduce((count, event) => count + (event.type === "source_added" ? 1 : 0), 0); const liveSourceCount = Math.max(sourceCount, streamedSourceCount); + const requestedImageCount = requestedReportImageCount(session.config); return (
@@ -2120,6 +2193,7 @@ function ResearchConversation({ error={streamError} onOpenSandbox={onOpenSandbox} active={active} + requestedImageCount={requestedImageCount} /> )} @@ -2169,6 +2243,15 @@ type TraceSource = { snippet?: string; }; +type TraceImage = { + position: number; + section: string; + status: "generating" | "completed" | "failed"; + name?: string; + alt?: string; + error?: string; +}; + type TraceSnapshot = { groups: { title: string; queries: string[] }[]; subtopics: string[]; @@ -2179,6 +2262,9 @@ type TraceSnapshot = { curationReason?: string; compressedSourceCount?: number; reportCompleted: boolean; + imageTotal: number; + images: TraceImage[]; + imagePhaseFinished: boolean; warnings: string[]; }; @@ -2198,17 +2284,30 @@ function numberValue(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } +function countEmbeddedReportImages(markdown?: string): number { + return (markdown?.match(/!\[[^\]]*\]\(deep-research:\/\/[^)\s]+\)/g) ?? []).length; +} + +function requestedReportImageCount(config?: Record): number { + if (config?.generate_images !== true) return 0; + const configured = numberValue(config.max_generated_images); + return Math.max(1, Math.min(4, Math.floor(configured ?? 2))); +} + function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot { const groupQueries = new Map(); const subtopics: string[] = []; const queryMap = new Map(); const sourceMap = new Map(); + const imageMap = new Map(); const selectedIds = new Set(); const removedIds = new Set(); const warnings: string[] = []; let curationReason: string | undefined; let compressedSourceCount: number | undefined; let reportCompleted = false; + let imageTotal = 0; + let imagePhaseFinished = false; const addQuery = (query: string, group = "研究课题") => { const normalized = query.trim(); @@ -2223,6 +2322,11 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot { for (const event of events) { const payload = event.payload ?? {}; + if (event.type === "phase_changed") { + const from = traceString(payload.from); + const to = traceString(payload.to); + if (from === "illustrating" || to === "exporting") imagePhaseFinished = true; + } if (event.type === "plan_created") { const plan = traceRecord(payload.plan); for (const subtopic of traceStrings(payload.subtopics).concat(traceStrings(plan.subtopics))) { @@ -2292,9 +2396,39 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot { reportCompleted = true; continue; } + if (event.type === "image_generation_started" || event.type === "image_generated") { + const position = Math.max(1, Math.floor(numberValue(payload.position) ?? imageMap.size + 1)); + const total = numberValue(payload.total); + if (total !== undefined) imageTotal = Math.max(imageTotal, Math.floor(total)); + imageTotal = Math.max(imageTotal, position); + const prior = imageMap.get(position); + imageMap.set(position, { + position, + section: traceString(payload.section) ?? prior?.section ?? `第 ${position} 张配图`, + status: event.type === "image_generated" ? "completed" : prior?.status === "completed" ? "completed" : "generating", + name: traceString(payload.name) ?? prior?.name, + alt: traceString(payload.alt) ?? prior?.alt, + }); + continue; + } if (event.type === "warning" || event.type === "error") { const message = traceString(payload.message) ?? traceString(payload.detail); if (message && !warnings.includes(message)) warnings.push(message); + if (traceString(payload.code) === "image_generation_failed") { + const position = Math.max(1, Math.floor(numberValue(payload.position) ?? imageMap.size + 1)); + const total = numberValue(payload.total); + if (total !== undefined) imageTotal = Math.max(imageTotal, Math.floor(total)); + imageTotal = Math.max(imageTotal, position); + const prior = imageMap.get(position); + imageMap.set(position, { + position, + section: traceString(payload.section) ?? prior?.section ?? `第 ${position} 张配图`, + status: "failed", + name: prior?.name, + alt: prior?.alt, + error: message ?? "报告配图生成失败", + }); + } } } @@ -2308,12 +2442,15 @@ function buildTraceSnapshot(events: ProgressEvent[]): TraceSnapshot { curationReason, compressedSourceCount, reportCompleted, + imageTotal, + images: [...imageMap.values()].sort((left, right) => left.position - right.position), + imagePhaseFinished, warnings, }; } function phaseAtOrAfter(phase: string, boundary: string): boolean { - const order = ["initializing", "planning", "collecting", "curating", "compressing", "deepening", "writing", "reviewing", "illustrating", "exporting", "done"]; + const order = ["initializing", "planning", "collecting", "curating", "compressing", "deepening", "writing", "summarizing", "reviewing", "illustrating", "exporting", "done"]; return Math.max(0, order.indexOf(phase)) >= Math.max(0, order.indexOf(boundary)); } @@ -2345,7 +2482,7 @@ function ReasoningStream({ text }: { text: string }) { ); } -const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, planReasoning, sourceCount, reportStarted, error, onOpenSandbox, active }: { events: ProgressEvent[]; phase: string; planText: string; planReasoning: string; sourceCount: number; reportStarted: boolean; error: string | null; onOpenSandbox: () => void; active: boolean }) { +const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, planReasoning, sourceCount, reportStarted, error, onOpenSandbox, active, requestedImageCount }: { events: ProgressEvent[]; phase: string; planText: string; planReasoning: string; sourceCount: number; reportStarted: boolean; error: string | null; onOpenSandbox: () => void; active: boolean; requestedImageCount: number }) { const [expanded, setExpanded] = useState(true); const snapshot = useMemo(() => buildTraceSnapshot(events), [events]); const sourcesById = useMemo(() => new Map(snapshot.sources.map((source) => [source.id, source])), [snapshot.sources]); @@ -2358,6 +2495,14 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla const curatingDone = phaseAtOrAfter(phase, "writing") || snapshot.selectedIds.size > 0; const writingDone = snapshot.reportCompleted || phase === "done"; const streamedQueries = planningDone ? [] : parseStreamedQueries(planText); + const imageStageVisible = requestedImageCount > 0 || snapshot.imageTotal > 0 || snapshot.images.length > 0; + const imageTotal = snapshot.imageTotal || requestedImageCount; + const completedImages = snapshot.images.filter((image) => image.status === "completed").length; + const failedImages = snapshot.images.filter((image) => image.status === "failed").length; + const processedImages = completedImages + failedImages; + const imageDone = imageStageVisible && (snapshot.imagePhaseFinished || phaseAtOrAfter(phase, "exporting") || (!active && phase === "done")); + const imageCurrent = imageStageVisible && !imageDone && (phase === "illustrating" || snapshot.images.some((image) => image.status === "generating")); + const imageProgress = imageTotal ? Math.min(100, Math.round((processedImages / imageTotal) * 100)) : 0; return (
@@ -2390,6 +2535,12 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla + {imageStageVisible && 0 ? `已生成 ${completedImages} 张配图并嵌入 report.md${failedImages ? `;另有 ${failedImages} 张未生成,文字报告已保留` : ""}。` : "配图未生成,已保留完整文字报告。" : imageCurrent ? `正在生成报告配图,已处理 ${processedImages}/${imageTotal || "?"} 张。` : "报告写完后会为关键章节生成并嵌入配图。"} done={imageDone} current={imageCurrent}> + {imageTotal > 0 &&
} + {snapshot.images.length > 0 ?
{snapshot.images.map((image) =>

第 {image.position} 张 · {image.section}

{image.status === "completed" ? `${image.name ?? "已保存并嵌入报告"}` : image.status === "failed" ? image.error ?? "生成失败,已保留文字报告" : "正在调用图片模型…"}

{image.status === "completed" ? "已生成" : image.status === "failed" ? "未生成" : "生成中"}
)}
:
{imageCurrent ? "正在准备第一张报告配图…" : `最多生成 ${imageTotal || requestedImageCount} 张,并在完成后插入对应章节。`}
} + {completedImages > 0 && } + } + {snapshot.warnings.length > 0 &&

过程提示

{snapshot.warnings.map((warning) =>

{warning}

)}
} {error &&

实时连接提示:{error}。研究仍会继续,页面会从已保存的事件记录恢复。

}
} @@ -2398,7 +2549,8 @@ const ResearchTrace = memo(function ResearchTrace({ events, phase, planText, pla }); function CompletionSummary({ session, sourceCount, onOpenSandbox, onExport }: { session: DeepResearchSession; sourceCount: number; onOpenSandbox: () => void; onExport: (format: "word" | "markdown" | "html") => void }) { - return

研究报告已完成

已基于 {sourceCount || session.sourceCount} 条资料完成报告,并保留来源供后续追问。报告正文在右侧沙箱中打开。

; + const imageCount = countEmbeddedReportImages(session.reportMarkdown); + return

研究报告已完成

已基于 {sourceCount || session.sourceCount} 条资料完成报告,并保留来源供后续追问。报告正文在右侧沙箱中打开。

{imageCount > 0 &&

已生成 {imageCount} 张报告配图,已嵌入正文对应章节。

}
; } function PlanReviewCard({ plan, submitting, onSubmit }: { plan: Record | undefined; submitting: boolean; onSubmit: (action: "approve" | "revise", feedback?: string) => void }) { @@ -2409,8 +2561,132 @@ function PlanReviewCard({ plan, submitting, onSubmit }: { plan: Record void; mode: ResearchMode; options: AdvancedResearchOptions; classicMode: boolean; onClassicModeChange: (value: boolean) => void; imageGenerationAvailable: boolean; imageLimit: number; onChange: (key: K, value: AdvancedResearchOptions[K]) => void }) { - return 高级研究配置本次设置会作为下一次研究的默认值;对话式收集的最终选项在报告配置卡片中确认。
onChange("language", value as AdvancedResearchOptions["language"])} options={[{ value: "zh-CN", label: "中文" }, { value: "en-US", label: "English" }]} /> onChange("tone", value as ResearchTone)} options={[{ value: "objective", label: "客观" }, { value: "formal", label: "正式" }, { value: "analytical", label: "分析" }, { value: "persuasive", label: "说服" }]} /> onChange("maxSearchResults", value)} /> onChange("maxIterations", value)} /> onChange("maxConcurrency", value)} /> onChange("maxContextWords", value)} />{(mode === "detailed" || mode === "multi_agent") && onChange("maxSubtopics", value)} />}{mode === "deep" && <> onChange("deepBreadth", value)} /> onChange("deepDepth", value)} />}{mode === "multi_agent" && onChange("maxRevisionLoops", value)} />}