diff --git a/frontend-web/docs/position-roundtable-sandbox/README.md b/frontend-web/docs/position-roundtable-sandbox/README.md new file mode 100644 index 00000000..c3679ba5 --- /dev/null +++ b/frontend-web/docs/position-roundtable-sandbox/README.md @@ -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」 diff --git a/frontend-web/docs/position-roundtable-sandbox/沙箱打开关闭与步骤条.md b/frontend-web/docs/position-roundtable-sandbox/沙箱打开关闭与步骤条.md new file mode 100644 index 00000000..035b5222 --- /dev/null +++ b/frontend-web/docs/position-roundtable-sandbox/沙箱打开关闭与步骤条.md @@ -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(null); +const sandboxArtifactRef = useRef(null); +const closedSandboxKeyRef = useRef(null); +const latestNodeThreadSnapshotRef = useRef(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 }) { + 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 && ( + + + +)} +``` + +### 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 节。 diff --git a/frontend-web/src/position-roundtable/README.md b/frontend-web/src/position-roundtable/README.md index b7edf6c0..80587346 100644 --- a/frontend-web/src/position-roundtable/README.md +++ b/frontend-web/src/position-roundtable/README.md @@ -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=`;策略页入口也兼容 `/#/page/strategy/qa/position-roundtable?taskId=`。不要使用任务标题、序号或每次请求都变化的临时 ID。 +2. 前端先调用 `GET /api/position-roundtable/sessions?external_task_id=`;返回的是该任务下所有用户共享的会商记录,按最近更新时间排序。选中一条后再调用 `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,8 +144,9 @@ PositionRoundtablePage 页面只维护一个 `sandboxArtifact`。`PositionNodeChat` 和 `PositionBuiltinAgentChat` 通过 `onPreviewArtifact` 上报预览目标;父页面统一渲染 `ArtifactFileDetail`。 -**对接 / 重做页面 / 排障指南(接口约定、必守规则、自测与症状表):** -[`docs/沙箱打开关闭与步骤条.md`](./docs/沙箱打开关闭与步骤条.md) +**对接 / 重做页面 / 排障指南(打开关闭时机、步骤条、接口约定):** +[`../../docs/position-roundtable-sandbox/沙箱打开关闭与步骤条.md`](../../docs/position-roundtable-sandbox/沙箱打开关闭与步骤条.md) +(目录说明见 [`../../docs/position-roundtable-sandbox/README.md`](../../docs/position-roundtable-sandbox/README.md)) 设计要点: @@ -163,8 +176,8 @@ PositionRoundtablePage | 方法 | 路径 | 用途 | | --- | --- | --- | -| `GET` | `/sessions?external_task_id=` | 按任务查询会话,用于恢复最近会话和历史记录。 | -| `POST` | `/sessions` | 创建初始会话并保存任务快照。 | +| `GET` | `/sessions?external_task_id=` | 传入时按任务作用域查询共享会话;不传参数时查询当前用户的个人历史。 | +| `POST` | `/sessions` | 创建会话并保存任务快照;传入非空 `external_task_id` 时服务端会将快照 ID 固定为该值并启用共享;缺失或空值时创建原有个人会话。 | | `GET` | `/sessions/{id}` | 恢复会话、节点和冻结快照。 | | `DELETE` | `/sessions/{id}` | 删除一条岗位会商历史及其节点;删除当前记录时前端会先屏蔽并发恢复请求。 | | `PUT` | `/sessions/{id}/task` | 更新未冻结的任务快照。 | @@ -178,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`。 diff --git a/frontend-web/src/position-roundtable/api/sessions.ts b/frontend-web/src/position-roundtable/api/sessions.ts index ac903dd6..e9e97497 100644 --- a/frontend-web/src/position-roundtable/api/sessions.ts +++ b/frontend-web/src/position-roundtable/api/sessions.ts @@ -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; chainId: string | null; chainSnapshot: Record | null; @@ -112,6 +115,7 @@ interface ServerNode { interface ServerSession { id: string; external_task_id: string | null; + task_scoped?: boolean; task_snapshot?: Record; chain_id: string | null; chain_snapshot?: Record | 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, diff --git a/frontend-web/src/position-roundtable/lib/mock-task-source.ts b/frontend-web/src/position-roundtable/lib/mock-task-source.ts index cc7f0d7c..97fbecd1 100644 --- a/frontend-web/src/position-roundtable/lib/mock-task-source.ts +++ b/frontend-web/src/position-roundtable/lib/mock-task-source.ts @@ -31,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 | null | undefined, fallback: PositionTask, + taskScopeId?: string, ): PositionTask { if (!snapshot || Object.keys(snapshot).length === 0) return fallback; const value = (keys: string[], defaultValue: string) => { @@ -50,7 +55,11 @@ 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), diff --git a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx index 348fb357..9143bbbc 100644 --- a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx +++ b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx @@ -140,6 +140,7 @@ import { getPositionSession, isPositionSessionNotFoundError, listPositionSessions, + PositionSessionNotFoundError, preparePositionActionPlan, preparePositionSummary, rejectPositionNode, @@ -182,6 +183,18 @@ type PositionArtifactPreview = { previewKey?: string; auto?: boolean; }; + +function isSessionInTaskScope( + session: PositionRoundtableSession, + taskScopeId: string | null, +): boolean { + // The legacy entry has no taskId and remains user-owned. Explicit taskId + // deep links are the only shared mode and must never cross task scopes. + return taskScopeId === null + ? !session.taskScoped + : session.taskScoped && session.externalTaskId === taskScopeId; +} + type ClosureStageKind = PositionBuiltinAgentKind; type IntentEditDraft = { objective: string; @@ -829,18 +842,21 @@ export default function PositionRoundtablePage({ const [positionId, setPositionId] = useState(() => positionIdFromUrl); const demoModeEnabled = isPositionRoundtableDemoEnabled(); const demoTask = useMemo(() => getPositionDemoTask(), []); - const taskIdFromUrl = + // A route taskId opts into shared task collaboration. Without it this page + // keeps the original personal-session behavior and uses mock data only for + // display defaults. + const taskScopeId = standaloneDemoMode ? demoTask.id - : searchParams.get("taskId") ?? - (demoModeEnabled ? demoTask.id : DEFAULT_MOCK_POSITION_TASK_ID); + : searchParams.get("taskId")?.trim() || null; + const displayTaskId = taskScopeId ?? (demoModeEnabled ? demoTask.id : DEFAULT_MOCK_POSITION_TASK_ID); const searchParamsString = searchParams.toString(); const fallbackTask = useMemo( () => - demoModeEnabled && taskIdFromUrl === demoTask.id + demoModeEnabled && displayTaskId === demoTask.id ? demoTask - : getMockPositionTask(taskIdFromUrl), - [demoModeEnabled, demoTask, taskIdFromUrl], + : getMockPositionTask(displayTaskId), + [demoModeEnabled, demoTask, displayTaskId], ); const rawSessionIdFromUrl = searchParams.get("sessionId"); const sessionIdFromUrl = @@ -1033,8 +1049,8 @@ export default function PositionRoundtablePage({ [positionSession?.intentSnapshot], ); const task = useMemo( - () => taskFromPositionSnapshot(positionSession?.taskSnapshot, fallbackTask), - [fallbackTask, positionSession?.taskSnapshot], + () => taskFromPositionSnapshot(positionSession?.taskSnapshot, fallbackTask, taskScopeId ?? undefined), + [fallbackTask, positionSession?.taskSnapshot, taskScopeId], ); const intentTaskContext = useMemo( () => ({ @@ -1144,9 +1160,10 @@ export default function PositionRoundtablePage({ positionSession?.status === "archived"; const syncSessionUrl = useCallback( - (sessionId: string, nextTaskId: string) => { + (sessionId: string, nextTaskId?: string | null) => { const next = new URLSearchParams(searchParamsString); - next.set("taskId", nextTaskId); + if (nextTaskId) next.set("taskId", nextTaskId); + else next.delete("taskId"); next.set("sessionId", sessionId); next.delete("new"); setSearchParams(next, { replace: true }); @@ -1162,20 +1179,20 @@ export default function PositionRoundtablePage({ const localSession = getLocalPositionDemoSession(); positionSessionRef.current = localSession; setPositionSession(localSession); - syncSessionUrl(localSession.id, localSession.externalTaskId ?? taskSnapshot.id); + syncSessionUrl(localSession.id, taskScopeId); return localSession; } if (sessionCreationRef.current) return sessionCreationRef.current; const creation = createPositionSession({ - externalTaskId: taskSnapshot.id || undefined, - taskSnapshot: { ...taskSnapshot }, + externalTaskId: taskScopeId ?? undefined, + taskSnapshot: taskScopeId ? { ...taskSnapshot, id: taskScopeId } : { ...taskSnapshot }, }) .then((created) => { // Update the ref synchronously. Several effects can observe a new // intent thread in the same render cycle; all must share this row. positionSessionRef.current = created; setPositionSession(created); - syncSessionUrl(created.id, taskSnapshot.id); + syncSessionUrl(created.id, taskScopeId); return created; }) .finally(() => { @@ -1186,7 +1203,7 @@ export default function PositionRoundtablePage({ sessionCreationRef.current = creation; return creation; }, - [standaloneDemoMode, syncSessionUrl], + [standaloneDemoMode, syncSessionUrl, taskScopeId], ); const openTaskEditor = useCallback(() => { @@ -1207,6 +1224,10 @@ export default function PositionRoundtablePage({ } const nextTask: PositionTask = { ...task, + // Task identity comes exclusively from the route. Task-card editing can + // change display fields but must never move this session to another + // shared task bucket. + id: taskScopeId ?? task.id, title, priority: taskDraft.priority?.trim() || task.priority || "紧急", description: taskDraft.description.trim(), @@ -1225,7 +1246,7 @@ export default function PositionRoundtablePage({ positionSessionRef.current = merged; return merged; }); - syncSessionUrl(saved.id, nextTask.id); + syncSessionUrl(saved.id, taskScopeId); setTaskEditorOpen(false); toast.success("任务信息已更新"); } catch (error) { @@ -1233,7 +1254,7 @@ export default function PositionRoundtablePage({ } finally { setTaskSaving(false); } - }, [ensureEditableSession, isArchivedSession, syncSessionUrl, task, taskDraft]); + }, [ensureEditableSession, isArchivedSession, syncSessionUrl, task, taskDraft, taskScopeId]); const openIntentEditor = useCallback(() => { if (isArchivedSession) return; @@ -1524,7 +1545,7 @@ export default function PositionRoundtablePage({ let cancelled = false; void getPositionSession(sessionIdFromUrl) .then((session) => { - if (!cancelled) { + if (!cancelled && isSessionInTaskScope(session, taskScopeId)) { positionSessionRef.current = session; setPositionSession(session); } @@ -1533,7 +1554,7 @@ export default function PositionRoundtablePage({ return () => { cancelled = true; }; - }, [positionSession, sessionIdFromUrl]); + }, [positionSession, sessionIdFromUrl, taskScopeId]); const handleNodeUpdate = useCallback((updatedNode: PositionRoundtableNode) => { setPositionSession((current) => { if (!current?.nodes) return current; @@ -1812,15 +1833,17 @@ export default function PositionRoundtablePage({ ); const openPositionConfig = useCallback( (chainId: string) => { - const taskQuery = `taskId=${encodeURIComponent(task.id)}${forceNewSession ? "&new=1" : ""}`; - const returnTo = sessionIdFromUrl - ? `/page/strategy/qa/position-roundtable?${taskQuery}&sessionId=${encodeURIComponent(sessionIdFromUrl)}` - : `/page/strategy/qa/position-roundtable?${taskQuery}`; + const returnParams = new URLSearchParams(); + if (taskScopeId) returnParams.set("taskId", taskScopeId); + if (forceNewSession) returnParams.set("new", "1"); + if (sessionIdFromUrl) returnParams.set("sessionId", sessionIdFromUrl); + const returnQuery = returnParams.toString(); + const returnTo = `/page/strategy/qa/position-roundtable${returnQuery ? `?${returnQuery}` : ""}`; navigate( `/page/workspace/roundtable/chains?chainId=${encodeURIComponent(chainId)}&mode=position&returnTo=${encodeURIComponent(returnTo)}`, ); }, - [forceNewSession, navigate, sessionIdFromUrl, task.id], + [forceNewSession, navigate, sessionIdFromUrl, taskScopeId], ); // The mock task source mirrors the future external-task contract: opening a @@ -1843,7 +1866,7 @@ export default function PositionRoundtablePage({ const useDemoFixture = demoModeEnabled && task.id === demoTask.id; const discovery = useDemoFixture ? initializePositionRoundtableDemo().then((session) => [session]) - : listPositionSessions(task.id); + : listPositionSessions(taskScopeId ?? undefined); void discovery .then((sessions) => { if (cancelled) return; @@ -1861,7 +1884,8 @@ export default function PositionRoundtablePage({ setSessionDiscoveryDone(true); } const next = new URLSearchParams(searchParamsString); - next.set("taskId", task.id); + if (taskScopeId) next.set("taskId", taskScopeId); + else next.delete("taskId"); next.set("sessionId", latest.id); setSearchParams(next, { replace: true }); }) @@ -1881,7 +1905,7 @@ export default function PositionRoundtablePage({ searchParamsString, sessionIdFromUrl, setSearchParams, - task.id, + taskScopeId, ]); // Create the database row as soon as discovery confirms this is a new task @@ -1928,6 +1952,11 @@ export default function PositionRoundtablePage({ : getPositionSession(sessionIdFromUrl); void request .then((session) => { + if (!isSessionInTaskScope(session, taskScopeId)) { + // A shared session id can be pasted with the wrong taskId. Treat it + // as stale navigation instead of allowing it to cross task scopes. + throw new PositionSessionNotFoundError(sessionIdFromUrl); + } if ( !cancelled && !ignoredSessionLoadIdsRef.current.has(sessionIdFromUrl) @@ -1966,7 +1995,7 @@ export default function PositionRoundtablePage({ return () => { cancelled = true; }; - }, [searchParamsString, sessionIdFromUrl, setSearchParams]); + }, [searchParamsString, sessionIdFromUrl, setSearchParams, taskScopeId]); const agentIdForPanel = activePosition.isIntentPosition ? INTENT_AGENT_ID : selectedAgentId; const { agent: selectedAgent } = useAgent(agentIdForPanel, { @@ -2061,7 +2090,8 @@ export default function PositionRoundtablePage({ setRightPanelOpen(true); } const next = new URLSearchParams(searchParams); - next.set("taskId", task.id); + if (taskScopeId) next.set("taskId", taskScopeId); + else next.delete("taskId"); next.set("sessionId", session.id); next.delete("new"); setSearchParams(next, { replace: true }); @@ -2083,6 +2113,7 @@ export default function PositionRoundtablePage({ setSearchParams, startingChain, task, + taskScopeId, intentPositionId, positionId, ]); @@ -2124,12 +2155,13 @@ export default function PositionRoundtablePage({ setBuiltinAutoStartRequestKey(0); setIntentCompletedAt(""); const next = new URLSearchParams(searchParams); - next.set("taskId", task.id); + if (taskScopeId) next.set("taskId", taskScopeId); + else next.delete("taskId"); next.set("positionId", intentPositionId); next.delete("sessionId"); next.set("new", "1"); setSearchParams(next); - }, [intent, intentPositionId, queueIntentConversationSave, searchParams, setSearchParams, task.id]); + }, [intent, intentPositionId, queueIntentConversationSave, searchParams, setSearchParams, taskScopeId]); const loadHistorySessions = useCallback(() => { if (isLocalPositionDemoSession(positionSessionRef.current?.id)) { @@ -2138,7 +2170,7 @@ export default function PositionRoundtablePage({ return; } setHistoryLoading(true); - void listPositionSessions(task.id) + void listPositionSessions(taskScopeId ?? undefined) .then((sessions) => { setHistorySessions( [...sessions].sort((left, right) => { @@ -2154,7 +2186,7 @@ export default function PositionRoundtablePage({ .finally(() => { setHistoryLoading(false); }); - }, [task.id]); + }, [taskScopeId]); const handleHistoryMenuOpenChange = useCallback( (open: boolean) => { @@ -2198,7 +2230,8 @@ export default function PositionRoundtablePage({ restoringIntentSessionRef.current = null; persistedIntentRef.current = null; const next = new URLSearchParams(searchParams); - next.set("taskId", task.id); + if (taskScopeId) next.set("taskId", taskScopeId); + else next.delete("taskId"); next.set("sessionId", sessionId); next.delete("new"); setSearchParams(next); @@ -2209,7 +2242,7 @@ export default function PositionRoundtablePage({ searchParams, sessionIdFromUrl, setSearchParams, - task.id, + taskScopeId, ], ); @@ -2269,7 +2302,8 @@ export default function PositionRoundtablePage({ setSelectedAgentId(null); setHistoryMenuOpen(false); const next = new URLSearchParams(searchParams); - next.set("taskId", task.id); + if (taskScopeId) next.set("taskId", taskScopeId); + else next.delete("taskId"); if (nextSession) { // Prefer the most recently active remaining conversation so the // user stays in history instead of being dropped onto a blank page. @@ -2299,7 +2333,7 @@ export default function PositionRoundtablePage({ sessionIdFromUrl, setSearchParams, standaloneDemoMode, - task.id, + taskScopeId, ], ); diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index fc9ef7d2..89a43bef 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -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 diff --git a/offline-backend-20260512/backend/README.md b/offline-backend-20260512/backend/README.md index d12cbc71..98c82227 100644 --- a/offline-backend-20260512/backend/README.md +++ b/offline-backend-20260512/backend/README.md @@ -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 ` 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. diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index fc8255e7..dec93fda 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -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) diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py index a2b4c5f2..52fb399b 100644 --- a/offline-backend-20260512/backend/app/gateway/deps.py +++ b/offline-backend-20260512/backend/app/gateway/deps.py @@ -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) diff --git a/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py b/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py index e604d63d..ae50eb50 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py +++ b/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py @@ -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) diff --git a/offline-backend-20260512/backend/app/gateway/routers/roundtable_artifact_submissions.py b/offline-backend-20260512/backend/app/gateway/routers/roundtable_artifact_submissions.py new file mode 100644 index 00000000..7a0afc20 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/roundtable_artifact_submissions.py @@ -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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260808_01_position_roundtable_shared_task_scope.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260808_01_position_roundtable_shared_task_scope.py new file mode 100644 index 00000000..38691fce --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260808_01_position_roundtable_shared_task_scope.py @@ -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") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py index b32bede6..c14e1a15 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py @@ -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", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/model.py index fee04be5..56419161 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/model.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/model.py @@ -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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/sql.py index 11dfd969..cb72b107 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/sql.py @@ -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() diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/__init__.py new file mode 100644 index 00000000..98cd66d0 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/__init__.py @@ -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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/base.py new file mode 100644 index 00000000..e45743a0 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/base.py @@ -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 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/memory.py new file mode 100644 index 00000000..a1cf4986 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/memory.py @@ -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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/model.py new file mode 100644 index 00000000..f973b4bc --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/model.py @@ -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), + ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/sql.py new file mode 100644 index 00000000..6f397a81 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_artifact_submissions/sql.py @@ -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) diff --git a/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql b/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql index e8e6e0cc..4b7d6cb3 100644 --- a/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql +++ b/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql @@ -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; diff --git a/offline-backend-20260512/backend/scripts/sql/leaderboard_indexes_mysql.sql b/offline-backend-20260512/backend/scripts/sql/leaderboard_indexes_mysql.sql index 8c4151c3..5ce390fc 100644 --- a/offline-backend-20260512/backend/scripts/sql/leaderboard_indexes_mysql.sql +++ b/offline-backend-20260512/backend/scripts/sql/leaderboard_indexes_mysql.sql @@ -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; diff --git a/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py b/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py index bebbd60d..fc99c6b1 100644 --- a/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py +++ b/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py @@ -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": "形成协同方案", diff --git a/tmp/maintenance-manual/DeerFlow系统运维手册-内网离线部署版.docx b/tmp/maintenance-manual/DeerFlow系统运维手册-内网离线部署版.docx new file mode 100644 index 00000000..c5ffed56 Binary files /dev/null and b/tmp/maintenance-manual/DeerFlow系统运维手册-内网离线部署版.docx differ diff --git a/tmp/maintenance-manual/DeerFlow系统运维手册-用户重点版.docx b/tmp/maintenance-manual/DeerFlow系统运维手册-用户重点版.docx new file mode 100644 index 00000000..757ada24 Binary files /dev/null and b/tmp/maintenance-manual/DeerFlow系统运维手册-用户重点版.docx differ diff --git a/tmp/maintenance-manual/assets/architecture.png b/tmp/maintenance-manual/assets/architecture.png new file mode 100644 index 00000000..0f580d3f Binary files /dev/null and b/tmp/maintenance-manual/assets/architecture.png differ diff --git a/tmp/maintenance-manual/assets/backup_upgrade.png b/tmp/maintenance-manual/assets/backup_upgrade.png new file mode 100644 index 00000000..ec0f83de Binary files /dev/null and b/tmp/maintenance-manual/assets/backup_upgrade.png differ diff --git a/tmp/maintenance-manual/assets/config_layers.png b/tmp/maintenance-manual/assets/config_layers.png new file mode 100644 index 00000000..95d5cfa1 Binary files /dev/null and b/tmp/maintenance-manual/assets/config_layers.png differ diff --git a/tmp/maintenance-manual/assets/lost_asset_flow.png b/tmp/maintenance-manual/assets/lost_asset_flow.png new file mode 100644 index 00000000..3aaea1f2 Binary files /dev/null and b/tmp/maintenance-manual/assets/lost_asset_flow.png differ diff --git a/tmp/maintenance-manual/assets/model_flow.png b/tmp/maintenance-manual/assets/model_flow.png new file mode 100644 index 00000000..b0e99ca5 Binary files /dev/null and b/tmp/maintenance-manual/assets/model_flow.png differ diff --git a/tmp/maintenance-manual/assets/persistence_map.png b/tmp/maintenance-manual/assets/persistence_map.png new file mode 100644 index 00000000..9d146a34 Binary files /dev/null and b/tmp/maintenance-manual/assets/persistence_map.png differ diff --git a/tmp/maintenance-manual/assets/publish_flow.png b/tmp/maintenance-manual/assets/publish_flow.png new file mode 100644 index 00000000..ad3199ca Binary files /dev/null and b/tmp/maintenance-manual/assets/publish_flow.png differ diff --git a/tmp/maintenance-manual/assets/roles.png b/tmp/maintenance-manual/assets/roles.png new file mode 100644 index 00000000..f6a49944 Binary files /dev/null and b/tmp/maintenance-manual/assets/roles.png differ diff --git a/tmp/maintenance-manual/assets/ui_map.png b/tmp/maintenance-manual/assets/ui_map.png new file mode 100644 index 00000000..9c1d79f0 Binary files /dev/null and b/tmp/maintenance-manual/assets/ui_map.png differ diff --git a/tmp/maintenance-manual/build_manual.py b/tmp/maintenance-manual/build_manual.py new file mode 100644 index 00000000..b5f7f915 --- /dev/null +++ b/tmp/maintenance-manual/build_manual.py @@ -0,0 +1,1905 @@ +from __future__ import annotations + +import os +from pathlib import Path +from textwrap import dedent + +from PIL import Image, ImageDraw, ImageFont +from docx import Document +from docx.enum.section import WD_SECTION +from docx.enum.style import WD_STYLE_TYPE +from docx.enum.table import WD_ALIGN_VERTICAL, WD_CELL_VERTICAL_ALIGNMENT +from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK, WD_LINE_SPACING +from docx.oxml import OxmlElement +from docx.oxml.ns import qn +from docx.shared import Inches, Pt, RGBColor + + +ROOT = Path(__file__).resolve().parent +ASSETS = ROOT / "assets" +OUTPUT = ROOT / "DeerFlow系统运维手册-内网离线部署版.docx" + +BLUE = "2E74B5" +DARK_BLUE = "1F4D78" +LIGHT_BLUE = "E8F2FA" +PALE_BLUE = "F3F8FC" +PALE_GREEN = "EAF5EE" +PALE_YELLOW = "FFF5D9" +PALE_RED = "FCE8E6" +GRAY = "606A73" +LIGHT_GRAY = "F3F5F7" +BORDER = "B9C6D2" +TEXT = "24313A" +WHITE = "FFFFFF" +USABLE_DXA = 9360 + + +def pil_color(value): + if isinstance(value, str) and len(value) in (6, 8) and not value.startswith("#"): + return f"#{value}" + return value + + +class ColorDraw: + """Normalize bare six-digit hex colors before forwarding to Pillow.""" + + def __init__(self, raw): + self.raw = raw + + def __getattr__(self, name): + attr = getattr(self.raw, name) + if not callable(attr): + return attr + + def wrapped(*args, **kwargs): + for key in ("fill", "outline"): + if key in kwargs: + kwargs[key] = pil_color(kwargs[key]) + return attr(*args, **kwargs) + + return wrapped + + +def ensure_dirs() -> None: + ASSETS.mkdir(parents=True, exist_ok=True) + + +def font_path(bold: bool = False) -> str: + candidates = [ + r"C:\Windows\Fonts\msyhbd.ttc" if bold else r"C:\Windows\Fonts\msyh.ttc", + r"C:\Windows\Fonts\simhei.ttf" if bold else r"C:\Windows\Fonts\simsun.ttc", + r"C:\Windows\Fonts\arialbd.ttf" if bold else r"C:\Windows\Fonts\arial.ttf", + ] + for candidate in candidates: + if Path(candidate).exists(): + return candidate + raise FileNotFoundError("No usable Windows font found") + + +def pil_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont: + return ImageFont.truetype(font_path(bold), size=size) + + +def rounded_box(draw: ImageDraw.ImageDraw, xy, fill, outline=BLUE, width=3, radius=24): + draw.rounded_rectangle(xy, radius=radius, fill=fill, outline=outline, width=width) + + +def center_text(draw: ImageDraw.ImageDraw, xy, text, font, fill=TEXT, spacing=8): + x1, y1, x2, y2 = xy + bbox = draw.multiline_textbbox((0, 0), text, font=font, align="center", spacing=spacing) + tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] + draw.multiline_text(((x1 + x2 - tw) / 2, (y1 + y2 - th) / 2), text, font=font, fill=fill, align="center", spacing=spacing) + + +def arrow(draw: ImageDraw.ImageDraw, start, end, fill=BLUE, width=6): + draw.line([start, end], fill=fill, width=width) + x1, y1 = start + x2, y2 = end + dx, dy = x2 - x1, y2 - y1 + length = max((dx * dx + dy * dy) ** 0.5, 1) + ux, uy = dx / length, dy / length + px, py = -uy, ux + tip = (x2, y2) + a = (x2 - ux * 24 + px * 12, y2 - uy * 24 + py * 12) + b = (x2 - ux * 24 - px * 12, y2 - uy * 24 - py * 12) + draw.polygon([tip, a, b], fill=fill) + + +def base_canvas(title: str, subtitle: str = ""): + img = Image.new("RGB", (1800, 1020), pil_color(WHITE)) + draw = ColorDraw(ImageDraw.Draw(img)) + draw.rounded_rectangle((30, 30, 1770, 990), radius=32, fill="F8FBFD", outline="CFDCE7", width=3) + draw.text((90, 72), title, font=pil_font(48, True), fill=DARK_BLUE) + if subtitle: + draw.text((92, 138), subtitle, font=pil_font(24), fill=GRAY) + draw.line((90, 190, 1710, 190), fill="D9E5EE", width=3) + return img, draw + + +def make_diagrams() -> dict[str, Path]: + paths: dict[str, Path] = {} + + img, draw = base_canvas("DeerFlow 生产访问链路", "推荐:浏览器只访问一个前端端口,Nginx 同源反向代理后端") + boxes = { + "browser": (90, 340, 390, 560), + "nginx": (520, 300, 870, 600), + "backend": (1010, 260, 1390, 490), + "data": (1010, 610, 1390, 840), + "external": (1480, 260, 1710, 840), + } + rounded_box(draw, boxes["browser"], "E8F2FA") + rounded_box(draw, boxes["nginx"], "DCECF8") + rounded_box(draw, boxes["backend"], "EAF5EE", outline="4B8C63") + rounded_box(draw, boxes["data"], "FFF5D9", outline="AA7D10") + rounded_box(draw, boxes["external"], "F1ECF8", outline="76519A") + center_text(draw, boxes["browser"], "用户浏览器\nhttp://服务器:APP_PORT", pil_font(31, True)) + center_text(draw, boxes["nginx"], "Web 容器 / Nginx\n静态前端 + /deerflow 代理\n容器内 80", pil_font(30, True)) + center_text(draw, boxes["backend"], "Backend 容器\nFastAPI + Agent Runtime\n容器内 8001", pil_font(30, True)) + center_text(draw, boxes["data"], "持久化目录\ndata/backend · data/skills\nconfig · 日志 · 知识库", pil_font(28, True)) + center_text(draw, boxes["external"], "外部依赖\nMySQL\n模型 API\nHindsight\n业务接口", pil_font(26, True)) + arrow(draw, (390, 450), (520, 450)) + arrow(draw, (870, 400), (1010, 375)) + arrow(draw, (1200, 490), (1200, 610), fill="AA7D10") + arrow(draw, (1390, 375), (1480, 375), fill="76519A") + path = ASSETS / "architecture.png" + img.save(path) + paths["architecture"] = path + + img, draw = base_canvas("配置分层:先判断改哪一层", "最常见的误区是改了源码 .env,却没有改运行中的部署配置") + levels = [ + ((110, 245, 1690, 390), "① 部署层 .env", "APP_PORT、镜像标签、Compose 变量\n改后重建 Web 或受影响服务", "E8F2FA"), + ((110, 420, 1690, 565), "② 后端配置\nbackend.env + config.yaml", "登录 JWT、模型 Key、数据库 URL\n环境变量变更需重建 Backend", "EAF5EE"), + ((110, 595, 1690, 740), "③ 前端运行时配置\nruntime-config.js", "后端/API、业务接口、Logo\n通常重启 Web + 浏览器强刷", "FFF5D9"), + ((110, 770, 1690, 915), "④ 管理页面配置", "菜单、主题、发布广场、敏感词\n写入数据库,保存后全局生效", "F1ECF8"), + ] + for xy, title, desc, fill in levels: + rounded_box(draw, xy, fill) + center_text(draw, (145, xy[1] + 10, 665, xy[3] - 10), title, pil_font(26, True), DARK_BLUE) + center_text(draw, (700, xy[1] + 10, 1650, xy[3] - 10), desc, pil_font(23), TEXT) + path = ASSETS / "config_layers.png" + img.save(path) + paths["config_layers"] = path + + img, draw = base_canvas("账号与权限边界", "四种身份不要混用:系统层权限不能替代应用层权限") + roles = [ + ((90, 265, 455, 800), "Linux / 1Panel 管理员", "安装镜像、改文件\n看日志、备份与恢复\n\n不是 DeerFlow\n应用管理员", "E8F2FA"), + ((505, 265, 870, 800), "DeerFlow admin", "菜单、主题、广场\n白名单与全局资产\n\nsystem_role=admin", "EAF5EE"), + ((920, 265, 1285, 800), "lqq 账号", "前端敏感词管理页\n专用入口\n\n用户名或邮箱 @ 前缀\n必须为 lqq", "FFF5D9"), + ((1335, 265, 1700, 800), "普通业务用户", "聊天、维护自己的\n技能和智能体、发布/下架\n\nsystem_role=user", "F1ECF8"), + ] + for xy, title, desc, fill in roles: + rounded_box(draw, xy, fill) + center_text(draw, (xy[0] + 18, xy[1] + 28, xy[2] - 18, xy[1] + 145), title, pil_font(29, True), fill=DARK_BLUE) + center_text(draw, (xy[0] + 24, xy[1] + 165, xy[2] - 24, xy[3] - 28), desc, pil_font(23), TEXT, spacing=12) + path = ASSETS / "roles.png" + img.save(path) + paths["roles"] = path + + img, draw = base_canvas("模型配置与验证闭环", "修改模型后必须同时验证配置解析、接口连通和实际对话") + steps = [ + ((80, 340, 360, 610), "1", "确定模型参数\nmodel / base_url\n上下文与输出限制"), + ((430, 340, 710, 610), "2", "backend.env\n写入 API Key\n只写变量值"), + ((780, 340, 1060, 610), "3", "config.yaml\n新增 models 条目\n添加 max_tokens"), + ((1130, 340, 1410, 610), "4", "重建/重启 Backend\n查看启动日志\n请求 /health"), + ((1480, 340, 1720, 610), "5", "UI 选模型\n最短问答验证\n再做长文本测试"), + ] + for i, (xy, num, label) in enumerate(steps): + rounded_box(draw, xy, ["E8F2FA", "EAF5EE", "FFF5D9", "F1ECF8", "E8F2FA"][i]) + draw.ellipse((xy[0] + 18, xy[1] + 18, xy[0] + 78, xy[1] + 78), fill=BLUE) + center_text(draw, (xy[0] + 18, xy[1] + 18, xy[0] + 78, xy[1] + 78), num, pil_font(29, True), WHITE) + center_text(draw, (xy[0] + 20, xy[1] + 90, xy[2] - 20, xy[3] - 20), label, pil_font(25, True)) + if i < len(steps) - 1: + arrow(draw, (xy[2], 475), (steps[i + 1][0][0], 475)) + draw.text((155, 725), "max_tokens 是单次生成输出上限,不是模型上下文窗口;必须小于或等于模型服务允许值。", font=pil_font(30, True), fill="A34B00") + path = ASSETS / "model_flow.png" + img.save(path) + paths["model_flow"] = path + + img, draw = base_canvas("技能与智能体的数据归属", "文件、数据库元数据、账号权限缺一不可;备份必须覆盖两边") + blocks = [ + ((100, 270, 690, 500), "技能文件", "data/skills/public/<技能>/SKILL.md\ndata/skills/custom/<技能>/SKILL.md\n可附带 .py、references、assets", "E8F2FA"), + ((100, 590, 690, 820), "智能体文件", "data/backend/agents//config.yaml\ndata/backend/agents//SOUL.md", "EAF5EE"), + ((940, 270, 1690, 500), "业务数据库", "技能/智能体所有者、名称、可见性、广场、发布状态\nSQLite 或外部 MySQL", "FFF5D9"), + ((940, 590, 1690, 820), "运行时与缓存", "启动扫描、技能系统提示缓存、容器权限、当前登录用户\n异常时可通过重启和日志核对", "F1ECF8"), + ] + for xy, title, desc, fill in blocks: + rounded_box(draw, xy, fill) + draw.text((xy[0] + 32, xy[1] + 28), title, font=pil_font(31, True), fill=DARK_BLUE) + draw.multiline_text((xy[0] + 32, xy[1] + 85), desc, font=pil_font(24), fill=TEXT, spacing=10) + arrow(draw, (690, 385), (940, 385)) + arrow(draw, (690, 705), (940, 705)) + draw.text((720, 510), "共同决定 UI 中能否看到、能否使用、能否发布", font=pil_font(25, True), fill=BLUE) + path = ASSETS / "persistence_map.png" + img.save(path) + paths["persistence_map"] = path + + img, draw = base_canvas("从创建到发布广场", "UI 发布会同步维护文件与数据库元数据,优先于手工拷贝") + steps = [ + ((90, 320, 380, 620), "创建 / 导入", "普通用户创建 custom 技能\n或创建自己的智能体"), + ((460, 320, 750, 620), "本地验证", "补齐说明与依赖\n完成一次真实调用\n检查无敏感信息"), + ((830, 320, 1120, 620), "点击发布", "资产详情或卡片 → 发布\n选择目标广场"), + ((1200, 320, 1490, 620), "权限校验", "所有者或 admin\n每个资产只能属于一个广场"), + ((1550, 320, 1730, 620), "生效", "广场可见\n可下架\n可改投其他广场"), + ] + for i, (xy, title, desc) in enumerate(steps): + rounded_box(draw, xy, ["E8F2FA", "EAF5EE", "FFF5D9", "F1ECF8", "E8F2FA"][i]) + center_text(draw, (xy[0] + 15, xy[1] + 25, xy[2] - 15, xy[1] + 110), title, pil_font(27, True), DARK_BLUE) + draw.multiline_text((xy[0] + 22, xy[1] + 135), desc, font=pil_font(22), fill=TEXT, spacing=8, align="center") + if i < len(steps) - 1: + arrow(draw, (xy[2], 470), (steps[i + 1][0][0], 470)) + draw.rounded_rectangle((240, 735, 1560, 860), radius=20, fill=PALE_RED, outline="C5645A", width=3) + center_text(draw, (240, 735, 1560, 860), "不要把“复制到 public 目录”和“发布到广场”混为一谈:前者是文件类别,后者是业务可见性与发布元数据。", pil_font(27, True), "8E3028") + path = ASSETS / "publish_flow.png" + img.save(path) + paths["publish_flow"] = path + + img, draw = base_canvas("管理入口地图", "所有入口均以当前前端版本为准;看不到入口时先检查账号身份") + items = [ + ((120, 250, 760, 410), "设置和更多", "菜单管理 · 敏感词管理 · 按钮管理"), + ((1040, 250, 1680, 410), "外观设置", "个人主题 · 系统默认主题 · 自定义主题库 · 发布广场"), + ((120, 500, 760, 660), "技能管理 / 技能设置", "上传、编辑、地址扫描、发布/下架、权限"), + ((1040, 500, 1680, 660), "智能体管理 / 智能体详情", "创建、编辑配置、发布/下架、私有广场"), + ((490, 750, 1310, 900), "用户与白名单管理", "账号启停 · system_role · 登录密码 · 管理员审批"), + ] + for i, (xy, title, desc) in enumerate(items): + rounded_box(draw, xy, ["E8F2FA", "EAF5EE", "FFF5D9", "F1ECF8", "FCE8E6"][i]) + draw.text((xy[0] + 30, xy[1] + 24), title, font=pil_font(30, True), fill=DARK_BLUE) + draw.text((xy[0] + 30, xy[1] + 84), desc, font=pil_font(23), fill=TEXT) + arrow(draw, (760, 330), (1040, 330)) + arrow(draw, (760, 580), (1040, 580)) + path = ASSETS / "ui_map.png" + img.save(path) + paths["ui_map"] = path + + img, draw = base_canvas("技能或智能体“丢失”的排查顺序", "先确定是显示问题、身份问题、数据库问题,还是文件/挂载真的丢失") + nodes = [ + ((80, 290, 380, 470), "1. 服务与项目", "确认容器、Compose 目录、端口"), + ((500, 290, 800, 470), "2. 登录身份", "账号是否变化、角色/所有者是否匹配"), + ((920, 290, 1220, 470), "3. 文件挂载", "宿主机 data 目录与容器内目录"), + ((1340, 290, 1720, 470), "4. 数据库", "SQLite/MySQL 是否切换、记录是否存在"), + ((1340, 650, 1720, 830), "5. 发布与过滤", "广场、私有状态、搜索筛选、权限"), + ((920, 650, 1220, 830), "6. 日志与权限", "permission、invalid config、扫描失败"), + ((500, 650, 800, 830), "7. 备份恢复", "先停止写入,再按文件+数据库恢复"), + ((80, 650, 380, 830), "8. 验证", "重启后端、重新登录、实际调用"), + ] + for i, (xy, title, desc) in enumerate(nodes): + rounded_box(draw, xy, ["E8F2FA", "EAF5EE", "FFF5D9", "F1ECF8"][i % 4]) + center_text(draw, (xy[0] + 12, xy[1] + 12, xy[2] - 12, xy[1] + 75), title, pil_font(25, True), DARK_BLUE) + center_text(draw, (xy[0] + 20, xy[1] + 78, xy[2] - 20, xy[3] - 15), desc, pil_font(21), TEXT) + for i in range(3): + arrow(draw, (nodes[i][0][2], 380), (nodes[i + 1][0][0], 380)) + arrow(draw, (1530, 470), (1530, 650)) + for i in range(4, 7): + arrow(draw, (nodes[i][0][0], 740), (nodes[i + 1][0][2], 740)) + path = ASSETS / "lost_asset_flow.png" + img.save(path) + paths["lost_asset_flow"] = path + + img, draw = base_canvas("备份、升级与回滚闭环", "离线环境的核心原则:先验证备份可恢复,再导入新镜像") + steps = [ + ((100, 310, 400, 620), "盘点", "记录版本、镜像摘要\n端口、数据库模式\n外部依赖地址"), + ((470, 310, 770, 620), "一致性备份", "停止写入窗口\n备份 data + config\n导出外部 MySQL"), + ((840, 310, 1140, 620), "新目录演练", "导入 ARM64 镜像\n复制配置并合并\n换测试端口启动"), + ((1210, 310, 1510, 620), "验收", "健康检查、登录\n模型、技能、智能体\n广场与关键业务"), + ((1580, 310, 1730, 620), "切换", "停旧启新\n保留旧镜像\n异常即回滚"), + ] + for i, (xy, title, desc) in enumerate(steps): + rounded_box(draw, xy, ["E8F2FA", "EAF5EE", "FFF5D9", "F1ECF8", "E8F2FA"][i]) + center_text(draw, (xy[0] + 12, xy[1] + 20, xy[2] - 12, xy[1] + 105), title, pil_font(28, True), DARK_BLUE) + center_text(draw, (xy[0] + 20, xy[1] + 110, xy[2] - 20, xy[3] - 20), desc, pil_font(23), TEXT) + if i < len(steps) - 1: + arrow(draw, (xy[2], 465), (steps[i + 1][0][0], 465)) + draw.text((210, 760), "切忌:在没有确认数据路径和备份的情况下执行 docker system prune -a 或删除部署目录。", font=pil_font(31, True), fill="9A342D") + path = ASSETS / "backup_upgrade.png" + img.save(path) + paths["backup_upgrade"] = path + + return paths + + +def set_cell_shading(cell, fill: str): + tc_pr = cell._tc.get_or_add_tcPr() + shd = tc_pr.find(qn("w:shd")) + if shd is None: + shd = OxmlElement("w:shd") + tc_pr.append(shd) + shd.set(qn("w:fill"), fill) + + +def set_cell_margins(cell, top=80, start=120, bottom=80, end=120): + tc = cell._tc + tc_pr = tc.get_or_add_tcPr() + tc_mar = tc_pr.first_child_found_in("w:tcMar") + if tc_mar is None: + tc_mar = OxmlElement("w:tcMar") + tc_pr.append(tc_mar) + for m, v in (("top", top), ("start", start), ("bottom", bottom), ("end", end)): + node = tc_mar.find(qn(f"w:{m}")) + if node is None: + node = OxmlElement(f"w:{m}") + tc_mar.append(node) + node.set(qn("w:w"), str(v)) + node.set(qn("w:type"), "dxa") + + +def set_repeat_table_header(row): + tr_pr = row._tr.get_or_add_trPr() + tbl_header = OxmlElement("w:tblHeader") + tbl_header.set(qn("w:val"), "true") + tr_pr.append(tbl_header) + + +def prevent_row_split(row): + tr_pr = row._tr.get_or_add_trPr() + cant_split = OxmlElement("w:cantSplit") + tr_pr.append(cant_split) + + +def set_table_fixed(table, widths: list[int]): + table.autofit = False + tbl_pr = table._tbl.tblPr + layout = tbl_pr.find(qn("w:tblLayout")) + if layout is None: + layout = OxmlElement("w:tblLayout") + tbl_pr.append(layout) + layout.set(qn("w:type"), "fixed") + tbl_w = tbl_pr.find(qn("w:tblW")) + if tbl_w is None: + tbl_w = OxmlElement("w:tblW") + tbl_pr.append(tbl_w) + tbl_w.set(qn("w:w"), str(sum(widths))) + tbl_w.set(qn("w:type"), "dxa") + tbl_ind = tbl_pr.find(qn("w:tblInd")) + if tbl_ind is None: + tbl_ind = OxmlElement("w:tblInd") + tbl_pr.append(tbl_ind) + tbl_ind.set(qn("w:w"), "120") + tbl_ind.set(qn("w:type"), "dxa") + grid = table._tbl.tblGrid + for child in list(grid): + grid.remove(child) + for width in widths: + col = OxmlElement("w:gridCol") + col.set(qn("w:w"), str(width)) + grid.append(col) + for row in table.rows: + for cell, width in zip(row.cells, widths): + tc_pr = cell._tc.get_or_add_tcPr() + tc_w = tc_pr.find(qn("w:tcW")) + if tc_w is None: + tc_w = OxmlElement("w:tcW") + tc_pr.append(tc_w) + tc_w.set(qn("w:w"), str(width)) + tc_w.set(qn("w:type"), "dxa") + set_cell_margins(cell) + + +def set_run_font(run, name="Microsoft YaHei", size=None, bold=None, color=None): + run.font.name = name + run._element.rPr.rFonts.set(qn("w:eastAsia"), name) + if size is not None: + run.font.size = Pt(size) + if bold is not None: + run.bold = bold + if color: + run.font.color.rgb = RGBColor.from_string(color) + + +def style_paragraph(p, before=0, after=6, line=1.25, keep=False): + fmt = p.paragraph_format + fmt.space_before = Pt(before) + fmt.space_after = Pt(after) + fmt.line_spacing = line + if keep: + fmt.keep_together = True + + +def add_field(paragraph, instr: str, display: str = ""): + run = paragraph.add_run() + fld_char1 = OxmlElement("w:fldChar") + fld_char1.set(qn("w:fldCharType"), "begin") + instr_text = OxmlElement("w:instrText") + instr_text.set(qn("xml:space"), "preserve") + instr_text.text = instr + fld_char2 = OxmlElement("w:fldChar") + fld_char2.set(qn("w:fldCharType"), "separate") + text_node = OxmlElement("w:t") + text_node.text = display + fld_char3 = OxmlElement("w:fldChar") + fld_char3.set(qn("w:fldCharType"), "end") + run._r.extend([fld_char1, instr_text, fld_char2, text_node, fld_char3]) + + +def configure_document(doc: Document): + section = doc.sections[0] + section.page_width = Inches(8.5) + section.page_height = Inches(11) + section.top_margin = Inches(1) + section.bottom_margin = Inches(1) + section.left_margin = Inches(1) + section.right_margin = Inches(1) + section.header_distance = Inches(0.492) + section.footer_distance = Inches(0.492) + section.different_first_page_header_footer = True + + styles = doc.styles + normal = styles["Normal"] + normal.font.name = "Calibri" + normal.font.size = Pt(11) + normal._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei") + normal.font.color.rgb = RGBColor.from_string(TEXT) + normal.paragraph_format.space_after = Pt(6) + normal.paragraph_format.line_spacing = 1.25 + + for style_name, size, color, before, after, level in ( + ("Title", 28, DARK_BLUE, 0, 14, 0), + ("Subtitle", 15, GRAY, 0, 8, 0), + ("Heading 1", 16, BLUE, 18, 10, 1), + ("Heading 2", 13, BLUE, 14, 7, 2), + ("Heading 3", 12, DARK_BLUE, 10, 5, 3), + ): + st = styles[style_name] + st.font.name = "Calibri" + st._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei") + st.font.size = Pt(size) + st.font.color.rgb = RGBColor.from_string(color) + st.font.bold = True + st.paragraph_format.space_before = Pt(before) + st.paragraph_format.space_after = Pt(after) + st.paragraph_format.keep_with_next = True + if level == 1: + st.paragraph_format.page_break_before = True + + for style_name in ("List Bullet", "List Number"): + st = styles[style_name] + st.font.name = "Calibri" + st._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei") + st.font.size = Pt(11) + st.paragraph_format.left_indent = Inches(0.375) + st.paragraph_format.first_line_indent = Inches(-0.188) + st.paragraph_format.space_after = Pt(4) + st.paragraph_format.line_spacing = 1.25 + + if "Code Block" not in styles: + code = styles.add_style("Code Block", WD_STYLE_TYPE.PARAGRAPH) + else: + code = styles["Code Block"] + code.font.name = "Consolas" + code._element.rPr.rFonts.set(qn("w:eastAsia"), "Microsoft YaHei") + code.font.size = Pt(8.8) + code.font.color.rgb = RGBColor.from_string("1F2933") + code.paragraph_format.left_indent = Inches(0.18) + code.paragraph_format.right_indent = Inches(0.12) + code.paragraph_format.space_before = Pt(3) + code.paragraph_format.space_after = Pt(6) + code.paragraph_format.line_spacing = 1.05 + code.paragraph_format.keep_together = True + + # Running header and page footer. + header = section.header + hp = header.paragraphs[0] + hp.alignment = WD_ALIGN_PARAGRAPH.RIGHT + hr = hp.add_run("DeerFlow 系统运维手册 | 内网离线部署版") + set_run_font(hr, size=8.5, color=GRAY) + + footer = section.footer + fp = footer.paragraphs[0] + fp.alignment = WD_ALIGN_PARAGRAPH.CENTER + fr = fp.add_run("内部运维资料 · 第 ") + set_run_font(fr, size=8.5, color=GRAY) + add_field(fp, "PAGE", "1") + fr2 = fp.add_run(" 页") + set_run_font(fr2, size=8.5, color=GRAY) + + update = OxmlElement("w:updateFields") + update.set(qn("w:val"), "true") + doc.settings._element.append(update) + + +def add_para(doc, text="", *, bold_prefix=None, italic=False, align=None, after=6, keep=False): + p = doc.add_paragraph() + if bold_prefix and text.startswith(bold_prefix): + r1 = p.add_run(bold_prefix) + set_run_font(r1, bold=True) + r2 = p.add_run(text[len(bold_prefix):]) + set_run_font(r2) + else: + r = p.add_run(text) + set_run_font(r) + r.italic = italic + style_paragraph(p, after=after, keep=keep) + if align is not None: + p.alignment = align + return p + + +def add_bullet(doc, text, level=0): + p = doc.add_paragraph(style="List Bullet") + if level: + p.paragraph_format.left_indent = Inches(0.375 + 0.25 * level) + r = p.add_run(text) + set_run_font(r) + return p + + +def new_numbering_instance(doc) -> int: + """Create a real numbered-list instance that restarts at 1.""" + numbering = doc.part.numbering_part.element + style = doc.styles["List Number"] + style_num_id = int(style._element.pPr.numPr.numId.val) + abstract_id = None + existing_ids = [] + for num in numbering.findall(qn("w:num")): + num_id = int(num.get(qn("w:numId"))) + existing_ids.append(num_id) + if num_id == style_num_id: + abstract_id = int(num.find(qn("w:abstractNumId")).get(qn("w:val"))) + if abstract_id is None: + raise RuntimeError("Unable to resolve List Number abstract numbering") + new_id = max(existing_ids or [0]) + 1 + num = OxmlElement("w:num") + num.set(qn("w:numId"), str(new_id)) + abstract = OxmlElement("w:abstractNumId") + abstract.set(qn("w:val"), str(abstract_id)) + num.append(abstract) + override = OxmlElement("w:lvlOverride") + override.set(qn("w:ilvl"), "0") + start = OxmlElement("w:startOverride") + start.set(qn("w:val"), "1") + override.append(start) + num.append(override) + numbering.append(num) + return new_id + + +def add_number(doc, text, level=0): + if getattr(doc, "_restart_numbering", True) or not hasattr(doc, "_active_num_id"): + doc._active_num_id = new_numbering_instance(doc) + doc._restart_numbering = False + p = doc.add_paragraph(style="List Number") + p_pr = p._p.get_or_add_pPr() + num_pr = p_pr.find(qn("w:numPr")) + if num_pr is None: + num_pr = OxmlElement("w:numPr") + p_pr.append(num_pr) + ilvl = OxmlElement("w:ilvl") + ilvl.set(qn("w:val"), str(level)) + num_id = OxmlElement("w:numId") + num_id.set(qn("w:val"), str(doc._active_num_id)) + num_pr.extend([ilvl, num_id]) + if level: + p.paragraph_format.left_indent = Inches(0.375 + 0.25 * level) + r = p.add_run(text) + set_run_font(r) + return p + + +def add_code(doc, code_text: str, language: str = ""): + if language: + label = doc.add_paragraph() + label.paragraph_format.space_before = Pt(3) + label.paragraph_format.space_after = Pt(2) + label.paragraph_format.keep_with_next = True + rr = label.add_run(language) + set_run_font(rr, size=8, bold=True, color=GRAY) + lines = dedent(code_text).strip("\n").splitlines() + for idx, line in enumerate(lines): + p = doc.add_paragraph(style="Code Block") + p.paragraph_format.space_before = Pt(0) + p.paragraph_format.space_after = Pt(0 if idx < len(lines) - 1 else 6) + p.paragraph_format.keep_with_next = idx < len(lines) - 1 + p_pr = p._p.get_or_add_pPr() + shd = OxmlElement("w:shd") + shd.set(qn("w:fill"), "F2F4F7") + p_pr.append(shd) + r = p.add_run(line if line else " ") + set_run_font(r, name="Consolas", size=8.8, color="1F2933") + return p + + +def add_callout(doc, title: str, text: str, kind="note"): + colors = { + "note": (LIGHT_BLUE, BLUE), + "ok": (PALE_GREEN, "4B8C63"), + "warn": (PALE_YELLOW, "A06F00"), + "danger": (PALE_RED, "A33A32"), + } + fill, color = colors[kind] + p = doc.add_paragraph() + p.paragraph_format.left_indent = Inches(0.12) + p.paragraph_format.right_indent = Inches(0.05) + p.paragraph_format.space_before = Pt(4) + p.paragraph_format.space_after = Pt(8) + p.paragraph_format.keep_together = True + p_pr = p._p.get_or_add_pPr() + shd = OxmlElement("w:shd") + shd.set(qn("w:fill"), fill) + p_pr.append(shd) + border = OxmlElement("w:pBdr") + left = OxmlElement("w:left") + left.set(qn("w:val"), "single") + left.set(qn("w:sz"), "18") + left.set(qn("w:space"), "6") + left.set(qn("w:color"), color) + border.append(left) + p_pr.append(border) + r1 = p.add_run(f"{title} ") + set_run_font(r1, bold=True, color=color) + r2 = p.add_run(text) + set_run_font(r2) + return p + + +def add_table(doc, headers: list[str], rows: list[list[str]], widths: list[int], font_size=9.2): + assert sum(widths) == USABLE_DXA, (sum(widths), widths) + table = doc.add_table(rows=1, cols=len(headers)) + table.style = "Table Grid" + table.alignment = WD_ALIGN_PARAGRAPH.CENTER + for i, h in enumerate(headers): + cell = table.rows[0].cells[i] + set_cell_shading(cell, "E8EEF5") + cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER + p = cell.paragraphs[0] + p.alignment = WD_ALIGN_PARAGRAPH.LEFT + p.paragraph_format.space_after = Pt(0) + r = p.add_run(h) + set_run_font(r, size=font_size, bold=True, color=DARK_BLUE) + set_repeat_table_header(table.rows[0]) + for row_idx, data in enumerate(rows): + row = table.add_row() + prevent_row_split(row) + for i, value in enumerate(data): + cell = row.cells[i] + cell.vertical_alignment = WD_CELL_VERTICAL_ALIGNMENT.CENTER + if row_idx % 2 == 1: + set_cell_shading(cell, "FAFBFC") + p = cell.paragraphs[0] + p.paragraph_format.space_after = Pt(0) + p.paragraph_format.line_spacing = 1.12 + r = p.add_run(str(value)) + set_run_font(r, size=font_size) + set_table_fixed(table, widths) + doc.add_paragraph().paragraph_format.space_after = Pt(1) + return table + + +def add_figure(doc, path: Path, caption: str, alt_text: str, width=6.35): + p = doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + p.paragraph_format.space_before = Pt(4) + p.paragraph_format.space_after = Pt(2) + run = p.add_run() + shape = run.add_picture(str(path), width=Inches(width)) + try: + doc_pr = shape._inline.docPr + doc_pr.set("descr", alt_text) + doc_pr.set("title", caption) + except Exception: + pass + cp = doc.add_paragraph() + cp.alignment = WD_ALIGN_PARAGRAPH.CENTER + cp.paragraph_format.space_after = Pt(8) + cr = cp.add_run(caption) + set_run_font(cr, size=9, color=GRAY) + cr.italic = True + + +def add_h1(doc, text): + doc._restart_numbering = True + return doc.add_heading(text, level=1) + + +def add_h2(doc, text): + doc._restart_numbering = True + return doc.add_heading(text, level=2) + + +def add_h3(doc, text): + doc._restart_numbering = True + return doc.add_heading(text, level=3) + + +def add_page_break(doc): + doc.add_page_break() + + +def build_document(diagrams: dict[str, Path]): + doc = Document() + configure_document(doc) + doc.core_properties.title = "DeerFlow 系统运维手册(内网离线部署版)" + doc.core_properties.subject = "DeerFlow 前后端、模型、账号、技能、智能体、广场、菜单、主题、敏感词及故障排查" + doc.core_properties.author = "DeerFlow 运维团队" + doc.core_properties.keywords = "DeerFlow, 运维, Docker, 1Panel, ARM64, 离线部署" + + # Cover + p = doc.add_paragraph() + p.paragraph_format.space_before = Inches(0.75) + p.paragraph_format.space_after = Pt(10) + p.alignment = WD_ALIGN_PARAGRAPH.LEFT + r = p.add_run("DeerFlow") + set_run_font(r, size=16, bold=True, color=BLUE) + p = doc.add_paragraph(style="Title") + p.paragraph_format.space_before = Pt(16) + p.paragraph_format.space_after = Pt(14) + p.alignment = WD_ALIGN_PARAGRAPH.LEFT + r = p.add_run("系统运维手册") + set_run_font(r, size=30, bold=True, color=DARK_BLUE) + p = doc.add_paragraph(style="Subtitle") + r = p.add_run("内网离线部署版 · Docker / 1Panel / ARM64") + set_run_font(r, size=15, color=GRAY) + add_figure(doc, diagrams["architecture"], "系统生产访问链路总览", "浏览器通过 Nginx 访问 DeerFlow 前端和后端的架构示意图", width=6.1) + add_callout(doc, "适用对象", "首次接手本系统的 Linux/1Panel 运维人员、DeerFlow 应用管理员、模型与数据管理员。", "note") + meta = add_table( + doc, + ["文档版本", "适用版本", "更新日期"], + [["V1.0", "当前离线部署包与本项目定制版", "2026-08-08"]], + [2200, 4200, 2960], + font_size=9.2, + ) + add_para(doc, "密级:内部运维资料。本文所有密钥、密码、IP 和数据库账号均使用占位符。", italic=True, align=WD_ALIGN_PARAGRAPH.CENTER, after=0) + + p = doc.add_paragraph(style="Title") + p.paragraph_format.page_break_before = True + r = p.add_run("文档导航") + set_run_font(r, size=24, bold=True, color=DARK_BLUE) + add_para(doc, "本文按“先理解、再配置、后维护、能恢复”的顺序编排。Word 左侧导航窗格可按标题跳转;如目录页码未自动刷新,请在 Word 中按 Ctrl+A 后按 F9。") + toc = doc.add_paragraph() + toc.paragraph_format.space_after = Pt(10) + add_field(toc, r'TOC \o "1-3" \h \z \u', "目录将在打开 Word 时自动更新") + add_callout(doc, "先看这四章", "首次接手请先读第 1、2、3、4 章;发生资产丢失或生产故障时直接跳到第 16、17 章。", "ok") + add_h2(doc, "版本与变更记录") + add_table( + doc, + ["版本", "日期", "变更说明", "维护人"], + [["V1.0", "2026-08-08", "首次发布,覆盖离线部署、配置、资产发布、备份恢复与排错", "DeerFlow 运维团队"]], + [1000, 1500, 5260, 1600], + ) + add_h2(doc, "阅读约定") + add_bullet(doc, "“部署目录”指解压离线包后,包含 docker-compose.yml、config、data、restart.sh 等内容的目录。") + add_bullet(doc, "命令默认以 Linux shell 执行;1Panel 的容器终端或 Xshell 均可。") + add_bullet(doc, "涉及生产数据的命令,先确认当前路径,再做备份;不要机械复制带删除含义的命令。") + add_bullet(doc, "本文以 APP_PORT=8090 为例,实际端口以部署目录 .env 为准。") + + add_h1(doc, "1. 系统全貌与运维边界") + add_para(doc, "DeerFlow 是由 React 前端、Nginx、FastAPI/LangGraph 后端、持久化文件目录和业务数据库共同组成的 AI 智能体平台。前端和后端在容器内使用不同端口,但生产访问通常只暴露一个 Web 端口:浏览器访问 Nginx,Nginx 再把 /deerflow 请求转发到后端。") + add_figure(doc, diagrams["architecture"], "图 1-1 生产访问与数据流", "DeerFlow Web、后端、持久化目录和外部依赖之间的数据流") + add_h2(doc, "1.1 服务与端口") + add_table( + doc, + ["组件", "容器内端口", "宿主机端口", "主要职责", "运维结论"], + [ + ["Web / Nginx", "80", "APP_PORT(默认 8090)", "前端静态文件、SPA 路由、反向代理", "公网或内网只需开放这一端口"], + ["Backend", "8001", "默认不直接映射", "登录、智能体、技能、模型调用、业务 API", "通过 /deerflow 由 Nginx 访问"], + ["MySQL(可选)", "由数据库决定", "通常仅内网", "业务表、资产元数据、用户与全局配置", "容器要能访问数据库地址"], + ], + [1300, 1200, 1650, 3100, 2110], + font_size=8.7, + ) + add_callout(doc, "关键结论", "前端与后端不是同一个容器端口。对用户而言可以是同一个访问端口,因为 Nginx 做了同源代理。", "note") + add_h2(doc, "1.2 需要纳入监控和备份的对象") + for item in [ + "部署配置:.env、config/backend.env、config/config.yaml、config/extensions_config.json、config/runtime-config.js。", + "持久化文件:data/backend、data/skills、data/knowledge、data/logs。", + "数据库:默认 SQLite 文件;切换外部 MySQL 后还要做 MySQL 备份。", + "离线镜像与版本:保留镜像 tar 包、SHA256、部署日期和变更记录。", + "外部依赖:模型 API、MySQL、Hindsight、MCP、企业业务接口的地址和账号。", + ]: + add_bullet(doc, item) + add_h2(doc, "1.3 本手册不替代的制度") + add_para(doc, "生产变更审批、账号实名、密码轮换、数据库备份保留期和等保要求仍以本单位制度为准。本文提供技术步骤,不构成放宽安全控制的依据。") + + add_h1(doc, "2. 账号、角色与登录密钥") + add_para(doc, "运维中最容易混淆的是“服务器 root”和“DeerFlow admin”。前者能管理容器和文件,后者才能在应用页面中配置菜单、主题、用户和发布广场。敏感词页面又有独立的 lqq 账号入口条件。") + add_figure(doc, diagrams["roles"], "图 2-1 四类身份与职责", "Linux 管理员、DeerFlow 管理员、lqq 账号、普通业务用户的权限边界") + heading = add_h2(doc, "2.1 建议准备的账号") + heading.paragraph_format.page_break_before = True + add_table( + doc, + ["账号类型", "至少数量", "用途", "注意事项"], + [ + ["Linux / 1Panel 管理员", "2", "部署、日志、备份、恢复", "不共用个人账号;禁止把 root 密码写进文档"], + ["DeerFlow admin", "2", "全局设置、白名单、菜单、主题、广场", "system_role 必须是 admin;保留一名应急管理员"], + ["lqq", "1", "前端敏感词管理", "显示用户名或邮箱 @ 前缀必须严格为 lqq"], + ["普通测试用户", "1", "验证普通权限、资产发布和页面可见性", "不能只用管理员做验收"], + ["模型/数据库服务账号", "按环境", "调用外部服务", "最小权限、单独轮换、不得与人员账号共用"], + ], + [1550, 900, 2850, 4060], + font_size=8.8, + ) + add_h2(doc, "2.2 AUTH_JWT_SECRET 的作用") + add_para(doc, "AUTH_JWT_SECRET 是后端签发和校验登录令牌的服务端密钥,不是用户登录密码。它必须在多进程、多容器和每次重启之间保持一致。若更换该值,所有现有登录会话会失效,需要重新登录。") + add_code(doc, """ + # config/backend.env + AUTH_JWT_SECRET=<至少32字节的高强度随机值> + """, "ENV") + add_para(doc, "在可联网或可信管理机生成随机值,再通过受控介质导入内网。Linux 可使用:") + add_code(doc, "openssl rand -hex 32", "Shell") + add_callout(doc, "禁止事项", "不要把密钥提交到 Git,不要写入 runtime-config.js,不要保留在 config.yaml 的注释中。浏览器可下载的前端配置中永远不能放服务端密钥。", "danger") + add_h2(doc, "2.3 用户名登录密码与 JWT 密钥的区别") + add_table( + doc, + ["项目", "保存位置", "谁使用", "变更影响"], + [ + ["AUTH_JWT_SECRET", "config/backend.env", "后端服务", "全部登录会话失效"], + ["用户名登录共享密码", "config/config.yaml 的 auth_login.username_login", "登录入口校验", "影响使用该入口的新登录"], + ["账号 login_password", "应用用户/白名单管理", "指定账号", "只影响该账号"], + ["VITE_ROUTE_LOGIN_PASSWORD", "runtime-config.js 或构建环境", "前端特定路由辅助", "不是后端安全边界"], + ], + [1700, 2600, 2100, 2960], + ) + add_h2(doc, "2.4 管理员与 lqq 的判定") + add_bullet(doc, "应用管理员由 system_role=admin 判定;内置 admin 用户通常会被提升为管理员,但仍应在用户管理页核验。") + add_bullet(doc, "敏感词写接口允许管理员或 lqq,但当前前端敏感词页面的入口和路由会检查显示用户名是否为 lqq。因此正常页面维护建议直接使用 lqq 登录。") + add_bullet(doc, "邮箱账号以 @ 前缀作为显示用户名时,应确保前缀为 lqq;不要使用类似 lqq01 代替。") + + add_h1(doc, "3. 部署目录、配置分层与变更生效") + add_para(doc, "同一项配置可能存在源码默认值、镜像内默认值和运行时覆盖值。生产运维只改部署目录中的运行时文件,并记录变更;不要进入容器直接改文件,因为重建容器后会丢失。") + add_figure(doc, diagrams["config_layers"], "图 3-1 配置分层和生效方式", "部署层、后端层、前端运行时层和管理页面配置的关系") + add_h2(doc, "3.1 标准部署目录") + add_code(doc, """ + deerflow-deploy/ + ├── docker-compose.yml + ├── .env + ├── config/ + │ ├── backend.env + │ ├── config.yaml + │ ├── extensions_config.json + │ └── runtime-config.js + ├── data/ + │ ├── backend/ + │ ├── skills/ + │ ├── knowledge/ + │ └── logs/ + ├── install.sh + ├── restart.sh + ├── stop.sh + ├── logs.sh + └── diagnose.sh + """, "目录结构") + add_h2(doc, "3.2 变更生效速查") + add_table( + doc, + ["修改对象", "典型内容", "最小生效动作", "验证"], + [ + [".env", "APP_PORT、镜像标签", "重建受影响容器", "端口监听、浏览器访问"], + ["backend.env", "JWT、模型 Key、MySQL URL", "强制重建 Backend", "后端日志、/health、实际功能"], + ["config.yaml", "模型、数据库模式、登录配置", "重启 Backend", "启动日志、模型列表、登录"], + ["runtime-config.js", "前端 API/业务接口、Logo", "建议重启 Web;浏览器 Ctrl+F5", "浏览器 Network 与页面"], + ["管理页面", "菜单、主题、敏感词、广场", "保存后通常即时生效;必要时刷新", "管理员和普通用户分别验证"], + ], + [1700, 2500, 2500, 2660], + ) + add_h2(doc, "3.3 修改前的通用流程") + for step in [ + "确认当前部署目录和 Compose 项目,不要在旧目录操作。", + "复制要修改的配置文件并加时间戳备份。", + "记录修改人、工单、原值、目标值和回退方法。", + "只重启受影响的服务;变更后执行健康检查和实际业务验证。", + "观察日志至少 5 分钟,再结束变更窗口。", + ]: + add_number(doc, step) + + add_h1(doc, "4. Docker / 1Panel 日常启停与日志") + add_h2(doc, "4.1 一键安装、重启、停止") + add_para(doc, "进入部署目录后执行以下脚本。生产环境建议保留 sudo,避免数据目录因不同用户写入而出现权限不一致。") + add_code(doc, """ + cd /opt/deerflow/deerflow-deploy + + # 首次安装 / 加载镜像并启动 + sudo bash install.sh + + # 一键重启全部服务 + sudo bash restart.sh + + # 一键停止全部服务 + sudo bash stop.sh + """, "Shell") + add_h2(doc, "4.2 只重启后端或前端") + add_code(doc, """ + # 日常重启后端,或仅 config.yaml / 挂载文件变更 + sudo docker compose restart backend + + # backend.env 发生变化时,必须重建容器以重新加载环境变量 + sudo docker compose up -d --force-recreate backend + + # 只重启 Web(前端运行时配置或 Nginx 变更) + sudo docker compose restart web + + # 查看容器状态 + sudo docker compose ps + """, "Shell") + add_callout(doc, "环境变量不会被普通 restart 重新读取", "restart.sh 和 docker compose restart 适合日常重启;修改 backend.env、APP_PORT 或其他 Compose 环境变量后,应使用 docker compose up -d --force-recreate <服务>。", "warn") + add_callout(doc, "1Panel 操作", "在 1Panel 的容器/编排页面执行“重启”也可以,但要确认选中的是当前部署目录对应的 Compose 项目。脚本方式更便于保留统一操作记录。", "note") + add_h2(doc, "4.3 打印实时日志") + add_code(doc, """ + # 全部服务实时日志(Ctrl+C 退出,不会停止服务) + sudo bash logs.sh + + # 只看后端 + sudo bash logs.sh backend + + # 只看前端 / Nginx + sudo bash logs.sh web + + # Compose 原生命令:最近 500 行并持续跟踪 + sudo docker compose logs --tail=500 -f backend + sudo docker compose logs --tail=500 -f web + """, "Shell") + add_h2(doc, "4.4 一键诊断") + add_code(doc, "sudo env TAIL=1000 bash diagnose.sh", "Shell") + add_para(doc, "诊断结果应至少包含架构、磁盘、内存、容器状态、端口监听、健康检查和最近错误日志。发送给研发前先检查输出中是否包含账号、Token、数据库 URL 或 API Key。") + add_h2(doc, "4.5 健康检查") + add_code(doc, """ + # 从服务器本机检查,端口以 .env 的 APP_PORT 为准 + curl -fsS http://127.0.0.1:8090/deerflow/health + + # 检查前端首页响应头 + curl -I http://127.0.0.1:8090/ + """, "Shell") + add_callout(doc, "不要只看容器 Up", "容器处于 Up 只代表进程存在,不代表模型、数据库和业务功能可用。每次重启后至少完成健康接口、登录和一次对话验证。", "warn") + + add_h1(doc, "5. 后端模型配置与 max_tokens") + add_para(doc, "模型列表由后端 config/config.yaml 管理,密钥放在 config/backend.env。推荐使用 OpenAI 兼容接口时配置 ChatOpenAI 适配器,并逐项核对模型 ID、base_url、超时、重试、输出上限和能力标志。") + add_figure(doc, diagrams["model_flow"], "图 5-1 模型配置与验证流程", "模型参数、密钥、后端配置、重启和界面验证的闭环") + add_h2(doc, "5.1 新增一个 OpenAI 兼容模型") + add_para(doc, "先在 backend.env 中保存密钥。变量名可自定义,但必须与 config.yaml 中 $ 后的名称完全一致。") + add_code(doc, """ + # config/backend.env + INTRANET_MODEL_API_KEY=<模型服务分配的密钥> + """, "ENV") + add_para(doc, "再在 config/config.yaml 的 models 列表中新增条目:") + add_code(doc, """ + models: + - name: intranet-model + display_name: 内网模型 + use: langchain_openai:ChatOpenAI + model: model-id-from-provider + api_key: $INTRANET_MODEL_API_KEY + base_url: http://10.0.0.20:8000/v1 + request_timeout: 600.0 + max_retries: 2 + max_tokens: 8192 + temperature: 0.7 + supports_vision: false + supports_thinking: false + """, "YAML") + add_h2(doc, "5.2 字段说明") + add_table( + doc, + ["字段", "必需", "说明", "常见错误"], + [ + ["name", "是", "系统内部唯一名称,智能体配置引用它", "重名或改名后旧智能体引用失效"], + ["display_name", "建议", "前端显示名称", "写成真实密钥或内部地址"], + ["use", "是", "模型适配器,例如 langchain_openai:ChatOpenAI", "适配器与接口协议不匹配"], + ["model", "是", "模型服务要求的精确 model id", "使用市场名称而非接口 ID,导致 404"], + ["api_key", "通常", "使用 $ENV_NAME 从 backend.env 解析", "写成明文、变量名拼错"], + ["base_url", "通常", "OpenAI 兼容接口通常以 /v1 结尾", "容器中 127.0.0.1 指向自身"], + ["request_timeout", "建议", "单次请求秒数", "长推理模型超时过短"], + ["max_retries", "建议", "网络或限流重试次数", "重试过多造成雪崩"], + ["max_tokens", "建议", "单次生成的最大输出 token", "超过提供方限制或误认为上下文窗口"], + ["supports_vision", "按能力", "是否允许图像输入", "服务不支持却标 true"], + ["supports_thinking", "按能力", "是否支持思考模式", "适配器/服务参数不兼容"], + ], + [1300, 850, 3900, 3310], + font_size=8.4, + ) + add_h2(doc, "5.3 如何添加或调整 max_tokens") + add_para(doc, "在目标模型条目中增加 max_tokens: <整数>。它控制模型本次生成的最大输出长度,不等于“输入 + 输出”的总上下文窗口。提供方若只允许 8192 输出,就不能配置 65536。") + add_table( + doc, + ["现象", "可能原因", "处理"], + [ + ["回答被截断", "max_tokens 太小、模型自行结束或上游网关限制", "逐步增大并检查 finish_reason"], + ["400 invalid max_tokens", "超过模型上限或字段名不兼容", "查模型服务文档/网关日志,改为允许值"], + ["长文本仍失败", "上下文窗口不足,不是输出上限问题", "缩短输入、启用摘要、换长上下文模型"], + ["成本或耗时突然上升", "上限过大且提示词未约束", "降低 max_tokens,并在提示词限定结构和篇幅"], + ], + [1900, 3500, 3960], + ) + add_h2(doc, "5.4 模型变更后的验证") + for step in [ + "备份 config.yaml 和 backend.env;检查 YAML 缩进,不使用 Tab。", + "若 backend.env 有变化,执行 sudo docker compose up -d --force-recreate backend;只改 config.yaml 时可 restart backend。", + "立即跟踪 backend 日志,确认没有 YAML 解析、环境变量缺失或模型初始化错误。", + "访问 /deerflow/health,登录页面确认新模型出现在可选列表。", + "完成短问答、长文本、工具调用;视觉模型再验证图片上传。", + "确认日志中没有 API Key;记录模型版本、base_url、限流和最大上下文。", + ]: + add_number(doc, step) + add_h2(doc, "5.5 模型故障快速定位") + add_table( + doc, + ["HTTP/日志", "含义", "优先检查"], + [ + ["401 / invalid_api_key", "模型密钥无效", "backend.env、变量名、密钥状态、是否有前后空格"], + ["403", "密钥无该模型权限或 IP 白名单", "模型服务授权与来源 IP"], + ["404 model_not_found", "model ID 或 base_url 不对", "接口真实 ID、/v1 路径、网关路由"], + ["429", "限流或余额不足", "并发、速率配额、余额、重试策略"], + ["timeout", "模型慢或网络不通", "容器到服务连通性、request_timeout、代理"], + ["connection refused", "地址/端口错误或服务未启动", "不要在容器内使用错误的 127.0.0.1"], + ], + [1950, 2910, 4500], + ) + + add_h1(doc, "6. 数据库:SQLite 与外部 MySQL") + add_para(doc, "默认部署可使用 SQLite,便于离线单机运行;已有外部 MySQL 时,可将业务数据库切换到 MySQL。切换数据库不会自动迁移旧 SQLite 中的业务数据,必须先做迁移和验收计划。LangGraph checkpointer 可继续保留 SQLite,两者不是同一配置项。") + add_h2(doc, "6.1 默认 SQLite") + add_code(doc, """ + database: + backend: sqlite + sqlite_dir: .deer-flow/data + + checkpointer: + type: sqlite + connection_string: .deer-flow/data/deerflow.db + """, "YAML") + add_para(doc, "在容器挂载下,关键数据位于宿主机 data/backend 对应目录。备份时应在写入低峰或停止后端,避免复制过程中数据库仍在变化。") + add_h2(doc, "6.2 切换到外部 MySQL") + add_code(doc, """ + # config/backend.env + MYSQL_DATABASE_URL=mysql+asyncmy://deerflow_user:@10.0.0.30:3306/deerflow?charset=utf8mb4 + """, "ENV") + add_code(doc, """ + # config/config.yaml + database: + backend: mysql + mysql_url: $MYSQL_DATABASE_URL + + # 可按现网设计继续使用 SQLite checkpointer + checkpointer: + type: sqlite + connection_string: .deer-flow/data/deerflow.db + """, "YAML") + add_callout(doc, "容器网络", "MySQL 在宿主机或另一台服务器时,地址必须是容器可访问的 IP/域名。容器内的 127.0.0.1 指后端容器自身,不指宿主机。", "warn") + add_h2(doc, "6.3 MySQL 前置条件") + for item in [ + "数据库已创建,字符集建议 utf8mb4;时区和排序规则按单位标准统一。", + "服务账号至少拥有本应用所需的建表、改表、查询、增删改权限;禁止使用 root 作为长期业务账号。", + "防火墙允许后端容器所在主机访问 3306;MySQL bind-address 和用户 host 范围正确。", + "密码中的 @、:、/、#、% 等字符进行 URL 编码,避免连接串解析错误。", + "首次启动预留数据库迁移时间;观察 Alembic/建表日志,不要在未评估的情况下跳过迁移。", + ]: + add_bullet(doc, item) + add_h2(doc, "6.4 切换与回退步骤") + for step in [ + "冻结资产编辑和用户写入,完整备份 SQLite、data 目录与配置。", + "创建 MySQL 数据库和最小权限账号;从后端容器验证网络。", + "制定并执行数据迁移。只改 backend=mysql 不会把旧数据自动搬过去。", + "修改 backend.env 和 config.yaml,执行 docker compose up -d --force-recreate backend,持续看迁移日志。", + "用 admin、lqq、普通用户分别验收登录、技能、智能体、广场、菜单、敏感词和对话。", + "确认稳定前保留旧 SQLite 与旧配置;回退时停止写入,避免两边各自产生新数据。", + ]: + add_number(doc, step) + heading = add_h2(doc, "6.5 数据库常见报错") + heading.paragraph_format.page_break_before = True + add_table( + doc, + ["报错", "原因", "处理"], + [ + ["Access denied", "账号/密码/host 授权错误", "核对 URL 编码、MySQL 用户 host、权限"], + ["Unknown database", "库未创建或名称错误", "创建库并核对大小写"], + ["Connection refused / timeout", "端口、防火墙、bind-address、路由", "从容器执行 TCP 连通测试"], + ["Too many connections", "连接池或并发超限", "先止损降并发,再调连接池和 MySQL 上限"], + ["SQLite database is locked", "多写者或磁盘/挂载问题", "停止并发写,检查权限和磁盘,完整备份后处理"], + ["Permission denied", "宿主机挂载所有者不正确", "核对 data/backend 的 UID/GID 与容器运行用户"], + ], + [2600, 3200, 3560], + ) + + add_h1(doc, "7. 前端端口、接口地址与运行时配置") + add_para(doc, "生产前端使用 Nginx,不使用开发端口 5174。对外端口由部署目录 .env 的 APP_PORT 控制;前端访问后端和外部业务接口的地址由 config/runtime-config.js 控制。该文件由浏览器直接读取,不能放任何服务端秘密。") + add_h2(doc, "7.1 修改前端访问端口") + add_code(doc, """ + # 部署目录 .env + APP_PORT=18090 + """, "ENV") + add_code(doc, """ + sudo docker compose up -d --force-recreate web + sudo docker compose ps + curl -I http://127.0.0.1:18090/ + """, "Shell") + add_para(doc, "1Panel 中若有端口安全组或主机防火墙,还要放行新的 TCP 端口。旧端口如不再使用应关闭。") + add_h2(doc, "7.2 推荐的同源后端配置") + add_code(doc, """ + // config/runtime-config.js + window.__DEERFLOW_RUNTIME_CONFIG__ = { + VITE_BACKEND_BASE_URL: "/deerflow", + VITE_LANGGRAPH_BASE_URL: "/deerflow/api" + }; + """, "JavaScript") + add_para(doc, "使用相对路径时,浏览器始终访问当前域名和 APP_PORT,再由 Nginx 转发到 backend:8001。这样最少遇到 CORS、证书和混合内容问题。") + add_h2(doc, "7.3 后端独立地址的配置") + add_code(doc, """ + window.__DEERFLOW_RUNTIME_CONFIG__ = { + VITE_BACKEND_BASE_URL: "https://api.example.internal", + VITE_LANGGRAPH_BASE_URL: "https://api.example.internal/api" + }; + """, "JavaScript") + add_callout(doc, "使用绝对地址时", "后端必须配置允许前端 Origin,HTTPS 页面不能调用 HTTP 接口;证书需被客户端信任。除非确有分离网关需求,优先使用 /deerflow 相对地址。", "warn") + add_h2(doc, "7.4 常用运行时变量") + add_table( + doc, + ["变量", "用途", "是否可含秘密", "变更验证"], + [ + ["VITE_BACKEND_BASE_URL", "Gateway 基础地址", "否", "登录、/health、普通 API"], + ["VITE_LANGGRAPH_BASE_URL", "LangGraph/流式对话地址", "否", "新建对话和流式输出"], + ["VITE_API_BASE_URL", "通用业务 API 根地址", "否", "对应业务页面"], + ["VITE_AIPI_CONSUMER_URL", "任务深链/业务查询", "否", "任务详情与深链"], + ["VITE_AIPI_CHAT_URL", "AIPI 对话类接口", "否", "对应智能体/业务"], + ["VITE_FORCE_COLLAPSED_SIDEBAR", "强制紧凑侧边栏", "否", "刷新后导航形态"], + ["Logo/欢迎页相关变量", "品牌素材 URL", "否", "明暗主题、登录页"], + ], + [3000, 2900, 1200, 2260], + font_size=8.6, + ) + add_h2(doc, "7.5 修改后的生效") + for step in [ + "备份 runtime-config.js,确认 JavaScript 逗号和引号语法正确。", + "重启 Web 容器,或至少确认挂载文件已经更新。", + "浏览器使用 Ctrl+F5 强制刷新;必要时清空站点缓存。", + "在开发者工具 Network 中检查 runtime-config.js、/deerflow/health、/deerflow/api 请求。", + "验证普通登录、对话流和至少一个外部业务接口。", + ]: + add_number(doc, step) + heading = add_h2(doc, "7.6 前端常见现象") + heading.paragraph_format.page_break_before = True + add_table( + doc, + ["现象", "检查", "处理"], + [ + ["页面白屏", "Console 是否有语法/资源错误", "修复 runtime-config.js;恢复备份;强刷"], + ["页面能开但所有 API 失败", "Network 请求地址", "修正 BACKEND/LANGGRAPH_BASE_URL"], + ["刷新二级路由 404", "Nginx 是否 SPA try_files", "恢复随包 Nginx 配置并重建 Web"], + ["502 Bad Gateway", "backend 状态与日志", "先恢复后端,不要反复刷新前端"], + ["CORS blocked", "前后端 Origin 是否不同", "优先改同源;或配置后端 CORS"], + ["Mixed Content", "HTTPS 页面调用 HTTP", "接口改 HTTPS 或统一由 Nginx 代理"], + ], + [2400, 3000, 3960], + ) + + add_h1(doc, "8. 技能:目录、上传、发布与维护") + add_para(doc, "技能由宿主机 data/skills 持久化到容器 /app/backend/skills。系统使用 public 和 custom 两类目录,每个技能至少应包含 SKILL.md;还可包含 Python 脚本、references 和 assets。启动脚本只会在技能挂载目录为空时写入默认技能,不会覆盖已有文件。") + add_figure(doc, diagrams["persistence_map"], "图 8-1 技能与智能体的持久化关系", "技能和智能体由文件、数据库元数据、权限和运行时共同决定") + add_h2(doc, "8.1 标准技能目录") + add_code(doc, """ + data/skills/ + ├── public/ + │ └── example-public-skill/ + │ ├── SKILL.md + │ ├── scripts/ + │ ├── references/ + │ └── assets/ + └── custom/ + └── example-custom-skill/ + ├── SKILL.md + └── tool.py + """, "目录结构") + add_h2(doc, "8.2 推荐:通过前端上传或创建") + for step in [ + "使用普通业务用户或管理员登录,进入技能设置/技能管理。", + "创建或上传技能,填写名称、描述、使用说明和依赖。", + "在技能详情页完成测试,确认工具地址、Python 依赖和文件权限。", + "需要共享时点击“发布”,选择目标广场;不共享则保留为自定义技能。", + ]: + add_number(doc, step) + add_callout(doc, "为什么推荐 UI", "UI 会同时维护技能文件、所有者、可见性、发布状态等数据库元数据。手工复制文件只解决文件存在,不一定能让正确用户在界面看到。", "note") + add_h2(doc, "8.3 手工离线导入技能") + add_para(doc, "仅在批量迁移或 UI 不可用时使用。先在测试环境核对技能内容,再复制整个技能目录,保持目录名稳定。") + add_code(doc, """ + # 例:导入为自定义技能 + sudo mkdir -p data/skills/custom/example-skill + sudo cp -a /mnt/offline/example-skill/. data/skills/custom/example-skill/ + + # 核对宿主机文件 + find data/skills/custom/example-skill -maxdepth 3 -type f -print + + # 核对容器内挂载 + sudo docker compose exec backend sh -lc \ + 'find /app/backend/skills/custom/example-skill -maxdepth 3 -type f -print' + """, "Shell") + add_para(doc, "若容器不是 root 运行,需确保运行用户对文件可读、对需要修改的目录可写。完成后重启 backend 以刷新技能扫描与系统提示缓存。") + add_h2(doc, "8.4 public 与广场发布的区别") + add_table( + doc, + ["概念", "含义", "建议权限", "是否等于广场可见"], + [ + ["public 目录", "技能文件类别/系统公共技能", "仅管理员直接写入", "不一定,仍受业务元数据和页面逻辑影响"], + ["custom 目录", "用户自定义技能", "普通用户维护自己的", "可发布到广场"], + ["发布广场", "业务可见性、目标广场和发布状态", "所有者或管理员", "是"], + ], + [1750, 3600, 1900, 2110], + ) + add_h2(doc, "8.5 技能地址管理") + add_para(doc, "管理员可在技能管理页的“技能地址管理”标签扫描运行中 skills/public 与 skills/custom 下 .md、.py 中出现的 HTTP(S) URL 和 IP:端口,预览后批量替换,并按修改记录回退。适用于 UAT 地址切生产地址。") + for item in [ + "替换前使用预览,核对每个 file:line 片段;不要直接全目录文本替换。", + "应用会生成磁盘备份和 manifest;回退前仍建议做整目录备份。", + "扫描范围只包含运行中技能目录,不会修改 deploy/minimal/skills。", + "接口为管理员权限;大量技能扫描时等待进度完成,不要重复点击。", + ]: + add_bullet(doc, item) + add_h2(doc, "8.6 技能发布到广场") + for step in [ + "确认当前登录用户是技能所有者,或使用 admin。", + "在技能列表/详情点击“发布”。", + "选择一个发布广场;未选择时可能进入系统默认广场。", + "确认发布后,使用普通用户进入广场检索并安装/使用。", + "需要改投时重新发布到另一个广场;一个技能同一时间只属于一个广场。", + ]: + add_number(doc, step) + add_h2(doc, "8.7 技能上传安全检查") + add_bullet(doc, "检查 SKILL.md 和脚本中是否包含真实 API Key、数据库密码、内网敏感地址或个人信息。") + add_bullet(doc, "检查 Python/shell 脚本是否包含删除、提权、任意命令执行或越权网络访问。") + add_bullet(doc, "需要外部依赖时,离线镜像必须已包含包,或提供受控离线 wheel/npm 包。") + add_bullet(doc, "仅允许可信管理员把技能直接放入 public;普通用户走 custom + 广场审批。") + + add_h1(doc, "9. 智能体:目录、模型、发布与恢复") + add_para(doc, "智能体的主要文件位于 data/backend/agents//,通常包含 config.yaml 与 SOUL.md;名称、所有者、发布状态、广场等信息在业务数据库中。因此备份或迁移智能体必须同时覆盖 data/backend 和数据库。") + add_h2(doc, "9.1 标准目录与职责") + add_code(doc, """ + data/backend/agents// + ├── config.yaml # 模型、工具、参数等结构化配置 + └── SOUL.md # 角色、任务、边界、表达风格等提示词 + """, "目录结构") + add_table( + doc, + ["对象", "主要内容", "缺失影响"], + [ + ["config.yaml", "模型引用、工具/技能和运行参数", "智能体可能无法加载或退化为最小配置"], + ["SOUL.md", "角色提示词和业务规则", "角色行为改变、回答质量下降"], + ["数据库记录", "所有者、名称、状态、发布信息", "界面看不到、权限异常、广场丢失"], + ], + [2200, 3800, 3360], + ) + add_h2(doc, "9.2 创建与维护建议") + for step in [ + "优先在智能体管理页面创建,系统会生成 ID 和数据库记录。", + "选择已在后端 models 中存在的模型 name;模型改名时先盘点引用。", + "配置工具/技能后完成一轮真实问答和错误场景测试。", + "SOUL.md 中不要写真实密钥;外部账号通过环境变量或受控凭据服务注入。", + "修改生产智能体前导出目录和数据库备份,保留变更说明。", + ]: + add_number(doc, step) + add_h2(doc, "9.3 发布到广场") + add_para(doc, "智能体列表卡片或详情页提供发布入口。所有者或管理员选择目标广场并确认;发布后普通用户可在对应广场发现。重新发布到另一广场表示移动,不会在多个广场重复复制。私有广场状态与公开发布状态应按页面规则保持互斥。") + add_figure(doc, diagrams["publish_flow"], "图 9-1 技能/智能体发布流程", "创建、验证、发布、权限校验和广场生效流程") + add_h2(doc, "9.4 直接发布与私有广场") + add_bullet(doc, "“直接发布到广场”仍建议从资产卡片/详情点击发布,这样所有者和发布元数据完整。") + add_bullet(doc, "管理员在外观/系统设置中维护广场列表、默认广场和私有广场密码。") + add_bullet(doc, "若资产已在私有广场,转为公开发布前按页面提示解除私有状态;反向操作同理。") + add_bullet(doc, "发布后要用普通用户验证,不要只看管理员列表。") + add_h2(doc, "9.5 智能体模型失效") + add_table( + doc, + ["现象", "原因", "处理"], + [ + ["智能体加载但对话报模型不存在", "config.yaml 引用了已删除/改名的模型 name", "恢复原 name 或批量更新智能体配置"], + ["模型列表有但智能体仍用旧模型", "智能体配置固定引用旧值或缓存未刷新", "修改智能体并重启 backend"], + ["同名智能体权限不同", "数据库所有者/作用域不同", "用 admin 检查记录,不要仅比较目录名"], + ], + [2800, 3300, 3260], + ) + + add_h1(doc, "10. 发布广场与私有广场管理") + add_para(doc, "发布广场是技能和智能体的分发入口。管理员维护广场列表、默认广场、启用状态与私有广场密码;资产所有者或管理员负责发布和下架。") + add_h2(doc, "10.1 管理员配置广场") + for step in [ + "使用 DeerFlow admin 登录。", + "进入设置中的外观/发布广场配置区域。", + "新增广场名称和说明,设置启用状态、排序和默认广场。", + "如启用私有广场,配置独立密码并通过安全渠道分发。", + "保存后用普通用户检查广场入口、默认选择和访问控制。", + ]: + add_number(doc, step) + add_h2(doc, "10.2 发布规则") + add_table( + doc, + ["规则", "解释", "运维影响"], + [ + ["一项资产只属于一个广场", "重新发布到新广场会移动", "迁移前通知原广场使用者"], + ["默认广场", "未明确选择时作为默认目标", "删除/停用前先指定新默认"], + ["所有者或管理员可发布", "防止普通用户操作他人资产", "账号变更时先转移所有权或由 admin 处理"], + ["私有与公开互斥", "防止同一资产同时处于两类可见性", "变更后用两类用户复核"], + ], + [2300, 3400, 3660], + ) + add_h2(doc, "10.3 看不到发布按钮") + add_bullet(doc, "当前用户不是资产所有者,也不是 admin。") + add_bullet(doc, "资产来自系统公共目录,页面只允许管理员操作。") + add_bullet(doc, "发布广场全部停用或没有默认广场。") + add_bullet(doc, "前端缓存、权限令牌过期,或后端数据库切换后记录不完整。") + add_bullet(doc, "打开浏览器 Network,检查 publish API 的 401/403/409 响应。") + + add_h1(doc, "11. 菜单名称、排序、隐藏与路由安全") + add_para(doc, "管理员可通过“设置和更多 → 菜单管理”修改侧边栏节点的显示名称、启停、顺序和父级。系统以静态路由树为基础,仅把数据库中的覆盖项叠加上去;图标和实际 path 不会因为改名称而改变。") + add_figure(doc, diagrams["ui_map"], "图 11-1 常用管理入口地图", "菜单、主题、技能、智能体和用户管理的页面入口") + add_h2(doc, "11.1 修改菜单名称") + for step in [ + "用 admin 登录,打开左下角“设置和更多”。", + "进入“菜单管理”,找到目标一级/二级/三级菜单。", + "编辑 label,保存;不要修改真实 path 或源代码 ID。", + "刷新页面,用普通用户核对侧边栏和顶部导航。", + "记录原名称,避免业务培训资料与新名称不一致。", + ]: + add_number(doc, step) + add_h2(doc, "11.2 隐藏菜单") + add_para(doc, "在菜单管理中将节点停用。停用父节点会隐藏整个子树;支持上下移动或拖拽调整顺序和父级,但系统严格限制最多三级。动态注入的 iframe 轻应用节点也可管理。") + add_callout(doc, "重要安全边界", "隐藏菜单只移除导航入口,不保证用户不能通过已知 URL 直接访问,也不替代后端 API 权限。敏感功能必须同时有前端路由守卫和后端角色校验。", "danger") + add_h2(doc, "11.3 需要真正禁用功能时") + for item in [ + "先停用菜单,避免用户继续进入。", + "检查对应页面是否有 admin/lqq/业务角色路由守卫。", + "检查后端 API 是否校验角色、所有者或账号。", + "必要时通过功能开关或后端配置关闭 API;不要只依赖 CSS 隐藏。", + "用普通账号直接输入 URL 和直接调用 API 进行验收。", + ]: + add_number(doc, item) + add_h2(doc, "11.4 菜单异常恢复") + add_table( + doc, + ["问题", "原因", "处理"], + [ + ["全部子菜单消失", "父节点被停用或移到错误父级", "admin 在菜单管理恢复父节点;必要时查 menu-overrides 数据"], + ["菜单名改了但路由未变", "这是设计行为", "继续使用原 path;不要以显示名推导 URL"], + ["轻应用菜单消失", "轻应用停用、父级停用或 override 隐藏", "同时检查轻应用和菜单覆盖"], + ["普通用户看不到,admin 能看到", "角色过滤或功能权限", "确认预期角色,不要直接改静态路由"], + ], + [2300, 3400, 3660], + ) + + add_h1(doc, "12. 主题色、布局和品牌配置") + add_para(doc, "所有用户都可在“设置 → 外观”选择个人主题和布局。管理员还可配置系统默认主题、默认布局和自定义主题库。用户已有个人选择时,个人设置通常优先于系统默认。") + add_h2(doc, "12.1 内置主题") + add_table( + doc, + ["主题标识", "界面名称", "使用建议"], + [ + ["light", "标准浅色", "默认办公环境"], + ["dark", "深色", "暗光环境和大屏"], + ["lightblue", "浅蓝", "政企蓝色体系"], + ["lightgreen", "浅绿", "运营/生态类场景"], + ["iceblue", "冰蓝", "冷色、低饱和界面"], + ["lightyellow", "浅黄", "暖色、阅读型界面"], + ], + [2100, 2400, 4860], + ) + add_h2(doc, "12.2 管理员设置系统默认") + for step in [ + "使用 admin 登录,进入设置 → 外观。", + "设置系统默认主题和默认布局;这些主要影响没有个人覆盖的新用户。", + "在自定义主题库中配置主题名称、主色、启用状态和排序。", + "保存后使用新普通用户或清除个人覆盖的测试用户验证。", + "分别检查明暗主题的 Logo、文字对比度、按钮和焦点状态。", + ]: + add_number(doc, step) + add_h2(doc, "12.3 自定义主题色的验收") + add_bullet(doc, "正文文字和背景必须有足够对比度;不要只看主按钮。") + add_bullet(doc, "检查 hover、selected、disabled、focus、error、success 等状态。") + add_bullet(doc, "检查表格、弹窗、下拉菜单、图表、侧边栏和移动窗口宽度。") + add_bullet(doc, "品牌主色不要直接当大面积背景,浅色主题应提供足够浅的层级色。") + add_bullet(doc, "主题调整后至少用 Chrome/Edge 验证,并保留一键恢复内置主题的方法。") + add_h2(doc, "12.4 强制紧凑侧边栏") + add_code(doc, """ + // config/runtime-config.js + window.__DEERFLOW_RUNTIME_CONFIG__ = { + VITE_FORCE_COLLAPSED_SIDEBAR: "true" + }; + """, "JavaScript") + add_para(doc, "这是部署级开关,适用于外部嵌入或小屏展示。若用户报告无法展开侧边栏,先检查该值。") + add_h2(doc, "12.5 iframe 嵌入主题") + add_para(doc, "外部系统可在深链加入 ?embed=1&theme=dark 等参数隐藏全局导航并强制当前标签页主题。该设置使用会话级隔离,不应覆盖普通标签页的个人主题。另有 ?hideJump=1 隐藏快捷跳转栏,?isolate=1 启用本标签页独立登录会话。") + + add_h1(doc, "13. 敏感词管理与显示层脱敏") + add_para(doc, "敏感词功能维护“原始词 → 替换词”的展示层映射。它会改变菜单/导航等页面显示,但不会修改原始业务数据、路由 path、节点 ID 或数据库中的源文本。因此它不是数据清洗,也不能代替数据库脱敏。") + add_h2(doc, "13.1 登录哪个用户") + add_callout(doc, "使用 lqq", "当前前端入口、路由守卫和页面守卫要求显示用户名转小写后等于 lqq。即使 admin 后端有写权限,非 lqq 管理员通常也看不到或进不了该页面。", "warn") + add_h2(doc, "13.2 操作步骤") + for step in [ + "以 lqq 登录,进入左下角“设置和更多 → 敏感词管理”。", + "新增原始词和替换词,设置启用状态;避免把过短的常用单字作为原始词。", + "如需恢复系统初始映射,使用“导入种子词”合并缺失项。", + "保存会整体替换当前配置,提交前导出或截图留档。", + "刷新页面,检查菜单、导航、标题;再用普通用户验证。", + ]: + add_number(doc, step) + add_h2(doc, "13.3 设计与风险") + add_table( + doc, + ["问题", "说明", "建议"], + [ + ["是否改数据库原文", "不会,只影响展示", "需要源数据脱敏时另走数据治理流程"], + ["是否影响 URL", "不会,path/id 保持不变", "文档仍使用真实路由"], + ["空配置怎么办", "加载失败或空时保留前端种子映射", "检查 /api/sensitive-words 与日志"], + ["替换冲突", "多个词重叠可能产生意外显示", "从长词到短词测试;避免循环式替换"], + ["保存影响范围", "全局共享", "必须有审批和变更记录"], + ], + [2200, 3400, 3760], + ) + add_h2(doc, "13.4 页面看不到时") + add_bullet(doc, "确认显示用户名是 lqq,而不是仅系统角色为 admin。") + add_bullet(doc, "邮箱登录时确认 @ 前缀;清理旧 Token 后重新登录。") + add_bullet(doc, "确认菜单覆盖没有停用“敏感词管理”节点。") + add_bullet(doc, "检查后端 /api/sensitive-words 的 401/403 和数据库连接。") + + add_h1(doc, "14. 备份、恢复、升级与回滚") + add_para(doc, "一次可恢复的备份至少包含配置、持久化文件和当前业务数据库。只保存 Docker 镜像不能恢复用户、技能、智能体和全局设置。外部 MySQL 必须单独导出。") + add_figure(doc, diagrams["backup_upgrade"], "图 14-1 离线升级与回滚闭环", "从盘点、备份、演练、验收到切换和回滚的过程") + add_h2(doc, "14.1 备份范围") + add_table( + doc, + ["对象", "必须", "建议频率", "恢复用途"], + [ + ["config 目录 + .env", "是", "每次变更后", "恢复模型、登录、接口、数据库配置"], + ["data/backend", "是", "每日 + 变更前", "SQLite、智能体文件和运行数据"], + ["data/skills", "是", "每日 + 技能变更后", "public/custom 技能"], + ["data/knowledge", "按使用", "每日/每周", "知识库文件"], + ["外部 MySQL dump", "使用 MySQL 时", "每日 + 变更前", "资产元数据、用户、菜单、主题、广场等"], + ["镜像 tar + SHA256", "是", "每个版本", "离线重装和回滚"], + ], + [2600, 1100, 2100, 3560], + ) + add_h2(doc, "14.2 文件备份示例") + add_code(doc, """ + cd /opt/deerflow/deerflow-deploy + + # 推荐先安排短暂停写窗口,再执行归档 + sudo tar -czf /backup/deerflow-files-$(date +%F-%H%M).tar.gz \ + .env config data + + sha256sum /backup/deerflow-files-*.tar.gz + """, "Shell") + add_h2(doc, "14.3 MySQL 备份示例") + add_code(doc, """ + # 不要把密码直接写在命令行历史中;优先使用受控 defaults-extra-file + mysqldump --single-transaction --routines --triggers \ + --host= --user= deerflow \ + > /backup/deerflow-mysql-$(date +%F-%H%M).sql + """, "Shell") + add_h2(doc, "14.4 恢复原则") + for step in [ + "确认备份版本、摘要、数据库模式和目标目录;禁止直接覆盖未知现网。", + "停止写入或停止 backend,先把现网再做一次应急备份。", + "在新目录恢复 config 和 data,检查文件所有者与权限。", + "外部 MySQL 恢复到新库或确认的空库,避免覆盖错误库。", + "用临时 APP_PORT 启动,完成登录、模型、技能、智能体、广场和对话验收。", + "切换正式端口;保留旧目录、旧库和旧镜像直到观察期结束。", + ]: + add_number(doc, step) + add_h2(doc, "14.5 离线升级") + add_bullet(doc, "核对离线包架构必须为 linux/arm64;SHA256 与交付清单一致。") + add_bullet(doc, "不要直接覆盖旧目录。解压到带版本号的新目录,复制 data 和配置后逐项合并。") + add_bullet(doc, "先用临时端口启动新版本,数据库升级必须有回退方案。") + add_bullet(doc, "升级后测试管理员、lqq、普通用户三类账号。") + add_bullet(doc, "观察期内保留旧镜像,禁止执行会清理回滚镜像的 prune。") + add_callout(doc, "高风险命令", "docker system prune -a、删除部署目录、覆盖数据库和递归修改所有者均可能造成不可逆损失。执行前必须明确目标、备份已验证并经过审批。", "danger") + add_h2(doc, "14.6 备份校验与恢复演练") + add_para(doc, "备份任务显示“成功”不代表一定可恢复。至少每季度在隔离目录或隔离服务器做一次完整恢复演练,并记录恢复用时和缺失项。") + add_table( + doc, + ["检查项", "检查方法", "合格标准"], + [ + ["归档完整性", "tar -tzf 列出 config、data 关键目录", "无读取错误,路径与部署台账一致"], + ["文件摘要", "重新计算 SHA256 并与备份台账比对", "摘要完全一致"], + ["SQLite", "在副本上执行完整性检查并启动测试环境", "检查通过,用户与资产数量正确"], + ["MySQL", "恢复到隔离库,核对表、行数和关键业务", "无迁移错误,三类账号验收通过"], + ["应用验证", "临时 APP_PORT 启动并执行上线验收清单", "健康、登录、模型、资产、管理功能通过"], + ["回退时长", "记录从故障确认到旧版本恢复的总耗时", "满足本单位 RTO/RPO 要求"], + ], + [2100, 4100, 3160], + font_size=8.7, + ) + + add_h1(doc, "15. 监控、巡检与日志留存") + add_h2(doc, "15.1 每日巡检") + for item in [ + "docker compose ps:backend、web 均为正常状态,无反复重启。", + "/deerflow/health 返回成功;前端首页可打开。", + "磁盘剩余空间、inode、内存和负载无异常。", + "backend/web 日志无持续 401、429、500、数据库断连或权限错误。", + "模型最短问答可用;关键业务接口按单位要求抽测。", + "前一日文件备份和数据库备份成功,摘要记录完整。", + ]: + add_bullet(doc, item) + add_h2(doc, "15.2 每周巡检") + for item in [ + "检查离线镜像、部署目录和文档版本是否对应。", + "统计模型调用错误率、超时和 429,评估并发与额度。", + "检查技能地址是否指向过期 UAT/测试环境。", + "抽查发布广场、私有广场、菜单、主题和敏感词是否符合审批。", + "在隔离环境随机做一次备份恢复演练。", + "清理日志前先确认保留策略;不要删除仍在被容器写入的文件。", + ]: + add_bullet(doc, item) + add_h2(doc, "15.3 建议监控指标") + add_table( + doc, + ["层级", "指标", "建议告警"], + [ + ["主机", "磁盘、inode、内存、负载、时间同步", "磁盘 <20% 预警,<10% 紧急"], + ["容器", "Up、重启次数、CPU/内存", "反复重启或持续高占用"], + ["HTTP", "首页、/health、5xx、延迟", "健康失败、连续 5xx"], + ["模型", "401/403/404/429、超时、首 token 延迟", "权限错误立即,429 持续告警"], + ["数据库", "连接数、慢查询、空间、备份", "连接耗尽、备份失败"], + ["业务资产", "技能/智能体数量、发布变更、管理员操作", "数量突降、未经审批的全局变更"], + ], + [1800, 4100, 3460], + ) + add_h2(doc, "15.4 日志采集包") + add_para(doc, "提交问题时应包含:发生时间(含时区)、用户和角色、访问 URL、操作步骤、浏览器 Network/Console、backend/web 最近日志、compose ps、健康检查、版本号与配置变更。任何日志对外发送前都要脱敏。") + + add_h1(doc, "16. 常见报错与解决办法") + add_h2(doc, "16.1 容器和主机") + add_table( + doc, + ["报错/现象", "根因", "解决"], + [ + ["exec format error", "镜像架构不匹配", "确认 uname -m;ARM 服务器使用 linux/arm64 镜像"], + ["address already in use", "APP_PORT 被占用", "ss -lntp 检查;改 .env 或停冲突服务"], + ["no space left on device", "磁盘或 inode 满", "先定位大目录和日志;扩容或受控清理;不要盲目 prune"], + ["容器不断 Restarting", "配置解析、数据库、权限或启动命令失败", "docker compose logs --tail=500 backend"], + ["permission denied", "挂载目录所有者/模式不匹配", "对照容器 UID/GID 修复目标目录,不要全盘 chmod 777"], + ["镜像导入失败", "tar 损坏或磁盘不足", "核对 SHA256、剩余空间、docker load 日志"], + ], + [2600, 2900, 3860], + font_size=8.7, + ) + add_h2(doc, "16.2 前端和 Nginx") + add_table( + doc, + ["报错/现象", "根因", "解决"], + [ + ["502 Bad Gateway", "backend 未启动或 Nginx 无法访问 backend:8001", "查 backend 日志和 compose 网络;先恢复后端"], + ["404 刷新二级路由", "SPA fallback 丢失", "Nginx location / 使用 try_files ... /index.html"], + ["静态资源 404", "错误 base 路径、缓存或镜像不完整", "查 Network 路径、runtime-config 和 Web 镜像"], + ["页面白屏", "JavaScript 语法、旧缓存、运行时配置异常", "看 Console;恢复 runtime-config;Ctrl+F5"], + ["请求发到旧地址", "浏览器缓存或改错文件", "确认 Network 中 runtime-config.js;重启 Web 并强刷"], + ], + [2600, 3150, 3610], + ) + add_h2(doc, "16.3 登录和权限") + add_table( + doc, + ["报错/现象", "根因", "解决"], + [ + ["401 Unauthorized", "Token 过期、JWT 密钥变化或存储隔离", "重新登录;核对 AUTH_JWT_SECRET 是否稳定"], + ["403 Forbidden", "角色/所有者不足", "核对 system_role、资产所有者、lqq 条件"], + ["重启后全员掉线", "JWT 密钥被改或未持久化", "恢复固定密钥;全员重新登录"], + ["admin 看不到敏感词入口", "前端要求用户名 lqq", "使用 lqq;不要仅提升 admin"], + ["iframe 与正常页账号混淆", "未使用 embed/isolate 会话隔离", "核对 URL 参数与 sessionStorage 策略"], + ], + [2600, 3300, 3460], + ) + add_h2(doc, "16.4 模型和流式对话") + add_table( + doc, + ["报错/现象", "根因", "解决"], + [ + ["模型 401/403", "Key 或权限", "核对 backend.env、模型授权、IP 白名单"], + ["模型 404", "base_url/model ID", "用提供方接口清单核对,注意 /v1"], + ["模型 429", "并发/限流/余额", "降并发、等待、申请额度;避免重试风暴"], + ["流式输出中断", "代理缓冲、超时、客户端断网", "查 Nginx SSE 配置、后端日志和 Network"], + ["回答截断", "max_tokens 或上游输出上限", "逐步调大,查 finish_reason;不要超过提供方限制"], + ["工具调用循环", "模型能力、提示词或工具返回异常", "查看线程日志,暂时禁用问题工具并修复输入输出"], + ], + [2600, 3000, 3760], + ) + add_h2(doc, "16.5 数据库和资产") + add_table( + doc, + ["报错/现象", "根因", "解决"], + [ + ["MySQL Access denied", "账号、密码或 host", "核对 URL 编码和授权"], + ["启动迁移报错", "版本跨度、权限或脏迁移", "停止反复启动,备份后按迁移日志处理"], + ["技能存在但 UI 不显示", "数据库所有者/作用域或扫描失败", "查文件、记录、权限和 backend 日志"], + ["智能体目录存在但列表为空", "数据库切换或元数据缺失", "恢复同版本数据库或按管理流程重建记录"], + ["发布后广场无资产", "发布目标、过滤、私有状态或缓存", "用普通用户查目标广场和 publish API"], + ], + [2600, 3300, 3460], + ) + + add_h1(doc, "17. 技能或智能体丢失专项排查") + add_para(doc, "“丢失”不一定是磁盘文件被删。账号变化、数据库切换、发布过滤、错误 Compose 目录、空挂载或权限失败都可能表现为界面看不到。按固定顺序排查,避免先复制文件造成数据进一步分叉。") + add_figure(doc, diagrams["lost_asset_flow"], "图 17-1 资产丢失排查流程", "从服务、账号、文件、数据库到恢复和验证的排查顺序") + add_h2(doc, "17.1 第一步:确认当前服务和部署目录") + add_code(doc, """ + pwd + sudo docker compose ps + sudo docker compose config --services + sudo docker compose images + """, "Shell") + add_para(doc, "重点排除在旧部署目录执行命令,或 1Panel 启动了另一个同名 Compose 项目。记录容器名称、镜像 ID 和端口。") + add_h2(doc, "17.2 第二步:确认登录身份") + add_bullet(doc, "退出重新登录,确认用户名、system_role 和是否使用 iframe/isolate 独立会话。") + add_bullet(doc, "确认资产原所有者是谁;普通用户不能看到他人私有资产。") + add_bullet(doc, "用 admin 查看全局资产;再用原所有者确认。") + add_h2(doc, "17.3 第三步:核对宿主机与容器内文件") + 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") + add_para(doc, "如果宿主机有、容器内没有,检查 docker-compose.yml 的 volume 映射和当前工作目录;如果两边都没有,先停止写入并查备份,不要创建同名空资产覆盖恢复线索。") + add_h2(doc, "17.4 第四步:确认数据库没有切换") + add_bullet(doc, "检查 config.yaml 的 database.backend 是 sqlite 还是 mysql。") + add_bullet(doc, "检查 backend.env 的 MYSQL_DATABASE_URL 指向哪个主机和库。") + add_bullet(doc, "核对变更前后资产数量;SQLite 与 MySQL 不会自动保持同步。") + add_bullet(doc, "如果切回旧数据库后资产恢复,先冻结写入,制定合并方案,避免两套库继续分叉。") + add_h2(doc, "17.5 第五步:检查过滤、广场和权限") + add_bullet(doc, "清除搜索词和分类筛选;查看“我的”“公共”“已发布”“私有”等不同视图。") + add_bullet(doc, "确认资产是否改投另一广场、被下架、进入私有广场或所有权变化。") + add_bullet(doc, "检查 menu-overrides 是否隐藏了技能/智能体入口。") + add_h2(doc, "17.6 第六步:日志和权限") + add_code(doc, """ + sudo docker compose logs --tail=1000 backend \ + | grep -Ei 'skill|agent|permission|database|mysql|sqlite|invalid|error|traceback' + """, "Shell") + add_para(doc, "重点关注:SKILL.md 格式不合法、config.yaml 解析失败、目录 permission denied、数据库连接失败、扫描缓存刷新失败和 401/403。") + add_h2(doc, "17.7 恢复决策") + add_table( + doc, + ["文件", "数据库", "建议"], + [ + ["存在", "存在", "修复权限、挂载、账号或过滤;重启 backend 验证"], + ["存在", "缺失", "从同版本数据库备份恢复元数据;不要直接伪造所有者"], + ["缺失", "存在", "从文件备份恢复;数据库可能只能提供最小信息"], + ["缺失", "缺失", "停止写入,恢复完整备份;保全日志和磁盘证据"], + ["两套库各有新数据", "分叉", "冻结写入,导出比对,编写一次性合并迁移并审计"], + ], + [2000, 2000, 5360], + ) + add_callout(doc, "不要做什么", "不要执行 docker system prune -a,不要删除 data 重新启动,不要把空目录覆盖到挂载点,不要用 chmod -R 777 掩盖所有权问题。", "danger") + + add_h1(doc, "18. 安全加固与密钥管理") + add_h2(doc, "18.1 密钥清单") + add_table( + doc, + ["密钥/凭据", "保存位置", "前端可见", "轮换建议"], + [ + ["AUTH_JWT_SECRET", "config/backend.env", "绝对不可", "事件触发或年度;轮换会使会话失效"], + ["模型 API Key", "config/backend.env", "绝对不可", "按提供方策略,泄露立即轮换"], + ["MySQL URL/密码", "config/backend.env 或安全挂载", "绝对不可", "90~180 天或按制度"], + ["私有广场密码", "后台全局设置/数据库", "受登录用户可见范围", "人员变化或定期"], + ["runtime-config 地址", "config/runtime-config.js", "是", "只能放非秘密地址和开关"], + ], + [2300, 2800, 1600, 2660], + font_size=8.8, + ) + add_h2(doc, "18.2 最低安全要求") + for item in [ + "服务器 SSH 禁止长期使用共享 root 密码,启用个人密钥、最小 sudo 和登录审计。", + "1Panel 使用独立安全入口、强密码、双因素认证和访问白名单;及时安装受控补丁。", + "只开放 APP_PORT、SSH 管理端口和必要数据库访问;后端 8001 默认不对外映射。", + "config/backend.env 权限建议 600,部署目录限制给运维组;备份同样加密和访问控制。", + "审计 config.yaml,包括注释中的历史 API Key;发现真实 Key 即轮换并删除,不因注释状态降低风险。", + "技能和智能体提示词中禁止嵌入密钥;上传脚本按代码审查流程执行。", + "内网不等于可信。模型、数据库和业务接口仍使用 TLS、账号权限和日志审计。", + ]: + add_bullet(doc, item) + add_h2(doc, "18.3 日志脱敏") + add_bullet(doc, "对外发送日志前删除 Authorization、Cookie、Token、API Key、数据库 URL、手机号和身份证号。") + add_bullet(doc, "保留时间戳、请求路径、状态码、trace ID 和错误栈,避免过度脱敏导致无法定位。") + add_bullet(doc, "不要在群聊粘贴完整 backend.env;必要时只报告变量是否已设置和哈希/末四位。") + add_h2(doc, "18.4 离线介质") + add_bullet(doc, "导入前扫描移动介质;镜像和文档核对 SHA256。") + add_bullet(doc, "记录介质编号、导入人、时间和文件清单。") + add_bullet(doc, "导入完成后按单位制度清除临时介质和服务器临时目录。") + + add_h1(doc, "19. 标准运维流程与交接清单") + add_h2(doc, "19.1 上线验收") + checks = [ + ["基础", "CPU 架构为 ARM64;镜像 SHA256 与交付清单一致", "□"], + ["服务", "web/backend 正常;健康接口成功;无重启循环", "□"], + ["网络", "APP_PORT 可访问;8001 未无意暴露;外部依赖可达", "□"], + ["登录", "admin、lqq、普通用户均可登录且权限符合预期", "□"], + ["模型", "短问答、长文本、工具调用和视觉能力按需通过", "□"], + ["资产", "技能、智能体数量一致;所有者、广场和私有状态正确", "□"], + ["管理", "菜单、主题、敏感词、发布广场保存并对普通用户生效", "□"], + ["备份", "文件和数据库备份成功,并完成至少一次恢复抽检", "□"], + ["文档", "端口、地址、版本、账号责任人和回退步骤已登记", "□"], + ] + add_table(doc, ["类别", "验收项", "结果"], checks, [1400, 7160, 800], font_size=8.8) + add_h2(doc, "19.2 变更单最少信息") + add_bullet(doc, "变更目标、影响范围、窗口、负责人和审批单号。") + add_bullet(doc, "修改前后值(密钥只记录变量名,不记录明文)。") + add_bullet(doc, "备份位置、摘要、恢复验证结果。") + add_bullet(doc, "执行命令、服务重启范围、健康和业务验收结果。") + add_bullet(doc, "回退触发条件、回退命令、回退负责人。") + add_h2(doc, "19.3 离职/交接") + add_bullet(doc, "回收 Linux/1Panel、DeerFlow、MySQL、模型平台和外部系统账号。") + add_bullet(doc, "轮换共享密钥、私有广场密码和应急账号密码。") + add_bullet(doc, "核对定时备份、监控、证书和离线介质责任人。") + add_bullet(doc, "移交最近一次升级记录、已知问题和未完成变更。") + add_h2(doc, "19.4 运维责任矩阵") + add_table( + doc, + ["事项", "Linux/1Panel 运维", "DeerFlow admin", "模型/数据库管理员"], + [ + ["容器、端口、磁盘", "主责", "知会", "知会"], + ["菜单、主题、广场、用户", "支持", "主责", "知会"], + ["模型参数与密钥", "执行部署", "业务验收", "主责并授权"], + ["数据库迁移与恢复", "容器与文件支持", "业务验收", "主责"], + ["技能/智能体丢失", "核对挂载和备份", "核对所有者和元数据", "核对数据库"], + ["安全事件", "隔离主机与日志保全", "冻结应用账号/资产", "轮换凭据并保全审计"], + ], + [2100, 2450, 2350, 2460], + font_size=8.5, + ) + add_h2(doc, "19.5 交接会必须确认") + add_bullet(doc, "现场演示一次后端环境变量变更后的强制重建、实时日志和一键诊断。") + add_bullet(doc, "现场演示 admin、lqq、普通用户三类账号的边界。") + add_bullet(doc, "现场定位技能、智能体文件目录和当前数据库,并抽查最近备份。") + add_bullet(doc, "明确故障升级联系人、响应时间、离线介质和回退决策人。") + + add_h1(doc, "附录 A:命令速查") + add_h2(doc, "A.1 服务") + add_code(doc, """ + sudo bash install.sh # 首次安装/加载镜像并启动 + sudo bash restart.sh # 重启全部 + sudo bash stop.sh # 停止全部 + sudo docker compose restart backend # 日常重启后端/挂载配置变更 + sudo docker compose up -d --force-recreate backend # backend.env 变更 + sudo docker compose restart web # 只重启前端 + sudo docker compose ps # 状态 + """, "Shell") + add_h2(doc, "A.2 日志与健康") + add_code(doc, """ + sudo bash logs.sh # 全部实时日志 + sudo bash logs.sh backend # 后端实时日志 + sudo bash logs.sh web # Web/Nginx 实时日志 + sudo env TAIL=1000 bash diagnose.sh # 一键诊断 + curl -fsS http://127.0.0.1:8090/deerflow/health + """, "Shell") + add_h2(doc, "A.3 主机资源") + add_code(doc, """ + uname -m # 架构,应为 aarch64/arm64 + df -h # 磁盘空间 + df -i # inode + free -h # 内存 + ss -lntp # 端口监听 + sudo docker stats --no-stream # 容器资源 + """, "Shell") + add_h2(doc, "A.4 文件与挂载") + add_code(doc, """ + sudo docker compose config # 展开后的 Compose 配置 + sudo docker compose exec backend sh -lc 'id; pwd; mount' + sudo docker compose exec backend sh -lc 'ls -la /app/backend/skills' + sudo docker compose exec backend sh -lc 'ls -la /app/backend/.deer-flow/agents' + """, "Shell") + + add_h1(doc, "附录 B:安全配置模板") + add_h2(doc, "B.1 部署 .env") + add_code(doc, """ + COMPOSE_PROJECT_NAME=deerflow-1panel + # 原样复制离线包内 .env 的标签值 + DEERFLOW_TAG= + APP_PORT=8090 + TZ=Asia/Shanghai + + # 不要只凭 tag 名称判断 CPU 架构;用 docker image inspect 核对 Architecture + """, "ENV") + add_h2(doc, "B.2 backend.env") + add_code(doc, """ + AUTH_JWT_SECRET=<强随机值> + INTRANET_MODEL_API_KEY=<模型密钥> + MYSQL_DATABASE_URL=mysql+asyncmy://:@:3306/?charset=utf8mb4 + + GATEWAY_ENABLE_DOCS=false + GATEWAY_WORKERS=1 + """, "ENV") + add_h2(doc, "B.3 runtime-config.js") + add_code(doc, """ + window.__DEERFLOW_RUNTIME_CONFIG__ = { + VITE_BACKEND_BASE_URL: "/deerflow", + VITE_LANGGRAPH_BASE_URL: "/deerflow/api", + VITE_API_BASE_URL: "<可选业务API地址>", + VITE_AIPI_CONSUMER_URL: "<可选任务业务地址>", + VITE_FORCE_COLLAPSED_SIDEBAR: "false" + }; + """, "JavaScript") + add_h2(doc, "B.4 模型条目") + add_code(doc, """ + models: + - name: intranet-model + display_name: 内网模型 + use: langchain_openai:ChatOpenAI + model: + api_key: $INTRANET_MODEL_API_KEY + base_url: http://:/v1 + request_timeout: 600.0 + max_retries: 2 + max_tokens: 8192 + temperature: 0.7 + supports_vision: false + supports_thinking: false + """, "YAML") + + add_h1(doc, "附录 C:问题升级模板") + add_para(doc, "向研发或供应商提交问题时,复制以下模板填写。密钥和个人信息必须脱敏。") + add_code(doc, """ + 【问题标题】 + 【发生时间】YYYY-MM-DD HH:mm:ss(Asia/Shanghai) + 【环境/版本】生产/测试;离线包版本;镜像 ID;主机架构 + 【影响范围】全部用户/指定用户;登录/对话/技能/智能体/广场等 + 【用户身份】admin / lqq / 普通用户(不要提供密码) + 【复现步骤】1... 2... 3... + 【期望结果】 + 【实际结果】 + 【HTTP 状态码 / trace ID】 + 【最近变更】配置、模型、数据库、镜像、菜单、主题等 + 【容器状态】docker compose ps + 【健康检查】/deerflow/health 结果 + 【日志附件】backend/web 最近 1000 行(已脱敏) + 【浏览器附件】Console + Network(已脱敏) + 【已尝试操作】 + 【是否可回退】是/否;回退点 + """, "故障单") + add_h2(doc, "C.1 现场信息保全") + add_bullet(doc, "故障期间先记录时间、容器状态和日志,再重启;重启会丢失部分现场。") + add_bullet(doc, "涉及资产丢失时不要新建同名资产,不要修改数据库,不要清理 Docker。") + add_bullet(doc, "涉及安全事件时立即隔离、轮换凭据并按本单位安全事件流程上报。") + + add_h1(doc, "附录 D:核心事实速记") + facts = [ + ["前端和后端端口", "容器内不同:Web 80、Backend 8001;对用户可由 Nginx 统一为 APP_PORT"], + ["前端改端口", "部署 .env 的 APP_PORT,重建/重启 Web,放行防火墙"], + ["前端改接口", "config/runtime-config.js;推荐 /deerflow 和 /deerflow/api"], + ["后端模型", "config/config.yaml;密钥在 config/backend.env"], + ["max_tokens", "模型单次输出上限,不是上下文窗口"], + ["技能位置", "data/skills/public 与 data/skills/custom"], + ["智能体位置", "data/backend/agents/;还依赖数据库元数据"], + ["发布广场", "资产所有者或 admin 在 UI 发布;每项资产同一时间一个广场"], + ["敏感词用户", "正常前端管理使用显示用户名为 lqq 的账号"], + ["菜单隐藏", "只隐藏导航,不等于禁止直接路由和 API"], + ["JWT 密钥", "AUTH_JWT_SECRET;必须持久化;改变会导致全员重新登录"], + ["数据库切换", "SQLite ↔ MySQL 不自动迁移;必须备份、迁移、验收"], + ["完整备份", "config + data + 外部 MySQL + 镜像及 SHA256"], + ["资产丢失", "先查账号、挂载、数据库和过滤,再决定恢复;不要先 prune/删除"], + ] + add_table(doc, ["主题", "结论"], facts, [2500, 6860], font_size=8.8) + add_callout(doc, "手册维护", "每次镜像升级、路由调整、权限变化或数据库迁移后,由应用管理员和 Linux 运维共同复核本手册并更新版本记录。", "ok") + + # Final metadata page without forced H1 to avoid a mostly blank page. + add_page_break(doc) + p = doc.add_paragraph(style="Title") + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + p.paragraph_format.space_before = Inches(1.45) + r = p.add_run("运维交付确认") + set_run_font(r, size=24, bold=True, color=DARK_BLUE) + add_para(doc, "以下信息应由接收方在正式上线前补充,并存放在受控运维台账中。不要在本 Word 文档中写明文密码。", align=WD_ALIGN_PARAGRAPH.CENTER) + add_table( + doc, + ["项目", "填写内容"], + [ + ["生产访问地址", ""], + ["部署目录", ""], + ["APP_PORT", ""], + ["镜像版本 / SHA256", ""], + ["数据库模式与库名", ""], + ["Linux / 1Panel 责任人", ""], + ["DeerFlow admin 责任人", ""], + ["lqq 账号责任人", ""], + ["备份位置与保留期", ""], + ["监控告警联系人", ""], + ["最近一次恢复演练日期", ""], + ], + [3000, 6360], + font_size=9, + ) + add_para(doc, "交付人签字:____________________ 接收人签字:____________________", align=WD_ALIGN_PARAGRAPH.CENTER, after=10) + add_para(doc, "日期:________年____月____日", align=WD_ALIGN_PARAGRAPH.CENTER) + + doc.save(OUTPUT) + return OUTPUT + + +if __name__ == "__main__": + ensure_dirs() + diagrams = make_diagrams() + output = build_document(diagrams) + print(output) diff --git a/tmp/maintenance-manual/build_manual_user_focused.py b/tmp/maintenance-manual/build_manual_user_focused.py new file mode 100644 index 00000000..3549d9dc --- /dev/null +++ b/tmp/maintenance-manual/build_manual_user_focused.py @@ -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:/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:/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://:@:3306/?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.md + │ ├── scripts/ # 可选 + │ ├── references/ # 可选 + │ └── assets/ # 可选 + └── custom/ + └── / + └── 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// + ├── 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/ + # 将经过审核的文件复制到上面的目录后执行: + 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:/deerflow/health + curl -I http://127.0.0.1:/ + 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 -u > <备份目录>/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:/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/;还依赖数据库元数据、所有者、可见性和发布状态。"], + ["敏感词用户", "页面维护需要显示用户名严格为 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)) diff --git a/tmp/maintenance-manual/make_contact_sheets.py b/tmp/maintenance-manual/make_contact_sheets.py new file mode 100644 index 00000000..33e9acc1 --- /dev/null +++ b/tmp/maintenance-manual/make_contact_sheets.py @@ -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") diff --git a/tmp/maintenance-manual/make_contact_sheets_user.py b/tmp/maintenance-manual/make_contact_sheets_user.py new file mode 100644 index 00000000..26e0b4d9 --- /dev/null +++ b/tmp/maintenance-manual/make_contact_sheets_user.py @@ -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") diff --git a/tmp/maintenance-manual/render_with_word.ps1 b/tmp/maintenance-manual/render_with_word.ps1 new file mode 100644 index 00000000..3c08676f --- /dev/null +++ b/tmp/maintenance-manual/render_with_word.ps1 @@ -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() +} diff --git a/tmp/maintenance-manual/rendered-user/DeerFlow系统运维手册-用户重点版.pdf b/tmp/maintenance-manual/rendered-user/DeerFlow系统运维手册-用户重点版.pdf new file mode 100644 index 00000000..266c1046 Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/DeerFlow系统运维手册-用户重点版.pdf differ diff --git a/tmp/maintenance-manual/rendered-user/contact-sheets/pages-01-04.jpg b/tmp/maintenance-manual/rendered-user/contact-sheets/pages-01-04.jpg new file mode 100644 index 00000000..607efdef Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/contact-sheets/pages-01-04.jpg differ diff --git a/tmp/maintenance-manual/rendered-user/contact-sheets/pages-05-08.jpg b/tmp/maintenance-manual/rendered-user/contact-sheets/pages-05-08.jpg new file mode 100644 index 00000000..4a01f0e3 Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/contact-sheets/pages-05-08.jpg differ diff --git a/tmp/maintenance-manual/rendered-user/contact-sheets/pages-09-12.jpg b/tmp/maintenance-manual/rendered-user/contact-sheets/pages-09-12.jpg new file mode 100644 index 00000000..94f18241 Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/contact-sheets/pages-09-12.jpg differ diff --git a/tmp/maintenance-manual/rendered-user/contact-sheets/pages-13-16.jpg b/tmp/maintenance-manual/rendered-user/contact-sheets/pages-13-16.jpg new file mode 100644 index 00000000..f9a3ab5e Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/contact-sheets/pages-13-16.jpg differ diff --git a/tmp/maintenance-manual/rendered-user/contact-sheets/pages-17-20.jpg b/tmp/maintenance-manual/rendered-user/contact-sheets/pages-17-20.jpg new file mode 100644 index 00000000..048a7847 Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/contact-sheets/pages-17-20.jpg differ diff --git a/tmp/maintenance-manual/rendered-user/contact-sheets/pages-21-24.jpg b/tmp/maintenance-manual/rendered-user/contact-sheets/pages-21-24.jpg new file mode 100644 index 00000000..744e97dc Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/contact-sheets/pages-21-24.jpg differ diff --git a/tmp/maintenance-manual/rendered-user/contact-sheets/pages-25-28.jpg b/tmp/maintenance-manual/rendered-user/contact-sheets/pages-25-28.jpg new file mode 100644 index 00000000..97c39dc5 Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/contact-sheets/pages-25-28.jpg differ diff --git a/tmp/maintenance-manual/rendered-user/manual-user.pdf b/tmp/maintenance-manual/rendered-user/manual-user.pdf new file mode 100644 index 00000000..266c1046 Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/manual-user.pdf differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-01.png b/tmp/maintenance-manual/rendered-user/pages/page-01.png new file mode 100644 index 00000000..34d3f502 Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-01.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-02.png b/tmp/maintenance-manual/rendered-user/pages/page-02.png new file mode 100644 index 00000000..c2b07bbc Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-02.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-03.png b/tmp/maintenance-manual/rendered-user/pages/page-03.png new file mode 100644 index 00000000..206b4f77 Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-03.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-04.png b/tmp/maintenance-manual/rendered-user/pages/page-04.png new file mode 100644 index 00000000..044848cd Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-04.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-05.png b/tmp/maintenance-manual/rendered-user/pages/page-05.png new file mode 100644 index 00000000..3e3099d9 Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-05.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-06.png b/tmp/maintenance-manual/rendered-user/pages/page-06.png new file mode 100644 index 00000000..acdea899 Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-06.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-07.png b/tmp/maintenance-manual/rendered-user/pages/page-07.png new file mode 100644 index 00000000..02621c54 Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-07.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-08.png b/tmp/maintenance-manual/rendered-user/pages/page-08.png new file mode 100644 index 00000000..d977dd60 Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-08.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-09.png b/tmp/maintenance-manual/rendered-user/pages/page-09.png new file mode 100644 index 00000000..ec52b56a Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-09.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-10.png b/tmp/maintenance-manual/rendered-user/pages/page-10.png new file mode 100644 index 00000000..e42f2a47 Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-10.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-11.png b/tmp/maintenance-manual/rendered-user/pages/page-11.png new file mode 100644 index 00000000..1f2a8d2a Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-11.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-12.png b/tmp/maintenance-manual/rendered-user/pages/page-12.png new file mode 100644 index 00000000..c5fac42d Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-12.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-13.png b/tmp/maintenance-manual/rendered-user/pages/page-13.png new file mode 100644 index 00000000..52a8552b Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-13.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-14.png b/tmp/maintenance-manual/rendered-user/pages/page-14.png new file mode 100644 index 00000000..7bca6e48 Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-14.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-15.png b/tmp/maintenance-manual/rendered-user/pages/page-15.png new file mode 100644 index 00000000..e48d1e02 Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-15.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-16.png b/tmp/maintenance-manual/rendered-user/pages/page-16.png new file mode 100644 index 00000000..6e0d606f Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-16.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-17.png b/tmp/maintenance-manual/rendered-user/pages/page-17.png new file mode 100644 index 00000000..fc0abfe1 Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-17.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-18.png b/tmp/maintenance-manual/rendered-user/pages/page-18.png new file mode 100644 index 00000000..9e003500 Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-18.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-19.png b/tmp/maintenance-manual/rendered-user/pages/page-19.png new file mode 100644 index 00000000..fce6c4b3 Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-19.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-20.png b/tmp/maintenance-manual/rendered-user/pages/page-20.png new file mode 100644 index 00000000..0ff24410 Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-20.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-21.png b/tmp/maintenance-manual/rendered-user/pages/page-21.png new file mode 100644 index 00000000..56be969c Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-21.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-22.png b/tmp/maintenance-manual/rendered-user/pages/page-22.png new file mode 100644 index 00000000..c3757892 Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-22.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-23.png b/tmp/maintenance-manual/rendered-user/pages/page-23.png new file mode 100644 index 00000000..993ac84b Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-23.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-24.png b/tmp/maintenance-manual/rendered-user/pages/page-24.png new file mode 100644 index 00000000..f820a78e Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-24.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-25.png b/tmp/maintenance-manual/rendered-user/pages/page-25.png new file mode 100644 index 00000000..ed03a1c0 Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-25.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-26.png b/tmp/maintenance-manual/rendered-user/pages/page-26.png new file mode 100644 index 00000000..cdf1afd4 Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-26.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-27.png b/tmp/maintenance-manual/rendered-user/pages/page-27.png new file mode 100644 index 00000000..88fb0044 Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-27.png differ diff --git a/tmp/maintenance-manual/rendered-user/pages/page-28.png b/tmp/maintenance-manual/rendered-user/pages/page-28.png new file mode 100644 index 00000000..a67539d1 Binary files /dev/null and b/tmp/maintenance-manual/rendered-user/pages/page-28.png differ diff --git a/tmp/maintenance-manual/rendered/DeerFlow系统运维手册-内网离线部署版.pdf b/tmp/maintenance-manual/rendered/DeerFlow系统运维手册-内网离线部署版.pdf new file mode 100644 index 00000000..75596144 Binary files /dev/null and b/tmp/maintenance-manual/rendered/DeerFlow系统运维手册-内网离线部署版.pdf differ diff --git a/tmp/maintenance-manual/rendered/contact-sheets/pages-01-04.jpg b/tmp/maintenance-manual/rendered/contact-sheets/pages-01-04.jpg new file mode 100644 index 00000000..3948fc35 Binary files /dev/null and b/tmp/maintenance-manual/rendered/contact-sheets/pages-01-04.jpg differ diff --git a/tmp/maintenance-manual/rendered/contact-sheets/pages-05-08.jpg b/tmp/maintenance-manual/rendered/contact-sheets/pages-05-08.jpg new file mode 100644 index 00000000..5999bfc6 Binary files /dev/null and b/tmp/maintenance-manual/rendered/contact-sheets/pages-05-08.jpg differ diff --git a/tmp/maintenance-manual/rendered/contact-sheets/pages-09-12.jpg b/tmp/maintenance-manual/rendered/contact-sheets/pages-09-12.jpg new file mode 100644 index 00000000..0f4750db Binary files /dev/null and b/tmp/maintenance-manual/rendered/contact-sheets/pages-09-12.jpg differ diff --git a/tmp/maintenance-manual/rendered/contact-sheets/pages-13-16.jpg b/tmp/maintenance-manual/rendered/contact-sheets/pages-13-16.jpg new file mode 100644 index 00000000..96b77529 Binary files /dev/null and b/tmp/maintenance-manual/rendered/contact-sheets/pages-13-16.jpg differ diff --git a/tmp/maintenance-manual/rendered/contact-sheets/pages-17-20.jpg b/tmp/maintenance-manual/rendered/contact-sheets/pages-17-20.jpg new file mode 100644 index 00000000..fa77cc2f Binary files /dev/null and b/tmp/maintenance-manual/rendered/contact-sheets/pages-17-20.jpg differ diff --git a/tmp/maintenance-manual/rendered/contact-sheets/pages-21-24.jpg b/tmp/maintenance-manual/rendered/contact-sheets/pages-21-24.jpg new file mode 100644 index 00000000..9c4929f0 Binary files /dev/null and b/tmp/maintenance-manual/rendered/contact-sheets/pages-21-24.jpg differ diff --git a/tmp/maintenance-manual/rendered/contact-sheets/pages-25-28.jpg b/tmp/maintenance-manual/rendered/contact-sheets/pages-25-28.jpg new file mode 100644 index 00000000..1b1bb76e Binary files /dev/null and b/tmp/maintenance-manual/rendered/contact-sheets/pages-25-28.jpg differ diff --git a/tmp/maintenance-manual/rendered/contact-sheets/pages-29-32.jpg b/tmp/maintenance-manual/rendered/contact-sheets/pages-29-32.jpg new file mode 100644 index 00000000..e2fbf295 Binary files /dev/null and b/tmp/maintenance-manual/rendered/contact-sheets/pages-29-32.jpg differ diff --git a/tmp/maintenance-manual/rendered/contact-sheets/pages-33-36.jpg b/tmp/maintenance-manual/rendered/contact-sheets/pages-33-36.jpg new file mode 100644 index 00000000..99e2315e Binary files /dev/null and b/tmp/maintenance-manual/rendered/contact-sheets/pages-33-36.jpg differ diff --git a/tmp/maintenance-manual/rendered/contact-sheets/pages-37-40.jpg b/tmp/maintenance-manual/rendered/contact-sheets/pages-37-40.jpg new file mode 100644 index 00000000..a6deed14 Binary files /dev/null and b/tmp/maintenance-manual/rendered/contact-sheets/pages-37-40.jpg differ diff --git a/tmp/maintenance-manual/rendered/contact-sheets/pages-41-44.jpg b/tmp/maintenance-manual/rendered/contact-sheets/pages-41-44.jpg new file mode 100644 index 00000000..47be5a07 Binary files /dev/null and b/tmp/maintenance-manual/rendered/contact-sheets/pages-41-44.jpg differ diff --git a/tmp/maintenance-manual/rendered/contact-sheets/pages-45-48.jpg b/tmp/maintenance-manual/rendered/contact-sheets/pages-45-48.jpg new file mode 100644 index 00000000..990e3767 Binary files /dev/null and b/tmp/maintenance-manual/rendered/contact-sheets/pages-45-48.jpg differ diff --git a/tmp/maintenance-manual/rendered/contact-sheets/pages-49-52.jpg b/tmp/maintenance-manual/rendered/contact-sheets/pages-49-52.jpg new file mode 100644 index 00000000..9b7fb7ea Binary files /dev/null and b/tmp/maintenance-manual/rendered/contact-sheets/pages-49-52.jpg differ diff --git a/tmp/maintenance-manual/rendered/contact-sheets/pages-53-56.jpg b/tmp/maintenance-manual/rendered/contact-sheets/pages-53-56.jpg new file mode 100644 index 00000000..6f7c8738 Binary files /dev/null and b/tmp/maintenance-manual/rendered/contact-sheets/pages-53-56.jpg differ diff --git a/tmp/maintenance-manual/rendered/contact-sheets/pages-57-60.jpg b/tmp/maintenance-manual/rendered/contact-sheets/pages-57-60.jpg new file mode 100644 index 00000000..c16f2ce9 Binary files /dev/null and b/tmp/maintenance-manual/rendered/contact-sheets/pages-57-60.jpg differ diff --git a/tmp/maintenance-manual/rendered/contact-sheets/pages-61-61.jpg b/tmp/maintenance-manual/rendered/contact-sheets/pages-61-61.jpg new file mode 100644 index 00000000..c48563cb Binary files /dev/null and b/tmp/maintenance-manual/rendered/contact-sheets/pages-61-61.jpg differ diff --git a/tmp/maintenance-manual/rendered/pages/page-01.png b/tmp/maintenance-manual/rendered/pages/page-01.png new file mode 100644 index 00000000..be5d589b Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-01.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-02.png b/tmp/maintenance-manual/rendered/pages/page-02.png new file mode 100644 index 00000000..d063cb4f Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-02.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-03.png b/tmp/maintenance-manual/rendered/pages/page-03.png new file mode 100644 index 00000000..81b81f7a Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-03.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-04.png b/tmp/maintenance-manual/rendered/pages/page-04.png new file mode 100644 index 00000000..c43bb49c Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-04.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-05.png b/tmp/maintenance-manual/rendered/pages/page-05.png new file mode 100644 index 00000000..8c2f0c47 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-05.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-06.png b/tmp/maintenance-manual/rendered/pages/page-06.png new file mode 100644 index 00000000..c6a32ba1 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-06.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-07.png b/tmp/maintenance-manual/rendered/pages/page-07.png new file mode 100644 index 00000000..3022fe5a Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-07.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-08.png b/tmp/maintenance-manual/rendered/pages/page-08.png new file mode 100644 index 00000000..cb350c44 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-08.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-09.png b/tmp/maintenance-manual/rendered/pages/page-09.png new file mode 100644 index 00000000..28d83d8d Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-09.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-10.png b/tmp/maintenance-manual/rendered/pages/page-10.png new file mode 100644 index 00000000..eb9f600f Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-10.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-11.png b/tmp/maintenance-manual/rendered/pages/page-11.png new file mode 100644 index 00000000..1e848a4c Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-11.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-12.png b/tmp/maintenance-manual/rendered/pages/page-12.png new file mode 100644 index 00000000..e7d2ef5f Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-12.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-13.png b/tmp/maintenance-manual/rendered/pages/page-13.png new file mode 100644 index 00000000..630bf988 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-13.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-14.png b/tmp/maintenance-manual/rendered/pages/page-14.png new file mode 100644 index 00000000..cf3ea12a Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-14.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-15.png b/tmp/maintenance-manual/rendered/pages/page-15.png new file mode 100644 index 00000000..6edf6ba5 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-15.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-16.png b/tmp/maintenance-manual/rendered/pages/page-16.png new file mode 100644 index 00000000..5fcf64de Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-16.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-17.png b/tmp/maintenance-manual/rendered/pages/page-17.png new file mode 100644 index 00000000..4e5576d3 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-17.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-18.png b/tmp/maintenance-manual/rendered/pages/page-18.png new file mode 100644 index 00000000..22b34f6a Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-18.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-19.png b/tmp/maintenance-manual/rendered/pages/page-19.png new file mode 100644 index 00000000..a3bad06f Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-19.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-20.png b/tmp/maintenance-manual/rendered/pages/page-20.png new file mode 100644 index 00000000..40f91627 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-20.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-21.png b/tmp/maintenance-manual/rendered/pages/page-21.png new file mode 100644 index 00000000..8cae4b85 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-21.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-22.png b/tmp/maintenance-manual/rendered/pages/page-22.png new file mode 100644 index 00000000..4b7d5a2c Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-22.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-23.png b/tmp/maintenance-manual/rendered/pages/page-23.png new file mode 100644 index 00000000..039f9aab Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-23.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-24.png b/tmp/maintenance-manual/rendered/pages/page-24.png new file mode 100644 index 00000000..75bd34f0 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-24.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-25.png b/tmp/maintenance-manual/rendered/pages/page-25.png new file mode 100644 index 00000000..1a47023a Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-25.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-26.png b/tmp/maintenance-manual/rendered/pages/page-26.png new file mode 100644 index 00000000..c2228353 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-26.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-27.png b/tmp/maintenance-manual/rendered/pages/page-27.png new file mode 100644 index 00000000..777039ee Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-27.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-28.png b/tmp/maintenance-manual/rendered/pages/page-28.png new file mode 100644 index 00000000..f0300a42 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-28.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-29.png b/tmp/maintenance-manual/rendered/pages/page-29.png new file mode 100644 index 00000000..94e32ba1 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-29.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-30.png b/tmp/maintenance-manual/rendered/pages/page-30.png new file mode 100644 index 00000000..4f89d66b Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-30.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-31.png b/tmp/maintenance-manual/rendered/pages/page-31.png new file mode 100644 index 00000000..88c1e42f Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-31.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-32.png b/tmp/maintenance-manual/rendered/pages/page-32.png new file mode 100644 index 00000000..09f7517b Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-32.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-33.png b/tmp/maintenance-manual/rendered/pages/page-33.png new file mode 100644 index 00000000..41a294f4 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-33.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-34.png b/tmp/maintenance-manual/rendered/pages/page-34.png new file mode 100644 index 00000000..d6106c13 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-34.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-35.png b/tmp/maintenance-manual/rendered/pages/page-35.png new file mode 100644 index 00000000..00ee5458 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-35.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-36.png b/tmp/maintenance-manual/rendered/pages/page-36.png new file mode 100644 index 00000000..d03e891a Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-36.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-37.png b/tmp/maintenance-manual/rendered/pages/page-37.png new file mode 100644 index 00000000..ff96032f Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-37.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-38.png b/tmp/maintenance-manual/rendered/pages/page-38.png new file mode 100644 index 00000000..1372026f Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-38.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-39.png b/tmp/maintenance-manual/rendered/pages/page-39.png new file mode 100644 index 00000000..c09842e2 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-39.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-40.png b/tmp/maintenance-manual/rendered/pages/page-40.png new file mode 100644 index 00000000..46278625 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-40.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-41.png b/tmp/maintenance-manual/rendered/pages/page-41.png new file mode 100644 index 00000000..a2224e33 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-41.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-42.png b/tmp/maintenance-manual/rendered/pages/page-42.png new file mode 100644 index 00000000..deb42c45 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-42.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-43.png b/tmp/maintenance-manual/rendered/pages/page-43.png new file mode 100644 index 00000000..e9c4b8a3 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-43.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-44.png b/tmp/maintenance-manual/rendered/pages/page-44.png new file mode 100644 index 00000000..e78e5db7 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-44.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-45.png b/tmp/maintenance-manual/rendered/pages/page-45.png new file mode 100644 index 00000000..db51a9ee Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-45.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-46.png b/tmp/maintenance-manual/rendered/pages/page-46.png new file mode 100644 index 00000000..349cdb25 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-46.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-47.png b/tmp/maintenance-manual/rendered/pages/page-47.png new file mode 100644 index 00000000..057cfcc6 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-47.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-48.png b/tmp/maintenance-manual/rendered/pages/page-48.png new file mode 100644 index 00000000..24f84bd4 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-48.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-49.png b/tmp/maintenance-manual/rendered/pages/page-49.png new file mode 100644 index 00000000..0f1b10b8 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-49.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-50.png b/tmp/maintenance-manual/rendered/pages/page-50.png new file mode 100644 index 00000000..29c33547 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-50.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-51.png b/tmp/maintenance-manual/rendered/pages/page-51.png new file mode 100644 index 00000000..ccd0b39a Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-51.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-52.png b/tmp/maintenance-manual/rendered/pages/page-52.png new file mode 100644 index 00000000..e9eae8c5 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-52.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-53.png b/tmp/maintenance-manual/rendered/pages/page-53.png new file mode 100644 index 00000000..7e2c0065 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-53.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-54.png b/tmp/maintenance-manual/rendered/pages/page-54.png new file mode 100644 index 00000000..88a81736 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-54.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-55.png b/tmp/maintenance-manual/rendered/pages/page-55.png new file mode 100644 index 00000000..df4bd728 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-55.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-56.png b/tmp/maintenance-manual/rendered/pages/page-56.png new file mode 100644 index 00000000..467ac10b Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-56.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-57.png b/tmp/maintenance-manual/rendered/pages/page-57.png new file mode 100644 index 00000000..a0b5f682 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-57.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-58.png b/tmp/maintenance-manual/rendered/pages/page-58.png new file mode 100644 index 00000000..7fa41ecc Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-58.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-59.png b/tmp/maintenance-manual/rendered/pages/page-59.png new file mode 100644 index 00000000..05459826 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-59.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-60.png b/tmp/maintenance-manual/rendered/pages/page-60.png new file mode 100644 index 00000000..ab6f81a1 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-60.png differ diff --git a/tmp/maintenance-manual/rendered/pages/page-61.png b/tmp/maintenance-manual/rendered/pages/page-61.png new file mode 100644 index 00000000..54eb0b44 Binary files /dev/null and b/tmp/maintenance-manual/rendered/pages/page-61.png differ