Merge branch 'master-nj' into 'master'

feat(frontend): 岗位会商页面布局与意图理解交互优化

See merge request knowledge-base-bj/deerflow-code!17
This commit is contained in:
董宏凯 committed 2026-08-11 04:59:37 +00:00
commit 69b95f0d6d
188 files changed
+8526 -597

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
import os
import paramiko
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(
"119.254.86.251",
port=22322,
username="root",
password=os.environ["TARGET_SSH_PASSWORD"],
timeout=15,
auth_timeout=15,
banner_timeout=15,
)
_, stdout, stderr = client.exec_command(os.environ["TARGET_SSH_COMMAND"], timeout=30)
print(stdout.read().decode(errors="replace"), end="")
print(stderr.read().decode(errors="replace"), end="")
client.close()
@@ -0,0 +1,15 @@
# 岗位会商沙箱文档
本目录存放**岗位会商文件预览沙箱**的对接与排障说明,独立于 `src/position-roundtable/docs/`,避免与模块内其它文档互相覆盖。
| 文档 | 说明 |
| --- | --- |
| [沙箱打开关闭与步骤条.md](./沙箱打开关闭与步骤条.md) | **详细版**:打开/关闭完整时序、步骤条关系、对接接口字段说明、必守规则、历史 bug 根因、排障手册、自测清单、接线示例与代码索引 |
模块总览仍见:`frontend-web/src/position-roundtable/README.md`。
建议读法:
- **从零对接新页面**:正文第 3、5、6、7、8、12、15、16 节
- **线上排障**:第 13、14 节
- **只要一页纸**:文末「附录 A」
@@ -0,0 +1,988 @@
# 岗位会商 · 沙箱打开 / 关闭与步骤条(详细对接与排障指南)
> **文档位置**:`frontend-web/docs/position-roundtable-sandbox/`
> **读者**:下次重做或对接岗位会商页面的同事;线上「沙箱不实时 / 卡死 / 第二次不刷新」时的排障同学。
> **最后依据**:当前仓库 `PositionRoundtablePage` + `PositionNodeChat` 实现(含临时名打开、外部 store、前沿节流、按 tool-call 重挂)。
---
## 目录
1. [文档用途与读法](#1-文档用途与读法)
2. [背景:为什么岗位会商要单独做沙箱](#2-背景为什么岗位会商要单独做沙箱)
3. [用户可见的正确行为(验收标准)](#3-用户可见的正确行为验收标准)
4. [整体架构与数据流](#4-整体架构与数据流)
5. [职责划分:谁拥有什么状态](#5-职责划分谁拥有什么状态)
6. [对接接口详解](#6-对接接口详解)
7. [打开沙箱的完整步骤](#7-打开沙箱的完整步骤)
8. [关闭沙箱的完整步骤](#8-关闭沙箱的完整步骤)
9. [步骤条与沙箱的关系](#9-步骤条与沙箱的关系)
10. [流式内容如何进入沙箱(性能关键)](#10-流式内容如何进入沙箱性能关键)
11. [与普通对话页的对照](#11-与普通对话页的对照)
12. [重做页面时的必守规则](#12-重做页面时的必守规则)
13. [历史问题与根因(务必读)](#13-历史问题与根因务必读)
14. [排障手册(按症状)](#14-排障手册按症状)
15. [对接自测清单](#15-对接自测清单)
16. [最小接线示例](#16-最小接线示例)
17. [关键代码索引](#17-关键代码索引)
18. [术语表](#18-术语表)
---
## 1. 文档用途与读法
### 1.1 这份文档解决什么问题
岗位会商右侧「文件预览沙箱」看起来像普通对话页的产物预览,但实现路径完全不同。历史上这块反复出过:
- 沙箱不实时打开,要等全文写完才弹;
- 打开了但内容冻住,生成完才一口气灌进去;
- 整页卡顿,连消息列表文字都不实时;
- 同轮「重新生成」第二次写入时,步骤条变了,沙箱却不动;
- 第一次正常,关掉后再问就不实时。
这些 bug **特别容易在「重做页面 / 换布局 / 自己重写开沙箱逻辑」时复现**。本文把正确对接方式、时序、接口、禁区和排障步骤写全,方便下一位同事直接按文档接线,而不是再靠猜。
### 1.2 建议读法
| 你的目标 | 先读哪些章节 |
| --- | --- |
| 从零接一个新页面壳 | 第 3、5、6、7、8、12、15、16 节 |
| 只改对话子组件探测逻辑 | 第 7、9、12、13 节 |
| 线上排障 | 第 13、14 节,必要时回看第 10 节 |
| 理解为什么不能用页面 state | 第 2、10、13 节 |
### 1.3 对照代码(以仓库为准)
| 角色 | 路径 |
| --- | --- |
| 页面壳(唯一沙箱宿主) | `frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx` |
| 节点对话(探测 write_file、上报预览) | `frontend-web/src/position-roundtable/components/PositionNodeChat.tsx` |
| 收口对话 | `frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx` |
| 预览 UI(共享组件,勿分叉逻辑) | `frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx` |
| 流式正文读取 | `frontend-web/src/core/artifacts/loader.ts` → `loadArtifactContentFromToolCall` |
| 普通对话页对照(步骤条自动开) | `frontend-web/src/components/workspace/messages/message-group.tsx` |
| 模块总览 | `frontend-web/src/position-roundtable/README.md` |
---
## 2. 背景:为什么岗位会商要单独做沙箱
### 2.1 普通对话页怎么开沙箱
普通对话页(`ChatPage`)大致是:
```text
ArtifactsProvider(页面级)
└─ ChatBox / MessageList
└─ message-group 渲染 write_file 步骤
└─ 发现 path 且 autoOpen → artifacts.select + setOpen(true)
└─ 右侧 ArtifactFileDetail 读同一套 ThreadContext
```
要点:
- 对话和预览共享同一套 `ThreadContext`(同一个 `useThreadStream`)。
- 步骤条组件自己就能触发打开。
- 流式 `content` 本来就在当前 React 树的 thread 里,不需要「把 thread 镜像到别的地方」。
### 2.2 岗位会商为什么不能照搬
岗位会商页面同时有:
- 左侧任务 / 意图 / 流程图;
- 中间某个节点的对话(`PositionNodeChat`);
- 右侧配置抽屉;
- **以及一个页面级单例沙箱**,它是对话区的**兄弟节点**,不在 `PositionNodeChat` 的 ThreadContext 里面。
因此:
1. 沙箱拿不到节点对话内部的 `thread`,除非节点主动上报;
2. 若把每次流式 thread 写进**页面级 `useState`**,整页(任务卡、意图、流程图、对话)都会按流式频率重渲染,主线程会被打爆;
3. 页面必须自己决定「开 / 关 / 抑制自动再开 / 切节点时清理」,不能让多个子组件各自 `setOpen`。
所以岗位会商采用:
```text
对话子组件探测 + 上报
→ 页面壳统一打开唯一 ArtifactFileDetail
→ 流式 thread 走外部 store,只让沙箱子树订阅
```
### 2.3 和「多智能体会商 Step2 沙箱」的区别
仓库里还有 `frontend-web/docs/roundtable-step2-sandbox-progress.md`,那是另一套圆桌 Step2 沙箱进度文档,**不是**岗位会商这条链路。对接岗位会商时以本文为准。
---
## 3. 用户可见的正确行为(验收标准)
重做或对接完成后,下列行为必须全部成立(用户感知对齐普通对话页):
| # | 行为 | 说明 |
| --- | --- | --- |
| A | 步骤条刚出现「写入文件」时,沙箱应已打开 | **不必等**步骤条上出现文件名 |
| B | 沙箱源码边写边滚;消息列表也实时出字 | 不能整页卡死、不能「生成完一口气刷」 |
| C | content 先于 path 时:先临时名打开,path 到了再改名 | 不闪屏、不重挂 |
| D | 同轮第二次同路径 `write_file`(重新生成) | 沙箱清空并重新流式 |
| E | 整轮结束后自动切 Markdown 预览 | 「正在生成,完成后自动预览」消失 |
| F | 用户手动关闭后,同一次写入不再自动弹 | 下一轮新写入仍可自动开 |
| G | 切岗位 / 切节点 / 新会话 / 切历史 | 沙箱关掉且不串内容 |
**不要误判的正常现象:**
- 流式期间沙箱停在「源码 / 编辑」视图,结束后才切「预览」——这是 `ArtifactFileDetail` 对 markdown/html 的既定行为,与普通对话页相同,**不是**打开时机 bug。
- 步骤条长时间显示「写入文件」、没有路径,但沙箱已经打开并在滚动——content-first 模型下这是**预期**。
---
## 4. 整体架构与数据流
```text
┌──────────────────────────────────────────────────────────────────────────┐
│ PositionRoundtablePage(页面壳) │
│ │
│ state: sandboxArtifact | null │
│ ref: sandboxArtifactRef / closedSandboxKeyRef / latestSnapshotRef │
│ store: createSandboxThreadStore() ←── 禁止改成 useState │
│ │
│ handlePreviewArtifact(artifact) ←──────────────────────────────────┐ │
│ handleNodeThreadSnapshot(...) ←───────────────────────────────┐ │ │
│ closeSandbox() │ │ │
│ │ │ │
│ {sandboxOpen && ( │ │ │
│ SandboxThreadProvider(store) │ │ │
│ └─ ArtifactFileDetail key=sandboxCloseKey(...) │ │ │
│ )} │ │ │
└───────────────────────────────────────────────────────────────────│──│───┘
│ │
┌───────────────────────────────────────────────────────────────────│──│───┐
│ PositionNodeChat / PositionBuiltinAgentChat │ │ │
│ │ │ │
│ useThreadStream → thread.messages / thread.isLoading │ │ │
│ │ │ │ │
│ ├─ MessageList → 步骤条(只展示 tool call) │ │ │
│ │ │ │ │
│ ├─ latestStreamingWriteFile(messages) │ │ │
│ │ → onPreviewArtifact({..., auto:true}) ───────────────────┘ │
│ │ │
│ └─ useEffect → onThreadSnapshot({threadId, thread}) ───────────────┘
└──────────────────────────────────────────────────────────────────────────┘
```
一句话:
- **谁打开**:对话子组件决定「该不该开、开哪次交付」;
- **谁真正开面板**:页面壳;
- **内容怎么更新**:子组件持续上报 thread 快照 → 页面壳 store → 只有沙箱子树重渲染。
---
## 5. 职责划分:谁拥有什么状态
### 5.1 页面壳必须拥有
| 状态 / 能力 | 为什么必须在页面壳 |
| --- | --- |
| `sandboxArtifact` | 唯一预览目标;切节点时要清 |
| `closedSandboxKeyRef` | 用户关过后的抑制;跨子组件生命周期 |
| `sandboxThreadStore` | 流式内容通道;不能进大页面 state |
| `closeSandbox()` | 切岗位 / 切节点 / 新会话统一入口 |
| 唯一 `ArtifactFileDetail` | 避免双实例、关闭失效、深度异常 |
### 5.2 对话子组件必须拥有
| 状态 / 能力 | 作用 |
| --- | --- |
| `autoPreviewRunArmedRef`(armed) | 只有本轮用户发送后才允许自动开 |
| `autoPreviewBaselineCallKeysRef`(baseline) | 忽略发送前已有的历史 write_file |
| `activeWriteFilePreviewRef` | 记录当前打开的 previewKey / path / toolCallId |
| `autoPreviewedArtifactKeyRef` | 同一次 identity 去重,避免每 chunk 重复上报 |
| `latestStreamingWriteFile` | 从 streaming messages 探测可预览的 write |
| `onThreadSnapshot` 上报 | 把 thread 喂给页面壳 |
### 5.3 明确禁止
1. 在 `PositionNodeChat` 内再挂一套 `ArtifactFileDetail`。
2. 让步骤条组件直接 `setOpen(true)` 作为岗位会商主路径(对照普通页可以,岗位会商不行)。
3. 把 `onThreadSnapshot` 落到 `PositionRoundtablePage` 的 `useState`。
4. 用「整个 threadId」做关闭抑制。
5. 用「path 相同」复用 `previewKey`。
---
## 6. 对接接口详解
### 6.1 `PositionArtifactPreview`
```ts
type PositionArtifactPreview = {
threadId: string; // 必填:LangGraph thread id
path: string; // 必填:真实路径,或 write-file: 虚拟 URL,或临时文件名路径
name: string; // 展示名(文件名)
mimeType: string | null; // 如 text/markdown;帮助预览语言判断
previewKey?: string; // 强烈建议:一次交付身份,推荐 `${messageId}:${toolCallId}`
auto?: boolean; // true = 流式自动打开;缺省/false = 用户点击打开
};
```
字段说明:
| 字段 | 详细含义 |
| --- | --- |
| `threadId` | 沙箱用来匹配 `onThreadSnapshot` 的线程;也对关闭抑制键有贡献 |
| `path` | 传给 `ArtifactFileDetail.filepath`。流式阶段通常是 `write-file:...` 虚拟 URL;落盘后的手动打开可以是真实 artifacts 路径 |
| `name` | UI 展示用;临时名阶段可能是 `未命名文件.md` |
| `mimeType` | 当 path 还不稳定时,帮助把语言判成 markdown/html,避免落成 TEXT |
| `previewKey` | **一次交付的身份**。同一次 tool call 的 path 升级要保持不变;新的 tool call 必须变 |
| `auto` | 自动打开才走关闭抑制;手动打开应清抑制 |
### 6.2 `onPreviewArtifact(artifact)` —— 对话 → 页面
页面壳 `handlePreviewArtifact` 当前逻辑顺序:
1. 校验 `threadId`、`path`,缺则 toast 并 return。
2. 计算 `artifactKey = sandboxCloseKey(artifact)`。
3. 若 `artifact.auto === true` 且 `closedSandboxKeyRef.current === artifactKey` → **抑制,直接 return**。
4. 清 `closedSandboxKeyRef`(手动打开或新的自动打开)。
5. `ArtifactsContext.select(path, true)` + `setOpen(true)`。
6. 判断是否同一预览 / 是否仅 path upgrade:
- `isSamePreview`:threadId + path + previewKey 全同 → 不重复 setState。
- `isPathUpgrade`:previewKey 相同、path 不同 → 更新 artifact,**不要**再折叠右侧抽屉。
7. 若不是 same preview:
- `setSandboxArtifact(artifact)`;
- **同步** `sandboxArtifactRef.current = artifact`(关键!flush 会立刻读 ref);
- `sandboxSnapshotAppliedAtRef.current = 0`(允许立刻推首帧内容)。
8. 若 store 里已有别的 thread 快照,先 `publish(null)`。
9. 若 `latestNodeThreadSnapshotRef` 的 threadId 匹配,立刻 `queueSandboxThreadSnapshot`。
10. 非 path upgrade 时 `setRightPanelOpen(false)`。
`sandboxCloseKey`:
```ts
if (artifact.previewKey) {
return `${artifact.threadId}:write:${artifact.previewKey}`;
}
return `${artifact.threadId}:${normalizePreviewArtifactPath(artifact.path)}`;
```
`ArtifactFileDetail` 的 React `key` 必须用这个 key:
**新 previewKey → 组件重挂 → 视觉上「清空再生成」。**
### 6.3 `onThreadSnapshot({ threadId, thread })` —— 对话 → 页面
每次 `thread` 引用变化(消息 chunk 到达)子组件都会调:
```ts
onThreadSnapshot?.({ threadId: activeThreadId, thread });
```
页面壳应:
1. 计算 `signature = sandboxThreadSignature(thread)`(用 isLoading、messages 长度、最近 write_file 的 path/content 长度/尾部等拼签名,用于去重)。
2. 写入 `latestNodeThreadSnapshotRef`(即使沙箱还没开,打开瞬间也要用)。
3. 若当前没有打开的沙箱,或打开的沙箱 threadId 不一致 → **只更新 ref,不 publish**。
4. 若一致 → `queueSandboxThreadSnapshot`(前沿节流后 `store.publish`)。
### 6.4 虚拟 URL 与正文读取
流式阶段 path 形如:
```text
write-file:${sourcePath}?message_id=${messageId}&tool_call_id=${toolCallId}&language=markdown
```
`loadArtifactContentFromToolCall`:
1. 解析 URL 的 `message_id`、`tool_call_id`;
2. 在 `thread.messages` 里找对应 AI message 的 tool call;
3. 返回 `toolCall.args.content`。
结论:
- 沙箱跟的是 **tool call 身份**,不是磁盘文件是否已经写完;
- 没有真实 path 也能显示正文;
- 临时文件名只影响展示名和语言猜测。
### 6.5 对话子组件 → 页面壳的其它相关回调
| 回调 | 用途 |
| --- | --- |
| `onPreviewArtifact` | 打开 / 更新沙箱 |
| `onThreadSnapshot` | 推送流式 thread |
| 页面壳 `closeSandbox` 传给 `ArtifactFileDetail.onClose` | 用户点关闭 |
手动从流程图 / 产物列表打开时,也可以直接调 `handlePreviewArtifact`(通常不带 `auto`,或不带 previewKey)。
---
## 7. 打开沙箱的完整步骤
### 7.1 武装(arm):只有「本轮用户提交」才允许自动开
在 `PositionNodeChat.handleSubmit` 真正 `sendMessage` 之前:
```text
activeWriteFilePreviewRef = null
autoPreviewBaselineCallKeysRef = writeFileCallKeys(thread.messages) // 历史 write 的 messageId:toolCallId
autoPreviewRunArmedRef = true
→ sendMessage(...)
```
发送失败要把 `armed` 设回 `false`。
为什么要 baseline?
线程里往往已经有上几次问答写过的 `write_file`。如果只看「messages 里有没有 write_file」,一提问就会把旧文件再打开一次。baseline 的含义是:**只有这次提交之后新出现的 tool call 才能自动开沙箱**。
### 7.2 探测 `write_file`:不要等完整 path
`write_file` 参数是一整段 JSON 流,**键顺序由模型决定**。
| 模型吐参顺序 | 步骤条表现 | 错误做法的后果 |
| --- | --- | --- |
| path / description 先到,再 content | 很快显示描述和文件名 | 普通对话页常见;等 path 也能开 |
| **content 先到,最后才 path** | 长时间只有「写入文件」,文件名很晚才出现 | 若硬等完整 path,沙箱会晚几十秒甚至一分钟 |
岗位会商报告类 agent **经常是第二种**。
`latestStreamingWriteFile` 规则:
1. 倒序扫描 AI messages 的 `tool_calls`;
2. 工具名是 `write_file` 或 `str_replace`;
3. **有完整扩展名的 path**(`hasCompleteArtifactExtension`)→ 用真实路径;
4. **尚无可用 path,但 `write_file` 的 content 已有非空字符** → 临时文件名:
- 正文头像 HTML → `未命名文件.html`
- 否则 → `未命名文件.md`
5. `str_replace` 必须等真实 path(它改已有文件);
6. 组装:
- `key = messageId:toolCallId`
- `path = write-file:虚拟URL`(write_file)或真实 path(str_replace)
- `mimeType`:markdown 则 `text/markdown`
为什么扩展名必须完整?
流式 path 会经历 `/a/b/report.m` → `/a/b/report.md`。若在 `.m` 时就当真实文件打开,预览语言可能被永久判成 TEXT。
### 7.3 上报 effect 的四个门闩
自动上报 `onPreviewArtifact` 前必须同时满足:
1. `thread.isLoading === true`
2. 探测到 streaming artifact,且其 key **不在** baseline
3. 已有 `previewThreadId`
4. `autoPreviewRunArmedRef === true`
然后处理 previewKey:
```text
若与 activePreview 是同一次 tool call(含 tool-start 前缀兼容)
→ 复用旧 previewKey // 用于临时名 → 真实 path
否则
→ previewKey = 新的 messageId:toolCallId
```
去重键:
```text
`${previewThreadId}:${previewKey}:${sourcePath}`
```
把 `sourcePath` 算进去,是为了:**同 previewKey 下临时名变成真实 path 时,还要再报一次**(path upgrade),但不会每来一个 content chunk 都报。
### 7.4 线程绑定竞态(不要误 disarm)
首轮对话常出现:
1. 用户发送(本地 draft threadId / 稍后 SDK 创建真实 id);
2. 流已经开始、armed=true;
3. 会话 API 把 `node.threadId` 从 `null` 写成真实 id。
这是「本节点绑定自己的新线程」,**不是**切节点。若绑定 effect 无脑清空 armed / baseline,首轮会永远开不了沙箱。
正确:识别 `isOwnThreadBinding`(同 nodeKey、旧 threadId 为空、新 threadId 等于当前 boundThreadId)时 **直接 return,不清 armed**。
### 7.5 path upgrade(改名)≠ 第二次交付
同一次 tool call:
```text
T1 content 到达 → path=未命名文件.md, previewKey=msg:tc1 → 打开
T2 真实 path 到达 → path=/mnt/.../报告.md, previewKey=msg:tc1 → 只改名
```
页面壳看到相同 previewKey、不同 path → `isPathUpgrade`:
- 更新 `sandboxArtifact`;
- **不**重挂(React key 仍基于 previewKey);
- **不**再折叠右侧抽屉。
### 7.6 同路径二次写入(重新生成)
同一次问答里模型可能:
```text
1) write_file → 步骤条「写入文件」→ 打开沙箱
2) 再 write_file 同 path,description=「重新生成优化版…」
```
这是**两次 tool call**,必须两个 previewKey。
若错误地「path 相同就复用 previewKey」:
- 步骤条出现第二条;
- 沙箱 React key 不变;
- 用户看到「重新生成了,但沙箱没变化」。
正确:身份只看 tool call。
### 7.7 时序总图(content-first)
```text
T0 用户发送 → armed=true,拍 baseline
T1 步骤条出现「写入文件」(尚无 path)
T1+ args.content 首字符到达 → 临时名打开沙箱,源码开始滚动
… store 约每 160ms 推一次 thread 快照 …
T2 args.path 拼完整(含 .md)→ path upgrade,标题改真实文件名(不重挂)
T3 write 结束 / isLoading=false → 自动切 Markdown 预览
```
### 7.8 时序总图(同轮二次写入)
```text
T1 write_file#1 → previewKey=msg:tc1 → 打开沙箱 A
T2 #1 结束(可能短暂切预览)
T3 write_file#2(同 path,新 description)→ previewKey=msg:tc2
→ key 变 → 组件重挂清空 → 跟 #2 重新流式
```
---
## 8. 关闭沙箱的完整步骤
### 8.1 用户手动关闭
`closeSandbox()` 应做:
1. `closedSandboxKeyRef = sandboxCloseKey(current)` —— 抑制**这一次**自动再开;
2. 关闭 `ArtifactsContext.open`(若开着);
3. 清节流定时器、pending 快照;
4. `sandboxSnapshotAppliedAtRef = 0`;
5. `sandboxArtifactRef = null` + `setSandboxArtifact(null)`;
6. `sandboxThreadStore.publish(null)`。
抑制与 path upgrade 的关系:
- 关掉临时名面板后,同一次 tool call 升级真实 path **仍被抑制**(同一 previewKey)——符合「用户不要看这次」;
- 下一次新的 `write_file`(新 previewKey)**可以**再自动开。
### 8.2 场景切换时强制关闭
以下操作应先调用 `closeSandbox()`,避免串台:
- 切岗位;
- 切节点;
- 切收口阶段;
- 新建会话;
- 切换历史会话。
是否写入 `closedSandboxKeyRef` 可按产品决定;至少必须清面板和 store。
### 8.3 错误的抑制粒度(历史坑)
若做成「关过一次就整个 thread 不再自动开」:
- 第一次实时开正常;
- 用户关掉;
- 再问一次 → 同 thread 被永久抑制 → 看起来像「第二次不实时 / 要等生成完」。
正确粒度:**单次交付的 `sandboxCloseKey`(含 previewKey)**。
---
## 9. 步骤条与沙箱的关系
### 9.1 步骤条从哪来
步骤条由 `MessageList` → `message-group.tsx` 渲染 tool call:
- 标题:优先 `args.description`;没有则 i18n 兜底「写入文件」;
- 子行:有 `args.path` 才显示路径;
- 普通对话页还会在「有 path + autoOpen」时自己 `select/setOpen`。
岗位会商里,步骤条**仍然只负责展示**;打开沙箱走 `onPreviewArtifact`,不要让两套逻辑抢。
### 9.2 对照表
| 你看到的步骤条 | 含义 | 沙箱应处于 |
| --- | --- | --- |
| 仅「写入文件」,无路径 | tool call 已开始;path/description 可能还没流到 | **应已打开**(临时名),源码在滚 |
| 「写入文件」+ 路径 | path 已可用 | 已打开;若刚从临时名升级则只改名 |
| 带自定义 description 的标题 + 路径 | 模型给了 description | 同上 |
| 第二条「重新生成…」+ 同路径 | 新的 write_file(新 toolCallId) | **应重挂**并重新流式 |
| 顶部「正在生成,完成后自动预览」 | 仍在 code 视图 | 正常,等 isLoading 结束切预览 |
| 生成结束,出现文件卡片 / present_files | 工具结果已落 | 通常已是预览模式;可手动再点开 |
### 9.3 常见误解
**误解 1:**「步骤条出现文件名 = 打开沙箱的正确时机」
→ 错。那是 path 到达的时机。content-first 时正确打开时机更早:content 首字节 / 工具调用已开始。
**误解 2:**「步骤条还在写『写入文件』,沙箱却开了,是 bug」
→ 通常不是。两边数据源相同,但沙箱允许无 path 打开,步骤条标题可能还没 description。
**误解 3:**「等生成完从编辑切预览 = 沙箱打开晚了」
→ 错。那是 `ArtifactFileDetail` 的 viewMode 策略。
---
## 10. 流式内容如何进入沙箱(性能关键)
### 10.1 为什么不能用页面 `useState` 存线程快照
错误写法:
```ts
setSandboxThreadSnapshot(snapshot) // 页面级 useState
```
后果:
- 消息 chunk 约每 30ms 一次,节流后仍约 6 次/秒;
- 每次都重渲染整页:任务卡、意图结果、流程图、节点对话……;
- 主线程占满 → 消息列表停绘、沙箱冻住;
- 纯尾随 `setTimeout(160)` 会被饿死,十几秒不 flush。
这同时解释了两个历史现象:
1. 「沙箱打开了但内容不更新」;
2. 「修完节流后整页更卡、消息也不实时」——因为节流终于开始真正 setState 了。
### 10.2 正确做法:外部 store + 子树订阅
```ts
createSandboxThreadStore()
subscribe / getSnapshot / publish
SandboxThreadProvider
useSyncExternalStore(store.subscribe, store.getSnapshot)
→ ThreadContext.Provider value={{ thread, isMock:false }}
→ ArtifactFileDetail
```
页面壳本身**不**因 snapshot 变化而重渲染;只有 Provider 及其子树重渲染。
### 10.3 节流必须是前沿(leading-edge)
`SANDBOX_SNAPSHOT_MIN_INTERVAL_MS = 160`:
```text
queue(snapshot):
pending = snapshot
if 距上次 publish >= 160ms:
立刻 flush(同步 publish)
else:
若没有在途 timer,则 setTimeout(剩余时间) 做补尾
flush:
清 timer
校验 pending.threadId === sandboxArtifactRef.threadId
publish(pending)
```
**不要**改回:
```ts
if (timer != null) return;
timer = setTimeout(flush, 160);
```
那是纯尾随;忙主线程下会假死。
### 10.4 signature 去重
`sandboxThreadSignature` 用最近 write_file 的 path、content 长度、content 尾 32 字等拼签名。
store.publish 时若 threadId+signature 未变,则不通知订阅者,减少无效渲染。
### 10.5 打开瞬间的首帧
`handlePreviewArtifact` 里往往会立刻 `queueSandboxThreadSnapshot(latestSnapshot)`。
此时 React 的 `setSandboxArtifact` 可能还没提交,若 flush 只读旧的 `sandboxArtifactRef`,会因 threadId 不匹配把首帧丢掉。
所以打开新预览时必须:
```ts
sandboxArtifactRef.current = artifact; // 同步
sandboxSnapshotAppliedAtRef.current = 0;
```
---
## 11. 与普通对话页的对照
| 点 | 普通对话页 | 岗位会商 |
| --- | --- | --- |
| 谁触发打开 | `message-group` 内 `artifacts.select` | 子组件 `onPreviewArtifact` → 页面壳 |
| 是否要求 path | 是(有 path 才自动开) | 否(write_file + content 可临时名开) |
| thread 从哪来 | 同树 ThreadContext | `onThreadSnapshot` + 外部 store |
| 关闭抑制 | 主要靠 ArtifactsContext / 用户操作 | `closedSandboxKeyRef` 按 previewKey |
| 同 path 重写 | 依赖新的 tool call / 选择逻辑 | **必须**新 previewKey 以重挂 |
| 结束后切预览 | ArtifactFileDetail 同一套 | 同一套 |
对齐目标是**用户感知一致**,不是代码路径完全相同。
普通对话页自动开的核心条件(对照用):
```ts
artifacts && isLoading && isLast && autoOpen && autoSelect && path && !result
```
它也要求 path,所以 content-first 时普通页同样会偏晚——只是很多主聊天示例 agent 先吐 path,观感更好。岗位会商报告 agent 更常 content-first,所以必须做临时名提前开。
---
## 12. 重做页面时的必守规则
按优先级:
1. **流式 thread 快照禁止进页面级 `useState`** → 用 store + `useSyncExternalStore`。
2. **节流用前沿 + 补尾**,不要纯尾随 timer。
3. **不要等完整 path 才开**;`write_file` + content 即可临时名打开。
4. **扩展名完整前不要把 path 当最终 path**。
5. **previewKey 按 tool call,不按 path**。
6. **打开时同步写 `sandboxArtifactRef`,并重置节流时间戳**。
7. **主路径扫描 `thread.messages`,不要只靠 `onToolStart`**。
8. **path upgrade 保持 previewKey,不要当第二次交付**。
9. **关闭抑制按 sandboxCloseKey,不要按整 thread**。
10. **沙箱单例在页面壳;子组件只上报**。
11. **本节点 threadId 回写不要 disarm**。
12. **「结束后才切预览」不要当打开 bug 去「修」掉共享组件**,除非产品明确要求边生成边渲染 Markdown(需单独评估性能)。
---
## 13. 历史问题与根因(务必读)
下面每个都在真实联调里出现过。重做时若踩中,优先回来对照。
### 13.1 沙箱要等文件名出来才开(晚几十秒)
**现象:** 步骤条早就「写入文件」,沙箱很晚才开;普通对话页对比显得「实时」。
**根因:** 探测逻辑硬等完整 `path`;而该 agent 先流 `content`、最后才流 `path`。
**修复:** content 首批字符即可用临时名打开;path 到了再 upgrade。
### 13.2 沙箱打开了,内容冻住,生成完才灌满
**现象:** 面板开了,但里面一直是空/旧内容;日志里 queue 很多,flush 很少。
**根因:** 纯尾随 160ms 定时器在忙主线程下被饿死。
**修复:** 前沿节流——到期就在流回调里同步 flush。
### 13.3 修完「内容更新」后,整页卡顿、消息也不实时
**现象:** 沙箱开始更新了,但页面更卡,消息列表也「攒到最后刷」。
**根因:** flush 走的是页面 `useState`,每 160ms 整页重渲染。以前 timer 饿死时反而「不卡」。
**修复:** 快照改外部 store,只让沙箱子树订阅。
### 13.4 第二次「重新生成」步骤有了,沙箱不变
**现象:** 同轮第二条二条 write 步骤,沙箱仍显示第一次完整结果。
**根因:** 用「path 相同」复用了第一次 previewKey,React key 不变,组件不重挂。
**修复:** previewKey 只按 tool call;同 path 新 call 必须新 key。
### 13.5 第一次实时,关掉后再问就不实时
**现象:** 关过沙箱后,下一轮要等生成完才开,或不开。
**根因:** 抑制绑在整个 threadId,或把 path upgrade / 新 call 误判成同一次。
**修复:** 抑制键 = `sandboxCloseKey(previewKey)`;仅抑制那一次交付。
### 13.6 首轮永远不开
**现象:** 新建节点第一次提问,armed 后很快又不满足条件。
**根因:** `node.threadId` 从 null 回写真实 id 时,绑定 effect 误 disarm。
**修复:** `isOwnThreadBinding` 时不清 armed。
### 13.7 预览变成纯文本
**现象:** 明明是 md,却按 TEXT 打开。
**根因:** 在 `.m` 阶段就当完整 path;或临时名没有 `.md` / 没传 language、mimeType;或 path 带空格。
**修复:** `hasCompleteArtifactExtension`;临时名带扩展名;URL 加 `language=markdown`;path trim。
### 13.8 依赖 onToolStart,线上偶发不开
**现象:** 本地偶发正常,联调经常不开。
**根因:** 后端 StreamBridge / worker 可能过滤 tools/events 类 stream mode。
**修复:** 主路径必须是 messages 里渐进出现的 `tool_calls`;onToolStart 只能当补充。
---
## 14. 排障手册(按症状)
排障时建议先看 Chrome Performance / React 重渲染是否整页抖,再看消息里 tool_calls 的 args 是 path-first 还是 content-first。
### 14.1 沙箱开得很晚
检查顺序:
1. `latestStreamingWriteFile` 是否仍要求完整 path?
2. armed 是否被 threadId 回写清掉?
3. 是否命中 `closedSandboxKey`?
4. 新 call 是否被 baseline 当成历史?
5. `previewThreadId` 是否为空(线程还没绑上)?
### 14.2 打开了但内容空白 / 冻住
1. 快照是否进了页面 useState?
2. 节流是否纯尾随?
3. `SandboxThreadProvider` 是否拿到带 messages 的 thread?
4. 虚拟 URL 的 message_id / tool_call_id 是否对得上?
5. 打开时 `sandboxArtifactRef` 是否同步?(首帧是否被 drop)
6. signature 是否因实现错误一直不变,导致 publish 被去重?
### 14.3 整页卡、消息列表也不实时
1. 搜页面壳是否还有 `setSandboxThreadSnapshot` / 类似大对象 state。
2. 是否在每 chunk `console.debug` 打大对象(DevTools 打开时更卡)。
3. 是否错误地让页面壳订阅了 store(页面壳不应 `useSyncExternalStore` 这份 store;只有 Provider 订)。
### 14.4 重新生成不刷新
1. 第二次 write 的 toolCallId 是否变了?
2. previewKey 是否仍用 path 复用?
3. `ArtifactFileDetail` 的 `key=` 是否基于 sandboxCloseKey/previewKey?
### 14.5 关后再问异常
1. 打印 `closedSandboxKeyRef` 与新 artifact 的 sandboxCloseKey。
2. 确认新一轮 send 时 armed/baseline 已重置。
3. 确认抑制不是整 thread。
### 14.6 语言 / 预览模式不对
1. 看打开时的 path / mimeType / language 查询参数。
2. 确认不是在扩展名不完整时打开。
3. 确认「源码→预览」发生在 isLoading false 之后(属正常)。
---
## 15. 对接自测清单
重做页面后至少跑完:
### 功能
- [ ] 首轮:步骤条「写入文件」出现时,沙箱是否已开并开始出字?
- [ ] content-first agent:无文件名阶段是否也能开?
- [ ] path 到达后:是否只改名、不整页闪、不重挂?
- [ ] 同轮第二次同路径 write:是否清空并重新流式?
- [ ] 结束后是否自动切 Markdown 预览?
- [ ] 手动点步骤条路径 / 产物卡片:能否打开正确文件?
### 关闭与抑制
- [ ] 流式中手动关闭:本次是否不再自动弹?
- [ ] 关闭后再发一轮新问题:是否仍可自动开?
- [ ] path upgrade 是否不会绕过「用户刚关闭」的抑制?
### 场景切换
- [ ] 切节点:沙箱关闭,旧内容不残留。
- [ ] 切岗位:同上。
- [ ] 新建会话 / 切历史:同上。
### 性能
- [ ] 流式中消息列表是否实时出字?
- [ ] 页面是否明显掉帧 / 卡死?
- [ ] React DevTools 中,大页面壳是否在每 160ms 整页 update?(不应)
---
## 16. 最小接线示例
### 16.1 页面壳(示意)
```tsx
const [sandboxArtifact, setSandboxArtifact] = useState<PositionArtifactPreview | null>(null);
const sandboxArtifactRef = useRef<PositionArtifactPreview | null>(null);
const closedSandboxKeyRef = useRef<string | null>(null);
const latestNodeThreadSnapshotRef = useRef<SandboxThreadSnapshot | null>(null);
const store = useMemo(createSandboxThreadStore, []);
// pending + timer + appliedAt 用于前沿节流(见 PositionRoundtablePage)
function handlePreviewArtifact(artifact: PositionArtifactPreview) {
if (!artifact.threadId || !artifact.path) return;
const key = sandboxCloseKey(artifact);
if (artifact.auto && closedSandboxKeyRef.current === key) return;
closedSandboxKeyRef.current = null;
artifactsCtx?.select(artifact.path, true);
artifactsCtx?.setOpen(true);
const prev = sandboxArtifactRef.current;
const isSame =
prev?.threadId === artifact.threadId &&
prev.path === artifact.path &&
prev.previewKey === artifact.previewKey;
const isPathUpgrade =
!isSame &&
Boolean(artifact.previewKey) &&
prev?.threadId === artifact.threadId &&
prev.previewKey === artifact.previewKey;
if (!isSame) {
sandboxArtifactRef.current = artifact; // 必须同步
setSandboxArtifact(artifact);
sandboxSnapshotAppliedAtRef.current = 0;
}
if (store.getSnapshot()?.threadId !== artifact.threadId) {
store.publish(null);
}
const latest = latestNodeThreadSnapshotRef.current;
if (latest?.threadId === artifact.threadId) {
queueSandboxThreadSnapshot(latest);
}
if (!isPathUpgrade) setRightPanelOpen(false);
}
function handleNodeThreadSnapshot(snapshot: { threadId: string; thread: BaseStream<AgentThreadState> }) {
const next = { ...snapshot, signature: sandboxThreadSignature(snapshot.thread) };
latestNodeThreadSnapshotRef.current = next;
if (sandboxArtifactRef.current?.threadId !== snapshot.threadId) return;
queueSandboxThreadSnapshot(next);
}
function closeSandbox() {
if (sandboxArtifactRef.current) {
closedSandboxKeyRef.current = sandboxCloseKey(sandboxArtifactRef.current);
}
// clear timer / pending / appliedAt
sandboxArtifactRef.current = null;
setSandboxArtifact(null);
store.publish(null);
artifactsCtx?.setOpen(false);
}
// 渲染
{sandboxArtifact && (
<SandboxThreadProvider
store={store}
threadId={sandboxArtifact.threadId}
fallbackThread={stub}
>
<ArtifactFileDetail
key={sandboxCloseKey(sandboxArtifact)}
filepath={sandboxArtifact.path}
threadId={sandboxArtifact.threadId}
mimeType={sandboxArtifact.mimeType}
onClose={closeSandbox}
allowVirtualMarkdownEdit
/>
</SandboxThreadProvider>
)}
```
### 16.2 对话子组件(示意)
```tsx
// send 前
activeWriteFilePreviewRef.current = null;
autoPreviewBaselineCallKeysRef.current = writeFileCallKeys(thread.messages);
autoPreviewRunArmedRef.current = true;
await sendMessage(...);
// 探测
const streaming = thread.isLoading ? latestStreamingWriteFile(thread.messages) : null;
const next =
streaming && !autoPreviewBaselineCallKeysRef.current.has(streaming.key)
? streaming
: null;
useEffect(() => {
if (!thread.isLoading || !next || !previewThreadId || !autoPreviewRunArmedRef.current) return;
// previewKey:同 tool call 复用,否则用 next.key
// artifactKey = threadId:previewKey:sourcePath 去重
onPreviewArtifact?.({
threadId: previewThreadId,
path: next.path,
name: next.name,
mimeType: next.mimeType,
previewKey,
auto: true,
});
}, [next, previewThreadId, thread.isLoading, onPreviewArtifact]);
useEffect(() => {
onThreadSnapshot?.({ threadId: activeThreadId, thread });
}, [activeThreadId, thread, onThreadSnapshot]);
```
**强烈建议:** 探测 / armed / baseline / 临时名逻辑直接复用或抽取自 `PositionNodeChat`,不要从零重写。
---
## 17. 关键代码索引
| 主题 | 位置 |
| --- | --- |
| 临时名 / 完整扩展名 / latestStreamingWriteFile | `PositionNodeChat.tsx` 中 `provisionalWriteFilePath`、`hasCompleteArtifactExtension`、`latestStreamingWriteFile` |
| armed / baseline / 上报 effect | `PositionNodeChat.tsx` `handleSubmit` + streaming preview `useEffect` |
| 同 tool call 判断 / 二次写入重挂 | 同上,`isSameToolCall` / `previewKey` |
| 线程绑定保护 | `PositionNodeChat.tsx` node binding `useEffect` 中 `isOwnThreadBinding` |
| sandboxCloseKey / store / 前沿节流 | `PositionRoundtablePage.tsx` `sandboxCloseKey`、`createSandboxThreadStore`、`queueSandboxThreadSnapshot` |
| 打开 / path upgrade / 抑制 | `PositionRoundtablePage.tsx` `handlePreviewArtifact` |
| 关闭 | `PositionRoundtablePage.tsx` `closeSandbox` |
| 虚拟 URL 读 content | `core/artifacts/loader.ts` `loadArtifactContentFromToolCall` |
| 源码/预览切换 | `artifact-file-detail.tsx` 中 `viewMode` + `thread.isLoading` |
| 普通页步骤条自动开 | `message-group.tsx` `write_file` 分支 |
相关提交(背景):
- `fix(frontend): 岗位会商沙箱在 write_file 流式阶段实时打开并刷新`
— 临时名打开、外部 store、前沿节流
- `fix(frontend): 同路径二次 write_file 时重挂岗位会商沙箱`
— previewKey 按 tool call,禁止按 path 复用
---
## 18. 术语表
| 术语 | 含义 |
| --- | --- |
| armed | 本轮用户发送后,允许自动打开沙箱的开关 |
| baseline | send 前已有的 write tool call 集合,用于忽略历史产物 |
| previewKey | 一次交付的稳定身份,推荐 `messageId:toolCallId` |
| sandboxCloseKey | 关闭抑制键,同时用作 `ArtifactFileDetail` 的 React key |
| provisional path / 临时名 | 尚无真实 path 时的 `未命名文件.md` / `.html` |
| path upgrade | 同 previewKey 下临时名升级为真实 path |
| sandboxThreadStore | 流式线程外部 store,避免整页重渲染 |
| write-file: URL | 用 message_id + tool_call_id 定位流式 content 的虚拟路径 |
| leading-edge throttle / 前沿节流 | 到期立即在回调里 publish,而不是只挂尾随 timer |
| content-first / path-first | 模型流式 JSON 参数时 content 与 path 哪个先到 |
| isOwnThreadBinding | 本节点把自己的新 threadId 写回 props,不是切节点 |
---
## 附录 A:给对接同学的一页纸摘要
1. 页面壳只挂一个沙箱;子组件只调用 `onPreviewArtifact` / `onThreadSnapshot`。
2. 自动打开时机 ≈ 步骤条出现「写入文件」时,而不是出现文件名时。
3. `write_file` 可以没有 path:临时名打开,path 到了再改名。
4. 流式 thread 进外部 store,进页面 state 会卡死。
5. 节流要前沿;previewKey 看 tool call;关闭抑制看 sandboxCloseKey。
6. 同路径重新生成 = 新 previewKey = 沙箱重挂。
7. 出问题先对照第 13、14 节,再看代码索引第 17 节。
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:none;stroke:#fff;stroke-linecap:round;stroke-linejoin:round;}</style></defs><rect class="a" width="16" height="16"/><path class="b" d="M24.036,10l-.012,9.333" transform="translate(-16.016 -6.667)"/><path class="b" d="M10,24h9.333" transform="translate(-6.667 -16)"/></svg>

After

Width:  |  Height:  |  Size: 401 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="36" height="36" viewBox="0 0 36 36"><defs><style>.a,.d{fill:#fff;}.a{opacity:0;}.b{fill:#da2331;}.c{fill:#0a54cf;}.e{fill:#1677ff;}.f{fill:#1e2433;}.g{fill:#10d6ff;}.h{fill:#ff8181;}.i{fill:none;stroke:#10d6ff;stroke-linecap:round;stroke-linejoin:round;}.j{filter:url(#d);}.k{filter:url(#a);}</style><filter id="a"><feOffset dx="4" input="SourceAlpha"/><feGaussianBlur stdDeviation="1" result="b"/><feFlood flood-opacity="0.161" result="c"/><feComposite operator="out" in="SourceGraphic" in2="b"/><feComposite operator="in" in="c"/><feComposite operator="in" in2="SourceGraphic"/></filter><filter id="d"><feOffset dx="-4" input="SourceAlpha"/><feGaussianBlur stdDeviation="1" result="e"/><feFlood flood-opacity="0.161" result="f"/><feComposite operator="out" in="SourceGraphic" in2="e"/><feComposite operator="in" in="f"/><feComposite operator="in" in2="SourceGraphic"/></filter></defs><g transform="translate(0 -1)"><rect class="a" width="36" height="36" transform="translate(0 1)"/><path class="b" d="M23.5,14.75a1.5,1.5,0,0,1-1.5-1.5V9.5a1.5,1.5,0,1,1,3,0v3.75A1.5,1.5,0,0,1,23.5,14.75Z" transform="translate(-5.5 -1)"/><g data-type="innerShadowGroup"><path class="c" d="M-2-2H1C3.206-2,5-.532,5,1.273V3.727C5,5.532,3.206,7,1,7H-2Z" transform="translate(28.66 19.541)"/><g class="k" transform="matrix(1, 0, 0, 1, 0, 1)"><path class="d" d="M-2-2H1C3.206-2,5-.532,5,1.273V3.727C5,5.532,3.206,7,1,7H-2Z" transform="translate(28.66 18.54)"/></g></g><g data-type="innerShadowGroup"><path class="c" d="M5-2H2C-.206-2-2-.532-2,1.273V3.727C-2,5.532-.206,7,2,7H5Z" transform="translate(4.34 19.541)"/><g class="j" transform="matrix(1, 0, 0, 1, 0, 1)"><path class="d" d="M5-2H2C-.206-2-2-.532-2,1.273V3.727C-2,5.532-.206,7,2,7H5Z" transform="translate(4.34 18.54)"/></g></g><path class="e" d="M5.5-2H16a7.508,7.508,0,0,1,7.5,7.5V13A7.508,7.508,0,0,1,16,20.5H5.5A7.508,7.508,0,0,1-2,13V5.5A7.508,7.508,0,0,1,5.5-2Z" transform="translate(7.25 13.5)"/><path class="f" d="M5.25,0h9A5.25,5.25,0,0,1,19.5,5.25V7.5a5.25,5.25,0,0,1-5.25,5.25h-9A5.25,5.25,0,0,1,0,7.5V5.25A5.25,5.25,0,0,1,5.25,0Z" transform="translate(8.25 15.25)"/><circle class="g" cx="1.5" cy="1.5" r="1.5" transform="translate(21 19)"/><circle class="g" cx="1.5" cy="1.5" r="1.5" transform="translate(12 19)"/><path class="d" d="M24,10v3.75" transform="translate(-6 -1.5)"/><circle class="h" cx="3" cy="3" r="3" transform="translate(15 4)"/><path class="i" d="M44,28.667a3.367,3.367,0,0,0,1.922.412,2.841,2.841,0,0,0,1.828-.412" transform="translate(-27.875 -5.017)"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><defs><style>.a{fill:#fff;opacity:0;}.b,.c{fill:none;stroke:#006efe;stroke-width:6px;}.b{stroke-linecap:round;stroke-linejoin:round;}</style></defs><g transform="translate(0 1)"><rect class="a" width="96" height="96" transform="translate(0 -1)"/><path class="b" d="M47,67H38L18,77V67H4V7H84V29" transform="translate(4 8)"/><circle class="c" cx="12" cy="12" r="12" transform="translate(56 45)"/><path class="b" d="M39,32l10,8" transform="translate(39 33)"/><path class="b" d="M12,15H24" transform="translate(12 16)"/><path class="b" d="M12,21H36" transform="translate(12 22)"/></g></svg>

After

Width:  |  Height:  |  Size: 670 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="13" height="11" viewBox="0 0 13 11"><defs><style>.a{stroke:rgba(0,0,0,0);stroke-miterlimit:10;fill:url(#a);}</style><linearGradient id="a" x1="0.277" y1="0.03" x2="0.725" y2="1.083" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#92eeff"/><stop offset="1" stop-color="#00c7ff"/></linearGradient></defs><path class="a" d="M1,10A1,1,0,1,1,1,8H6a1,1,0,0,1,0,2Zm9.8-.482L7.848,7.361A.445.445,0,0,1,7.661,7a.46.46,0,0,1,.187-.366L10.8,4.481a.45.45,0,0,1,.716.365V9.152a.44.44,0,0,1-.131.317.445.445,0,0,1-.322.136A.454.454,0,0,1,10.8,9.518ZM1,6A1,1,0,1,1,1,4H6A1,1,0,0,1,6,6ZM1,2A1,1,0,1,1,1,0H11a1,1,0,1,1,0,2Z" transform="translate(0.5 0.5)"/></svg>

After

Width:  |  Height:  |  Size: 753 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="13" height="11" viewBox="0 0 13 11"><defs><style>.a{fill:url(#a);}.b{fill:url(#b);}</style><linearGradient id="a" x1="0.023" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#92eeff" stop-opacity="0.302"/><stop offset="1" stop-color="#00c7ff" stop-opacity="0.302"/></linearGradient><linearGradient id="b" x1="0.023" x2="1" y2="1" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#92eeff"/><stop offset="1" stop-color="#00c7ff"/></linearGradient></defs><path class="a" d="M5.5,0,11,8H0Z" transform="translate(13) rotate(90)"/><path class="b" d="M5.5,0,11,8H0Z" transform="translate(8) rotate(90)"/></svg>

After

Width:  |  Height:  |  Size: 735 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b,.c{fill:none;stroke:#fff;stroke-linejoin:round;}.b{stroke-linecap:round;}</style></defs><g transform="translate(-1)"><rect class="a" width="16" height="16" transform="translate(1)"/><path class="b" d="M7,42H19" transform="translate(-4 -28)"/><path class="b" d="M0,0H3" transform="translate(9.848 3.848) rotate(45)"/><path class="c" d="M11,12.907v2.427h2.439l6.894-6.9L17.9,6Z" transform="translate(-6.667 -4)"/></g></svg>

After

Width:  |  Height:  |  Size: 545 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:rgba(255,255,255,0.8);}</style></defs><rect class="a" width="16" height="16"/><path class="b" d="M10,1.5A1.5,1.5,0,1,1,11.5,3,1.5,1.5,0,0,1,10,1.5Zm-5,0A1.5,1.5,0,1,1,6.5,3,1.5,1.5,0,0,1,5,1.5Zm-5,0A1.5,1.5,0,1,1,1.5,3,1.5,1.5,0,0,1,0,1.5Z" transform="translate(1.498 6.5)"/></svg>

After

Width:  |  Height:  |  Size: 410 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:rgba(85,166,254,0.6);}</style></defs><rect class="a" width="16" height="16"/><path class="b" d="M12.715,13.421,9.588,10.294a5.841,5.841,0,1,1,.707-.707l3.128,3.128a.5.5,0,0,1-.708.706ZM1,5.833A4.833,4.833,0,1,0,5.834,1,4.838,4.838,0,0,0,1,5.833Z" transform="translate(1.334 1.216)"/></svg>

After

Width:  |  Height:  |  Size: 418 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;}</style></defs><path class="a" d="M14.488.053.824,4.256a1.167,1.167,0,0,0-.285,2.1L4.576,8.936a1.167,1.167,0,0,0,1.382-.092L11,4.593A.292.292,0,0,1,11.408,5L7.156,10.043a1.167,1.167,0,0,0-.091,1.382l2.58,4.036a1.168,1.168,0,0,0,2.1-.286l4.2-13.663A1.167,1.167,0,0,0,14.488.053Z" transform="translate(0)"/></svg>

After

Width:  |  Height:  |  Size: 422 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16.131" height="16" viewBox="0 0 16.131 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:none;stroke:#97edff;stroke-linecap:round;stroke-linejoin:round;}</style></defs><rect class="a" width="16" height="16"/><path class="b" d="M6,30V34.12H18.361V30" transform="translate(-2.73 -19.994)"/><path class="b" d="M21.181,9.09,18.09,6,15,9.09" transform="translate(-8.64 -4.235)"/><path class="b" d="M23.992,14.241V6" transform="translate(-14.544 -4.235)"/></svg>

After

Width:  |  Height:  |  Size: 504 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b,.c{fill:none;stroke:#346fb1;stroke-linejoin:round;}.c{stroke-linecap:round;}</style></defs><rect class="a" width="16" height="16"/><path class="b" d="M10.667,17.333A6.667,6.667,0,1,0,4,10.667,6.667,6.667,0,0,0,10.667,17.333Z" transform="translate(-2.667 -2.667)"/><path class="c" d="M24.008,12v4l2.826,2.826" transform="translate(-16.005 -8)"/></svg>

After

Width:  |  Height:  |  Size: 473 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(212,120,0,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>

After

Width:  |  Height:  |  Size: 320 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(207,19,34,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>

After

Width:  |  Height:  |  Size: 320 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(29,171,123,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>

After

Width:  |  Height:  |  Size: 321 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(132,85,230,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>

After

Width:  |  Height:  |  Size: 321 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(201,181,0,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>

After

Width:  |  Height:  |  Size: 320 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="95.001" height="26" viewBox="0 0 95.001 26"><defs><style>.a{fill:rgba(69,195,243,0.5);}</style></defs><path class="a" d="M250.146,407.92h91l-11.4,18.235c-2.679,4.26-5.243,7.5-11.211,7.765H246.146v-22A4,4,0,0,1,250.146,407.92Z" transform="translate(-246.146 -407.92)"/></svg>

After

Width:  |  Height:  |  Size: 321 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:none;stroke:#346fb1;stroke-linecap:round;stroke-linejoin:round;}</style></defs><g transform="translate(0 -2)"><rect class="a" width="16" height="16" transform="translate(0 2)"/><path class="b" d="M17.333,8H4V18H9l1.667,1.667L12.333,18h5Z" transform="translate(-2.667 -3.667)"/><circle class="b" cx="1.667" cy="1.667" r="1.667" transform="translate(6.333 6.333)"/><path class="b" d="M21,26.667A2.848,2.848,0,0,0,18,24a2.848,2.848,0,0,0-3,2.667" transform="translate(-10 -14.333)"/></g></svg>

After

Width:  |  Height:  |  Size: 618 B

@@ -100,15 +100,17 @@ function Section({
icon,
title,
extra,
className,
children,
}: {
icon?: ReactNode;
title: string;
extra?: ReactNode;
className?: string;
children: ReactNode;
}) {
return (
<section className="bg-card/60 rounded-xl border p-4">
<section className={cn("bg-card/60 rounded-xl border p-4", className)}>
<div className="mb-3 flex items-center justify-between gap-2">
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
{icon}
@@ -139,6 +141,15 @@ export function AgentDetailPanel({
defaultCollapsed,
showRecentChats,
compactAppearanceActions,
avatarSrc,
positionName,
skillsIcon,
skillsTitle,
showCommonQuestions = false,
panelTitle,
embedded,
hideStatusBadges = false,
editIcon,
}: {
agent: Agent | null | undefined;
agentName: string;
@@ -150,6 +161,19 @@ export function AgentDetailPanel({
showRecentChats?: boolean;
/** 简洁模式下挂在右上角收起/展开按钮左侧的外观控件。 */
compactAppearanceActions?: ReactNode;
avatarSrc?: string;
positionName?: string;
skillsIcon?: ReactNode;
skillsTitle?: string;
showCommonQuestions?: boolean;
/** Overrides the standard panel heading for an embedded consumer. */
panelTitle?: string;
/** Renders one fixed panel instead of the responsive collapsible rail. */
embedded?: boolean;
/** Keeps deployment/status badges out of an embedded product surface. */
hideStatusBadges?: boolean;
/** Optional icon used by the compact embedded edit action. */
editIcon?: ReactNode;
}) {
const router = useRouter();
const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false);
@@ -472,11 +496,25 @@ export function AgentDetailPanel({
</div>
) : (
<div className="flex items-center gap-1">
<Button size="sm" variant="outline" onClick={beginEdit}>
<SquarePenIcon className="size-3.5" />
编辑
</Button>
{canTogglePublish && (
{embedded ? (
<Tooltip content="编辑智能体">
<Button
type="button"
size="icon-sm"
variant="ghost"
onClick={beginEdit}
aria-label="编辑智能体"
>
{editIcon ?? <SquarePenIcon className="size-3.5" />}
</Button>
</Tooltip>
) : (
<Button size="sm" variant="outline" onClick={beginEdit}>
<SquarePenIcon className="size-3.5" />
编辑
</Button>
)}
{!embedded && canTogglePublish && (
<Tooltip content={agent?.published ? "下架" : "发布"}>
<Button
size="icon-sm"
@@ -493,17 +531,19 @@ export function AgentDetailPanel({
</Button>
</Tooltip>
)}
<Tooltip content="删除智能体">
<Button
size="icon-sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => setDeleteOpen(true)}
aria-label="删除智能体"
>
<Trash2Icon className="size-3.5" />
</Button>
</Tooltip>
{!embedded && (
<Tooltip content="删除智能体">
<Button
size="icon-sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => setDeleteOpen(true)}
aria-label="删除智能体"
>
<Trash2Icon className="size-3.5" />
</Button>
</Tooltip>
)}
</div>
)
) : null;
@@ -521,7 +561,11 @@ export function AgentDetailPanel({
<section className="bg-card/60 rounded-xl border p-4">
<div className="flex items-start gap-3">
<div className="agent-avatar bg-primary/10 ring-primary/15 flex size-12 shrink-0 items-center justify-center rounded-2xl ring-1">
<BotIcon className="text-primary size-6" />
{avatarSrc ? (
<img src={avatarSrc} alt="" aria-hidden="true" className="size-9" />
) : (
<BotIcon className="text-primary size-6" />
)}
</div>
<div className="min-w-0 flex-1 space-y-1.5">
{editing && draft ? (
@@ -541,9 +585,16 @@ export function AgentDetailPanel({
{displayName}
</div>
)}
<div className="flex flex-wrap items-center gap-1">
{statusBadges}
</div>
{!editing && positionName && (
<div className="position-agent-role text-sm">
岗位:{desensitize(positionName)}
</div>
)}
{!hideStatusBadges && (
<div className="flex flex-wrap items-center gap-1">
{statusBadges}
</div>
)}
{!editing && agent.owner_username && !agent.builtin && (
<div className="text-muted-foreground flex items-center gap-1 text-xs">
<UserRoundIcon className="size-3 shrink-0" />
@@ -646,7 +697,7 @@ export function AgentDetailPanel({
{/* ── 常用问题 ───────────────────────────────────────────────────── */}
{(editing || questions.length > 0 || canEdit) && (
{(showCommonQuestions || editing || questions.length > 0 || canEdit) && (
<Section
icon={<MessageCircleQuestionIcon className="size-3.5" />}
title="常用问题"
@@ -787,8 +838,9 @@ export function AgentDetailPanel({
{/* ── 技能管理 ───────────────────────────────────────────────────── */}
<Section
icon={<SparklesIcon className="size-3.5" />}
title={`已启用技能 ${skillNamesShown.length} 个`}
icon={skillsIcon ?? <SparklesIcon className="size-3.5" />}
title={skillsTitle ?? `已启用技能 ${skillNamesShown.length} 个`}
className="position-roundtable-skills-section"
extra={
editing ? (
<Button
@@ -830,7 +882,9 @@ export function AgentDetailPanel({
const panelChrome = (collapseButton: ReactNode) => (
<>
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<span className="shrink-0 whitespace-nowrap text-sm font-semibold">智能体信息</span>
<span className="shrink-0 whitespace-nowrap text-sm font-semibold">
{panelTitle ?? "智能体信息"}
</span>
<div className="flex min-w-0 items-center gap-1">
{headerActions}
{compactAppearanceActions}
@@ -844,7 +898,11 @@ export function AgentDetailPanel({
return (
<>
{/* ── Desktop (lg+): inline aside, collapsible to a rail ───────────── */}
{collapsed ? (
{embedded ? (
<aside className="bg-card/40 flex h-full w-full min-w-0 shrink-0 flex-col border-l">
{panelChrome(null)}
</aside>
) : collapsed ? (
<div className="hidden shrink-0 items-start border-l px-2 pt-3 lg:flex">
<div className="flex items-center gap-1">
{compactAppearanceActions}
@@ -877,30 +935,34 @@ export function AgentDetailPanel({
</aside>
)}
{/* ── Narrow screens (<lg): rail trigger + drawer with the same body ── */}
<div className="flex shrink-0 flex-col items-center border-l pt-3 lg:hidden">
<Tooltip content="智能体信息">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setMobileOpen(true)}
aria-label="打开智能体信息"
>
<InfoIcon className="size-4" />
</Button>
</Tooltip>
</div>
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-md">
<SheetHeader className="border-b px-4 pb-3 pt-4">
<SheetTitle className="flex items-center justify-between text-sm">
智能体信息
<span className="mr-6">{headerActions}</span>
</SheetTitle>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
</SheetContent>
</Sheet>
{!embedded && (
<>
{/* ── Narrow screens (<lg): rail trigger + drawer with the same body ── */}
<div className="flex shrink-0 flex-col items-center border-l pt-3 lg:hidden">
<Tooltip content="智能体信息">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setMobileOpen(true)}
aria-label="打开智能体信息"
>
<InfoIcon className="size-4" />
</Button>
</Tooltip>
</div>
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-md">
<SheetHeader className="border-b px-4 pb-3 pt-4">
<SheetTitle className="flex items-center justify-between text-sm">
智能体信息
<span className="mr-6">{headerActions}</span>
</SheetTitle>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
</SheetContent>
</Sheet>
</>
)}
{/* ── Secondary surfaces ───────────────────────────────────────────── */}
<SkillPickerSheet
@@ -121,8 +121,9 @@ function findScrollableArtifactElement(root: HTMLElement | null) {
export function ArtifactFileDetail({
className,
filepath: filepathFromProps,
threadId,
filepath: filepathFromProps,
threadId,
mimeType = null,
onClose,
allowVirtualMarkdownEdit = false,
disableDesensitize = false,
@@ -131,6 +132,8 @@ export function ArtifactFileDetail({
className?: string;
filepath: string;
threadId: string;
/** Server/tool supplied type wins over path guessing when available. */
mimeType?: string | null;
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
onClose?: () => void;
/**
@@ -150,16 +153,38 @@ export function ArtifactFileDetail({
const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts();
const { isMock, thread, publicShareCode } = useThread();
const isWriteFileVirtual = useMemo(() => {
return filepathFromProps.startsWith("write-file:");
}, [filepathFromProps]);
const filepath = useMemo(() => {
if (isWriteFileVirtual) {
const url = new URL(filepathFromProps);
return decodeURIComponent(url.pathname);
}
return filepathFromProps;
}, [filepathFromProps, isWriteFileVirtual]);
const isWriteFileVirtual = useMemo(() => {
return filepathFromProps.startsWith("write-file:");
}, [filepathFromProps]);
// A streamed tool call can be opened before the path has been normalized by
// the artifacts API. Keep the format supplied by the writer so Markdown is
// rendered as Markdown instead of falling back to a TEXT source preview.
const writeFileLanguageHint = useMemo(() => {
if (!isWriteFileVirtual) return null;
try {
const language = new URL(filepathFromProps).searchParams.get("language");
return language === "markdown" ? language : null;
} catch {
return null;
}
}, [filepathFromProps, isWriteFileVirtual]);
const mimeLanguageHint = useMemo(() => {
const normalized = mimeType?.toLowerCase().trim() ?? "";
if (normalized.includes("markdown")) return "markdown";
if (normalized.includes("html")) return "html";
if (normalized.includes("mermaid")) return "mermaid";
if (normalized.startsWith("text/")) return "text";
return null;
}, [mimeType]);
const filepath = useMemo(() => {
if (isWriteFileVirtual) {
const url = new URL(filepathFromProps);
return decodeURIComponent(url.pathname).trim();
}
// Tool arguments may contain harmless surrounding whitespace. Passing it
// into extension detection turns `report.md ` into a generic TEXT file.
return filepathFromProps.trim();
}, [filepathFromProps, isWriteFileVirtual]);
const isSkillFile = useMemo(() => {
return filepath.endsWith(".skill");
}, [filepath]);
@@ -186,17 +211,22 @@ export function ArtifactFileDetail({
const useWriteFileStreaming = useMemo(() => {
return isWriteFileVirtual && !isImageFile(filepath) && !editVirtualMarkdown;
}, [filepath, isWriteFileVirtual, editVirtualMarkdown]);
const { isCodeFile, language } = useMemo(() => {
if (useWriteFileStreaming) {
let nextLanguage = checkCodeFile(filepath).language;
nextLanguage ??= "text";
return { isCodeFile: true, language: nextLanguage };
const { isCodeFile, language } = useMemo(() => {
if (useWriteFileStreaming) {
let nextLanguage =
mimeLanguageHint ?? writeFileLanguageHint ?? checkCodeFile(filepath).language;
nextLanguage ??= "text";
return { isCodeFile: true, language: nextLanguage };
}
if (isSkillFile) {
return { isCodeFile: true, language: "markdown" };
}
return checkCodeFile(filepath);
}, [filepath, useWriteFileStreaming, isSkillFile]);
const detected = checkCodeFile(filepath);
if (mimeLanguageHint) {
return { isCodeFile: true as const, language: mimeLanguageHint };
}
return detected;
}, [filepath, useWriteFileStreaming, isSkillFile, mimeLanguageHint, writeFileLanguageHint]);
const isSupportPreview = useMemo(() => {
return (
language === "html" ||
@@ -209,7 +239,8 @@ export function ArtifactFileDetail({
// editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟
// URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容
// 不会被旧的内存 stub 值覆盖回退。
filepath: editVirtualMarkdown ? filepath : filepathFromProps,
filepath:
editVirtualMarkdown || !isWriteFileVirtual ? filepath : filepathFromProps,
enabled: isCodeFile && !useWriteFileStreaming,
});
+51 -27
View File
@@ -25,10 +25,13 @@ import { promptInputFilePartToFile, uploadFiles } from "../uploads";
import type { AgentThread, AgentThreadState } from "./types";
export type ToolEndEvent = {
name: string;
data: unknown;
};
export type ToolEndEvent = {
name: string;
data: unknown;
toolCallId?: string;
};
export type ToolStartEvent = ToolEndEvent;
export type ThreadStreamOptions = {
threadId?: string | null | undefined;
@@ -47,10 +50,11 @@ export type ThreadStreamOptions = {
/** 额外写到新建 thread 的 metadata(与 agent_id 合并)。
* 任务深链用 `{ taskId }` 把对话挂到任务下(1:N),列表按 metadata.taskId 过滤。 */
threadMetadata?: Record<string, unknown>;
onStart?: (threadId: string) => void;
onFinish?: (state: AgentThreadState) => void;
onToolEnd?: (event: ToolEndEvent) => void;
};
onStart?: (threadId: string) => void;
onFinish?: (state: AgentThreadState) => void;
onToolStart?: (event: ToolStartEvent) => void;
onToolEnd?: (event: ToolEndEvent) => void;
};
type SendMessageOptions = {
additionalKwargs?: Record<string, unknown>;
@@ -447,10 +451,11 @@ export function useThreadStream({
assistantId,
runOptions,
threadMetadata,
onStart,
onFinish,
onToolEnd,
}: ThreadStreamOptions) {
onStart,
onFinish,
onToolStart,
onToolEnd,
}: ThreadStreamOptions) {
const { t } = useI18n();
const [localSettings] = useLocalSettings();
// Track the thread ID that is currently streaming to handle thread changes during streaming
@@ -466,16 +471,17 @@ export function useThreadStream({
lastStreamActivityAtRef.current = Date.now();
}, []);
const listeners = useRef({
onStart,
onFinish,
onToolEnd,
});
const listeners = useRef({
onStart,
onFinish,
onToolStart,
onToolEnd,
});
// Keep listeners ref updated with latest callbacks
useEffect(() => {
listeners.current = { onStart, onFinish, onToolEnd };
}, [onStart, onFinish, onToolEnd]);
useEffect(() => {
listeners.current = { onStart, onFinish, onToolStart, onToolEnd };
}, [onStart, onFinish, onToolStart, onToolEnd]);
useEffect(() => {
const normalizedThreadId = threadId ?? null;
@@ -545,12 +551,6 @@ export function useThreadStream({
},
onLangChainEvent(event) {
markStreamActivity();
if (event.event === "on_tool_end") {
listeners.current.onToolEnd?.({
name: event.name,
data: event.data,
});
}
// 操作日志(Y-Log):每一次大模型调用结束都各上报一条(一次 LLM 调用 = 一条),
// 让问答日志尽量「多、全」——一个问答轮次里 lead agent / 子代理的每次模型调用
// 都会落一条。门控与失败静默都在 writeYLog 内部处理,绝不影响问答。
@@ -572,8 +572,32 @@ export function useThreadStream({
} catch {
/* 上报绝不影响问答 */
}
}
}
},
// The SDK emits UI tool lifecycle events on the dedicated `tools` stream
// mode. `events` is a separate LangChain trace channel; using it for UI
// reactions can miss a tool that the conversation has already rendered.
onToolEvent:
onToolStart || onToolEnd
? (event) => {
markStreamActivity();
if (event.event === "on_tool_start") {
listeners.current.onToolStart?.({
name: event.name,
data: event.input,
toolCallId: event.toolCallId,
});
return;
}
if (event.event === "on_tool_end") {
listeners.current.onToolEnd?.({
name: event.name,
data: event.output,
toolCallId: event.toolCallId,
});
}
}
: undefined,
onUpdateEvent(data) {
markStreamActivity();
const updates: Array<Partial<AgentThreadState> | null> = Object.values(
+23 -5
View File
@@ -24,11 +24,23 @@
| 参数 | 是否必填 | 作用 |
| --- | --- | --- |
| `taskId` | 否 | 外部任务 ID。缺失时使用本地 mock 任务,便于演示和开发。 |
| `taskId` | 否 | 外部任务 ID。传入后它是岗位会商的共享存储作用域;缺失时保持原有的个人会话逻辑。mock 仅提供任务卡片的显示兜底,绝不替换路由传入的 ID。 |
| `sessionId` | 否 | 直接恢复指定岗位会商会话;缺失时页面会按 `taskId` 查找最近一条未归档会话。 |
| `positionId` | 否 | 初始打开的岗位,同时同步顶部岗位选择器。无效值回退到 `intelligence`。 |
| `new=1` | 否 | 强制不恢复历史会话,以新会话开始。 |
传入 `taskId` 时,同一个任务下可以有多条会商会话,所有用户共享同一份历史、节点快照与 LangGraph 线程;当前 `user_id` 仅保留创建审计,不参与这些任务会话的读写过滤。页面恢复 `sessionId` 时会核验其 `externalTaskId` 与路由 `taskId` 一致,不一致会丢弃该会话并按当前任务重新发现历史。未传 `taskId` 时,会继续只读取当前用户的原有个人会话。
### 外部系统按 taskId 对接
1. 进入页面时将业务侧任务的稳定主键原样放入 URL。工作区入口使用 `/#/page/workspace/roundtable/position?taskId=<taskId>`;策略页入口也兼容 `/#/page/strategy/qa/position-roundtable?taskId=<taskId>`。不要使用任务标题、序号或每次请求都变化的临时 ID。
2. 前端先调用 `GET /api/position-roundtable/sessions?external_task_id=<taskId>`;返回的是该任务下所有用户共享的会商记录,按最近更新时间排序。选中一条后再调用 `GET /sessions/{sessionId}` 恢复完整节点和聊天快照。
3. 没有可恢复记录时,调用 `POST /sessions`,至少传入 `{ external_task_id: taskId, task_snapshot: { ...任务展示字段 } }`。服务端将 `task_scoped` 设为 `true`,并固定快照中的 `id` / `externalTaskId` 为传入的 taskId。
4. 后续所有意图、链条、节点、收口智能体和历史删除 API 只使用返回的 `sessionId`;不要在每次消息请求中重新创建会话。一个 taskId 可以有多条 sessionId。
5. 不传 `taskId`(同时省略 `external_task_id`)就是兼容模式:后端只返回和操作当前用户自己的个人会话,绝不会把历史记录升级为共享。响应中的 `task_scoped` 可用来区分两种记录。
服务端从请求中的非空 `external_task_id` 判断是否开启共享,调用方无需也不应自行传 `task_scoped`。
当前岗位 ID 定义在 `lib/positions.ts`:
| ID | 页面岗位 | 职责 |
@@ -132,10 +144,16 @@ PositionRoundtablePage
页面只维护一个 `sandboxArtifact`。`PositionNodeChat` 和 `PositionBuiltinAgentChat` 通过 `onPreviewArtifact` 上报预览目标;父页面统一渲染 `ArtifactFileDetail`。
**对接 / 重做页面 / 排障指南(打开关闭时机、步骤条、接口约定):**
[`../../docs/position-roundtable-sandbox/沙箱打开关闭与步骤条.md`](../../docs/position-roundtable-sandbox/沙箱打开关闭与步骤条.md)
(目录说明见 [`../../docs/position-roundtable-sandbox/README.md`](../../docs/position-roundtable-sandbox/README.md))
设计要点:
- 文件正在由 `write_file` 流式生成时,优先显示源码;完成后交由预览组件切换 Markdown 预览;
- `sandboxThreadSnapshot` 有 160ms 合并节流,只保留当前打开文件所在线程的最新快照,避免流式时频繁 setState;
- `write_file` 的参数是一整个 JSON 流,键顺序由模型决定。若 `content` 先于 `path` 到达,会先用临时文件名(`未命名文件.md`/`.html`,按正文首部嗅探)开沙箱——流式内容只按 `message_id` + `tool_call_id` 定位,与路径无关;真实 `path` 到达后沿用同一个 `previewKey` 改名,属于「更新」而非第二次交付;
- 流式快照**不能进页面 state**。本页很大(任务面板、意图结果、流程图、节点对话),按流式频率重渲染整页会把主线程占满,连消息列表都会停止绘制直到本轮结束。快照走 `createSandboxThreadStore()` + `SandboxThreadProvider`(`useSyncExternalStore`),只有沙箱子树重渲染;
- 快照的合并节流是**前沿**节流:距上次推送超过 160ms 就在流回调里同步推送,未满则挂一个补尾定时器。不能只靠尾随定时器——主线程繁忙时回调会被饿死,面板会一直冻在打开时的内容;
- 用户手动关闭后,`closedSandboxKeyRef` 与 `suppressedAutoPreviewThreadIdsRef` 阻止同一线程立即把沙箱重新打开;
- 切岗位、切节点、切收口阶段、新建会话和切换历史会话都会先调用 `closeSandbox()`。
@@ -158,8 +176,8 @@ PositionRoundtablePage
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| `GET` | `/sessions?external_task_id=` | 按任务查询会话,用于恢复最近会话和历史记录。 |
| `POST` | `/sessions` | 创建初始会话并保存任务快照。 |
| `GET` | `/sessions?external_task_id=<taskId>` | 传入时按任务作用域查询共享会话;不传参数时查询当前用户的个人历史。 |
| `POST` | `/sessions` | 创建会话并保存任务快照;传入非空 `external_task_id` 时服务端会将快照 ID 固定为该值并启用共享;缺失或空值时创建原有个人会话。 |
| `GET` | `/sessions/{id}` | 恢复会话、节点和冻结快照。 |
| `DELETE` | `/sessions/{id}` | 删除一条岗位会商历史及其节点;删除当前记录时前端会先屏蔽并发恢复请求。 |
| `PUT` | `/sessions/{id}/task` | 更新未冻结的任务快照。 |
@@ -173,7 +191,7 @@ PositionRoundtablePage
| `POST` | `/sessions/{id}/summary/*` | 准备、发送和完成方案总结。 |
| `POST` | `/sessions/{id}/action-plan/*` | 准备、发送和完成行动规划。 |
| `PUT` | `/sessions/{id}/{summary|action-plan}/conversation` | 保存两个收口智能体的消息与未发送草稿。 |
| `POST` | `/demo/seed` | 按用户和 `demoKey` 幂等初始化完整演示会话。 |
| `POST` | `/demo/seed` | 按 `demoKey` 幂等初始化完整演示会话。 |
后端实现在 `offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py`。
@@ -68,7 +68,10 @@ export interface PositionRoundtableNode {
*/
export interface PositionRoundtableSession {
id: string;
/** Present only when the workspace was opened with a route `taskId`. */
externalTaskId: string | null;
/** True only for sessions intentionally shared by an explicit route taskId. */
taskScoped: boolean;
taskSnapshot: Record<string, unknown>;
chainId: string | null;
chainSnapshot: Record<string, unknown> | null;
@@ -112,6 +115,7 @@ interface ServerNode {
interface ServerSession {
id: string;
external_task_id: string | null;
task_scoped?: boolean;
task_snapshot?: Record<string, unknown>;
chain_id: string | null;
chain_snapshot?: Record<string, unknown> | null;
@@ -270,6 +274,7 @@ function toSession(session: ServerSession): PositionRoundtableSession {
const result: PositionRoundtableSession = {
id: session.id,
externalTaskId: session.external_task_id,
taskScoped: session.task_scoped ?? false,
taskSnapshot: session.task_snapshot ?? {},
chainId: session.chain_id,
chainSnapshot: session.chain_snapshot ?? null,
@@ -1,10 +1,30 @@
import { useEffect, useRef, type ComponentProps } from "react";
import { useEffect, useMemo, useRef, type ComponentProps } from "react";
import { InputBox } from "@/components/workspace/input-box";
import { usePromptInputController } from "@/components/ai-elements/prompt-input";
import { cn } from "@/lib/utils";
import {
usePromptInputController,
type PromptInputMessage,
} from "@/components/ai-elements/prompt-input";
import type { InputBox } from "@/components/workspace/input-box";
import { useModels } from "@/core/models/hooks";
import type { AgentQuestion } from "@/core/agents";
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
type PositionAgentInputProps = ComponentProps<typeof InputBox> & {
type InputBoxProps = ComponentProps<typeof InputBox>;
type PositionAgentInputProps = Pick<
InputBoxProps,
| "className"
| "assistantId"
| "status"
| "disabled"
| "context"
| "onContextChange"
| "onSubmit"
| "onStop"
| "isNewThread"
| "threadId"
| "hideAdminRecommendedQuestions"
> & {
commonQuestions?: (string | AgentQuestion)[] | null;
/** Stable node/builtin key used to restore the matching SQL-backed draft. */
draftKey?: string;
draftValue?: string;
@@ -18,18 +38,36 @@ type PositionAgentInputProps = ComponentProps<typeof InputBox> & {
* 只改这个组件即可,不会影响普通对话、工作区或其他业务页面。
*/
export function PositionAgentInput({
className,
context,
disabled = false,
status = "ready",
onContextChange,
onSubmit,
onStop,
commonQuestions,
draftKey = "",
draftValue = "",
onDraftChange,
...props
}: PositionAgentInputProps) {
const { textInput } = usePromptInputController();
const { textInput, attachments } = usePromptInputController();
const { models } = useModels();
const activeDraftKeyRef = useRef("");
const restoringValueRef = useRef<string | null>(null);
const textInputRef = useRef(textInput);
textInputRef.current = textInput;
const modelOptions = useMemo(
() => models.map((model) => ({ label: model.display_name ?? model.name, value: model.name })),
[models],
);
const attachedFiles = useMemo(
() =>
attachments.files
.map((attachment) => attachment.file)
.filter((file): file is File => file instanceof File),
[attachments.files],
);
useEffect(() => {
activeDraftKeyRef.current = draftKey;
if (textInputRef.current.value !== draftValue) {
@@ -51,13 +89,60 @@ export function PositionAgentInput({
onDraftChange?.(textInput.value);
}, [draftKey, onDraftChange, textInput.value]);
// Keep the page-specific class on the wrapper rather than adding a variant
// to InputBox. Node chat and built-in chat can evolve together while every
// other workspace keeps the global composer untouched.
const handleSend = () => {
if (status === "streaming") {
onStop?.();
return;
}
const text = textInput.value.trim();
if (!text && attachedFiles.length === 0) return;
const message: PromptInputMessage = {
text: textInput.value,
files: attachments.files,
};
void onSubmit?.(message);
textInput.clear();
attachments.clear();
};
const handleModelChange = (modelName: string) => {
const model = models.find((candidate) => candidate.name === modelName);
if (!model) return;
onContextChange?.({ ...context, model_name: model.name });
};
const selectedModel =
typeof context.model_name === "string"
? context.model_name
: modelOptions[0]?.value;
return (
<InputBox
{...props}
className={cn("position-roundtable-agent-input w-full", className)}
<PositionIntentInput
value={textInput.value}
onValueChange={textInput.setInput}
attachedFiles={attachedFiles}
onAddFiles={attachments.add}
onRemoveFile={(index) => {
const attachment = attachments.files[index];
if (attachment) attachments.remove(attachment.id);
}}
charactersLeft={textInput.value.length}
isSending={status === "streaming"}
canSend={!disabled && (Boolean(textInput.value.trim()) || attachedFiles.length > 0)}
pendingClarification={false}
onSend={handleSend}
onStop={() => onStop?.()}
onFollowUp={() => undefined}
onSkip={() => undefined}
onEnterRoundtable={() => undefined}
enterButtonLabel=""
intentReady={null}
hideEnterRoundtable
onManualSave={() => undefined}
onTriggerToast={() => undefined}
modelOptions={modelOptions}
selectedModel={selectedModel}
onSelectedModelChange={handleModelChange}
commonQuestions={commonQuestions}
/>
);
}
@@ -716,7 +716,7 @@ export function PositionBuiltinAgentChat({
</div>
)}
<div className="position-roundtable-agent-composer-bar shrink-0 border-t border-slate-100 px-4 py-3 dark:border-[#2b8ffe]/20">
<div className="position-roundtable-agent-composer-bar position-roundtable-node-composer shrink-0">
<PositionAgentInput
className="w-full"
isNewThread={!boundThreadId}
@@ -1,12 +1,9 @@
import { useRef } from "react";
import { useRef, useState } from "react";
import {
FileText,
Loader2,
Paperclip,
Pause,
Repeat,
Save,
Send,
Smile,
Sparkles,
X,
@@ -14,7 +11,11 @@ import {
import { Select as TSelect } from "tdesign-react";
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { normalizeAgentQuestions, type AgentQuestion } from "@/core/agents";
import type { Step1InputAreaProps } from "@/roundtable-planning/components/Step1Panel";
import moreTaskIcon from "@/assets/position-roundtable/more-task.svg";
import uploadFileIcon from "@/assets/position-roundtable/upload-file.svg";
import sendMessageIcon from "@/assets/position-roundtable/send-message.svg";
interface PositionIntentModelOption {
label: string;
@@ -25,6 +26,7 @@ interface PositionIntentInputProps extends Step1InputAreaProps {
modelOptions?: PositionIntentModelOption[];
selectedModel?: string;
onSelectedModelChange?: (model: string) => void;
commonQuestions?: (string | AgentQuestion)[] | null;
}
/** Keep attachment metadata presentation local to this position-only composer. */
@@ -64,40 +66,25 @@ export function PositionIntentInput({
modelOptions = [],
selectedModel,
onSelectedModelChange,
commonQuestions,
}: PositionIntentInputProps) {
// Files are owned by Step1Panel. This ref only opens the native picker and
// forwards its selection through onAddFiles, so replacement layouts do not
// accidentally create a second attachment state.
const fileInputRef = useRef<HTMLInputElement>(null);
const [showMoreQuestions, setShowMoreQuestions] = useState(false);
const normalizedCommonQuestions = normalizeAgentQuestions(commonQuestions);
const visibleCommonQuestions = normalizedCommonQuestions.slice(0, 2);
const extraCommonQuestions = normalizedCommonQuestions.slice(2);
const pickCommonQuestion = (question: AgentQuestion) => {
onValueChange(question.prompt);
setShowMoreQuestions(false);
};
return (
<div className="position-roundtable-intent-input position-roundtable-intent-composer shrink-0 border-t border-slate-200 bg-[#f7faff] px-4 py-3 dark:border-[#2b8ffe]/35 dark:bg-[#071a38]">
<div className="position-roundtable-intent-composer-frame relative overflow-hidden rounded-[4px] border border-slate-300 bg-white shadow-[0_2px_10px_rgba(15,23,42,0.08)] transition-all focus-within:border-primary focus-within:ring-2 focus-within:ring-[var(--brand-200)]/45 dark:border-[#2b8ffe]/55 dark:bg-[#0d2855] dark:shadow-none dark:focus-within:border-[#56baff] dark:focus-within:ring-[#2b8ffe]/25">
{attachedFiles.length > 0 && (
<div className="position-roundtable-intent-attachments mx-3 mb-2 flex flex-wrap gap-1.5 border-b border-slate-100 pb-2 pt-2.5 dark:border-[#2b8ffe]/25">
{attachedFiles.map((file, index) => (
<span
key={`${file.name}-${file.size}-${index}`}
className="inline-flex max-w-[200px] items-center gap-1.5 rounded-[4px] border border-slate-200 bg-slate-50 py-1 pl-2 pr-1 text-[11px] text-slate-600 dark:border-[#2b8ffe]/30 dark:bg-[#102f63] dark:text-[#c4dcff]"
>
<FileText className="size-3 shrink-0 text-primary" />
<span className="truncate font-medium" title={file.name}>
{file.name}
</span>
<span className="shrink-0 text-slate-400">{formatFileSize(file.size)}</span>
<button
type="button"
onClick={() => onRemoveFile(index)}
className="shrink-0 rounded-[3px] p-0.5 text-slate-400 transition hover:bg-slate-200 hover:text-slate-600 dark:text-[#8aaee0] dark:hover:bg-[#21447c] dark:hover:text-white"
title="移除"
>
<X className="size-3" />
</button>
</span>
))}
</div>
)}
<AutoGrowTextarea
id="input-text-area"
value={value}
@@ -144,7 +131,8 @@ export function PositionIntentInput({
title="上传参考文件(智能体将读取文件内容辅助理解任务)"
className="rounded-[3px] p-1.5 transition hover:bg-white hover:text-primary dark:hover:bg-[#16386e] dark:hover:text-[#73c8ff]"
>
<Paperclip className="size-4" />
<img src={uploadFileIcon} alt="" aria-hidden="true" />
<span>上传附件</span>
</button>
</div>
<span className="font-mono text-[10px] text-slate-400">{charactersLeft}/500</span>
@@ -154,8 +142,79 @@ export function PositionIntentInput({
Parent pages can still hide optional actions by their stable ids
without changing this component's layout or global input styles.
*/}
<div className="position-roundtable-intent-toolbar flex flex-wrap items-center gap-3 border-t border-slate-100 bg-white px-3 py-2.5 dark:border-[#2b8ffe]/25 dark:bg-[#0b234c]">
<div className="flex shrink-0 items-center">
<div className="position-roundtable-intent-toolbar flex flex-wrap items-center gap-3 border-t border-slate-100 px-3 py-2.5 dark:border-[#2b8ffe]/25">
<div className="position-roundtable-intent-common-slot">
{visibleCommonQuestions.length > 0 && (
<div className="position-roundtable-intent-common-questions">
{visibleCommonQuestions.map((question, index) => (
<button
key={`${question.prompt}-${index}`}
type="button"
className="position-roundtable-intent-common-question"
title={question.prompt}
onClick={() => pickCommonQuestion(question)}
>
<span aria-hidden="true" />
<span>{question.title}</span>
</button>
))}
{extraCommonQuestions.length > 0 && (
<div className="position-roundtable-intent-common-more">
<button
type="button"
className="position-roundtable-intent-common-more-button"
aria-label="更多常用问题"
aria-expanded={showMoreQuestions}
title="更多常用问题"
onClick={() => setShowMoreQuestions((visible) => !visible)}
>
<img src={moreTaskIcon} alt="" aria-hidden="true" />
</button>
{showMoreQuestions && (
<div className="position-roundtable-intent-common-more-menu">
{extraCommonQuestions.map((question, index) => (
<button
key={`${question.prompt}-${index}`}
type="button"
title={question.prompt}
onClick={() => pickCommonQuestion(question)}
>
{question.title}
</button>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
{attachedFiles.length > 0 && (
<div className="position-roundtable-intent-file-cards flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto">
{attachedFiles.map((file, index) => (
<span
key={`${file.name}-${file.size}-${index}`}
className="inline-flex max-w-[220px] shrink-0 items-center gap-1.5 rounded-[4px] border border-[#2b8ffe]/45 bg-[#0b3168] py-1 pl-2 pr-1 text-[11px] text-[#c4dcff]"
>
<FileText className="size-3 shrink-0 text-[#73c8ff]" />
<span className="truncate font-medium" title={file.name}>
{file.name}
</span>
<span className="shrink-0 text-[#8aaee0]">{formatFileSize(file.size)}</span>
<button
type="button"
onClick={() => onRemoveFile(index)}
className="shrink-0 cursor-pointer rounded-[3px] p-0.5 text-[#8aaee0] transition hover:bg-[#21447c] hover:text-white"
title="移除"
>
<X className="size-3" />
</button>
</span>
))}
</div>
)}
<div className="position-roundtable-intent-actions ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1.5">
<div className="position-roundtable-intent-upload-wrap flex shrink-0 items-center">
<input
ref={fileInputRef}
type="file"
@@ -169,28 +228,28 @@ export function PositionIntentInput({
<button
type="button"
onClick={() => fileInputRef.current?.click()}
title="上传参考文件"
aria-label="上传参考文件"
className="position-roundtable-intent-upload-btn flex size-10 items-center justify-center rounded-[4px] border border-slate-200 bg-white text-slate-500 transition hover:border-primary/45 hover:text-primary dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b] dark:hover:text-[#73c8ff]"
title="上传附件"
aria-label="上传附件"
className="position-roundtable-intent-upload-btn flex items-center justify-center rounded-[24px] border-0"
>
<Paperclip className="size-4" />
<img src={uploadFileIcon} alt="" aria-hidden="true" />
<span>上传附件</span>
</button>
</div>
<div className="position-roundtable-intent-actions ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1.5">
{modelOptions.length > 0 && (
<div
className="position-roundtable-intent-model-switch flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white pl-2 text-xs font-bold text-slate-700 shadow-sm dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7]"
title="切换意图理解模型"
className="position-roundtable-intent-model-switch flex h-8 shrink-0 items-center gap-1.5 rounded-[20px] border border-[#2b8ffe]/55 bg-[#0b3168] px-2.5 text-xs font-bold text-[#dceaff]"
title="鍒囨崲鎰忓浘鐞嗚В妯″瀷"
>
<Sparkles className="size-3.5 shrink-0 text-primary dark:text-[#73c8ff]" />
<Sparkles className="size-3.5 shrink-0 text-[#73c8ff]" />
<TSelect
size="small"
aria-label="切换意图理解模型"
aria-label="鍒囨崲鎰忓浘鐞嗚В妯″瀷"
value={selectedModel}
options={modelOptions}
disabled={isSending}
onChange={(value) => onSelectedModelChange?.(String(value))}
className="w-[150px] shrink-0 [&_.t-input]:!h-8 [&_.t-input]:!border-0 [&_.t-input]:!bg-transparent [&_.t-input]:!px-1 [&_.t-input]:!text-xs [&_.t-input]:!font-bold [&_.t-input]:!shadow-none dark:[&_.t-input]:!text-[#dceaff]"
className="w-[150px] shrink-0 [&_.t-input]:!h-7 [&_.t-input]:!rounded-[4px] [&_.t-input]:!border-0 [&_.t-input]:!bg-transparent [&_.t-input]:!px-1 [&_.t-input]:!text-xs [&_.t-input]:!font-bold [&_.t-input]:!text-[#dceaff] [&_.t-input]:!shadow-none"
popupProps={{ overlayClassName: "z-[80]" }}
/>
</div>
@@ -198,26 +257,20 @@ export function PositionIntentInput({
<button
id="send-message-btn"
type="button"
onClick={onSend}
disabled={!canSend}
className="position-roundtable-intent-send-btn flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] bg-primary px-3 text-xs font-bold text-white shadow-[0_2px_8px_rgba(245,34,45,0.15)] transition-all active:scale-[0.98] hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:bg-slate-300 dark:bg-[#1478e8] dark:hover:bg-[#2995ff] dark:disabled:bg-[#28476f]"
onClick={isSending ? onStop : onSend}
disabled={isSending ? false : !canSend}
aria-label={isSending ? "暂停生成" : "发送"}
title={isSending ? "暂停生成" : "发送"}
className={`position-roundtable-intent-send-btn flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] bg-primary px-3 text-xs font-bold text-white shadow-[0_2px_8px_rgba(245,34,45,0.15)] transition-all active:scale-[0.98] hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:bg-slate-300 dark:bg-[#1478e8] dark:hover:bg-[#2995ff] dark:disabled:bg-[#28476f]${isSending ? " position-roundtable-intent-send-btn--stopping" : ""}`}
>
{isSending ? <Loader2 className="size-3 animate-spin" /> : <Send className="size-3" />}
{isSending ? "发送中" : "发送"}
{isSending ? (
<Pause className="size-4" aria-hidden="true" />
) : (
<img src={sendMessageIcon} alt="" aria-hidden="true" />
)}
<span className="sr-only">{isSending ? "暂停生成" : "发送"}</span>
</button>
{isSending && (
<button
id="pause-intent-btn"
type="button"
onClick={onStop}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-amber-200/40 bg-amber-100 px-3 text-xs font-bold text-amber-800 shadow-sm transition active:scale-[0.98] hover:bg-amber-200 dark:border-amber-300/30 dark:bg-amber-400/15 dark:text-amber-200 dark:hover:bg-amber-400/25"
>
<Pause className="size-3" />
暂停
</button>
)}
<button
id="suggest-followup-btn"
type="button"
@@ -15,7 +15,11 @@ import { ThreadContext } from "@/components/workspace/messages/context";
import { MessageList } from "@/components/workspace/messages";
import { useAgent } from "@/core/agents";
import { useLocalSettings } from "@/core/settings";
import { useEditMessage, useThreadStream } from "@/core/threads/hooks";
import {
useEditMessage,
useThreadStream,
type ToolStartEvent,
} from "@/core/threads/hooks";
import type { AgentThreadState } from "@/core/threads/types";
import { textOfMessage } from "@/core/threads/utils";
import { uuid } from "@/core/utils/uuid";
@@ -40,7 +44,6 @@ import {
readConversationDraft,
readConversationMessages,
} from "@/position-roundtable/lib/conversation-snapshot";
interface PositionNodeSwitchOption {
node: PositionRoundtableNode;
agentName?: string | null;
@@ -164,17 +167,100 @@ function artifactDisplayName(pathOrUrl: string): string {
return path.split("/").filter(Boolean).at(-1) || "Markdown 报告";
}
function normalizeWriteFilePath(path: string): string {
let normalized = path.trim();
const first = normalized[0];
const last = normalized.at(-1);
if (
normalized.length >= 2 &&
((first === '"' && last === '"') ||
(first === "'" && last === "'") ||
(first === "`" && last === "`"))
) {
normalized = normalized.slice(1, -1).trim();
}
return normalized;
}
function isMarkdownPath(path: string): boolean {
const pathname = normalizeWriteFilePath(path).split(/[?#]/, 1)[0]?.toLowerCase() ?? "";
return pathname.endsWith(".md") || pathname.endsWith(".markdown");
}
function hasCompleteArtifactExtension(path: string): boolean {
const pathname = normalizeWriteFilePath(path).split(/[?#]/, 1)[0] ?? "";
const filename = pathname.split("/").filter(Boolean).at(-1) ?? "";
// During JSON argument streaming a Markdown path arrives as `.m` and then
// `.md`. Opening on the partial suffix permanently labels the preview TEXT.
// Extensionless files still open from on_tool_start once the full path is known.
return /\.[a-z0-9]{2,12}$/i.test(filename);
}
/**
* `write_file` arguments stream as one JSON object and the key order is up to
* the model. Agents that emit `content` before `path` leave the file nameless
* for as long as the body takes to write — a minute for a full report. The
* sandbox follows a streaming file by message/tool-call id, not by path, so it
* can open under a provisional name and adopt the real one later. Sniff the
* body so the provisional name at least carries the right extension.
*/
function provisionalWriteFilePath(content: string): string {
const head = content.slice(0, 256).trimStart().toLowerCase();
if (head.startsWith("<!doctype html") || head.startsWith("<html")) {
return "未命名文件.html";
}
return "未命名文件.md";
}
function writeFilePathFromToolEvent(data: unknown, depth = 0): string | null {
if (depth > 3 || data == null) return null;
if (typeof data === "string") {
const value = data.trim();
if (!value) return null;
if (value.startsWith("{") || value.startsWith("[")) {
try {
return writeFilePathFromToolEvent(JSON.parse(value), depth + 1);
} catch {
return null;
}
}
return null;
}
if (typeof data !== "object" || Array.isArray(data)) return null;
const record = data as Record<string, unknown>;
if (typeof record.path === "string") {
const path = normalizeWriteFilePath(record.path);
return path || null;
}
for (const key of ["input", "inputs", "args", "kwargs"]) {
const path = writeFilePathFromToolEvent(record[key], depth + 1);
if (path) return path;
}
return null;
}
function buildWriteFileVirtualUrl(path: string, messageId: string, toolCallId: string): string {
const language = isMarkdownPath(path) ? "&language=markdown" : "";
return new URL(
`write-file:${path}?message_id=${encodeURIComponent(
messageId,
)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
)}&tool_call_id=${encodeURIComponent(toolCallId)}${language}`,
).toString();
}
function isArtifactWriteTool(name: string | undefined): name is "write_file" | "str_replace" {
return name === "write_file" || name === "str_replace";
}
function latestStreamingWriteFile(
messages: AgentThreadState["messages"],
): { key: string; path: string; name: string; mimeType: string | null } | null {
): {
key: string;
path: string;
sourcePath: string;
name: string;
mimeType: string | null;
} | null {
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
const message = messages[messageIndex] as (typeof messages)[number] & {
id?: string;
@@ -183,17 +269,33 @@ function latestStreamingWriteFile(
if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue;
for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) {
const toolCall = message.tool_calls[toolIndex];
if (toolCall?.name !== "write_file" || !toolCall.id) continue;
const rawPath = toolCall.args?.path;
if (typeof rawPath !== "string" || !rawPath.trim()) continue;
const content = toolCall.args?.content;
if (typeof content !== "string" || content.length === 0) continue;
const virtualPath = buildWriteFileVirtualUrl(rawPath, message.id, toolCall.id);
if (!isArtifactWriteTool(toolCall?.name) || !toolCall.id) continue;
const rawPath = typeof toolCall.args?.path === "string" ? toolCall.args.path : "";
const rawContent = typeof toolCall.args?.content === "string" ? toolCall.args.content : "";
const normalizedPath = normalizeWriteFilePath(rawPath);
const hasUsablePath =
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
if (!hasUsablePath) {
// `str_replace` edits an existing file, so it can only be previewed once
// its real path is known. A new file is identified by its tool call, so
// its body can be previewed as soon as the first characters arrive.
if (toolCall.name !== "write_file" || !rawContent.trim()) continue;
}
const sourcePath = hasUsablePath ? normalizedPath : provisionalWriteFilePath(rawContent);
// Do not wait for the complete `content` argument. LangGraph streams
// write_file arguments incrementally, and the virtual artifact loader
// will redraw as `content` arrives. Waiting for it turns this into a
// post-generation preview instead of a live sandbox.
const previewPath =
toolCall.name === "write_file"
? buildWriteFileVirtualUrl(sourcePath, message.id, toolCall.id)
: sourcePath;
return {
key: `${message.id}:${toolCall.id}`,
path: virtualPath,
name: artifactDisplayName(rawPath),
mimeType: rawPath.toLowerCase().endsWith(".md") ? "text/markdown" : null,
path: previewPath,
sourcePath,
name: artifactDisplayName(sourcePath),
mimeType: isMarkdownPath(sourcePath) ? "text/markdown" : null,
};
}
}
@@ -214,7 +316,7 @@ function writeFileCallKeys(messages: AgentThreadState["messages"]): Set<string>
};
if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue;
for (const toolCall of message.tool_calls) {
if (toolCall?.name === "write_file" && toolCall.id) {
if (isArtifactWriteTool(toolCall?.name) && toolCall.id) {
keys.add(`${message.id}:${toolCall.id}`);
}
}
@@ -265,11 +367,21 @@ export function PositionNodeChat({
);
const [boundThreadId, setBoundThreadId] = useState<string | null>(node.threadId);
const boundThreadIdRef = useRef<string | null>(node.threadId);
const previousNodeBindingRef = useRef({
nodeKey: node.nodeKey,
threadId: node.threadId,
});
const draftThreadIdRef = useRef(node.threadId ?? uuid());
const upstreamRevisionRef = useRef<Record<string, unknown> | null>(null);
const autoPreviewedArtifactKeyRef = useRef<string | null>(null);
const autoPreviewBaselineCallKeysRef = useRef<Set<string>>(new Set());
const autoPreviewRunArmedRef = useRef(false);
const writeFilePreviewSequenceRef = useRef(0);
const activeWriteFilePreviewRef = useRef<{
path: string;
previewKey: string;
toolCallId?: string;
} | null>(null);
const editMessage = useEditMessage();
const [messageContentOverrides, setMessageContentOverrides] = useState<Record<string, string>>({});
const persistedMessages = useMemo(
@@ -288,12 +400,28 @@ export function PositionNodeChat({
!node.invalidatedBy;
useEffect(() => {
const previousBinding = previousNodeBindingRef.current;
previousNodeBindingRef.current = { nodeKey: node.nodeKey, threadId: node.threadId };
// `onStart` binds a newly created LangGraph thread before the session API
// persists that id back into `node.threadId`. That prop update happens
// while the first turn is already streaming; it is not a node switch and
// must not clear the preview run that has just been armed.
const isOwnThreadBinding =
previousBinding.nodeKey === node.nodeKey &&
previousBinding.threadId == null &&
node.threadId != null &&
node.threadId === boundThreadIdRef.current;
if (isOwnThreadBinding) return;
setBoundThreadId(node.threadId);
boundThreadIdRef.current = node.threadId;
setInputDraft(readConversationDraft(node.conversationSnapshot));
setMessageContentOverrides({});
autoPreviewedArtifactKeyRef.current = null;
autoPreviewBaselineCallKeysRef.current = new Set();
autoPreviewRunArmedRef.current = false;
writeFilePreviewSequenceRef.current = 0;
activeWriteFilePreviewRef.current = null;
// The component is keyed by nodeKey in the page. This reset is therefore
// only a defensive guard for callers that reuse the component instance.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -314,6 +442,43 @@ export function PositionNodeChat({
[node.nodeKey, onNodeUpdate, sessionId],
);
const handleToolStart = useCallback(
(event: ToolStartEvent) => {
if (!isArtifactWriteTool(event.name) || !autoPreviewRunArmedRef.current) return;
const path = writeFilePathFromToolEvent(event.data);
const threadId = boundThreadIdRef.current;
if (!path || !threadId) return;
// Message chunks and on_tool_start can describe the same invocation in
// either order. Reuse the first preview key so a manual close suppresses
// only this write, while a later rewrite receives a fresh key and opens.
const activePreview = activeWriteFilePreviewRef.current;
if (
activePreview?.path === path &&
(activePreview.toolCallId === event.toolCallId ||
(event.toolCallId != null && activePreview.previewKey.endsWith(`:${event.toolCallId}`)))
) {
return;
}
const previewKey = event.toolCallId
? `tool-start:${event.toolCallId}`
: `tool-start:${node.nodeKey}:${++writeFilePreviewSequenceRef.current}`;
activeWriteFilePreviewRef.current = { path, previewKey, toolCallId: event.toolCallId };
const artifactKey = `${threadId}:${previewKey}`;
if (autoPreviewedArtifactKeyRef.current === artifactKey) return;
autoPreviewedArtifactKeyRef.current = artifactKey;
onPreviewArtifact?.({
threadId,
path,
name: artifactDisplayName(path),
mimeType: isMarkdownPath(path) ? "text/markdown" : null,
previewKey,
auto: true,
});
},
[node.nodeKey, onPreviewArtifact],
);
const [thread, sendMessage, , actualThreadId] = useThreadStream({
threadId: boundThreadId,
assistantId: node.agentId,
@@ -334,15 +499,21 @@ export function PositionNodeChat({
agent_id: node.agentId,
},
onStart: bindCreatedThread,
onToolStart: handleToolStart,
onFinish: (state) => {
// A turn is not considered delivered by the UI alone. The persisted
// completion call records the answer, file manifest and upstream version
// so the service can decide whether this seat unlocks its downstream.
const threadId = boundThreadIdRef.current;
const latestAi = [...(state.messages ?? [])]
// `present_files` is often the last AI tool call and has no prose of
// its own. Keep walking backwards to the last actual answer; otherwise
// a successful file delivery would return early here and never refresh
// the node's artifact cards.
const latestAnswer = [...(state.messages ?? [])]
.reverse()
.find((message) => message.type === "ai");
const latestAnswer = latestAi ? textOfMessage(latestAi) ?? "" : "";
.filter((message) => message.type === "ai")
.map((message) => textOfMessage(message) ?? "")
.find((content) => content.trim()) ?? "";
if (!threadId || !latestAnswer.trim()) return;
void (async () => {
let artifactManifest: Array<Record<string, unknown>> = [];
@@ -378,6 +549,10 @@ export function PositionNodeChat({
const activeThreadId =
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? draftThreadIdRef.current;
// A sandbox must always receive the server thread id. In particular, do not
// attach a streaming preview to the provisional UUID used for a new node
// before `useThreadStream` has created and bound its thread.
const previewThreadId = boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? null;
const persistedThreadId =
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? null;
const recoveredMessages = useMemo(
@@ -566,32 +741,60 @@ export function PositionNodeChat({
() => (thread.isLoading ? latestStreamingWriteFile(thread.messages) : null),
[thread.isLoading, thread.messages],
);
const newStreamingArtifact =
streamingArtifact && !autoPreviewBaselineCallKeysRef.current.has(streamingArtifact.key)
? streamingArtifact
: null;
useEffect(() => {
if (
!thread.isLoading ||
!streamingArtifact ||
!autoPreviewRunArmedRef.current ||
autoPreviewBaselineCallKeysRef.current.has(streamingArtifact.key)
!newStreamingArtifact ||
!previewThreadId ||
!autoPreviewRunArmedRef.current
) {
return;
}
// One write_file call should open at most one preview. The parent further
// guards manual close state, because it owns the page-wide singleton sandbox.
// A report is often rewritten to the same output path. The tool-call key
// (not just the path) makes that a new previewable delivery.
const artifactKey = `${activeThreadId}:${streamingArtifact.key}`;
// A report is often rewritten to the same output path — identity must be the
// tool-call key, not the path, otherwise a second write keeps the old panel
// and the sandbox never clears / remounts for the rewrite.
const toolCallKey = newStreamingArtifact.key;
const toolCallId = toolCallKey.includes(":")
? toolCallKey.slice(toolCallKey.lastIndexOf(":") + 1)
: undefined;
const startedPreview = activeWriteFilePreviewRef.current;
const isSameToolCall =
startedPreview != null &&
(startedPreview.previewKey === toolCallKey ||
(toolCallId != null &&
(startedPreview.toolCallId === toolCallId ||
startedPreview.previewKey.endsWith(`:${toolCallId}`))));
// Same tool call may upgrade a provisional filename → real path; keep the
// open panel. A different tool call is a new delivery even on the same path.
const previewKey = isSameToolCall ? startedPreview.previewKey : toolCallKey;
activeWriteFilePreviewRef.current = {
path: newStreamingArtifact.sourcePath,
previewKey,
toolCallId: toolCallId ?? startedPreview?.toolCallId,
};
// The path takes part in the identity so that a preview opened under a
// provisional name is re-published once the real path streams in. The
// preview key stays the same, so the parent treats it as an update of the
// open panel rather than a second delivery.
const artifactKey = `${previewThreadId}:${previewKey}:${newStreamingArtifact.sourcePath}`;
if (autoPreviewedArtifactKeyRef.current === artifactKey) return;
autoPreviewedArtifactKeyRef.current = artifactKey;
onPreviewArtifact?.({
threadId: activeThreadId,
path: streamingArtifact.path,
name: streamingArtifact.name,
mimeType: streamingArtifact.mimeType,
previewKey: streamingArtifact.key,
threadId: previewThreadId,
path: newStreamingArtifact.path,
name: newStreamingArtifact.name,
mimeType: newStreamingArtifact.mimeType,
previewKey,
auto: true,
});
}, [activeThreadId, onPreviewArtifact, streamingArtifact, thread.isLoading]);
}, [newStreamingArtifact, onPreviewArtifact, previewThreadId, thread.isLoading]);
// SDK thread creation is asynchronous. ``onStart`` normally binds it, but
// watching the actual stream id as well closes the last race where a page
@@ -628,6 +831,7 @@ export function PositionNodeChat({
: null;
// A thread retains historical write_file tool calls. Only files whose
// call id appears after this exact submission may open the sandbox.
activeWriteFilePreviewRef.current = null;
autoPreviewBaselineCallKeysRef.current = writeFileCallKeys(thread.messages);
autoPreviewRunArmedRef.current = true;
await sendMessage(boundThreadIdRef.current ?? draftThreadIdRef.current, message, {
@@ -811,7 +1015,7 @@ export function PositionNodeChat({
</div>
)}
<div className="position-roundtable-agent-composer-bar shrink-0 border-t border-slate-100 px-4 py-3 dark:border-[#2b8ffe]/20">
<div className="position-roundtable-agent-composer-bar position-roundtable-node-composer shrink-0">
<PositionAgentInput
className="w-full"
isNewThread={!boundThreadId}
@@ -825,6 +1029,7 @@ export function PositionNodeChat({
onDraftChange={setInputDraft}
hideAdminRecommendedQuestions
onContextChange={(context) => setSettings("context", context)}
commonQuestions={agent?.common_questions}
onSubmit={(message) => void handleSubmit(message)}
onStop={() => void thread.stop()}
/>
@@ -97,6 +97,7 @@ export function getPositionDemoTask(): PositionTask {
direction: String(task.direction ?? ""),
source: String(task.source ?? "演示数据"),
issuedAt: String(task.issuedAt ?? ""),
priority: String(task.priority ?? "紧急"),
};
}
@@ -0,0 +1,391 @@
# 岗位会商沙箱对接指南(重做页面 / 排障)
## 文档用途
给**下次重做或对接岗位会商页面**的同事用,也给线上「沙箱不实时 / 卡死 / 第二次不刷新」时排障用。
读完应能回答:
1. 新页面要接哪几个回调、传什么字段?
2. 打开 / 关闭沙箱各在什么时机?和步骤条是什么关系?
3. 哪些写法绝对不能用(会复现历史 bug)?
4. 出问题时按什么顺序查?
对照实现(当前生产路径):
| 角色 | 文件 |
| --- | --- |
| 页面壳(沙箱唯一宿主) | `pages/PositionRoundtablePage.tsx` |
| 节点对话(探测 write_file、上报预览) | `components/PositionNodeChat.tsx` |
| 收口对话 | `components/PositionBuiltinAgentChat.tsx` |
| 预览 UI(共享组件,勿分叉逻辑) | `components/workspace/artifacts/artifact-file-detail.tsx` |
| 流式正文读取 | `core/artifacts/loader.ts` → `loadArtifactContentFromToolCall` |
| 普通对话页对照(步骤条自动开) | `components/workspace/messages/message-group.tsx` |
模块总览见同目录上级 [`../README.md`](../README.md);本文只覆盖**文件预览沙箱**这一条链路。
---
## 1. 验收标准(对接完成即通过)
重做页面后,下列行为必须全部成立(与普通对话页用户感知一致):
| # | 行为 |
| --- | --- |
| A | 步骤条刚出现「写入文件」时,右侧沙箱应已打开(不必等文件名出现) |
| B | 沙箱内源码随模型输出边写边滚;消息列表文字也实时出现,不能整页卡死 |
| C | 模型若先吐正文、后吐 `path`:先用临时名打开,path 到了再改名,不闪屏重挂 |
| D | 同一次问答里第二次 `write_file` 写同一路径(「重新生成」):沙箱清空并重新流式 |
| E | 整轮 `isLoading=false` 后,自动从源码切到 Markdown 预览 |
| F | 用户手动关闭后,**同一次**写入不再自动弹;**下一轮**新写入仍可自动开 |
| G | 切岗位 / 切节点 / 新建或切换会话时,沙箱关掉且不串内容 |
---
## 2. 职责划分(谁干什么)
```text
┌─────────────────────────────────────────────────────────────┐
│ 页面壳(PositionRoundtablePage 或你的新页面) │
│ · 唯一 sandboxArtifact 状态 │
│ · handlePreviewArtifact / closeSandbox │
│ · 流式线程外部 store(禁止整页 useState) │
│ · 渲染唯一 ArtifactFileDetail │
└──────────────────────────▲──────────────────────────────────┘
│ onPreviewArtifact / onThreadSnapshot
┌──────────────────────────┴──────────────────────────────────┐
│ 对话子组件(PositionNodeChat 等) │
│ · useThreadStream 拉消息 │
│ · 渲染 MessageList / 步骤条(只展示,不直接开沙箱) │
│ · 探测 write_file → 组装 PositionArtifactPreview 上报 │
│ · 每次 thread 变化上报 onThreadSnapshot │
└─────────────────────────────────────────────────────────────┘
```
硬性规则:
1. **沙箱只有一个实例**,挂在页面壳上,不要在对话子组件里再挂 `ArtifactFileDetail`。
2. 对话子组件**不要**直接 `artifacts.setOpen(true)` 当作岗位会商的主路径(页面壳统一开合,才能做关闭抑制、切节点清理)。
3. 步骤条和沙箱是**两条线**:步骤条只反映 tool call 展示;开沙箱走 `onPreviewArtifact`。
---
## 3. 对接接口(重新接页面时照此接线)
### 3.1 对话子组件 → 页面壳
#### `onPreviewArtifact(artifact)`
请求打开或更新沙箱。载荷:
```ts
type PositionArtifactPreview = {
threadId: string; // 必填:LangGraph thread id
path: string; // 必填:真实路径,或 write-file: 虚拟 URL,或临时文件名
name: string; // 展示名
mimeType: string | null; // 如 text/markdown;帮助预览语言判断
previewKey?: string; // 强烈建议:一次交付的身份,推荐 `${messageId}:${toolCallId}`
auto?: boolean; // true = 流式自动打开;false/缺省 = 用户点击打开
};
```
页面壳收到后应:
1. `auto===true` 且 `closedSandboxKey === sandboxCloseKey(artifact)` → **直接 return**(用户刚关过这一次)。
2. 更新页面级 `sandboxArtifact`(并同步 ref,见下)。
3. 同步 `ArtifactsContext.select(path) + setOpen(true)`(与共享预览组件生命周期对齐)。
4. 若已有该 thread 的最新快照,立刻推进沙箱 store。
5. React `key` 用 `sandboxCloseKey(artifact)`(有 `previewKey` 时优先它),保证新交付会重挂。
`sandboxCloseKey` 约定:
```ts
// 有 previewKey:threadId:write:${previewKey}
// 无 previewKey:threadId:${normalizePath(path)}
```
#### `onThreadSnapshot({ threadId, thread })`
把当前 `useThreadStream` 的 thread 对象持续交给页面壳,供沙箱读流式 `content`。
页面壳应:
1. 始终写入 `latestNodeThreadSnapshotRef`(打开瞬间可能用到)。
2. **仅当**当前沙箱 `threadId` 与 snapshot 一致时,才 `queue` → store `publish`。
3. 节流:前沿 160ms(见第 5 节),不要纯尾随 `setTimeout`。
### 3.2 页面壳 → 预览组件
```tsx
{sandboxOpen && sandboxArtifact && (
<SandboxThreadProvider store={store} threadId={sandboxArtifact.threadId} fallbackThread={stub}>
<ArtifactFileDetail
key={sandboxCloseKey(sandboxArtifact)}
filepath={sandboxArtifact.path}
threadId={sandboxArtifact.threadId}
mimeType={sandboxArtifact.mimeType}
onClose={closeSandbox}
allowVirtualMarkdownEdit
// ...
/>
</SandboxThreadProvider>
)}
```
流式正文依赖虚拟 URL:
```text
write-file:${path}?message_id=...&tool_call_id=...&language=markdown
```
`loadArtifactContentFromToolCall` **只认** URL 里的 `message_id` + `tool_call_id`,从 `thread.messages` 对应 tool call 的 `args.content` 取值。
因此:**可以没有真实磁盘 path 就开沙箱**;path 主要用于文件名展示和语言猜测。
### 3.3 对话子组件内部必做状态(可复用现有逻辑)
重新实现探测时,至少保留这些语义(不必同名,语义要对):
| 状态 | 作用 |
| --- | --- |
| `armed` | 仅本轮用户 `send` 成功武装后才允许自动 `onPreviewArtifact` |
| `baselineCallKeys` | send 前已有的 `messageId:toolCallId` 集合;忽略历史 write,防止一提问就开旧文件 |
| `activePreview` | 当前已打开的 previewKey / path / toolCallId,用于 path 升级与同 call 去重 |
| 线程绑定保护 | `node.threadId` 从 null→本节点刚创建的 id 时,**不要** disarm |
发送时:
```text
baseline = 当前历史 write keys
armed = true
activePreview = null
→ sendMessage(...)
```
探测时(`isLoading`):
```text
找到最新 write_file / str_replace
若不在 baseline:
- 有完整扩展名 path → 用真实 path
- write_file 且仅有 content → 临时名(未命名文件.md / .html)
- str_replace 无 path → 先不报
previewKey = 同 tool call 复用,否则新 key
→ onPreviewArtifact({ ..., auto: true })
```
---
## 4. 打开 / 关闭时序(对接时按此实现)
### 4.1 打开(自动)
```text
用户发送
→ armed=true + 拍 baseline
→ 模型开始 write_file
→ 步骤条出现「写入文件」(可能还没有 path)
→ content 或完整 path 任一满足探测条件
→ onPreviewArtifact(auto)
→ 页面壳打开 ArtifactFileDetail + 推送 thread 快照
→ 源码视图滚动更新
→(可选)真实 path 到达:同 previewKey 改名(path upgrade,不重挂)
→ isLoading=false:共享组件自动切预览
```
### 4.2 同轮第二次写入(重新生成)
```text
write_file #1 → previewKey = msg:tc1 → 打开
write_file #2 同 path → previewKey = msg:tc2(必须变)
→ React key 变 → 组件重挂 → 视觉上清空再生成
```
**禁止**用「path 相同 ⇒ 复用 previewKey」。那会导致步骤条有「重新生成」、沙箱却不动。
### 4.3 关闭
| 触发 | 行为 |
| --- | --- |
| 用户点关闭 | `closedSandboxKeyRef = sandboxCloseKey(current)`;清 artifact / store / 定时器 |
| 切岗位、切节点、新会话、切历史 | 先 `closeSandbox()`(可按产品决定是否写入抑制键;至少清面板) |
| 自动打开被抑制 | 仅当 `auto && closedKey === 当前 key`;手动打开应清抑制 |
抑制粒度必须是**单次交付(previewKey)**,不能是「整个 thread 永远不再自动开」。
---
## 5. 重做页面时的必守规则(违反必出 bug)
1. **流式 thread 快照禁止进页面级 `useState`**
大页面每秒重渲染数次会占满主线程:消息列表停更、沙箱冻住、定时器饿死。
正确:外部 store + `useSyncExternalStore`,只让沙箱子树订阅。
2. **节流用前沿(leading-edge),不要纯尾随 `setTimeout`**
距上次 publish ≥ 160ms → 同步 publish;否则补尾定时器。
纯「有 timer 就 return」在忙主线程下会十几秒不 flush。
3. **不要等完整 `path` 才开沙箱**
报告类 agent 常先流 `content`、最后才流 `path`,硬等会晚几十秒。
`write_file` + 已有 content → 临时名打开;`str_replace` 仍等真实 path。
4. **扩展名要完整再当「真实 path」**
流式中 `.m` → `.md`,过早打开会把语言锁成 TEXT。
5. **previewKey 按 tool call,不按 path**
同 path 多次写入 = 多次交付。
6. **打开瞬间同步推进 `sandboxArtifactRef`**
若 flush 快照时只看 ref、而 setState 尚未提交,首帧会被「threadId 不匹配」丢掉。
7. **不要依赖 `onToolStart` 作为唯一打开手段**
当前后端对部分 stream mode 可能过滤;主路径必须是 `thread.messages` 里渐进出现的 `tool_calls`。
8. **path upgrade 不要当第二次打开**
同 previewKey 改 path:更新 artifact,不要乱关抽屉 / 不要重挂。
9. **「结束后才切预览」不是打开 bug**
`ArtifactFileDetail` 对 markdown/html 在 `isLoading` 期间强制源码视图,与普通对话页相同。
---
## 6. 步骤条怎么对接理解
步骤条来自消息里的 tool call 渲染(`message-group`),标题优先 `args.description`,否则文案「写入文件」;子行显示 `args.path`。
| 你看到的步骤条 | 含义 | 沙箱应处于 |
| --- | --- | --- |
| 仅「写入文件」,无路径 | tool call 已开始,path 可能还没流到 | **应已打开**(临时名) |
| 「写入文件」+ 路径 | path 已可用 | 已打开;若刚从临时名升级则只改名 |
| 第二条「重新生成…」+ 同路径 | 新的 write_file(新 toolCallId) | **应重挂**并重新流式 |
| 生成中顶部「正在生成,完成后自动预览」 | 仍在 code 视图 | 正常,等 isLoading 结束切预览 |
对接时不要把「步骤条出现文件名」当成「开沙箱的唯一信号」——那会在 content-first 模型上系统性偏晚。
---
## 7. 排障手册(按症状查)
### 7.1 沙箱开得很晚 / 要等文件名才开
检查顺序:
1. 探测是否仍要求「必须有完整 path」?→ 改为 content 可开临时名。
2. `armed` 是否在 `threadId` 回写时被误清?→ 保护「本节点绑定自己的新 thread」。
3. 是否被 `closedSandboxKey` 抑制?→ 看是否刚关过同 previewKey。
4. 新 call 是否落在 baseline 里?→ baseline 只能在 send 时拍一次。
### 7.2 沙箱打开了但内容空白 / 冻住,生成完才一口气出来
1. 快照是否写进了页面 `useState`?→ 改 store。
2. 节流是否纯尾随定时器?→ 改前沿。
3. `SandboxThreadProvider` 是否把带 `messages` 的 thread 传给了 `ArtifactFileDetail`?
4. 虚拟 URL 的 `tool_call_id` 是否与消息里一致?
5. 打开时 `sandboxArtifactRef` 是否已同步(避免首帧 flush 被丢)?
### 7.3 消息列表也不实时,整页很卡
优先怀疑页面壳在流式频率下整页重渲染。
确认没有把 `onThreadSnapshot` 落到大页面 state;临时 `console.debug` 每 chunk 打大对象也会加重(排障完删掉)。
### 7.4 第二次「重新生成」步骤有了,沙箱不变
previewKey 是否按 path 复用了?→ 必须按 `messageId:toolCallId`。
`ArtifactFileDetail` 的 `key` 是否跟 previewKey 走?否则不会重挂。
### 7.5 第一次实时开,关掉后再问就不实时 / 很晚才开
抑制是否绑在整个 `threadId` 上?→ 改为单次 `sandboxCloseKey(previewKey)`。
### 7.6 预览变成纯文本 / 不像 Markdown
1. 是否在 `.m` 阶段就当完整 path 打开了?
2. 临时名是否带 `.md` / URL 是否带 `language=markdown` / 是否传了 `mimeType`?
3. path 是否有首尾空格?
### 7.7 只靠 onToolStart,线上偶发永远不开
后端可能不推 tools/events。主路径改为扫描 `thread.messages` 的 streaming `tool_calls`。
---
## 8. 对接自测清单(重做页面后必跑)
- [ ] 首轮:步骤条「写入文件」出现时,沙箱是否已开并开始出字?
- [ ] content-first agent:无文件名阶段是否也能开?
- [ ] path 到达后:是否只改名、不整页闪?
- [ ] 同轮第二次同路径 write:是否清空并重新流式?
- [ ] 流式中消息列表是否仍实时?页面是否明显卡顿?
- [ ] 手动关闭后本次是否不再弹?下一轮是否仍可自动开?
- [ ] 结束后是否自动切 Markdown 预览?
- [ ] 切节点 / 切岗位 / 新会话:沙箱是否关闭且内容不串?
---
## 9. 最小接线伪代码(新页面可抄)
```tsx
// 页面壳
const [sandboxArtifact, setSandboxArtifact] = useState<PositionArtifactPreview | null>(null);
const sandboxArtifactRef = useRef(sandboxArtifact);
const closedSandboxKeyRef = useRef<string | null>(null);
const store = useMemo(createSandboxThreadStore, []);
function handlePreviewArtifact(artifact: PositionArtifactPreview) {
const key = sandboxCloseKey(artifact);
if (artifact.auto && closedSandboxKeyRef.current === key) return;
const prev = sandboxArtifactRef.current;
const sameKey = prev?.previewKey && prev.previewKey === artifact.previewKey
&& prev.threadId === artifact.threadId;
const pathOnlyUpgrade = sameKey && prev.path !== artifact.path;
sandboxArtifactRef.current = artifact;
setSandboxArtifact(artifact);
// publish latest thread snapshot into store (leading-edge throttle)
// ArtifactsContext.select + setOpen(true)
if (!pathOnlyUpgrade) closeRightDrawer();
}
function closeSandbox() {
if (sandboxArtifactRef.current) {
closedSandboxKeyRef.current = sandboxCloseKey(sandboxArtifactRef.current);
}
sandboxArtifactRef.current = null;
setSandboxArtifact(null);
store.publish(null);
}
// 对话子组件
<PositionNodeChat
onPreviewArtifact={handlePreviewArtifact}
onThreadSnapshot={handleNodeThreadSnapshot}
/>
```
对话侧探测逻辑优先**复用** `PositionNodeChat` 内的 `latestStreamingWriteFile` / armed / baseline,而不是从零再写一套——这是最容易写错的部分。
---
## 10. 术语
| 词 | 含义 |
| --- | --- |
| armed | 本轮用户发送后,允许自动打开沙箱 |
| baseline | send 前已有 write tool call 集合,用于忽略历史产物 |
| previewKey | 一次交付身份,推荐 `messageId:toolCallId` |
| sandboxCloseKey | 关闭抑制键 + React key |
| provisional path | 无真实 path 时的临时文件名 |
| path upgrade | 同 previewKey 下临时名 → 真实 path |
| sandboxThreadStore | 流式线程外部 store,避免整页重渲染 |
| write-file: URL | 用 message/tool call id 定位流式 content 的虚拟路径 |
---
## 11. 相关提交(背景)
若需对照修复动机,可查:
- `fix(frontend): 岗位会商沙箱在 write_file 流式阶段实时打开并刷新` — 临时名打开、store、前沿节流
- `fix(frontend): 同路径二次 write_file 时重挂岗位会商沙箱` — previewKey 按 tool call
排障时优先对照本文第 5、7 节,再对照上述提交的 diff。
@@ -13,6 +13,7 @@ export interface PositionTask {
direction: string;
source: string;
issuedAt: string;
priority?: string;
}
export const MOCK_POSITION_TASKS: readonly PositionTask[] = [
@@ -30,16 +31,21 @@ export const MOCK_POSITION_TASKS: readonly PositionTask[] = [
export const DEFAULT_MOCK_POSITION_TASK_ID = MOCK_POSITION_TASKS[0].id;
export function getMockPositionTask(taskId?: string | null): PositionTask {
return (
const fixture =
MOCK_POSITION_TASKS.find((task) => task.id === taskId) ??
MOCK_POSITION_TASKS[0]
);
MOCK_POSITION_TASKS[0];
const scopedTaskId = taskId?.trim();
// The fixture supplies only display defaults. Preserve the caller's task
// identity so persistence, history and LangGraph thread metadata all remain
// bound to the route taskId.
return scopedTaskId ? { ...fixture, id: scopedTaskId } : fixture;
}
/** Normalizes a persisted Session task snapshot without exposing source internals. */
export function taskFromPositionSnapshot(
snapshot: Record<string, unknown> | null | undefined,
fallback: PositionTask,
taskScopeId?: string,
): PositionTask {
if (!snapshot || Object.keys(snapshot).length === 0) return fallback;
const value = (keys: string[], defaultValue: string) => {
@@ -49,10 +55,15 @@ export function taskFromPositionSnapshot(
return typeof found === "string" ? found : defaultValue;
};
return {
id: value(["id", "taskId", "externalTaskId"], fallback.id),
// Session snapshots are editable display data, while the route taskId is
// the stable shared-storage scope. Keeping them separate prevents a
// legacy/mock snapshot from moving a live external task into another
// task's history bucket.
id: taskScopeId?.trim() || value(["id", "taskId", "externalTaskId"], fallback.id),
title: value(["title", "taskName", "name"], fallback.title),
description: value(["description", "taskContent", "overview", "content"], fallback.description),
direction: value(["direction", "taskDirection", "category"], fallback.direction),
priority: value(["priority", "level", "urgency"], fallback.priority ?? "紧急"),
source: (() => {
const source = value(["source", "taskSource", "sourceName"], fallback.source);
return source === "mock" ? "本系统创建" : source;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -7,6 +7,9 @@ import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
export type { ClarificationPayload };
/** Stable built-in agent used by the multi-agent roundtable Step 1 flow. */
export const INTENT_AGENT_ID = "roundtable-intent";
/**
* Task-intent agent (Step 1) API. Mirror of multi-agent.ts but with a single
* built-in agent and no per-call coordinator: one /init creates a thread
@@ -103,6 +103,12 @@ export interface Step1PanelProps {
*/
headerLeftSlot?: React.ReactNode;
headerRightSlot?: React.ReactNode;
/** Optional side content rendered below the shared header, beside the dialogue area. */
rightSidebar?: React.ReactNode;
/** Hide the shared question-count badge when a workspace supplies its own header actions. */
hideQuestionCount?: boolean;
/** Optional empty state rendered in the centre of an otherwise blank dialogue list. */
emptyState?: React.ReactNode;
interactiveMessages: InteractiveMessage[];
intentThreadId: string | null;
intentReady: object | null;
@@ -146,6 +152,9 @@ export function Step1Panel(props: Step1PanelProps) {
const {
headerLeftSlot,
headerRightSlot,
rightSidebar,
hideQuestionCount = false,
emptyState,
rootClassName,
interactiveMessages,
intentThreadId,
@@ -230,6 +239,7 @@ export function Step1Panel(props: Step1PanelProps) {
// 顶部标题栏整片脱敏(消息列表正文在下方,不在此容器内,保持原文)。
const headerDzRef = useDesensitizeSubtree<HTMLDivElement>();
const showEmptyState = interactiveMessages.length === 0 && Boolean(emptyState);
return (
<div className={`flex-1 flex flex-col min-h-0 ${rootClassName ?? ""}`}>
@@ -244,34 +254,45 @@ export function Step1Panel(props: Step1PanelProps) {
</div>
)}
<div className="flex shrink-0 items-center gap-2">
<span className="shrink-0 text-[11px] font-bold text-slate-400 bg-white border px-2 py-0.5 rounded-md">
问答轮次:{interactiveMessages.filter((m) => m.sender === "user").length} 轮
</span>
{!hideQuestionCount && (
<span className="shrink-0 text-[11px] font-bold text-slate-400 bg-white border px-2 py-0.5 rounded-md">
问答轮次:{interactiveMessages.filter((m) => m.sender === "user").length} 轮
</span>
)}
{headerRightSlot}
</div>
</div>
<div className={rightSidebar ? "flex min-h-0 flex-1" : "contents"}>
<div className={rightSidebar ? "flex min-w-0 flex-1 flex-col" : "contents"}>
{/* Chat list */}
<div className="relative flex-1 min-h-0 flex flex-col font-normal">
<div
ref={step1ScrollRef}
// overflow-x-hidden:消息列表只竖向滚(见 Step2Panel 同处注释),避免误触横向拖动抢滑块。
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden bg-white"
className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white"
>
<div ref={step1ContentRef} className="space-y-4 p-6">
{interactiveMessages.map((msg) => (
<Step1Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? messageMetrics.get(msg.id) : undefined}
intentReady={intentReady}
pendingIntentClarificationMsgId={pendingIntentClarificationMsgId}
intentClarificationSelections={intentClarificationSelections}
setIntentClarificationSelections={setIntentClarificationSelections}
isIntentSending={isIntentSending}
submitIntentClarifications={submitIntentClarifications}
/>
))}
<div
ref={step1ContentRef}
className={showEmptyState ? "flex min-h-full flex-1 p-6" : "space-y-4 p-6"}
>
{showEmptyState ? (
<div className="flex min-h-full w-full flex-1 items-center justify-center">{emptyState}</div>
) : (
interactiveMessages.map((msg) => (
<Step1Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? messageMetrics.get(msg.id) : undefined}
intentReady={intentReady}
pendingIntentClarificationMsgId={pendingIntentClarificationMsgId}
intentClarificationSelections={intentClarificationSelections}
setIntentClarificationSelections={setIntentClarificationSelections}
isIntentSending={isIntentSending}
submitIntentClarifications={submitIntentClarifications}
/>
))
)}
</div>
</div>
{!step1AtBottom && <ScrollToBottomButton onClick={scrollStep1ToBottom} />}
@@ -453,6 +474,9 @@ export function Step1Panel(props: Step1PanelProps) {
</div>
</div>
)}
</div>
{rightSidebar}
</div>
</div>
);
}
@@ -94,6 +94,8 @@ export interface UseStep1IntentOptions {
taskContext?: Record<string, unknown> | null;
/** Optional task-aware welcome bubble text before the first user turn. */
initialWelcomeText?: string;
/** Keep the dialogue visually empty while its intent thread is initialising. */
showInitialWelcome?: boolean;
/** 主页面统一的 toast 入口。 */
triggerToast: (msg: string) => void;
}
@@ -104,6 +106,7 @@ export interface UseStep1IntentReturn {
intentReady: IntentSummary | null;
intentInitError: string | null;
intentInitErrorDetail: IntentErrorDetail | null;
isIntentInitializing: boolean;
isIntentSending: boolean;
pendingIntentClarificationMsgId: number | null;
/** Per-question selections, keyed by `clarificationKey(msgId, clarId, index)`. */
@@ -172,13 +175,21 @@ function intentPhaseLabel(phase: IntentStreamPhase, toolName?: string): string {
}
export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentReturn {
const { currentStep, selectedModel, taskContext, initialWelcomeText, triggerToast } = options;
const {
currentStep,
selectedModel,
taskContext,
initialWelcomeText,
showInitialWelcome = true,
triggerToast,
} = options;
const [intentThreadId, setIntentThreadId] = useState<string | null>(null);
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
const [intentInitError, setIntentInitError] = useState<string | null>(null);
const [intentInitErrorDetail, setIntentInitErrorDetail] =
useState<IntentErrorDetail | null>(null);
const [isIntentInitializing, setIsIntentInitializing] = useState<boolean>(false);
const [isIntentSending, setIsIntentSending] = useState<boolean>(false);
const [pendingIntentClarificationMsgId, setPendingIntentClarificationMsgId] =
useState<number | null>(null);
@@ -230,18 +241,23 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
setIntentInitError(null);
setIntentInitErrorDetail(null);
setIntentThreadId(null);
setIsIntentInitializing(true);
setPendingIntentClarificationMsgId(null);
setIntentClarificationSelections(new Map());
setTimelineEntries([]);
setInteractiveMessages([
{
id: 1,
sender: "agent",
senderName: "任务理解智能体",
content: initialWelcomeTextRef.current || INTENT_WELCOME_TEXT,
time: nowTime(),
},
]);
setInteractiveMessages(
showInitialWelcome
? [
{
id: 1,
sender: "agent",
senderName: "任务理解智能体",
content: initialWelcomeTextRef.current || INTENT_WELCOME_TEXT,
time: nowTime(),
},
]
: [],
);
(async () => {
try {
// initIntent 当前不在 thread 上落盘 model,只为占位与兼容;空时由后端
@@ -252,6 +268,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
);
if (step1InitGenRef.current !== myGen) return;
setIntentThreadId(res.thread_id);
setIsIntentInitializing(false);
setTimelineEntries([
{
id: 1,
@@ -262,6 +279,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
]);
} catch (err) {
if (step1InitGenRef.current !== myGen) return;
setIsIntentInitializing(false);
console.error("init intent failed:", err);
if (err instanceof IntentInitError) {
setIntentInitError(err.detail?.message || err.message);
@@ -272,13 +290,14 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
}
}
})();
}, []);
}, [showInitialWelcome]);
const resetIntent = useCallback(() => {
stopIntentStream();
step1InitRef.current = false;
step1InitGenRef.current += 1;
setIntentThreadId(null);
setIsIntentInitializing(false);
setIntentReady(null);
setIntentInitError(null);
setIntentInitErrorDetail(null);
@@ -654,6 +673,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
// 或继续显示旧会话的澄清选择。
stopIntentStream();
setIsIntentSending(false);
setIsIntentInitializing(false);
setPendingIntentClarificationMsgId(null);
setIntentClarificationSelections(new Map());
setIntentThreadId(snapshot.intentThreadId);
@@ -690,6 +710,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
intentReady,
intentInitError,
intentInitErrorDetail,
isIntentInitializing,
isIntentSending,
pendingIntentClarificationMsgId,
intentClarificationSelections,
+1 -1
View File
@@ -746,7 +746,7 @@
/* 岗位会商左侧总面板:仅收窄滚动条宽度,沿用全局既有的滑块颜色、圆角和轨道样式。 */
.position-roundtable-sidebar-scrollbar::-webkit-scrollbar {
width: 8px;
width: 5px;
}
.workspace-shell {
@@ -1132,6 +1132,26 @@ the initial intent workspace plus the summary/action-plan closing agents. Do
not delete a built-in id because historical chain nodes persist those ids;
custom role ids may be added and removed.
### Position-roundtable task-scoped session history
`position_roundtable_sessions.external_task_id` is optional. When populated from the frontend route `taskId`, new rows set `task_scoped = true`: a task can have multiple sessions and all users opening that task see the same sessions, nodes and persisted conversation snapshots. When it is NULL/empty, keep the original `user_id`-scoped personal workspace behavior. `20260808_01` adds `task_scoped` with default `false` (so all historical records remain personal) plus the task/update-time index; it must not backfill or make `external_task_id` non-null.
### Roundtable artifact submission status
`roundtable_artifact_submissions` keeps task-level submission state for a
generated deliverable and is deliberately separate from task details and any
external task-system delivery. The API is
`GET /api/roundtable/tasks/{taskId}/artifacts?submitted=true` and
`PUT /api/roundtable/tasks/{taskId}/artifacts/submission`. All callers use the
normal Gateway Bearer authentication; records are shared by task while
`created_by`/`submitted_by`/`cancelled_by` are audit-only. The server derives a
SHA-256 idempotency key from `taskId + sourceKey + path + revision`: submitting
the same revision is an upsert, while cancellation only flips `submitted` and
sets `cancelled_at` (it never deletes history). Store only the sandbox file
reference (`threadId`, path, source/node/session fields, name/MIME and revision)
and timestamps; do not persist Markdown content or call a formal delivery
interface. Previews continue to read the DeerFlow artifact by `threadId + path`.
See `docs/` directory for detailed documentation:
- [../README.md](../README.md) - Offline Docker wheelhouse preparation and startup workflow. The pinned versions in `../offline-runtime-requirements.txt` and `../start_backend.sh` must stay synchronized. `start_backend.sh` stays aligned with the deployment script and adds only the Redis wheel fallback line. The download target defaults to CPython 3.12 on Linux x86_64 and verifies dependency closure using wheelhouse only.
- [CONFIGURATION.md](docs/CONFIGURATION.md) - Configuration options
@@ -144,6 +144,8 @@ FastAPI application providing REST endpoints for frontend integration:
The separately deployed TaskCOP Vue app uses `POST /api/v1/auth/login/username` directly (its `username`, `userId`, or `yUserId` URL parameter identifies the user), so this compatibility flow does not depend on the retired Consumer login service. It calls the Gateway address configured as `b1ConsumerUrl` directly; it does not use a Vite `/api` reverse proxy. An empty TaskCOP store remains empty; task data is introduced through normal creation or the JSON import API. The Gateway CORS middleware always merges the local TaskCOP Vite origins `http://localhost:8080` and `http://127.0.0.1:8080`, including when `GATEWAY_CORS_ORIGINS` is set for another frontend. `scripts/env.sh` also includes them in its startup default. For a deployment, set `GATEWAY_CORS_ORIGINS` to the exact additional frontend origins that are allowed to call the Gateway.
Position-roundtable history supports two modes. A non-empty `external_task_id` from the frontend route's `taskId` creates a row with `task_scoped = true`, so all sessions, nodes and persisted conversation snapshots for that task are shared across users; `user_id` is then creator audit metadata only. When the field is absent or empty, the original per-user personal-session behavior remains unchanged. Alembic revision `20260808_01` adds `task_scoped` with the safe default `false` for historical rows plus the task/update-time index.
AI writing supports a sample-imitation mode: the frontend can upload or paste a sample article, the Gateway extracts text for document files, and the `ai_writing` graph runs a `sample_analyzer` node before normal intent/research/outline/draft stages. The resulting style profile is injected into outline and draft prompts with explicit guardrails against copying source sentences, facts, names, or data.
### Custom Agents
@@ -507,3 +509,23 @@ is required. It is the initial intent-identification workspace and owns the
summary/action-plan closing agents. Built-in rows retain stable ids so
historical chain nodes remain readable; they can be edited or disabled but not
deleted. Custom roles may be freely added or removed.
## Roundtable artifact submission status
The task-scoped roundtable workbench exposes a lightweight submission-status
API for generated deliverables:
- `GET /api/roundtable/tasks/{taskId}/artifacts?submitted=true` returns the
submitted artifact references for a task (or `items: []`).
- `PUT /api/roundtable/tasks/{taskId}/artifacts/submission` records
`submitted: true` or cancels it with `submitted: false`.
Both routes use the normal Gateway `Authorization: Bearer <token>` middleware.
The record is shared by `taskId`; the authenticated user is retained only in
audit columns. Its server-owned idempotency key is
`taskId + sourceKey + path + revision`. Repeating the same submitted payload
returns the existing record; cancellation updates its state and timestamp
without deleting the audit row. DeerFlow stores only the artifact reference
(`threadId` + sandbox `path`, plus the optional session/node/name/MIME fields),
never the Markdown body and never a task-system delivery. Continue to preview
the file through the existing thread-artifact endpoint.
@@ -65,8 +65,9 @@ from app.gateway.routers import (
recommend,
flow_extract,
recommended_questions,
roundtable_chains,
roundtable_diagnostics,
roundtable_chains,
roundtable_artifact_submissions,
roundtable_diagnostics,
roundtable_draft_shares,
roundtable_drafts,
roundtable_jobs,
@@ -1465,8 +1466,11 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Roundtable-planning drafts + recommendation history at /api/roundtable-drafts
app.include_router(roundtable_drafts.router)
# Task-scoped roundtable drafts (taskId 深链聊天记录,独立存、不分权) at /api/roundtable-task-drafts
app.include_router(roundtable_task_drafts.router)
# Task-scoped roundtable drafts (taskId 深链聊天记录,独立存、不分权) at /api/roundtable-task-drafts
app.include_router(roundtable_task_drafts.router)
# Task-scoped artifact submission status; stores references only, never artifact content.
app.include_router(roundtable_artifact_submissions.router)
# Step3 流程图结构化抽取 + 校验 (SSE) at /api/flow-extract
app.include_router(flow_extract.router)
@@ -305,6 +305,11 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.task_report_store = make_task_report_store(sf)
from deerflow.persistence.roundtable_artifact_submissions import make_roundtable_artifact_submission_store
# Task-scoped artifact submission status: shared by task; actor is audit-only.
app.state.roundtable_artifact_submission_store = make_roundtable_artifact_submission_store(sf)
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf)
@@ -15,8 +15,8 @@ from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field, field_validator
from app.gateway.path_utils import aresolve_thread_virtual_path
from deerflow.runtime.user_context import get_effective_user_id
@@ -79,7 +79,10 @@ class NodeResponse(BaseModel):
class SessionResponse(BaseModel):
id: str
# Older personal rows can already contain a mock task id; task_scoped is
# the authoritative opt-in marker for cross-user task sharing.
external_task_id: str | None = None
task_scoped: bool = False
task_snapshot: dict[str, Any] = Field(default_factory=dict)
chain_id: str | None = None
chain_snapshot: dict[str, Any] | None = None
@@ -105,6 +108,14 @@ class SessionCreateRequest(BaseModel):
external_task_id: str | None = Field(default=None, max_length=128)
task_snapshot: dict[str, Any] = Field(default_factory=dict)
@field_validator("external_task_id")
@classmethod
def _normalize_external_task_id(cls, value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip()
return normalized or None
class TaskSnapshotUpdateRequest(BaseModel):
task_snapshot: dict[str, Any] = Field(default_factory=dict)
@@ -180,6 +191,25 @@ def _current_user_id(request: Request) -> str:
return str(user.id) if user is not None else get_effective_user_id()
def _task_snapshot_for_scope(
external_task_id: str | None,
task_snapshot: dict[str, Any],
*,
task_scoped: bool = True,
) -> dict[str, Any]:
"""Lock the snapshot id only for an explicit shared task scope."""
if not task_scoped or not external_task_id:
return dict(task_snapshot)
return {**task_snapshot, "id": external_task_id, "externalTaskId": external_task_id}
def _normalize_external_task_id(value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip()
return normalized or None
def _get_store(request: Request):
store = getattr(request.app.state, "position_roundtable_store", None)
if store is None:
@@ -1082,9 +1112,13 @@ async def seed_demo_session(
@router.get("/sessions", response_model=list[SessionResponse])
async def list_sessions(request: Request, external_task_id: str | None = None) -> list[SessionResponse]:
async def list_sessions(
request: Request,
external_task_id: str | None = Query(default=None, max_length=128),
) -> list[SessionResponse]:
task_id = _normalize_external_task_id(external_task_id)
rows = await _get_store(request).list_sessions(
_current_user_id(request), external_task_id=external_task_id
_current_user_id(request), external_task_id=task_id
)
return [_project_session(row) for row in rows]
@@ -1094,7 +1128,12 @@ async def create_session(request: Request, body: SessionCreateRequest) -> Sessio
row = await _get_store(request).create_session(
_current_user_id(request),
external_task_id=body.external_task_id,
task_snapshot=body.task_snapshot,
task_snapshot=_task_snapshot_for_scope(
body.external_task_id,
body.task_snapshot,
task_scoped=bool(body.external_task_id),
),
task_scoped=bool(body.external_task_id),
)
return _project_session(row)
@@ -1108,8 +1147,16 @@ async def get_session(request: Request, session_id: str) -> SessionDetailRespons
async def update_task_snapshot(
request: Request, session_id: str, body: TaskSnapshotUpdateRequest
) -> SessionResponse:
store, user_id, _ = await _require_writable_session(request, session_id)
row = await store.update_session(session_id, user_id, task_snapshot=body.task_snapshot)
store, user_id, current = await _require_writable_session(request, session_id)
row = await store.update_session(
session_id,
user_id,
task_snapshot=_task_snapshot_for_scope(
current.get("external_task_id"),
body.task_snapshot,
task_scoped=bool(current.get("task_scoped")),
),
)
if row is None:
raise HTTPException(status_code=404, detail="Position roundtable session not found")
return _project_session(row)
@@ -0,0 +1,196 @@
"""Task-scoped submission status for roundtable artifacts.
This API records only an artifact reference (thread + sandbox path) and its
submission state. It intentionally does not fetch task detail, copy Markdown,
or deliver a file to an external task system.
Routes (authenticated by the Gateway middleware):
GET /api/roundtable/tasks/{task_id}/artifacts?submitted=true
PUT /api/roundtable/tasks/{task_id}/artifacts/submission
"""
from __future__ import annotations
from pathlib import PurePosixPath
from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, StrictInt, StrictStr, field_validator
from app.gateway.deps import get_current_user
from deerflow.persistence.roundtable_artifact_submissions import RoundtableArtifactSubmissionStore
router = APIRouter(prefix="/api/roundtable/tasks", tags=["roundtable-artifact-submissions"])
class ArtifactReferenceInput(BaseModel):
"""Reference to a generated file; the file content remains in its thread sandbox."""
model_config = ConfigDict(populate_by_name=True)
session_id: StrictStr | None = Field(default=None, max_length=128, validation_alias=AliasChoices("sessionId", "session_id"))
thread_id: StrictStr | None = Field(default=None, max_length=128, validation_alias=AliasChoices("threadId", "thread_id"))
node_key: StrictStr | None = Field(default=None, max_length=256, validation_alias=AliasChoices("nodeKey", "node_key"))
source_key: StrictStr = Field(..., min_length=1, max_length=512, validation_alias=AliasChoices("sourceKey", "source_key"))
path: StrictStr = Field(..., min_length=1, max_length=2048)
name: StrictStr | None = Field(default=None, max_length=512)
mime_type: StrictStr | None = Field(default=None, max_length=255, validation_alias=AliasChoices("mimeType", "mime_type"))
revision: StrictInt = Field(..., ge=0)
@field_validator("session_id", "thread_id", "node_key", "source_key", "path", "name", "mime_type", mode="before")
@classmethod
def _trim_text(cls, value: Any) -> Any:
if isinstance(value, str):
value = value.strip()
return value or None
return value
class ArtifactSubmissionRequest(BaseModel):
model_config = ConfigDict(populate_by_name=True)
task_id: StrictStr | None = Field(default=None, max_length=128, validation_alias=AliasChoices("taskId", "task_id"))
submitted: bool
submission_id: StrictStr | None = Field(default=None, max_length=64, validation_alias=AliasChoices("submissionId", "submission_id"))
task_delivery_id: StrictStr | None = Field(default=None, max_length=255, validation_alias=AliasChoices("taskDeliveryId", "task_delivery_id"))
artifact: ArtifactReferenceInput
@field_validator("task_id", "submission_id", "task_delivery_id", mode="before")
@classmethod
def _trim_optional_text(cls, value: Any) -> Any:
if isinstance(value, str):
value = value.strip()
return value or None
return value
class ArtifactReferenceResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True)
session_id: str | None = Field(default=None, serialization_alias="sessionId")
thread_id: str | None = Field(default=None, serialization_alias="threadId")
node_key: str | None = Field(default=None, serialization_alias="nodeKey")
source_key: str = Field(serialization_alias="sourceKey")
path: str
name: str | None = None
mime_type: str | None = Field(default=None, serialization_alias="mimeType")
revision: int
class ArtifactSubmissionResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: str
task_id: str = Field(serialization_alias="taskId")
submitted: bool
submitted_at: str | None = Field(default=None, serialization_alias="submittedAt")
cancelled_at: str | None = Field(default=None, serialization_alias="cancelledAt")
task_delivery_id: str | None = Field(default=None, serialization_alias="taskDeliveryId")
artifact: ArtifactReferenceResponse
class ArtifactSubmissionListResponse(BaseModel):
items: list[ArtifactSubmissionResponse]
def _get_store(request: Request) -> RoundtableArtifactSubmissionStore:
store = getattr(request.app.state, "roundtable_artifact_submission_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Roundtable artifact submission store not available")
return store
def _task_id_or_422(value: str) -> str:
task_id = value.strip()
if not task_id:
raise HTTPException(status_code=422, detail="task id is required")
if len(task_id) > 128:
raise HTTPException(status_code=422, detail="task id must be at most 128 characters")
return task_id
def _artifact_payload(artifact: ArtifactReferenceInput) -> dict[str, Any]:
source_key = artifact.source_key
path = artifact.path
if not source_key or not path:
raise HTTPException(status_code=422, detail="artifact.sourceKey and artifact.path are required")
name = artifact.name or PurePosixPath(path).name or path
return {
"session_id": artifact.session_id,
"thread_id": artifact.thread_id,
"node_key": artifact.node_key,
"source_key": source_key,
"path": path,
"name": name,
"mime_type": artifact.mime_type,
"revision": artifact.revision,
}
def _response(row: dict[str, Any]) -> ArtifactSubmissionResponse:
return ArtifactSubmissionResponse(
id=str(row["id"]),
task_id=str(row["task_id"]),
submitted=bool(row["submitted"]),
submitted_at=row.get("submitted_at"),
cancelled_at=row.get("cancelled_at"),
task_delivery_id=row.get("task_delivery_id"),
artifact=ArtifactReferenceResponse(
session_id=row.get("session_id"),
thread_id=row.get("thread_id"),
node_key=row.get("node_key"),
source_key=str(row["source_key"]),
path=str(row["path"]),
name=row.get("name"),
mime_type=row.get("mime_type"),
revision=int(row["revision"]),
),
)
@router.get("/{task_id}/artifacts", response_model=ArtifactSubmissionListResponse)
async def list_artifact_submissions(
request: Request,
task_id: str,
submitted: bool | None = Query(default=True),
) -> ArtifactSubmissionListResponse:
"""List task artifacts by submission state (defaults to submitted only)."""
rows = await _get_store(request).list_by_task(_task_id_or_422(task_id), submitted=submitted)
return ArtifactSubmissionListResponse(items=[_response(row) for row in rows])
@router.put("/{task_id}/artifacts/submission", response_model=ArtifactSubmissionResponse)
async def set_artifact_submission(
request: Request,
task_id: str,
body: ArtifactSubmissionRequest,
) -> ArtifactSubmissionResponse:
"""Create/update a submission or retain an audit row while cancelling it."""
path_task_id = _task_id_or_422(task_id)
if body.task_id is not None and body.task_id != path_task_id:
raise HTTPException(status_code=422, detail="taskId must match the path task id")
artifact = _artifact_payload(body.artifact)
actor_id = await get_current_user(request)
store = _get_store(request)
if body.submitted:
# The frontend previews submitted artifacts by threadId + path. Without
# a thread reference a saved state could never be previewed correctly.
if not artifact["thread_id"]:
raise HTTPException(status_code=422, detail="artifact.threadId is required when submitted is true")
row = await store.submit(
path_task_id,
artifact,
task_delivery_id=body.task_delivery_id,
actor_id=actor_id,
)
else:
row = await store.cancel(
path_task_id,
artifact,
submission_id=body.submission_id,
actor_id=actor_id,
)
if row is None:
raise HTTPException(status_code=404, detail="Artifact submission not found")
return _response(row)
@@ -0,0 +1,69 @@
"""Index optional task-scoped position-roundtable history.
Revision ID: 20260808_01
Revises: 20260729_01
Create Date: 2026-08-08
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260808_01"
down_revision = "20260729_01"
branch_labels = None
depends_on = None
_TABLE = "position_roundtable_sessions"
_INDEX = "ix_position_roundtable_sessions_task_updated"
def _has_table(table_name: str) -> bool:
return table_name in inspect(op.get_bind()).get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
return any(
column["name"] == column_name
for column in inspect(op.get_bind()).get_columns(table_name)
)
def _has_index(table_name: str, index_name: str) -> bool:
if not _has_table(table_name):
return False
return index_name in {
index["name"] for index in inspect(op.get_bind()).get_indexes(table_name)
}
def upgrade() -> None:
if not _has_table(_TABLE):
return
# Historical records must stay in the original personal mode. New records
# set this flag only when a route taskId explicitly requests sharing.
if not _has_column(_TABLE, "task_scoped"):
op.add_column(
_TABLE,
sa.Column(
"task_scoped",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
)
if not _has_index(_TABLE, _INDEX):
op.create_index(_INDEX, _TABLE, ["external_task_id", "updated_at"])
def downgrade() -> None:
if not _has_table(_TABLE):
return
if _has_index(_TABLE, _INDEX):
op.drop_index(_INDEX, table_name=_TABLE)
if _has_column(_TABLE, "task_scoped"):
op.drop_column(_TABLE, "task_scoped")
@@ -56,6 +56,7 @@ from deerflow.persistence.positions.model import PositionRow, PositionTagBinding
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
from deerflow.persistence.research_templates.model import ResearchTemplateRow
from deerflow.persistence.roundtable_chains.model import RoundtableChainRow
from deerflow.persistence.roundtable_artifact_submissions.model import RoundtableArtifactSubmissionRow
from deerflow.persistence.roundtable_diagnostics.model import RoundtableDiagnosticRow
from deerflow.persistence.roundtable_draft_shares.model import RoundtableDraftShareRow
from deerflow.persistence.roundtable_drafts.model import (
@@ -118,6 +119,7 @@ __all__ = [
"RecommendedQuestionRow",
"ResearchTemplateRow",
"RoundtableChainRow",
"RoundtableArtifactSubmissionRow",
"RoundtableDraftRow",
"RoundtableDraftShareRow",
"RoundtableDiagnosticRow",
@@ -12,7 +12,7 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy import Boolean, ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
@@ -21,12 +21,23 @@ from deerflow.persistence.types import BeijingDateTime, PortableLongText
class PositionRoundtableSessionRow(Base):
__tablename__ = "position_roundtable_sessions"
__table_args__ = (
# History is read as "all conversations for one task, newest first".
# `user_id` remains audit data only; it must not partition a shared
# task workspace.
Index(
"ix_position_roundtable_sessions_task_updated",
"external_task_id",
"updated_at",
),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
# Phase one uses normal account isolation. A future membership / role ACL
# can be added without changing the session or thread identifiers.
# Creator audit field. `task_scoped`, not merely external_task_id, opts a
# session into sharing. This keeps historical sidebar/mock rows private.
user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
external_task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
task_scoped: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="0")
task_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
chain_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
chain_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
@@ -46,11 +46,17 @@ class PositionRoundtableRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@staticmethod
def _can_access(row: PositionRoundtableSessionRow, user_id: str) -> bool:
"""Task-bound sessions are shared; unbound legacy sessions stay private."""
return bool(row.task_scoped) or row.user_id == user_id
@staticmethod
def _session_dict(row: PositionRoundtableSessionRow) -> dict[str, Any]:
return {
"id": row.id,
"external_task_id": row.external_task_id,
"task_scoped": bool(row.task_scoped),
"task_snapshot": _loads(row.task_snapshot) or {},
"chain_id": row.chain_id,
"chain_snapshot": _loads(row.chain_snapshot),
@@ -98,12 +104,14 @@ class PositionRoundtableRepository:
*,
external_task_id: str | None,
task_snapshot: dict[str, Any] | None,
task_scoped: bool | None = None,
) -> dict[str, Any]:
now = datetime.now(UTC)
row = PositionRoundtableSessionRow(
id=uuid4().hex,
user_id=user_id,
external_task_id=external_task_id or None,
task_scoped=bool(external_task_id) if task_scoped is None else task_scoped,
task_snapshot=_dumps(task_snapshot or {}),
status="intent_pending",
created_at=now,
@@ -165,7 +173,8 @@ class PositionRoundtableRepository:
row = PositionRoundtableSessionRow(
id=session_id,
user_id=user_id,
external_task_id=session_payload.get("external_task_id") or None,
external_task_id=str(session_payload.get("external_task_id") or "").strip() or None,
task_scoped=False,
task_snapshot=_dumps(session_payload.get("task_snapshot") or {}),
chain_id=session_payload.get("chain_id") or None,
chain_snapshot=_dumps(session_payload.get("chain_snapshot")),
@@ -246,9 +255,17 @@ class PositionRoundtableRepository:
async def list_sessions(
self, user_id: str, *, external_task_id: str | None = None, limit: int = 50
) -> list[dict[str, Any]]:
stmt = select(PositionRoundtableSessionRow).where(PositionRoundtableSessionRow.user_id == user_id)
"""List shared task history or the caller's legacy personal history."""
if external_task_id:
stmt = stmt.where(PositionRoundtableSessionRow.external_task_id == external_task_id)
stmt = select(PositionRoundtableSessionRow).where(
PositionRoundtableSessionRow.external_task_id == external_task_id,
PositionRoundtableSessionRow.task_scoped.is_(True),
)
else:
stmt = select(PositionRoundtableSessionRow).where(
PositionRoundtableSessionRow.user_id == user_id,
PositionRoundtableSessionRow.task_scoped.is_(False),
)
stmt = stmt.order_by(PositionRoundtableSessionRow.updated_at.desc()).limit(limit)
async with self._sf() as session:
rows = (await session.execute(stmt)).scalars().all()
@@ -257,7 +274,7 @@ class PositionRoundtableRepository:
async def get_session(self, session_id: str, user_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(PositionRoundtableSessionRow, session_id)
if row is None or row.user_id != user_id:
if row is None or not self._can_access(row, user_id):
return None
return self._session_dict(row)
@@ -280,7 +297,7 @@ class PositionRoundtableRepository:
) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(PositionRoundtableSessionRow, session_id)
if row is None or row.user_id != user_id:
if row is None or not self._can_access(row, user_id):
return None
if task_snapshot is not _UNSET:
row.task_snapshot = _dumps(task_snapshot)
@@ -326,7 +343,7 @@ class PositionRoundtableRepository:
now = datetime.now(UTC)
async with self._sf() as session:
row = await session.get(PositionRoundtableSessionRow, session_id)
if row is None or row.user_id != user_id:
if row is None or not self._can_access(row, user_id):
return None
node_rows = (
await session.execute(
@@ -371,7 +388,7 @@ class PositionRoundtableRepository:
async def list_nodes(self, session_id: str, user_id: str) -> list[dict[str, Any]] | None:
async with self._sf() as session:
parent = await session.get(PositionRoundtableSessionRow, session_id)
if parent is None or parent.user_id != user_id:
if parent is None or not self._can_access(parent, user_id):
return None
rows = (
await session.execute(
@@ -384,16 +401,13 @@ class PositionRoundtableRepository:
async def get_node(self, session_id: str, node_key: str, user_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
stmt = (
select(PositionRoundtableNodeRow)
.join(PositionRoundtableSessionRow, PositionRoundtableSessionRow.id == PositionRoundtableNodeRow.session_id)
.where(
PositionRoundtableNodeRow.session_id == session_id,
PositionRoundtableNodeRow.node_key == node_key,
PositionRoundtableSessionRow.user_id == user_id,
)
.limit(1)
)
parent = await session.get(PositionRoundtableSessionRow, session_id)
if parent is None or not self._can_access(parent, user_id):
return None
stmt = select(PositionRoundtableNodeRow).where(
PositionRoundtableNodeRow.session_id == session_id,
PositionRoundtableNodeRow.node_key == node_key,
).limit(1)
row = (await session.execute(stmt)).scalars().first()
return self._node_dict(row) if row is not None else None
@@ -415,16 +429,13 @@ class PositionRoundtableRepository:
increment_rejection: bool = False,
) -> dict[str, Any] | None:
async with self._sf() as session:
stmt = (
select(PositionRoundtableNodeRow)
.join(PositionRoundtableSessionRow, PositionRoundtableSessionRow.id == PositionRoundtableNodeRow.session_id)
.where(
PositionRoundtableNodeRow.session_id == session_id,
PositionRoundtableNodeRow.node_key == node_key,
PositionRoundtableSessionRow.user_id == user_id,
)
.limit(1)
)
parent = await session.get(PositionRoundtableSessionRow, session_id)
if parent is None or not self._can_access(parent, user_id):
return None
stmt = select(PositionRoundtableNodeRow).where(
PositionRoundtableNodeRow.session_id == session_id,
PositionRoundtableNodeRow.node_key == node_key,
).limit(1)
row = (await session.execute(stmt)).scalars().first()
if row is None:
return None
@@ -472,7 +483,7 @@ class PositionRoundtableRepository:
now = datetime.now(UTC)
async with self._sf() as session:
parent = await session.get(PositionRoundtableSessionRow, session_id)
if parent is None or parent.user_id != user_id:
if parent is None or not self._can_access(parent, user_id):
return None
rows = (
await session.execute(
@@ -538,19 +549,18 @@ class PositionRoundtableRepository:
return await self.update_session(session_id, user_id, status="archived")
async def delete_session(self, session_id: str, user_id: str) -> bool:
"""Delete one user-owned history record and all of its node snapshots."""
"""Delete a task-shared row or the caller's personal legacy row."""
async with self._sf() as session:
parent = (
await session.execute(
select(PositionRoundtableSessionRow)
.where(
PositionRoundtableSessionRow.id == session_id,
PositionRoundtableSessionRow.user_id == user_id,
)
.limit(1)
)
).scalar_one_or_none()
if parent is None:
if parent is None or not self._can_access(parent, user_id):
return False
# Do not rely on database-specific FK cascade settings. SQLite
@@ -564,7 +574,6 @@ class PositionRoundtableRepository:
await session.execute(
delete(PositionRoundtableSessionRow).where(
PositionRoundtableSessionRow.id == session_id,
PositionRoundtableSessionRow.user_id == user_id,
)
)
await session.commit()
@@ -0,0 +1,22 @@
"""Task-scoped roundtable artifact submission status persistence."""
from __future__ import annotations
from deerflow.persistence.roundtable_artifact_submissions.base import RoundtableArtifactSubmissionStore
from deerflow.persistence.roundtable_artifact_submissions.memory import MemoryRoundtableArtifactSubmissionStore
from deerflow.persistence.roundtable_artifact_submissions.model import RoundtableArtifactSubmissionRow
from deerflow.persistence.roundtable_artifact_submissions.sql import RoundtableArtifactSubmissionRepository
__all__ = [
"MemoryRoundtableArtifactSubmissionStore",
"RoundtableArtifactSubmissionRepository",
"RoundtableArtifactSubmissionRow",
"RoundtableArtifactSubmissionStore",
"make_roundtable_artifact_submission_store",
]
def make_roundtable_artifact_submission_store(session_factory):
if session_factory is None:
return MemoryRoundtableArtifactSubmissionStore()
return RoundtableArtifactSubmissionRepository(session_factory)
@@ -0,0 +1,44 @@
"""Persistence contract for task-scoped roundtable artifact submissions.
The store deliberately keeps only a stable reference to an artifact in a
DeerFlow thread sandbox. It does not copy the artifact body and never calls a
downstream task-delivery service.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class RoundtableArtifactSubmissionStore(ABC):
"""Shared submission-status store, partitioned only by task id."""
@abstractmethod
async def list_by_task(self, task_id: str, *, submitted: bool | None = True) -> list[dict[str, Any]]:
"""Return artifact submission records for one task, newest first."""
raise NotImplementedError
@abstractmethod
async def submit(
self,
task_id: str,
artifact: dict[str, Any],
*,
task_delivery_id: str | None = None,
actor_id: str | None = None,
) -> dict[str, Any]:
"""Create or update the idempotent submission record for an artifact."""
raise NotImplementedError
@abstractmethod
async def cancel(
self,
task_id: str,
artifact: dict[str, Any],
*,
submission_id: str | None = None,
actor_id: str | None = None,
) -> dict[str, Any] | None:
"""Mark a submission as cancelled without deleting its audit record."""
raise NotImplementedError
@@ -0,0 +1,127 @@
"""In-memory roundtable artifact-submission store."""
from __future__ import annotations
from copy import deepcopy
from datetime import UTC, datetime
from hashlib import sha256
from typing import Any
from uuid import uuid4
from deerflow.persistence.roundtable_artifact_submissions.base import RoundtableArtifactSubmissionStore
from deerflow.persistence.types import BEIJING_TZ
def submission_key(task_id: str, artifact: dict[str, Any]) -> str:
"""Return the canonical server-owned idempotency key for an artifact."""
source_key = str(artifact["source_key"])
path = str(artifact["path"])
revision = int(artifact["revision"])
raw = "\x1f".join((task_id, source_key, path, str(revision)))
return sha256(raw.encode("utf-8")).hexdigest()
def _now() -> str:
return datetime.now(UTC).astimezone(BEIJING_TZ).isoformat()
def _artifact_values(artifact: dict[str, Any]) -> dict[str, Any]:
return {
"session_id": artifact.get("session_id"),
"thread_id": artifact.get("thread_id"),
"node_key": artifact.get("node_key"),
"source_key": artifact["source_key"],
"path": artifact["path"],
"name": artifact.get("name"),
"mime_type": artifact.get("mime_type"),
"revision": int(artifact["revision"]),
}
class MemoryRoundtableArtifactSubmissionStore(RoundtableArtifactSubmissionStore):
def __init__(self) -> None:
self._rows: dict[tuple[str, str], dict[str, Any]] = {}
async def list_by_task(self, task_id: str, *, submitted: bool | None = True) -> list[dict[str, Any]]:
rows = [row for row in self._rows.values() if row["task_id"] == task_id and (submitted is None or row["submitted"] is submitted)]
rows.sort(key=lambda row: row["updated_at"], reverse=True)
return deepcopy(rows)
async def submit(
self,
task_id: str,
artifact: dict[str, Any],
*,
task_delivery_id: str | None = None,
actor_id: str | None = None,
) -> dict[str, Any]:
key = submission_key(task_id, artifact)
row = self._rows.get((task_id, key))
values = _artifact_values(artifact)
if row is None:
now = _now()
row = {
"id": uuid4().hex,
"task_id": task_id,
"idempotency_key": key,
**values,
"task_delivery_id": task_delivery_id,
"submitted": True,
"submitted_at": now,
"cancelled_at": None,
"created_by": actor_id,
"updated_by": actor_id,
"submitted_by": actor_id,
"cancelled_by": None,
"created_at": now,
"updated_at": now,
}
self._rows[(task_id, key)] = row
return deepcopy(row)
changed = (
not row["submitted"]
or row.get("task_delivery_id") != task_delivery_id
or any(row.get(name) != value for name, value in values.items())
)
if changed:
now = _now()
row.update(values)
row.update(
{
"task_delivery_id": task_delivery_id,
"submitted": True,
"submitted_at": now,
"cancelled_at": None,
"updated_by": actor_id,
"submitted_by": actor_id,
"cancelled_by": None,
"updated_at": now,
}
)
return deepcopy(row)
async def cancel(
self,
task_id: str,
artifact: dict[str, Any],
*,
submission_id: str | None = None,
actor_id: str | None = None,
) -> dict[str, Any] | None:
key = submission_key(task_id, artifact)
row = self._rows.get((task_id, key))
if row is None or (submission_id is not None and row["id"] != submission_id):
return None
if row["submitted"]:
now = _now()
row.update(
{
"submitted": False,
"cancelled_at": now,
"updated_by": actor_id,
"cancelled_by": actor_id,
"updated_at": now,
}
)
return deepcopy(row)
@@ -0,0 +1,56 @@
"""ORM model for task-scoped roundtable artifact submission status."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Boolean, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class RoundtableArtifactSubmissionRow(Base):
"""One auditable submission state for one task artifact revision.
The natural idempotency key is a SHA-256 digest of
``task_id + source_key + artifact_path + revision``. Keeping the digest
short avoids a MySQL unique-index limit while the component columns remain
available for diagnostics and response shaping.
"""
__tablename__ = "roundtable_artifact_submissions"
__table_args__ = (
UniqueConstraint("task_id", "idempotency_key", name="uq_roundtable_artifact_submission_task_key"),
Index("ix_roundtable_artifact_submission_task_state_updated", "task_id", "submitted", "updated_at"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
task_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
idempotency_key: Mapped[str] = mapped_column(String(64), nullable=False)
source_key: Mapped[str] = mapped_column(String(512), nullable=False)
artifact_path: Mapped[str] = mapped_column(String(2048), nullable=False)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
session_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
node_key: Mapped[str | None] = mapped_column(String(256), nullable=True)
artifact_name: Mapped[str | None] = mapped_column(String(512), nullable=True)
mime_type: Mapped[str | None] = mapped_column(String(255), nullable=True)
task_delivery_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
submitted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
submitted_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
cancelled_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), nullable=True)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
submitted_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
cancelled_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(
BeijingDateTime(),
nullable=False,
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
@@ -0,0 +1,188 @@
"""SQLAlchemy-backed task-scoped roundtable artifact-submission store."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.roundtable_artifact_submissions.base import RoundtableArtifactSubmissionStore
from deerflow.persistence.roundtable_artifact_submissions.memory import submission_key
from deerflow.persistence.roundtable_artifact_submissions.model import RoundtableArtifactSubmissionRow
def _timestamp(value: datetime | None) -> str | None:
return value.isoformat() if isinstance(value, datetime) else value
def _to_dict(row: RoundtableArtifactSubmissionRow) -> dict[str, Any]:
return {
"id": row.id,
"task_id": row.task_id,
"idempotency_key": row.idempotency_key,
"session_id": row.session_id,
"thread_id": row.thread_id,
"node_key": row.node_key,
"source_key": row.source_key,
"path": row.artifact_path,
"name": row.artifact_name,
"mime_type": row.mime_type,
"revision": row.revision,
"task_delivery_id": row.task_delivery_id,
"submitted": row.submitted,
"submitted_at": _timestamp(row.submitted_at),
"cancelled_at": _timestamp(row.cancelled_at),
"created_by": row.created_by,
"updated_by": row.updated_by,
"submitted_by": row.submitted_by,
"cancelled_by": row.cancelled_by,
"created_at": _timestamp(row.created_at),
"updated_at": _timestamp(row.updated_at),
}
def _apply_artifact(row: RoundtableArtifactSubmissionRow, artifact: dict[str, Any]) -> None:
row.session_id = artifact.get("session_id")
row.thread_id = artifact.get("thread_id")
row.node_key = artifact.get("node_key")
row.source_key = artifact["source_key"]
row.artifact_path = artifact["path"]
row.artifact_name = artifact.get("name")
row.mime_type = artifact.get("mime_type")
row.revision = int(artifact["revision"])
def _requires_submission_update(
row: RoundtableArtifactSubmissionRow,
artifact: dict[str, Any],
task_delivery_id: str | None,
) -> bool:
return (
not row.submitted
or row.task_delivery_id != task_delivery_id
or row.session_id != artifact.get("session_id")
or row.thread_id != artifact.get("thread_id")
or row.node_key != artifact.get("node_key")
or row.artifact_name != artifact.get("name")
or row.mime_type != artifact.get("mime_type")
)
class RoundtableArtifactSubmissionRepository(RoundtableArtifactSubmissionStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def list_by_task(self, task_id: str, *, submitted: bool | None = True) -> list[dict[str, Any]]:
stmt = select(RoundtableArtifactSubmissionRow).where(RoundtableArtifactSubmissionRow.task_id == task_id)
if submitted is not None:
stmt = stmt.where(RoundtableArtifactSubmissionRow.submitted.is_(submitted))
stmt = stmt.order_by(RoundtableArtifactSubmissionRow.updated_at.desc())
async with self._sf() as session:
result = await session.execute(stmt)
return [_to_dict(row) for row in result.scalars()]
async def submit(
self,
task_id: str,
artifact: dict[str, Any],
*,
task_delivery_id: str | None = None,
actor_id: str | None = None,
) -> dict[str, Any]:
key = submission_key(task_id, artifact)
stmt = select(RoundtableArtifactSubmissionRow).where(
RoundtableArtifactSubmissionRow.task_id == task_id,
RoundtableArtifactSubmissionRow.idempotency_key == key,
)
async with self._sf() as session:
result = await session.execute(stmt)
row = result.scalars().first()
now = datetime.now(UTC)
created = row is None
if row is None:
row = RoundtableArtifactSubmissionRow(
id=uuid4().hex,
task_id=task_id,
idempotency_key=key,
task_delivery_id=task_delivery_id,
submitted=True,
submitted_at=now,
created_by=actor_id,
updated_by=actor_id,
submitted_by=actor_id,
created_at=now,
updated_at=now,
)
_apply_artifact(row, artifact)
session.add(row)
elif _requires_submission_update(row, artifact, task_delivery_id):
_apply_artifact(row, artifact)
row.task_delivery_id = task_delivery_id
row.submitted = True
row.submitted_at = now
row.cancelled_at = None
row.updated_by = actor_id
row.submitted_by = actor_id
row.cancelled_by = None
row.updated_at = now
try:
await session.commit()
except IntegrityError:
# Two callers can both observe a missing row. The unique key
# turns that race into a normal idempotent retry instead of a
# 500. If their non-key metadata differs, apply this request
# after loading the winner so PUT still has upsert semantics.
await session.rollback()
if not created:
raise
result = await session.execute(stmt)
row = result.scalars().first()
if row is None:
raise
if _requires_submission_update(row, artifact, task_delivery_id):
now = datetime.now(UTC)
_apply_artifact(row, artifact)
row.task_delivery_id = task_delivery_id
row.submitted = True
row.submitted_at = now
row.cancelled_at = None
row.updated_by = actor_id
row.submitted_by = actor_id
row.cancelled_by = None
row.updated_at = now
await session.commit()
await session.refresh(row)
return _to_dict(row)
async def cancel(
self,
task_id: str,
artifact: dict[str, Any],
*,
submission_id: str | None = None,
actor_id: str | None = None,
) -> dict[str, Any] | None:
key = submission_key(task_id, artifact)
stmt = select(RoundtableArtifactSubmissionRow).where(
RoundtableArtifactSubmissionRow.task_id == task_id,
RoundtableArtifactSubmissionRow.idempotency_key == key,
)
async with self._sf() as session:
result = await session.execute(stmt)
row = result.scalars().first()
if row is None or (submission_id is not None and row.id != submission_id):
return None
if row.submitted:
now = datetime.now(UTC)
row.submitted = False
row.cancelled_at = now
row.updated_by = actor_id
row.cancelled_by = actor_id
row.updated_at = now
await session.commit()
await session.refresh(row)
return _to_dict(row)
@@ -117,6 +117,11 @@ CALL _add_column_if_missing('knowledge_notes', 'folder', 'VARCHAR(512) NULL DEFA
-- ORM: RoundtableJobRow.task_id = String(128), nullable, indexed。
CALL _add_column_if_missing('roundtable_jobs', 'task_id', 'VARCHAR(128) NULL DEFAULT NULL');
-- ---- position_roundtable_sessions: opt-in task sharing, migration 20260808_01 ----
-- Existing records remain personal (`task_scoped = 0`). New records become
-- shared only when the caller explicitly sends a non-empty route taskId.
CALL _add_column_if_missing('position_roundtable_sessions', 'task_scoped', 'TINYINT(1) NOT NULL DEFAULT 0');
-- ---- 按钮管理「外部链接」追加登录参数,对应 migration 20260625_01 ----------------
-- login_params_json: url 外链追加的登录参数列表 JSON([{key,source,custom_value},...]),
-- 由前端运行时从 userInfo 解析后拼到地址尾部;NULL/空 = 不追加(旧行为)。
@@ -199,5 +204,6 @@ SELECT table_name, column_name
OR (table_name = 'agents' AND column_name IN ('square_id'))
OR (table_name = 'roundtable_drafts' AND column_name = 'task_id')
OR (table_name = 'roundtable_jobs' AND column_name = 'task_id')
OR (table_name = 'position_roundtable_sessions' AND column_name = 'task_scoped')
OR (table_name = 'task_buttons' AND column_name IN ('append_auth', 'auth_token_param', 'auth_name_param')))
ORDER BY table_name, column_name;
@@ -68,6 +68,10 @@ CALL _create_index_if_missing('llm_call_metrics', 'ix_llm_call_metrics_run_creat
CALL _create_index_if_missing('threads_meta', 'ix_threads_meta_created_user', '`created_at`, `user_id`');
CALL _create_index_if_missing('threads_meta', 'ix_threads_meta_user_created', '`user_id`, `created_at`');
-- ---- position_roundtable_sessions --------------------------------------------
-- Explicit taskId history is loaded by taskId and newest update time.
CALL _create_index_if_missing('position_roundtable_sessions', 'ix_position_roundtable_sessions_task_updated', '`external_task_id`, `updated_at`');
-- ---- 清理临时存储过程 --------------------------------------------------------
DROP PROCEDURE IF EXISTS _create_index_if_missing;
@@ -208,14 +208,91 @@ async def test_conversation_snapshots_round_trip_and_history_delete_removes_node
assert saved_node is not None
assert saved_node["conversation_snapshot"] == node_conversation
# Ownership checks apply to delete as well.
assert not await store.delete_session(created["id"], "user-b")
assert await store.get_session(created["id"], "user-a") is not None
assert await store.delete_session(created["id"], "user-a")
# Position roundtable history is shared by taskId, so a collaborator can
# read and remove the same task conversation without being its creator.
assert await store.get_session(created["id"], "user-b") is not None
assert await store.delete_session(created["id"], "user-b")
assert await store.get_session(created["id"], "user-a") is None
assert await store.list_nodes(created["id"], "user-a") is None
@pytest.mark.asyncio
async def test_task_scoped_history_is_shared_but_never_mixes_tasks(store):
shared = await store.create_session(
"user-a",
external_task_id="external-task-100",
task_snapshot={"id": "external-task-100", "title": "共享任务"},
)
await store.create_session(
"user-b",
external_task_id="external-task-200",
task_snapshot={"id": "external-task-200", "title": "另一任务"},
)
visible_to_collaborator = await store.list_sessions(
"user-b", external_task_id="external-task-100"
)
assert [row["id"] for row in visible_to_collaborator] == [shared["id"]]
updated = await store.update_session(
shared["id"],
"user-b",
task_snapshot={"id": "external-task-100", "title": "协作者已更新"},
)
assert updated is not None
assert updated["task_snapshot"]["title"] == "协作者已更新"
assert (await store.get_session(shared["id"], "user-a"))["task_snapshot"]["title"] == "协作者已更新"
@pytest.mark.asyncio
async def test_unscoped_history_keeps_original_user_isolation(store):
personal = await store.create_session(
"user-a",
external_task_id=None,
task_snapshot={"id": "legacy-card-id", "title": "个人会话"},
)
assert await store.get_session(personal["id"], "user-b") is None
assert await store.list_sessions("user-b") == []
assert await store.update_session(personal["id"], "user-b", status="active") is None
assert not await store.delete_session(personal["id"], "user-b")
# Old client records could already carry a mock task id. The new database
# marker keeps them personal after the migration; only a new explicit
# route taskId creates a shared row.
legacy_with_mock_task_id = await store.create_session(
"user-a",
external_task_id="legacy-mock-task-id",
task_scoped=False,
task_snapshot={"id": "legacy-card-id", "title": "鏃х増涓汉浼氳瘽"},
)
assert await store.get_session(legacy_with_mock_task_id["id"], "user-b") is None
assert await store.list_sessions("user-b", external_task_id="legacy-mock-task-id") == []
def test_session_create_allows_missing_task_id_and_scoped_snapshots_keep_scope():
body = position_roundtable_router.SessionCreateRequest(
external_task_id=" task-route-001 ",
task_snapshot={"id": "wrong-id", "title": "任务"},
)
assert body.external_task_id == "task-route-001"
assert position_roundtable_router._task_snapshot_for_scope(
body.external_task_id,
body.task_snapshot,
) == {
"id": "task-route-001",
"externalTaskId": "task-route-001",
"title": "任务",
}
assert position_roundtable_router.SessionCreateRequest(task_snapshot={}).external_task_id is None
assert position_roundtable_router.SessionCreateRequest(external_task_id=" ").external_task_id is None
assert position_roundtable_router._task_snapshot_for_scope(
"legacy-mock-task-id",
{"id": "legacy-card-id", "title": "旧记录"},
task_scoped=False,
) == {"id": "legacy-card-id", "title": "旧记录"}
@pytest.mark.asyncio
async def test_demo_seed_is_complete_idempotent_and_user_scoped(store):
payload = {
@@ -490,6 +567,7 @@ async def test_builtin_summary_completion_persists_fallback_markdown_when_agent_
}
session = {
"id": "session-a",
"external_task_id": "task-fallback",
"status": "active",
"task_snapshot": {},
"chain_id": "chain-a",
@@ -561,7 +639,7 @@ async def test_builtin_summary_completion_persists_fallback_markdown_when_agent_
@pytest.mark.asyncio
async def test_node_updates_are_user_scoped_and_keep_thread_out_of_messages(store):
async def test_node_updates_are_user_scoped_without_task_id(store):
created = await store.create_session("user-a", external_task_id=None, task_snapshot={})
_, nodes = await store.activate_session(
created["id"],
@@ -602,7 +680,9 @@ async def test_node_updates_are_user_scoped_and_keep_thread_out_of_messages(stor
@pytest.mark.asyncio
async def test_reject_node_marks_current_and_invalidates_later_stages(store):
created = await store.create_session("user-a", external_task_id=None, task_snapshot={})
created = await store.create_session(
"user-a", external_task_id="task-reject", task_snapshot={}
)
_, nodes = await store.activate_session(
created["id"],
"user-a",
@@ -682,7 +762,7 @@ async def test_reject_node_marks_current_and_invalidates_later_stages(store):
async def test_builtin_summary_and_action_plan_snapshots_are_invalidated_by_reject(store):
created = await store.create_session(
"user-a",
external_task_id=None,
external_task_id="task-builtin-invalidation",
task_snapshot={
"title": "测试任务",
"description": "形成协同方案",
Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,624 @@
from __future__ import annotations
"""Build the user-focused DeerFlow maintenance handbook.
This deliberately reuses the established Word style helpers and diagrams from
the original builder, but changes the information architecture for a normal
Docker deployment: no 1Panel-, ARM-, or platform-specific deployment content.
"""
from pathlib import Path
import build_manual as b
OUT = Path(__file__).resolve().parent / "DeerFlow系统运维手册-用户重点版.docx"
def set_running_furniture(doc):
"""Replace the prior deployment-specific running header/footer."""
header_p = doc.sections[0].header.paragraphs[0]
header_p.clear()
header_p.alignment = b.WD_ALIGN_PARAGRAPH.RIGHT
run = header_p.add_run("DeerFlow 系统运维手册 | 用户重点版")
b.set_run_font(run, size=8.5, color=b.GRAY)
footer_p = doc.sections[0].footer.paragraphs[0]
footer_p.clear()
footer_p.alignment = b.WD_ALIGN_PARAGRAPH.CENTER
run = footer_p.add_run("内部运维资料 · 第 ")
b.set_run_font(run, size=8.5, color=b.GRAY)
b.add_field(footer_p, "PAGE", "1")
run = footer_p.add_run(" 页")
b.set_run_font(run, size=8.5, color=b.GRAY)
def add_cover(doc, diagrams):
p = doc.add_paragraph()
p.paragraph_format.space_before = b.Inches(0.78)
p.paragraph_format.space_after = b.Pt(10)
run = p.add_run("DeerFlow")
b.set_run_font(run, size=16, bold=True, color=b.BLUE)
p = doc.add_paragraph(style="Title")
p.paragraph_format.space_before = b.Pt(16)
run = p.add_run("系统运维手册")
b.set_run_font(run, size=30, bold=True, color=b.DARK_BLUE)
p = doc.add_paragraph(style="Subtitle")
run = p.add_run("通用 Docker 容器版 · 配置、资产与故障处理优先")
b.set_run_font(run, size=15, color=b.GRAY)
b.add_figure(doc, diagrams["architecture"], "系统访问与数据流概览", "浏览器经由 Web 容器访问前端和后端服务的流程", width=6.1)
b.add_callout(
doc,
"本版重点",
"以常规 Docker 容器运行环境为基准;将常见报错、config.yaml、.env、技能/智能体和前端管理设置提前,便于业务管理员直接查用。",
"note",
)
b.add_table(
doc,
["文档版本", "适用范围", "更新日期"],
[["V1.1", "当前 DeerFlow Docker 部署包及本项目定制功能", "2026-08-08"]],
[1800, 5000, 2560],
font_size=9.2,
)
b.add_para(doc, "密级:内部运维资料。密钥、密码、IP、数据库账号均必须使用占位符;不要写入本手册。", italic=True, align=b.WD_ALIGN_PARAGRAPH.CENTER, after=0)
def add_navigation(doc):
p = doc.add_paragraph(style="Title")
p.paragraph_format.page_break_before = True
run = p.add_run("先用目录找到答案")
b.set_run_font(run, size=24, bold=True, color=b.DARK_BLUE)
b.add_para(doc, "本手册按“先排错、再配置、再维护”的顺序编排。打开 Word 后可使用左侧导航窗格按标题跳转;目录页码没有更新时,按 Ctrl+A 后按 F9。")
toc = doc.add_paragraph()
toc.paragraph_format.space_after = b.Pt(8)
b.add_field(toc, r'TOC \o "1-3" \h \z \u', "目录将在 Word 中自动更新")
b.add_callout(doc, "最常用的五处", "页面或服务异常先看第 1 章;模型与 max_tokens 看第 2 章;登录、数据库和接口地址看第 3 章;技能/智能体问题看第 4 章;菜单、敏感词、主题看第 5 章。", "ok")
b.add_h2(doc, "阅读约定")
for text in [
"“部署目录”指存放 docker-compose.yml、.env、config 和 data 的当前运行目录。所有命令都应在此目录执行。",
"前端和后端不是同一个容器端口。用户通常只访问 APP_PORT;Web/Nginx 会把 /deerflow 请求转发到后端。",
"改配置前先备份原文件;改完只重启受影响服务;保存日志、健康检查和实际功能验证结果。",
"本文中的 <尖括号内容> 都是待替换占位符,不能原样复制到生产环境。",
]:
b.add_bullet(doc, text)
def add_chapter_common_errors(doc):
b.add_h1(doc, "1. 常见报错与第一时间怎么处理")
b.add_para(doc, "先收集状态再修改配置。大多数问题并非代码故障,而是当前目录错误、环境变量未重新加载、后端未启动、数据库连不上、权限不足或资产被过滤。")
b.add_h2(doc, "1.1 先执行这组无损检查")
b.add_code(doc, """
# 必须在部署目录执行
pwd
sudo docker compose ps
sudo docker compose logs --tail=200 backend
sudo docker compose logs --tail=200 web
curl -fsS http://127.0.0.1:<APP_PORT>/deerflow/health
""", "Shell")
b.add_callout(doc, "先不要做", "不要执行 docker system prune -a;不要删除 data 后重启;不要创建同名技能或智能体覆盖可能的恢复线索;不要把完整 backend.env 发到群里。", "danger")
b.add_h2(doc, "1.2 容器、端口和磁盘")
b.add_table(
doc,
["现象/报错", "优先检查", "处理方式"],
[
["address already in use", "APP_PORT 已被其他进程占用", "用 ss -lntp 查占用;修改部署目录 .env 的 APP_PORT,然后重建 web。"],
["容器不断 Restarting", "backend 的最后 200 行日志", "重点查看 YAML、环境变量、数据库 URL、挂载权限或启动命令;修复后再重建 backend。"],
["no space left on device", "磁盘、inode、data 和日志目录", "先清理可确认的旧日志或无用缓存;不要在未备份时清理 data 或盲目 prune。"],
["permission denied", "宿主机挂载目录所有者和容器运行用户", "核对 compose volume 与目录权限;只对目标目录做最小权限修正,不使用 chmod -R 777。"],
["Web 访问失败", "docker compose ps、APP_PORT、主机防火墙", "确认 web 容器正常、端口监听,并放行实际访问端口。"],
],
[2200, 2650, 4510],
font_size=8.8,
)
b.add_h2(doc, "1.3 前端、Nginx 和接口")
b.add_table(
doc,
["现象", "原因方向", "解决"],
[
["502 Bad Gateway", "backend 未启动或 Web 无法访问 backend:8001", "先查 backend 日志和 Compose 网络;后端恢复后再刷新前端。"],
["刷新二级页面 404", "Nginx 的 SPA 回退缺失", "恢复 Nginx 的 try_files 到 /index.html 配置并重启 web。"],
["页面白屏", "runtime-config.js 语法或地址错误、缓存未刷新", "浏览器 Console/Network 定位;恢复配置,重启 web,使用 Ctrl+F5。"],
["所有 API 都失败", "VITE_BACKEND_BASE_URL 或 VITE_LANGGRAPH_BASE_URL 配错", "优先恢复 /deerflow 和 /deerflow/api 相对路径;见第 3.5 节。"],
["CORS / Mixed Content", "前后端不同源或 HTTPS 页面调用 HTTP 接口", "优先同源代理;必须跨域时在后端配置允许 Origin 并统一 HTTPS。"],
],
[2100, 3000, 4260],
font_size=8.8,
)
b.add_h2(doc, "1.4 登录、模型、数据库和资产")
b.add_table(
doc,
["现象/状态", "最常见原因", "首要动作"],
[
["401 / invalid_api_key", "模型 Key 缺失、变量名不一致或已过期", "对照 config.yaml 的 $变量名 与 backend.env;更新后强制重建 backend。"],
["404 model not found", "model 写成显示名或 base_url 路径不兼容", "使用服务商实际 model ID;核对 API 是否要求 /v1。"],
["429 / timeout", "并发、配额、模型服务或网络问题", "降低并发/重试,检查模型服务和网络连通性。"],
["401 / 403 登录或发布失败", "Token 过期、角色/所有者不足、广场未启用", "重新登录;核对 system_role、资产所有者和目标广场。"],
["技能/智能体看不到", "目录未挂载、数据库切换、过滤、权限或发布状态", "按第 4.6 节逐步排查,不要立即新建同名资产。"],
["MySQL Access denied / timeout", "账号、密码 URL 编码、host 授权、网络或端口", "查看第 3.4 节连接格式和第 7 章数据库恢复原则。"],
],
[2250, 3150, 3960],
font_size=8.65,
)
def add_chapter_model(doc, diagrams):
b.add_h1(doc, "2. config.yaml:模型、max_tokens 与后端配置")
b.add_para(doc, "模型条目由 config/config.yaml 管理;密钥由 config/backend.env 管理。两处的变量名必须完全一致。新增模型时先写密钥,再写模型条目,再验证后端日志和实际对话。")
b.add_figure(doc, diagrams["model_flow"], "图 2-1 模型配置与验证闭环", "从密钥、模型条目到后端验证和前端选择的流程", width=6.35)
b.add_h2(doc, "2.1 新增或修改一个 OpenAI 兼容模型")
b.add_number(doc, "备份 config/backend.env 和 config/config.yaml,并记录要替换的模型名称。")
b.add_number(doc, "在 backend.env 写入密钥,变量名仅供后端读取。")
b.add_code(doc, """
# config/backend.env
INTRANET_MODEL_API_KEY=<服务商分配的密钥>
""", "ENV")
b.add_number(doc, "在 config.yaml 的 models: 下增加或修改模型条目。name 是系统引用名;model 是服务商真实模型 ID;display_name 是前端显示名。")
b.add_code(doc, """
models:
- name: intranet-model
display_name: 内网模型
use: langchain_openai:ChatOpenAI
model: <服务商实际模型ID>
api_key: $INTRANET_MODEL_API_KEY
base_url: https://<模型服务地址>/v1
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 0.7
supports_vision: false
supports_thinking: false
""", "YAML")
b.add_number(doc, "强制重建 backend 以重新读取 backend.env;随后查看启动日志、健康检查和前端模型列表。")
b.add_code(doc, """
sudo docker compose up -d --force-recreate backend
sudo docker compose logs --tail=300 -f backend
curl -fsS http://127.0.0.1:<APP_PORT>/deerflow/health
""", "Shell")
b.add_h2(doc, "2.2 字段说明与常见误配")
b.add_table(
doc,
["字段", "用途", "常见问题"],
[
["name", "系统内部模型名称,配置引用以它为准", "重名会覆盖或引用混乱;不要随意改已被智能体使用的 name。"],
["display_name", "前端显示名称", "改显示名不等于改 model;可用于给业务人员更易懂的名字。"],
["use", "适配器,例如 langchain_openai:ChatOpenAI", "适配器与接口协议不匹配会导致初始化失败。"],
["model", "模型服务商要求的真实 ID", "误填显示名经常造成 404 model not found。"],
["api_key", "使用 $变量名 从 backend.env 读取密钥", "写明文、变量拼错或漏掉 $ 都会导致鉴权失败。"],
["base_url", "接口基地址", "有的服务要求 /v1,有的路径已包含 /v1;以服务商接口文档为准。"],
["request_timeout / max_retries", "单次等待时间与失败重试次数", "超时不等于模型不存在,先检查服务和网络。"],
["max_tokens", "单次生成输出上限", "不是输入+输出总上下文窗口,不能超过服务商允许的最大输出。"],
],
[1850, 2850, 4660],
font_size=8.45,
)
b.add_h2(doc, "2.3 如何添加或调整 max_tokens")
b.add_para(doc, "在目标模型条目中添加或修改 max_tokens: <整数>。它控制单次回答最多生成多少 token。回答被截断时可在服务商允许范围内逐步增加;成本或响应时间异常时应适度降低。")
b.add_table(
doc,
["现象", "判断", "处理"],
[
["回答结尾被截断", "max_tokens 太小,或服务端提前结束", "先查看 finish_reason;确认服务商上限后逐步提高。"],
["400 invalid max_tokens", "超过模型限制或字段不兼容", "按服务商模型上限修改;必要时删除该字段验证兼容性。"],
["长文本仍失败", "上下文不足,不只是输出上限", "缩短输入、启用摘要或选择更大上下文模型。"],
["费用/耗时异常", "输出限制过高或提示词过长", "降低 max_tokens;优化提示词和业务限流。"],
],
[2000, 3000, 4360],
font_size=8.9,
)
b.add_h2(doc, "2.4 后端模型变更后的验收")
for text in [
"YAML 使用空格缩进,不能使用 Tab;密钥变量名必须同时存在于 backend.env 和 config.yaml。",
"修改 backend.env 后必须使用 docker compose up -d --force-recreate backend;普通 restart 不会重新读取环境文件。",
"只修改 config.yaml 时可重启 backend,但仍应查看日志确认配置解析成功。",
"先进行短问答验证,再验证长文本、工具调用、视觉输入或思考模式(如果该模型声明支持)。",
"记录模型 name、服务地址、变更人、验证时间;不要把 API Key 写入变更单或截图。",
]:
b.add_bullet(doc, text)
def add_chapter_config(doc, diagrams):
b.add_h1(doc, "3. .env、backend.env、数据库与前端接口地址")
b.add_para(doc, "部署目录的运行配置分为三类:.env 控制 Compose 与对外端口;backend.env 保存后端密钥和连接串;config.yaml 定义模型、登录、数据库模式等;runtime-config.js 控制前端运行时接口地址。不要进入容器直接改文件。")
b.add_figure(doc, diagrams["config_layers"], "图 3-1 配置分层与生效方式", "部署层、后端层、前端运行时层和管理页面配置的关系", width=6.35)
heading = b.add_h2(doc, "3.1 .env:项目名、镜像标签、访问端口")
heading.paragraph_format.page_break_before = True
b.add_code(doc, """
# 部署目录 .env
COMPOSE_PROJECT_NAME=deerflow
DEERFLOW_TAG=<当前镜像包对应标签>
APP_PORT=8090
TZ=Asia/Shanghai
""", "ENV")
b.add_table(
doc,
["变量", "作用", "修改后的操作"],
[
["COMPOSE_PROJECT_NAME", "Compose 项目名称,用于隔离容器、网络和卷名称", "确认未与其他项目冲突;通常不在运行中随意修改。"],
["DEERFLOW_TAG", "docker-compose.yml 使用的镜像标签", "应与已导入镜像的标签一致;镜像升级时核对交付清单和 SHA256。"],
["APP_PORT", "用户访问 Web/Nginx 的宿主机端口", "修改后执行 docker compose up -d --force-recreate web;确认新端口开放。"],
["TZ", "容器日志和页面显示时区", "重建受影响容器后生效。"],
],
[1900, 3600, 3860],
font_size=8.85,
)
b.add_h2(doc, "3.2 backend.env:JWT、模型密钥和数据库连接")
b.add_code(doc, """
# config/backend.env
AUTH_JWT_SECRET=<至少32字节的随机值>
INTRANET_MODEL_API_KEY=<模型服务密钥>
MYSQL_DATABASE_URL=mysql+asyncmy://<USER>:<URL_ENCODED_PASSWORD>@<HOST>:3306/<DB>?charset=utf8mb4
""", "ENV")
b.add_callout(doc, "密钥规则", "AUTH_JWT_SECRET 是签发登录令牌的服务端密钥,不是用户登录密码。修改它会让所有已登录会话失效。backend.env 的任何改动都要强制重建 backend 才会重新读取。", "warn")
b.add_h2(doc, "3.3 config.yaml:登录密码与数据库模式")
b.add_code(doc, """
# config/config.yaml(示意;保留现有其他配置)
auth_login:
username_login:
enabled: true
database:
backend: mysql # 使用默认文件数据库时为 sqlite
mysql_url: $MYSQL_DATABASE_URL
""", "YAML")
b.add_para(doc, "用户名登录的共享密码或账号管理项应遵循当前 config.yaml 的 auth_login 配置;单个用户的 login_password、system_role 和资产所有权以应用用户管理/数据库记录为准。不要把用户密码与 AUTH_JWT_SECRET 混为一谈。")
b.add_h2(doc, "3.4 切换或排查外部 MySQL")
b.add_number(doc, "冻结可能影响资产写入的操作,完整备份 data/backend、当前配置和现有数据库。")
b.add_number(doc, "创建数据库和最小权限账号,确认字符集 utf8mb4,并允许容器所在网络访问。")
b.add_number(doc, "密码中 @、:、/、#、% 等字符需要 URL 编码;HOST 必须是容器能访问的地址,不能把容器内 127.0.0.1 当成外部数据库。")
b.add_number(doc, "同时更新 backend.env 的 MYSQL_DATABASE_URL 与 config.yaml 的 database.backend/mysql_url,强制重建 backend 并持续查看迁移日志。")
b.add_number(doc, "分别用管理员、lqq 和普通用户验证登录、技能、智能体、菜单、敏感词和发布状态。SQLite 与 MySQL 不会自动双向同步。")
b.add_h2(doc, "3.5 前端接口地址与端口")
b.add_code(doc, """
// config/runtime-config.js:推荐同源相对路径
window.__DEERFLOW_RUNTIME_CONFIG__ = {
VITE_BACKEND_BASE_URL: "/deerflow",
VITE_LANGGRAPH_BASE_URL: "/deerflow/api",
VITE_AIPI_BASE_URL: "<可选的业务接口地址>",
};
""", "JavaScript")
b.add_para(doc, "推荐保持相对路径。浏览器会自动访问当前域名和 APP_PORT,再由 Nginx 转到 backend:8001,这样最少遇到 CORS、证书与混合内容问题。确需使用独立后端域名时,应同步配置允许 Origin 和 HTTPS。")
b.add_code(doc, """
# 修改前端端口或运行时配置后的推荐操作
sudo docker compose up -d --force-recreate web
sudo docker compose ps
sudo docker compose logs --tail=200 web
# 浏览器使用 Ctrl+F5;在 Network 检查 runtime-config.js 和 /deerflow/health
""", "Shell")
def add_chapter_assets(doc, diagrams):
b.add_h1(doc, "4. 技能与智能体:放置、发布、丢失排查")
b.add_para(doc, "技能和智能体能否在页面出现,取决于文件、数据库元数据、当前登录身份、发布状态和目录挂载是否一致。优先在前端创建/上传;仅在批量迁移或前端不可用时手工导入。")
b.add_figure(doc, diagrams["persistence_map"], "图 4-1 技能和智能体的持久化关系", "技能文件、智能体文件、业务数据库和运行时缓存共同决定资产可见性", width=6.35)
heading = b.add_h2(doc, "4.1 技能放在哪里")
heading.paragraph_format.page_break_before = True
b.add_code(doc, """
data/skills/
├── public/
│ └── <skill-id>/
│ ├── SKILL.md
│ ├── scripts/ # 可选
│ ├── references/ # 可选
│ └── assets/ # 可选
└── custom/
└── <skill-id>/
└── SKILL.md
""", "目录结构")
b.add_table(
doc,
["目录", "含义", "注意"],
[
["data/skills/public", "系统共享/公共技能文件分类", "放入 public 不等于已发布到广场;仍需有正确元数据、权限和发布状态。"],
["data/skills/custom", "用户自定义技能目录", "适合从前端创建或上传的技能;应保留完整目录而不是只复制一个文件。"],
["SKILL.md", "技能说明、使用条件、操作规范的核心文件", "避免写入真实 API Key、数据库密码、内网敏感地址或个人信息。"],
["scripts/references/assets", "脚本、参考资料和附件", "脚本必须可执行且依赖完整;上传前检查许可证、体积和敏感信息。"],
],
[2200, 2800, 4360],
font_size=8.7,
)
b.add_h2(doc, "4.2 智能体放在哪里")
b.add_code(doc, """
data/backend/agents/<agent-id>/
├── config.yaml # 模型、工具、技能、行为参数
└── SOUL.md # 角色、提示词、表达和规则
""", "目录结构")
b.add_para(doc, "智能体文件只是一部分。名称、所有者、可见性、广场和发布状态等业务元数据通常在 SQLite 或 MySQL 中。因此恢复或迁移智能体时,必须同时备份/恢复 data/backend 与对应数据库。")
b.add_h2(doc, "4.3 推荐的创建、上传和发布流程")
b.add_figure(doc, diagrams["publish_flow"], "图 4-2 从创建到发布广场", "在界面创建、验证、发布、权限校验直至生效的流程", width=6.35)
for text in [
"使用普通业务用户或管理员进入技能管理/智能体管理,创建或上传资产并填写名称、说明、依赖和使用范围。",
"完成本地实际验证:技能要验证工具调用、脚本依赖和文件权限;智能体要验证所选模型、技能/工具和首轮问答。",
"在资产详情或卡片中点击“发布”,选择一个启用的目标广场。资产所有者或 admin 才能发布/下架。",
"发布后用普通用户验证广场可见性;重新发布到其他广场通常是迁移,不是多份复制。",
"若只需要个人使用,保持私有或不发布;不要通过手工复制文件代替发布流程。",
]:
b.add_number(doc, text)
b.add_h2(doc, "4.4 手工导入时的安全做法")
b.add_code(doc, """
# 示例:先将完整技能目录复制到 custom,再确认容器内可见
sudo mkdir -p data/skills/custom/<skill-id>
# 将经过审核的文件复制到上面的目录后执行:
sudo docker compose exec backend sh -lc 'find /app/backend/skills/custom -maxdepth 3 -type f -print'
sudo docker compose restart backend
""", "Shell")
b.add_callout(doc, "手工导入后的边界", "手工复制只解决文件存在的问题,不会自动补齐所有者、可见性或发布元数据。导入后应通过界面核对资产记录;若无记录,按应用支持流程创建或恢复元数据。", "warn")
b.add_h2(doc, "4.5 发布按钮或广场不可用")
b.add_table(
doc,
["问题", "检查项", "处理"],
[
["看不到“发布”按钮", "当前用户是否资产所有者或 admin", "切换到所有者/admin;不要只凭文件目录判断权限。"],
["选择不到广场", "广场是否启用、是否存在默认/私有范围限制", "由 admin 在发布广场管理中启用或创建目标广场。"],
["发布后普通用户看不到", "广场、私有状态、筛选、缓存和角色", "确认实际发布到哪个广场;退出重登并清除筛选;检查发布 API 401/403。"],
["智能体能看见但模型不可用", "config.yaml 中模型 name 是否仍存在", "恢复同名模型条目或将智能体改到已验证模型后重启 backend。"],
],
[2200, 3000, 4160],
font_size=8.7,
)
b.add_h2(doc, "4.6 技能或智能体“丢失”专项排查")
b.add_figure(doc, diagrams["lost_asset_flow"], "图 4-3 资产丢失排查顺序", "从服务目录、身份、挂载、数据库、过滤到恢复验证的排查流程", width=6.35)
b.add_number(doc, "确认当前部署目录和服务:执行 pwd、docker compose ps,排除在旧目录启动了另一套服务。")
b.add_number(doc, "确认登录身份:重新登录,核对用户名、system_role、资产所有者和是否处于独立会话/嵌入会话。")
b.add_number(doc, "同时检查宿主机和容器内文件。宿主机有而容器没有,重点检查 docker-compose.yml 的 volume 映射与当前工作目录。")
b.add_code(doc, """
find data/skills -maxdepth 3 -type f -print | sort
find data/backend/agents -maxdepth 3 -type f -print | sort
sudo docker compose exec backend sh -lc 'find /app/backend/skills -maxdepth 3 -type f -print | sort'
sudo docker compose exec backend sh -lc 'find /app/backend/.deer-flow/agents -maxdepth 3 -type f -print | sort'
""", "Shell")
b.add_number(doc, "确认数据库未切换:查看 config.yaml 的 database.backend 和 backend.env 的 MYSQL_DATABASE_URL。切换数据库不会自动搬迁或同步资产记录。")
b.add_number(doc, "检查过滤、广场和权限:清除搜索条件,切换“我的/公共/已发布/私有”等视图,并用 admin 核验发布记录。")
b.add_number(doc, "查看 backend 日志。文件和数据库都缺失时,停止写入,从备份恢复;重启 backend、重新登录并实际调用验证。")
def add_chapter_ui(doc, diagrams):
b.add_h1(doc, "5. 前端管理:菜单名称、敏感词、主题与账号")
b.add_para(doc, "菜单、主题、敏感词和广场主要通过应用管理页面维护,属于数据库中的全局设置。保存后通常立即生效,但应使用管理员和普通用户分别验证。")
b.add_figure(doc, diagrams["ui_map"], "图 5-1 常用管理入口", "菜单、外观、技能、智能体和用户管理入口关系", width=6.35)
b.add_h2(doc, "5.1 配置菜单名称、排序和隐藏")
b.add_number(doc, "使用 system_role=admin 的 DeerFlow 管理员登录,打开左下角“设置和更多”中的“菜单管理”。")
b.add_number(doc, "找到目标一级、二级或三级菜单,修改显示名称 label、启用状态、排序或父级;保存后刷新页面验证。")
b.add_number(doc, "菜单停用会隐藏该节点及其子树;修改名称不要改真实 path、路由 ID 或代码常量。")
b.add_number(doc, "用普通用户检查左侧菜单、顶部导航和直接访问链接;记录修改前名称、目的和回退方式。")
b.add_callout(doc, "安全边界", "隐藏菜单只移除导航入口,不等于阻止用户通过已知 URL 或 API 访问。真正禁用功能还需后端 API 权限、路由守卫和角色权限共同生效。", "danger")
b.add_h2(doc, "5.2 配置敏感词:必须用哪个用户")
b.add_para(doc, "敏感词功能是“展示层脱敏映射”:原始词 -> 替换词。它不会修改数据库里的原文,也不会改变 URL、路由 path 或节点 ID。")
b.add_callout(doc, "登录要求", "敏感词管理前端入口、路由守卫和页面级校验都要求当前显示用户名严格等于 lqq。即使 admin 拥有后端写权限,也应使用 lqq 登录来维护页面。邮箱账号时,@ 前缀必须为 lqq。", "warn")
b.add_number(doc, "用 lqq 登录,进入“设置和更多 -> 敏感词管理”。")
b.add_number(doc, "新增原始词与替换词,设置启用状态;需要初始规则时可使用“导入种子词”合并缺失映射。")
b.add_number(doc, "保存的是整套规则;修改前先导出/截图并经业务确认,避免替换词冲突或过度脱敏。")
b.add_number(doc, "刷新页面,检查菜单、导航、标题和普通内容的显示;用普通用户复核最终效果。")
b.add_h2(doc, "5.3 主题色、布局和嵌入页面")
b.add_table(
doc,
["配置目标", "操作入口/变量", "验证"],
[
["个人主题", "设置 -> 外观,用户自己的选择通常优先", "新开页面检查浅/深主题、文字对比度和焦点状态。"],
["系统默认与主题库", "admin 在外观设置中配置默认主题、主题名称、主色和启用状态", "用没有个人覆盖的新测试用户验证。"],
["强制紧凑侧边栏", "runtime-config.js 的 VITE_FORCE_COLLAPSED_SIDEBAR", "重启 web 并刷新浏览器后检查导航。"],
["iframe 嵌入", "URL 参数 embed=1、theme=...、hideJump=1、isolate=1", "在嵌入页验证无全局导航、主题和独立会话行为。"],
],
[2200, 3500, 3660],
font_size=8.7,
)
b.add_callout(doc, "主题验收", "不仅看按钮颜色。还要检查正文、表格、弹窗、下拉框、禁用、hover、focus、错误和成功状态的对比度;不要用品牌主色铺满大面积背景。", "note")
heading = b.add_h2(doc, "5.4 必备账号与角色")
heading.paragraph_format.page_break_before = True
b.add_table(
doc,
["账号类型", "至少准备", "主要用途", "注意"],
[
["DeerFlow admin", "2 个", "菜单、主题、广场、用户和全局资产管理", "system_role 必须为 admin;保留一个应急管理员。"],
["lqq", "1 个", "敏感词管理前端入口", "显示用户名/邮箱前缀必须严格为 lqq。"],
["普通测试用户", "1 个", "验收菜单隐藏、资产可见性和广场发布", "不能只用管理员验收,管理员会掩盖权限问题。"],
["模型/数据库服务账号", "按环境", "调用外部服务", "遵循最小权限和定期轮换,不与个人账号共用。"],
],
[2100, 1250, 3300, 2710],
font_size=8.65,
)
def add_chapter_operations(doc):
b.add_h1(doc, "6. 日常启停、日志和健康检查")
b.add_para(doc, "一键脚本和 Docker Compose 命令都应从部署目录运行。日常 restart 适合重启进程;任何环境变量文件改动都必须通过 force-recreate 重建受影响容器。")
b.add_h2(doc, "6.1 常用命令速查")
b.add_code(doc, """
# 整套服务
sudo bash restart.sh
sudo bash stop.sh
sudo bash logs.sh
sudo bash diagnose.sh
# 只处理后端或前端
sudo docker compose restart backend
sudo docker compose up -d --force-recreate backend # backend.env 改动后
sudo docker compose restart web
sudo docker compose up -d --force-recreate web # .env 的 APP_PORT / Web 配置改动后
sudo docker compose ps
""", "Shell")
b.add_h2(doc, "6.2 如何打印日志")
b.add_code(doc, """
# 全部服务日志;Ctrl+C 仅停止跟踪,不停止服务
sudo bash logs.sh
# 只看后端或 Web/Nginx
sudo bash logs.sh backend
sudo bash logs.sh web
# Compose 原生命令:最后 500 行并持续跟踪
sudo docker compose logs --tail=500 -f backend
sudo docker compose logs --tail=500 -f web
""", "Shell")
b.add_h2(doc, "6.3 健康检查和变更后验收")
b.add_code(doc, """
curl -fsS http://127.0.0.1:<APP_PORT>/deerflow/health
curl -I http://127.0.0.1:<APP_PORT>/
sudo docker compose ps
""", "Shell")
for text in [
"容器 Up 不等于业务可用:健康接口成功后仍要登录一次、发起一次对话并验证修改的功能。",
"backend.env、.env 改动:force-recreate;config.yaml 改动:至少 restart backend;runtime-config.js 改动:重启 web 并强制刷新浏览器。",
"变更完成后继续观察后端日志至少 5 分钟,确认没有反复重启、401/429/500、数据库错误或模型初始化失败。",
]:
b.add_bullet(doc, text)
def add_chapter_backup(doc):
b.add_h1(doc, "7. 数据库、备份、恢复与升级")
b.add_para(doc, "一次可恢复的备份至少包含 config、data 和当前业务数据库。只保存 Docker 镜像无法恢复用户、技能、智能体、发布广场、菜单、主题与敏感词。外部 MySQL 必须单独导出。")
b.add_h2(doc, "7.1 必须备份的对象")
b.add_table(
doc,
["对象", "作用", "建议频率", "恢复价值"],
[
["config 与 .env", "模型、登录、数据库、接口地址和运行配置", "每次变更后", "恢复服务连接和端口配置。"],
["data/backend", "文件数据库、智能体文件及运行数据", "每日 + 变更前", "恢复智能体、用户和运行相关资产。"],
["data/skills", "public/custom 技能文件和附件", "每日 + 技能变更后", "恢复技能内容和脚本。"],
["data/knowledge", "知识库文件", "按使用频率", "恢复知识资产。"],
["外部 MySQL dump", "资产元数据、用户、菜单、主题、广场等", "每日 + 变更前", "恢复数据库层资产与全局设置。"],
],
[1900, 3150, 1850, 2460],
font_size=8.5,
)
b.add_h2(doc, "7.2 文件和数据库备份示例")
b.add_code(doc, """
# 文件备份:确保备份目录位于独立存储,不在当前 data 内
cd <部署目录>
sudo tar -czf <备份目录>/deerflow-files-$(date +%F-%H%M).tar.gz .env config data
sha256sum <备份目录>/deerflow-files-*.tar.gz
# MySQL 备份:密码不要直接写到命令历史,优先使用受控 defaults-extra-file
mysqldump --single-transaction --routines --triggers \
-h <MYSQL_HOST> -u <BACKUP_USER> <DB_NAME> > <备份目录>/deerflow-mysql-$(date +%F-%H%M).sql
""", "Shell")
b.add_h2(doc, "7.3 恢复与升级原则")
for text in [
"恢复前先确认备份版本、SHA256、数据模式和目标目录;停止或冻结写入,避免恢复过程中产生新分叉数据。",
"先恢复到隔离或测试环境验证,再进行正式切换;恢复后检查目录权限、数据库连接、健康接口、登录、模型、技能、智能体和广场。",
"升级时保留旧镜像、旧配置和完整备份;新版本使用独立测试端口验证,确认无误再切换。",
"SQLite 与 MySQL 的资产数据不会自动双向同步。任何切换、迁移或恢复都要有明确来源和一次性合并计划。",
]:
b.add_bullet(doc, text)
b.add_callout(doc, "高风险操作", "删除部署目录、删除 data、覆盖未知数据库、docker system prune -a 和递归修改所有者都可能造成不可逆损失。执行前必须明确目标、验证备份可用并获得审批。", "danger")
def add_chapter_security_handover(doc):
b.add_h1(doc, "8. 巡检、安全与交接")
b.add_h2(doc, "8.1 每日与每周巡检")
b.add_table(
doc,
["频率", "检查内容", "合格标准"],
[
["每日", "docker compose ps、/deerflow/health、磁盘/inode、backend/web 日志", "服务正常、无重启循环、无连续 5xx/401/429、空间充足。"],
["每周", "备份是否完成、最近恢复演练、模型配额与超时、资产发布与权限变更", "备份可校验;高频失败有负责人;变更有记录。"],
["每次变更后", "端口、日志、健康、登录、实际业务功能、普通用户权限", "功能验收通过且可回退。"],
],
[1500, 3900, 3960],
font_size=8.75,
)
b.add_h2(doc, "8.2 最低安全要求")
for text in [
"AUTH_JWT_SECRET、模型 API Key、数据库 URL/密码只保存在受控的 backend.env 或密钥系统中,不进入 Git、截图、聊天记录和前端 runtime-config.js。",
"管理员、lqq、普通测试用户和服务账号分开管理;定期轮换密码/密钥,离职或交接时立即回收不再需要的权限。",
"对外只开放必要的 APP_PORT;后端 8001 默认不直接暴露。数据库地址、模型服务和业务接口应采用最小网络范围。",
"对外发送日志前删除 Authorization、Cookie、Token、API Key、数据库 URL、手机号和身份信息,只保留时间、状态码、trace ID 与关键报错。",
]:
b.add_bullet(doc, text)
b.add_h2(doc, "8.3 交接清单")
b.add_table(
doc,
["项目", "交接时填写"],
[
["生产访问地址 / APP_PORT", ""],
["当前部署目录与 Compose 项目名", ""],
["镜像版本 / SHA256", ""],
["数据库模式、地址与库名(不写密码)", ""],
["DeerFlow admin、lqq、普通测试用户责任人", ""],
["模型服务与密钥保管责任人", ""],
["备份位置、保留期与最近恢复演练日期", ""],
["监控、告警和问题升级联系人", ""],
],
[3500, 5860],
font_size=9.1,
)
def add_appendices(doc):
b.add_h1(doc, "附录 A:最短命令清单")
b.add_code(doc, """
cd <部署目录>
sudo docker compose ps
sudo docker compose logs --tail=500 -f backend
sudo docker compose logs --tail=500 -f web
sudo docker compose restart backend
sudo docker compose up -d --force-recreate backend
sudo docker compose restart web
sudo docker compose up -d --force-recreate web
curl -fsS http://127.0.0.1:<APP_PORT>/deerflow/health
""", "Shell")
heading = b.add_h1(doc, "附录 B:问题升级信息模板")
heading.paragraph_format.page_break_before = False
b.add_code(doc, """
【问题标题】
【发生时间】YYYY-MM-DD HH:mm:ss(时区)
【访问地址/端口】<不含密码>
【当前账号角色】admin / lqq / 普通用户(不要提供密码)
【操作步骤】1... 2... 3...
【实际现象】
【期望结果】
【HTTP 状态码 / trace ID】
【容器状态】docker compose ps
【已脱敏日志】backend/web 最近 200 行
【最近变更】模型、数据库、菜单、主题、镜像或配置
【已采取动作】以及结果
""", "故障单模板")
heading = b.add_h1(doc, "附录 C:核心事实速记")
heading.paragraph_format.page_break_before = True
b.add_table(
doc,
["主题", "结论"],
[
["前端和后端端口", "容器内部不同:Web 80、Backend 8001;用户通常经由 APP_PORT 访问 Web。"],
["配置文件", ".env 管端口/Compose;backend.env 管密钥和连接串;config.yaml 管模型/登录/数据库;runtime-config.js 管前端地址。"],
["环境变量生效", "backend.env 或 .env 改动后必须 force-recreate 受影响容器;普通 restart 不会重新读取。"],
["模型与 max_tokens", "模型条目在 config.yaml;Key 在 backend.env;max_tokens 是单次输出上限,不是上下文窗口。"],
["技能位置", "data/skills/public 与 data/skills/custom;目录文件存在不等于已经发布。"],
["智能体位置", "data/backend/agents/<agent-id>;还依赖数据库元数据、所有者、可见性和发布状态。"],
["敏感词用户", "页面维护需要显示用户名严格为 lqq;admin 不能替代该前端入口条件。"],
["菜单隐藏", "只隐藏导航入口,不等于禁止直接 URL 或 API,需要后端权限配合。"],
["资产丢失", "先查目录、挂载、数据库、身份、过滤和日志,最后才恢复;不要急着创建同名资产。"],
["完整备份", "config + data + 外部 MySQL dump + 镜像版本/SHA256;只备份镜像不足以恢复业务资产。"],
],
[3100, 6260],
font_size=8.7,
)
def build_document(diagrams):
doc = b.Document()
b.configure_document(doc)
set_running_furniture(doc)
doc.core_properties.title = "DeerFlow 系统运维手册(用户重点版)"
doc.core_properties.subject = "DeerFlow 常见报错、模型、配置、技能、智能体、前端管理与恢复"
doc.core_properties.author = "DeerFlow 运维团队"
doc.core_properties.keywords = "DeerFlow, 运维, Docker, config.yaml, 技能, 智能体"
add_cover(doc, diagrams)
add_navigation(doc)
add_chapter_common_errors(doc)
add_chapter_model(doc, diagrams)
add_chapter_config(doc, diagrams)
add_chapter_assets(doc, diagrams)
add_chapter_ui(doc, diagrams)
add_chapter_operations(doc)
add_chapter_backup(doc)
add_chapter_security_handover(doc)
add_appendices(doc)
doc.save(OUT)
return OUT
if __name__ == "__main__":
b.ensure_dirs()
diagrams = b.make_diagrams()
print(build_document(diagrams))
@@ -0,0 +1,33 @@
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parent
PAGES = ROOT / "rendered" / "pages"
OUT = ROOT / "rendered" / "contact-sheets"
OUT.mkdir(parents=True, exist_ok=True)
font_path = Path(r"C:\Windows\Fonts\arialbd.ttf")
font = ImageFont.truetype(str(font_path), 28)
page_paths = sorted(PAGES.glob("page-*.png"))
for sheet_index in range(0, len(page_paths), 4):
group = page_paths[sheet_index : sheet_index + 4]
images = [Image.open(p).convert("RGB") for p in group]
page_w, page_h = images[0].size
gutter = 48
label_h = 52
sheet = Image.new("RGB", (page_w * 2 + gutter * 3, (page_h + label_h) * 2 + gutter * 3), "#D7DEE5")
draw = ImageDraw.Draw(sheet)
for i, (path, img) in enumerate(zip(group, images)):
col, row = i % 2, i // 2
x = gutter + col * (page_w + gutter)
y = gutter + row * (page_h + label_h + gutter)
draw.text((x, y), path.stem, font=font, fill="#172A3A")
sheet.paste(img, (x, y + label_h))
first = sheet_index + 1
last = sheet_index + len(group)
sheet.save(OUT / f"pages-{first:02d}-{last:02d}.jpg", quality=92, subsampling=0)
print(f"created {len(list(OUT.glob('*.jpg')))} contact sheets")
@@ -0,0 +1,30 @@
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parent / "rendered-user"
PAGES = ROOT / "pages"
OUT = ROOT / "contact-sheets"
OUT.mkdir(parents=True, exist_ok=True)
font = ImageFont.truetype(r"C:\Windows\Fonts\arialbd.ttf", 28)
page_paths = sorted(PAGES.glob("page-*.png"))
for sheet_index in range(0, len(page_paths), 4):
group = page_paths[sheet_index:sheet_index + 4]
images = [Image.open(path).convert("RGB") for path in group]
page_w, page_h = images[0].size
gutter, label_h = 48, 52
sheet = Image.new("RGB", (page_w * 2 + gutter * 3, (page_h + label_h) * 2 + gutter * 3), "#D7DEE5")
draw = ImageDraw.Draw(sheet)
for index, (path, image) in enumerate(zip(group, images)):
col, row = index % 2, index // 2
x = gutter + col * (page_w + gutter)
y = gutter + row * (page_h + label_h + gutter)
draw.text((x, y), path.stem, font=font, fill="#172A3A")
sheet.paste(image, (x, y + label_h))
first, last = sheet_index + 1, sheet_index + len(group)
sheet.save(OUT / f"pages-{first:02d}-{last:02d}.jpg", quality=92, subsampling=0)
print(f"created {len(list(OUT.glob('*.jpg')))} contact sheets")
@@ -0,0 +1,38 @@
param(
[Parameter(Mandatory = $true)][string]$InputDocx,
[Parameter(Mandatory = $true)][string]$OutputPdf
)
$ErrorActionPreference = 'Stop'
$word = $null
$doc = $null
try {
$inputPath = (Resolve-Path -LiteralPath $InputDocx).Path
$outputPath = [System.IO.Path]::GetFullPath($OutputPdf)
$outputDir = [System.IO.Path]::GetDirectoryName($outputPath)
[System.IO.Directory]::CreateDirectory($outputDir) | Out-Null
$word = New-Object -ComObject Word.Application
$word.Visible = $false
$word.DisplayAlerts = 0
$doc = $word.Documents.Open($inputPath, $false, $false)
foreach ($toc in $doc.TablesOfContents) {
$toc.Update() | Out-Null
}
$doc.Fields.Update() | Out-Null
$doc.Save()
$doc.ExportAsFixedFormat($outputPath, 17)
}
finally {
if ($null -ne $doc) {
$doc.Close(0)
[void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($doc)
}
if ($null -ne $word) {
$word.Quit()
[void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($word)
}
[GC]::Collect()
[GC]::WaitForPendingFinalizers()
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 914 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 906 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Loaded 100 of 188 files, more files were not shown because too many files have changed in this diff. Show more