diff --git a/.codex-server-diagnose.py b/.codex-server-diagnose.py new file mode 100644 index 00000000..0eb682ba --- /dev/null +++ b/.codex-server-diagnose.py @@ -0,0 +1,20 @@ +import os + +import paramiko + + +client = paramiko.SSHClient() +client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +client.connect( + "119.254.86.251", + port=22322, + username="root", + password=os.environ["TARGET_SSH_PASSWORD"], + timeout=15, + auth_timeout=15, + banner_timeout=15, +) +_, stdout, stderr = client.exec_command(os.environ["TARGET_SSH_COMMAND"], timeout=30) +print(stdout.read().decode(errors="replace"), end="") +print(stderr.read().decode(errors="replace"), end="") +client.close() diff --git a/.gitignore b/.gitignore index 444a95df..ab7a0082 100644 --- a/.gitignore +++ b/.gitignore @@ -83,12 +83,15 @@ offline-backend-20260512/backend/.deer-flow/* # Per-user skill_evolution overrides — runtime data written by the API offline-backend-20260512/backend/skill_evolution.json -skills/ -hermes-agent-main/ +skills/ +hermes-agent-main/ .gitignore offline-backend-20260512/backend/tests/* !offline-backend-20260512/backend/tests/test_llmwiki_weknora.py !offline-backend-20260512/backend/tests/test_health_auth_bypass.py +# Keep the model configuration regression coverage versioned even though +# this deployment repository otherwise ignores newly added backend tests. +!offline-backend-20260512/backend/tests/test_model_management.py deploy .history 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/assets/position-roundtable/add-task.svg b/frontend-web/src/assets/position-roundtable/add-task.svg new file mode 100644 index 00000000..e27cab8f --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/add-task.svg @@ -0,0 +1 @@ + diff --git a/frontend-web/src/assets/position-roundtable/agent-config-logo.svg b/frontend-web/src/assets/position-roundtable/agent-config-logo.svg new file mode 100644 index 00000000..d51eb71d --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/agent-config-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/position-roundtable/ask-question.svg b/frontend-web/src/assets/position-roundtable/ask-question.svg new file mode 100644 index 00000000..af9052eb --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/ask-question.svg @@ -0,0 +1 @@ + diff --git a/frontend-web/src/assets/position-roundtable/collapse-panel.svg b/frontend-web/src/assets/position-roundtable/collapse-panel.svg new file mode 100644 index 00000000..9f064680 --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/collapse-panel.svg @@ -0,0 +1 @@ + diff --git a/frontend-web/src/assets/position-roundtable/double-arrow-decoration.svg b/frontend-web/src/assets/position-roundtable/double-arrow-decoration.svg new file mode 100644 index 00000000..bf85a192 --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/double-arrow-decoration.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/position-roundtable/edit-task.svg b/frontend-web/src/assets/position-roundtable/edit-task.svg new file mode 100644 index 00000000..ab6762a2 --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/edit-task.svg @@ -0,0 +1 @@ + diff --git a/frontend-web/src/assets/position-roundtable/more-task.svg b/frontend-web/src/assets/position-roundtable/more-task.svg new file mode 100644 index 00000000..055a158d --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/more-task.svg @@ -0,0 +1 @@ + diff --git a/frontend-web/src/assets/position-roundtable/search-task.svg b/frontend-web/src/assets/position-roundtable/search-task.svg new file mode 100644 index 00000000..fc8fc78c --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/search-task.svg @@ -0,0 +1 @@ + diff --git a/frontend-web/src/assets/position-roundtable/send-message.svg b/frontend-web/src/assets/position-roundtable/send-message.svg new file mode 100644 index 00000000..3a96000e --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/send-message.svg @@ -0,0 +1 @@ + diff --git a/frontend-web/src/assets/position-roundtable/upload-file.svg b/frontend-web/src/assets/position-roundtable/upload-file.svg new file mode 100644 index 00000000..9f47e22a --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/upload-file.svg @@ -0,0 +1 @@ + diff --git a/frontend-web/src/assets/position-roundtable/时间.svg b/frontend-web/src/assets/position-roundtable/时间.svg new file mode 100644 index 00000000..26888994 --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/时间.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/position-roundtable/标签1.svg b/frontend-web/src/assets/position-roundtable/标签1.svg new file mode 100644 index 00000000..780de1a5 --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/标签1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/position-roundtable/标签2.svg b/frontend-web/src/assets/position-roundtable/标签2.svg new file mode 100644 index 00000000..7443e13d --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/标签2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/position-roundtable/标签3.svg b/frontend-web/src/assets/position-roundtable/标签3.svg new file mode 100644 index 00000000..7d9257a5 --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/标签3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/position-roundtable/标签4.svg b/frontend-web/src/assets/position-roundtable/标签4.svg new file mode 100644 index 00000000..2be60358 --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/标签4.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/position-roundtable/标签5.svg b/frontend-web/src/assets/position-roundtable/标签5.svg new file mode 100644 index 00000000..df814d72 --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/标签5.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/position-roundtable/标签6.svg b/frontend-web/src/assets/position-roundtable/标签6.svg new file mode 100644 index 00000000..3df12c8a --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/标签6.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/assets/position-roundtable/责任岗位.svg b/frontend-web/src/assets/position-roundtable/责任岗位.svg new file mode 100644 index 00000000..66040aa7 --- /dev/null +++ b/frontend-web/src/assets/position-roundtable/责任岗位.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index eb1f0bd1..5119a2ba 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useNavigate, useLocation } from "react-router-dom"; import { Bell, + Cpu, ChevronLeft, ChevronRight, ChevronsUpDown, @@ -252,6 +253,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) { 白名单管理 )} + {isAdmin && ( + navigate("/page/workspace/admin/models")} + > + + 模型配置 + + )} {isAdmin && ( navigate("/page/strategy/settings/model-concurrency")} @@ -555,6 +564,12 @@ function LegacyAccordionSidebar({ 岗位角色配置 )} + {isAdmin && ( + navigate("/page/workspace/admin/models")}> + + 模型配置 + + )} {isAdmin && ( navigate("/page/strategy/admin/login-whitelist")}> diff --git a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx index dd2f8212..a09653aa 100644 --- a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx +++ b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx @@ -104,15 +104,17 @@ function Section({ icon, title, extra, + className, children, }: { icon?: ReactNode; title: string; extra?: ReactNode; + className?: string; children: ReactNode; }) { return ( -
+

{icon} @@ -143,6 +145,15 @@ export function AgentDetailPanel({ defaultCollapsed, showRecentChats, compactAppearanceActions, + avatarSrc, + positionName, + skillsIcon, + skillsTitle, + showCommonQuestions = false, + panelTitle, + embedded, + hideStatusBadges = false, + editIcon, }: { agent: Agent | null | undefined; agentName: string; @@ -154,6 +165,19 @@ export function AgentDetailPanel({ showRecentChats?: boolean; /** 简洁模式下挂在右上角收起/展开按钮左侧的外观控件。 */ compactAppearanceActions?: ReactNode; + avatarSrc?: string; + positionName?: string; + skillsIcon?: ReactNode; + skillsTitle?: string; + showCommonQuestions?: boolean; + /** Overrides the standard panel heading for an embedded consumer. */ + panelTitle?: string; + /** Renders one fixed panel instead of the responsive collapsible rail. */ + embedded?: boolean; + /** Keeps deployment/status badges out of an embedded product surface. */ + hideStatusBadges?: boolean; + /** Optional icon used by the compact embedded edit action. */ + editIcon?: ReactNode; }) { const router = useRouter(); const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false); @@ -481,11 +505,25 @@ export function AgentDetailPanel({

) : (
- - {canTogglePublish && ( + {embedded ? ( + + + + ) : ( + + )} + {!embedded && canTogglePublish && ( - + {!embedded && ( + + + + )}
) ) : null; @@ -530,7 +570,11 @@ export function AgentDetailPanel({
- + {avatarSrc ? ( + + ) : ( + + )}
{editing && draft ? ( @@ -550,9 +594,16 @@ export function AgentDetailPanel({ {displayName}
)} -
- {statusBadges} -
+ {!editing && positionName && ( +
+ 岗位:{desensitize(positionName)} +
+ )} + {!hideStatusBadges && ( +
+ {statusBadges} +
+ )} {!editing && agent.owner_username && !agent.builtin && (
@@ -670,7 +721,7 @@ export function AgentDetailPanel({ {/* ── 常用问题 ───────────────────────────────────────────────────── */} - {(editing || questions.length > 0 || canEdit) && ( + {(showCommonQuestions || editing || questions.length > 0 || canEdit) && (
} title="常用问题" @@ -811,8 +862,9 @@ export function AgentDetailPanel({ {/* ── 技能管理 ───────────────────────────────────────────────────── */}
} - title={`已启用技能 ${skillNamesShown.length} 个`} + icon={skillsIcon ?? } + title={skillsTitle ?? `已启用技能 ${skillNamesShown.length} 个`} + className="position-roundtable-skills-section" extra={ editing ? ( - -
- - - - - 智能体信息 - {headerActions} - - -
{body}
-
-
+ {!embedded && ( + <> + {/* ── Narrow screens ( + + + +
+ + + + + 智能体信息 + {headerActions} + + +
{body}
+
+
+ + )} {/* ── Secondary surfaces ───────────────────────────────────────────── */} void; /** @@ -150,16 +153,38 @@ export function ArtifactFileDetail({ const { t } = useI18n(); const { artifacts, setOpen, select } = useArtifacts(); const { isMock, thread, publicShareCode } = useThread(); - const isWriteFileVirtual = useMemo(() => { - return filepathFromProps.startsWith("write-file:"); - }, [filepathFromProps]); - const filepath = useMemo(() => { - if (isWriteFileVirtual) { - const url = new URL(filepathFromProps); - return decodeURIComponent(url.pathname); - } - return filepathFromProps; - }, [filepathFromProps, isWriteFileVirtual]); + const isWriteFileVirtual = useMemo(() => { + return filepathFromProps.startsWith("write-file:"); + }, [filepathFromProps]); + // A streamed tool call can be opened before the path has been normalized by + // the artifacts API. Keep the format supplied by the writer so Markdown is + // rendered as Markdown instead of falling back to a TEXT source preview. + const writeFileLanguageHint = useMemo(() => { + if (!isWriteFileVirtual) return null; + try { + const language = new URL(filepathFromProps).searchParams.get("language"); + return language === "markdown" ? language : null; + } catch { + return null; + } + }, [filepathFromProps, isWriteFileVirtual]); + const mimeLanguageHint = useMemo(() => { + const normalized = mimeType?.toLowerCase().trim() ?? ""; + if (normalized.includes("markdown")) return "markdown"; + if (normalized.includes("html")) return "html"; + if (normalized.includes("mermaid")) return "mermaid"; + if (normalized.startsWith("text/")) return "text"; + return null; + }, [mimeType]); + const filepath = useMemo(() => { + if (isWriteFileVirtual) { + const url = new URL(filepathFromProps); + return decodeURIComponent(url.pathname).trim(); + } + // Tool arguments may contain harmless surrounding whitespace. Passing it + // into extension detection turns `report.md ` into a generic TEXT file. + return filepathFromProps.trim(); + }, [filepathFromProps, isWriteFileVirtual]); const isSkillFile = useMemo(() => { return filepath.endsWith(".skill"); }, [filepath]); @@ -186,17 +211,22 @@ export function ArtifactFileDetail({ const useWriteFileStreaming = useMemo(() => { return isWriteFileVirtual && !isImageFile(filepath) && !editVirtualMarkdown; }, [filepath, isWriteFileVirtual, editVirtualMarkdown]); - const { isCodeFile, language } = useMemo(() => { - if (useWriteFileStreaming) { - let nextLanguage = checkCodeFile(filepath).language; - nextLanguage ??= "text"; - return { isCodeFile: true, language: nextLanguage }; + const { isCodeFile, language } = useMemo(() => { + if (useWriteFileStreaming) { + let nextLanguage = + mimeLanguageHint ?? writeFileLanguageHint ?? checkCodeFile(filepath).language; + nextLanguage ??= "text"; + return { isCodeFile: true, language: nextLanguage }; } if (isSkillFile) { return { isCodeFile: true, language: "markdown" }; } - return checkCodeFile(filepath); - }, [filepath, useWriteFileStreaming, isSkillFile]); + const detected = checkCodeFile(filepath); + if (mimeLanguageHint) { + return { isCodeFile: true as const, language: mimeLanguageHint }; + } + return detected; + }, [filepath, useWriteFileStreaming, isSkillFile, mimeLanguageHint, writeFileLanguageHint]); const isSupportPreview = useMemo(() => { return ( language === "html" || @@ -209,7 +239,8 @@ export function ArtifactFileDetail({ // editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟 // URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容 // 不会被旧的内存 stub 值覆盖回退。 - filepath: editVirtualMarkdown ? filepath : filepathFromProps, + filepath: + editVirtualMarkdown || !isWriteFileVirtual ? filepath : filepathFromProps, enabled: isCodeFile && !useWriteFileStreaming, }); diff --git a/frontend-web/src/components/workspace/chats/chat-box.tsx b/frontend-web/src/components/workspace/chats/chat-box.tsx index 295d047a..342abb5e 100644 --- a/frontend-web/src/components/workspace/chats/chat-box.tsx +++ b/frontend-web/src/components/workspace/chats/chat-box.tsx @@ -15,6 +15,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { apiFetch } from "@/core/api"; import { getBackendBaseURL } from "@/core/config"; import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; +import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag"; import { useSkills } from "@/core/skills/hooks"; import type { SkillDisplayConfig } from "@/core/skills/type"; import { @@ -92,6 +93,13 @@ const ChatBox: React.FC<{ const [settings] = useLocalSettings(); const ragModeEnabled = settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); + // Frontend single-round RAG writes its own source tool message, so it should + // use the same reference panel without changing the user's global RAG mode. + const hasSingleRoundRag = useMemo( + () => (thread.values.messages ?? []).some(isSingleRoundRagMessage), + [thread.values.messages], + ); + const referencesEnabled = ragModeEnabled || hasSingleRoundRag; const { skills } = useSkills(); const { @@ -106,11 +114,11 @@ const ChatBox: React.FC<{ const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); const skillDisplayMap = useMemo( - () => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), - [ragModeEnabled, skills], + () => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()), + [referencesEnabled, skills], ); const fallbackReferenceBatches = useMemo(() => { - if (!ragModeEnabled) { + if (!referencesEnabled) { return []; } return buildTurnReferenceBatches( @@ -118,9 +126,9 @@ const ChatBox: React.FC<{ skillDisplayMap, thread.isLoading, ); - }, [ragModeEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]); + }, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]); useEffect(() => { - if (!ragModeEnabled) { + if (!referencesEnabled) { setBackendReferenceBatches([]); return; } @@ -156,7 +164,7 @@ const ChatBox: React.FC<{ } }); return () => controller.abort(); - }, [ragModeEnabled, thread.isLoading, threadId, thread.values.messages]); + }, [referencesEnabled, thread.isLoading, threadId, thread.values.messages]); const adaptedBackendReferenceBatches = useMemo( () => adaptBackendReferenceBatches( @@ -175,7 +183,7 @@ const ChatBox: React.FC<{ [adaptedBackendReferenceBatches, fallbackReferenceBatches], ); useEffect(() => { - if (!ragModeEnabled) { + if (!referencesEnabled) { return; } const signature = JSON.stringify({ @@ -202,7 +210,7 @@ const ChatBox: React.FC<{ fallbackBatches: debugReferenceBatches(fallbackReferenceBatches), finalBatches: debugReferenceBatches(referenceBatches), }); - }, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, ragModeEnabled, referenceBatches, threadId]); + }, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, referencesEnabled, referenceBatches, threadId]); const latestReferenceBatch = referenceBatches.length > 0 ? referenceBatches[referenceBatches.length - 1]! @@ -213,7 +221,7 @@ const ChatBox: React.FC<{ const referenceSources = activeReferenceBatch?.sources ?? []; const hasCitationReferences = referenceSources.length > 0; useEffect(() => { - if (!ragModeEnabled || !activeReferenceBatch) { + if (!referencesEnabled || !activeReferenceBatch) { return; } logReferenceDebug("frontend-active-reference-batch-payload", threadId, { @@ -222,7 +230,7 @@ const ChatBox: React.FC<{ sourceCount: activeReferenceBatch.sources.length, sources: activeReferenceBatch.sources.map(debugReferenceSource), }); - }, [activeReferenceBatch, ragModeEnabled, threadId]); + }, [activeReferenceBatch, referencesEnabled, threadId]); useEffect(() => { if (hasCitationReferences) { setReferencesOpen(true); diff --git a/frontend-web/src/components/workspace/input-box.tsx b/frontend-web/src/components/workspace/input-box.tsx index 4e35ac23..da77009e 100644 --- a/frontend-web/src/components/workspace/input-box.tsx +++ b/frontend-web/src/components/workspace/input-box.tsx @@ -65,6 +65,7 @@ import { getBackendBaseURL } from "@/core/config"; import { useI18n } from "@/core/i18n/hooks"; import { isHiddenFromUIMessage } from "@/core/messages/utils"; import { useModels } from "@/core/models/hooks"; +import type { Model } from "@/core/models/types"; import type { AgentThreadContext } from "@/core/threads"; import { textOfMessage } from "@/core/threads/utils"; import { desensitize } from "@/lib/desensitize"; @@ -136,6 +137,7 @@ export function InputBox({ promptPrefix, agentSelector, knowledgeSelector, + extraModels = [], assistantId, hideAdminRecommendedQuestions, compact, @@ -198,12 +200,19 @@ export function InputBox({ }; /** WeKnora-style knowledge-space picker rendered inside the composer toolbar. */ knowledgeSelector?: ReactNode; + /** Page-scoped model choices implemented outside the DeerFlow model API. */ + extraModels?: Model[]; }) { const { t } = useI18n(); const [localSettings, setLocalSettings] = useLocalSettings(); const searchParams = useSearchParams(); - const [modelDialogOpen, setModelDialogOpen] = useState(false); - const { models } = useModels(); + const [modelDialogOpen, setModelDialogOpen] = useState(false); + const { models } = useModels(); + const singleRoundRagActive = + context.execution_profile === "single_round_rag"; + const modelOptions = useMemo(() => { + return singleRoundRagActive ? extraModels : models; + }, [extraModels, models, singleRoundRagActive]); const { thread, isMock } = useThread(); const { textInput } = usePromptInputController(); const promptRootRef = useRef(null); @@ -227,34 +236,40 @@ export function InputBox({ recommendedQuestions.length > 0; useEffect(() => { - if (models.length === 0) { - return; - } - const currentModel = models.find((m) => m.name === context.model_name); - const fallbackModel = currentModel ?? models[0]!; + if (modelOptions.length === 0) { + return; + } + const currentModel = modelOptions.find((m) => m.name === context.model_name); + const fallbackModel = currentModel ?? modelOptions[0]!; const supportsThinking = fallbackModel.supports_thinking ?? false; const nextModelName = fallbackModel.name; const nextMode = getResolvedMode(context.mode, supportsThinking); - if (context.model_name === nextModelName && context.mode === nextMode) { - return; - } + if ( + context.model_name === nextModelName && + context.mode === nextMode && + context.execution_profile === fallbackModel.execution_profile + ) { + return; + } onContextChange?.({ - ...context, - model_name: nextModelName, - mode: nextMode, - }); - }, [context, models, onContextChange]); + ...context, + model_name: nextModelName, + mode: nextMode, + execution_profile: fallbackModel.execution_profile, + }); + }, [context, modelOptions, onContextChange]); + + const selectedModel = useMemo(() => { + if (modelOptions.length === 0) { + return undefined; + } + return modelOptions.find((m) => m.name === context.model_name) ?? modelOptions[0]; + }, [context.model_name, modelOptions]); - const selectedModel = useMemo(() => { - if (models.length === 0) { - return undefined; - } - return models.find((m) => m.name === context.model_name) ?? models[0]; - }, [context.model_name, models]); - - const resolvedModelName = selectedModel?.name; + const resolvedModelName = selectedModel?.name; + const selectedSingleRoundRag = singleRoundRagActive; const supportThinking = useMemo( () => selectedModel?.supports_thinking ?? false, @@ -268,38 +283,65 @@ export function InputBox({ const handleModelSelect = useCallback( (model_name: string) => { - const model = models.find((m) => m.name === model_name); + const model = modelOptions.find((m) => m.name === model_name); if (!model) { return; } onContextChange?.({ ...context, model_name, - mode: getResolvedMode(context.mode, model.supports_thinking ?? false), - reasoning_effort: context.reasoning_effort, - }); - setModelDialogOpen(false); - }, - [onContextChange, context, models], + mode: getResolvedMode(context.mode, model.supports_thinking ?? false), + reasoning_effort: context.reasoning_effort, + execution_profile: model.execution_profile, + }); + setModelDialogOpen(false); + }, + [onContextChange, context, modelOptions], ); - const handleModeSelect = useCallback( - (mode: InputMode) => { - onContextChange?.({ - ...context, - mode: getResolvedMode(mode, supportThinking), - reasoning_effort: - mode === "ultra" - ? "high" + const handleModeSelect = useCallback( + (mode: InputMode) => { + const ordinaryModel = + models.find((model) => model.name === context.model_name) ?? models[0]; + onContextChange?.({ + ...context, + model_name: ordinaryModel?.name ?? context.model_name, + mode: getResolvedMode( + mode, + ordinaryModel?.supports_thinking ?? supportThinking, + ), + reasoning_effort: + mode === "ultra" + ? "high" : mode === "pro" ? "medium" : mode === "thinking" - ? "low" - : "minimal", - }); - }, - [onContextChange, context, supportThinking], - ); + ? "low" + : "minimal", + execution_profile: undefined, + }); + }, + [onContextChange, context, models, supportThinking], + ); + + const handleSingleRoundRagSelect = useCallback(() => { + const ragModel = + extraModels.find((model) => model.name === context.model_name) ?? + extraModels[0]; + if (!ragModel) { + return; + } + + onContextChange?.({ + ...context, + model_name: ragModel.name, + mode: getResolvedMode( + context.mode, + ragModel.supports_thinking ?? false, + ), + execution_profile: "single_round_rag", + }); + }, [context, extraModels, onContextChange]); const handleReasoningEffortSelect = useCallback( (effort: "minimal" | "low" | "medium" | "high") => { @@ -365,10 +407,11 @@ export function InputBox({ onContextChange?.({ ...context, model_name: resolvedModelName, - mode: getResolvedMode( - context.mode, - selectedModel?.supports_thinking ?? false, - ), + mode: getResolvedMode( + context.mode, + selectedModel?.supports_thinking ?? false, + ), + execution_profile: selectedModel?.execution_profile, }); setTimeout(() => onSubmit?.(message), 0); return; @@ -383,7 +426,8 @@ export function InputBox({ onStop, resolvedModelName, ragModeEnabled, - selectedModel?.supports_thinking, + selectedModel?.supports_thinking, + selectedModel?.execution_profile, status, threadId, ], @@ -441,8 +485,9 @@ export function InputBox({ }, [pendingSuggestion, requestFormSubmit, textInput]); const showFollowups = - !disabled && - !isNewThread && + !disabled && + !isNewThread && + !selectedSingleRoundRag && !followupsHidden && (followupsLoading || followups.length > 0); @@ -468,7 +513,7 @@ export function InputBox({ return; } - if (disabled || isMock) { + if (disabled || isMock || selectedSingleRoundRag) { return; } @@ -530,7 +575,7 @@ export function InputBox({ }); return () => controller.abort(); - }, [context.model_name, disabled, isMock, status, thread.messages, threadId]); + }, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]); return (
@@ -786,54 +831,64 @@ export function InputBox({ - + ? context.mode + : "flash" + } + tooltipContent={ + selectedSingleRoundRag + ? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料" + : undefined + } + > -
- {context.mode === "flash" && } - {context.mode === "thinking" && ( - - )} - {context.mode === "pro" && ( - - )} - {context.mode === "ultra" && ( - - )} -
+ > +
+ {selectedSingleRoundRag ? ( + + ) : context.mode === "flash" ? ( + + ) : context.mode === "thinking" ? ( + + ) : context.mode === "pro" ? ( + + ) : context.mode === "ultra" ? ( + + ) : null} +
- {(context.mode === "flash" && t.inputBox.flashMode) || - (context.mode === "thinking" && - t.inputBox.reasoningMode) || - (context.mode === "pro" && t.inputBox.proMode) || - (context.mode === "ultra" && t.inputBox.ultraMode)} -
+ {selectedSingleRoundRag + ? "单轮 RAG" + : (context.mode === "flash" && t.inputBox.flashMode) || + (context.mode === "thinking" && + t.inputBox.reasoningMode) || + (context.mode === "pro" && t.inputBox.proMode) || + (context.mode === "ultra" && t.inputBox.ultraMode)} +
@@ -842,11 +897,11 @@ export function InputBox({ {t.inputBox.mode} - handleModeSelect("flash")} > @@ -854,9 +909,9 @@ export function InputBox({
{t.inputBox.flashMode} @@ -865,7 +920,7 @@ export function InputBox({ {t.inputBox.flashModeDescription}
- {context.mode === "flash" ? ( + {!selectedSingleRoundRag && context.mode === "flash" ? ( ) : (
@@ -873,9 +928,9 @@ export function InputBox({ {supportThinking && ( handleModeSelect("thinking")} @@ -884,9 +939,9 @@ export function InputBox({
{t.inputBox.reasoningMode} @@ -895,7 +950,7 @@ export function InputBox({ {t.inputBox.reasoningModeDescription}
- {context.mode === "thinking" ? ( + {!selectedSingleRoundRag && context.mode === "thinking" ? ( ) : (
@@ -903,8 +958,8 @@ export function InputBox({ )} @@ -925,15 +980,15 @@ export function InputBox({ {t.inputBox.proModeDescription}
- {context.mode === "pro" ? ( + {!selectedSingleRoundRag && context.mode === "pro" ? ( ) : (
)}
{t.inputBox.ultraMode} @@ -959,13 +1018,46 @@ export function InputBox({ {t.inputBox.ultraModeDescription}
- {context.mode === "ultra" ? ( + {!selectedSingleRoundRag && context.mode === "ultra" ? ( ) : (
)} - - + + {extraModels.length > 0 && ( + <> + + +
+
+ + 单轮 RAG 问答 +
+
+ 基于知识库检索生成答案,并展示思考内容与参考资料 +
+
+ {selectedSingleRoundRag ? ( + + ) : ( +
+ )} + + + )} + @@ -1151,7 +1243,7 @@ export function InputBox({ - {models.map((m) => ( + {modelOptions.map((m) => (
{m.display_name} - - {m.model} - + + {selectedSingleRoundRag + ? `单轮知识库问答 · ${m.name}` + : m.model} +
{m.name === context.model_name ? ( @@ -1169,8 +1263,8 @@ export function InputBox({
)} - ))} - + ))} + (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), - [ragModeEnabled, skills], + const ragModeEnabled = + settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); + const hasSingleRoundRag = useMemo( + () => messages.some(isSingleRoundRagMessage), + [messages], + ); + const referencesEnabled = ragModeEnabled || hasSingleRoundRag; + const { skills } = useSkills(); + const skillDisplayMap = useMemo( + () => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()), + [referencesEnabled, skills], ); // Same per-turn reference batches the right-side panel renders (backend // batches preferred). Inline [n] tokens must resolve against these so badge @@ -373,7 +379,7 @@ export function MessageList({ if (group.type === "human" || group.type === "assistant") { return group.messages.map((msg) => { const isAssistantMessage = - ragModeEnabled && + referencesEnabled && group.type === "assistant" && msg.type === "ai"; const batchSources = isAssistantMessage diff --git a/frontend-web/src/components/workspace/mode-hover-guide.tsx b/frontend-web/src/components/workspace/mode-hover-guide.tsx index 6a76eb5a..bb9bd24d 100644 --- a/frontend-web/src/components/workspace/mode-hover-guide.tsx +++ b/frontend-web/src/components/workspace/mode-hover-guide.tsx @@ -46,20 +46,23 @@ function getModeDescriptionKey( } } -export function ModeHoverGuide({ - mode, - children, - showTitle = true, -}: { - mode: AgentMode; - children: React.ReactNode; - /** When true, tooltip shows "ModeName: Description". When false, only description. */ - showTitle?: boolean; -}) { - const { t } = useI18n(); - const label = t.inputBox[getModeLabelKey(mode)]; - const description = t.inputBox[getModeDescriptionKey(mode)]; - const content = showTitle ? `${label}: ${description}` : description; +export function ModeHoverGuide({ + mode, + children, + showTitle = true, + tooltipContent, +}: { + mode: AgentMode; + children: React.ReactNode; + /** When true, tooltip shows "ModeName: Description". When false, only description. */ + showTitle?: boolean; + /** Overrides the standard mode tooltip for page-specific execution modes. */ + tooltipContent?: string; +}) { + const { t } = useI18n(); + const label = t.inputBox[getModeLabelKey(mode)]; + const description = t.inputBox[getModeDescriptionKey(mode)]; + const content = tooltipContent ?? (showTitle ? `${label}: ${description}` : description); return {children}; } diff --git a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx index 4f6a3e7d..42f92a24 100644 --- a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx @@ -71,8 +71,9 @@ import { useTheme } from "@/contexts/ThemeContext"; import { getStoredAuth } from "@/core/auth"; import { useI18n } from "@/core/i18n/hooks"; import type { ValidateSkillUploadResponse } from "@/core/skills/api"; -import { - useDeleteCustomSkill, +import { + useDeleteBuiltinSkill, + useDeleteCustomSkill, useDuplicateSkill, useGenerateMissingSkillNameZh, useGenerateSkillNameZh, @@ -616,9 +617,10 @@ export function SkillRow({ const { data: cardPublishSquares } = usePublishSquares(); const setPinned = useSetSkillPinned(); const setFeatured = useSetSkillFeatured(); - const setAlwaysOn = useSetSkillAlwaysOn(); - const { data: compression } = useSkillCompressionSettings(); - const deleteSkill = useDeleteCustomSkill(); + const setAlwaysOn = useSetSkillAlwaysOn(); + const { data: compression } = useSkillCompressionSettings(); + const deleteCustom = useDeleteCustomSkill(); + const deleteBuiltin = useDeleteBuiltinSkill(); const duplicateSkillMutation = useDuplicateSkill(); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [tagPickerOpen, setTagPickerOpen] = useState(false); @@ -634,8 +636,9 @@ export function SkillRow({ const isOwned = isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId; const isFromOthers = isCustom && !isBuiltin && !isOwned; - // Admin can edit/delete/unpublish anyone's skill; non-admin only their own. - const canManage = isCustom && (isOwned || isAdmin); + // Admin can manage custom skills from any owner; non-admin only their own. + const canManage = isCustom && (isOwned || isAdmin); + const canDelete = isAdmin || isOwned; const canTogglePublish = canManage && !isBuiltin; // 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。 const canSetAlwaysOn = isAdmin || isOwned; @@ -686,11 +689,15 @@ export function SkillRow({ } } - async function handleDelete() { - try { - await deleteSkill.mutateAsync(skill.name); - toast.success(t.settings.skills.deleteSuccess); - setConfirmDeleteOpen(false); + async function handleDelete() { + try { + if (isBuiltin) { + await deleteBuiltin.mutateAsync(skill.name); + } else { + await deleteCustom.mutateAsync(skill.name); + } + toast.success(t.settings.skills.deleteSuccess); + setConfirmDeleteOpen(false); } catch (err) { toast.error(err instanceof Error ? err.message : String(err)); } @@ -918,7 +925,7 @@ export function SkillRow({ > - {canManage && ( + {canManage && ( )} - {canManage && ( - - {canManage && ( - diff --git a/frontend-web/src/components/workspace/skills/skill-file-preview-dialog.tsx b/frontend-web/src/components/workspace/skills/skill-file-preview-dialog.tsx index 000aa9ae..d9a75f45 100644 --- a/frontend-web/src/components/workspace/skills/skill-file-preview-dialog.tsx +++ b/frontend-web/src/components/workspace/skills/skill-file-preview-dialog.tsx @@ -5,6 +5,7 @@ import { DownloadIcon, Loader2, PencilIcon, + Trash2Icon, XIcon, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; @@ -22,6 +23,7 @@ import { Textarea } from "@/components/ui/textarea"; import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; import { getSkillFileDownloadURL } from "@/core/skills/api"; import { + useDeleteSkillFile, useSaveSkillFile, useSkillFileContent, } from "@/core/skills/hooks"; @@ -66,6 +68,7 @@ export function SkillFilePreviewDialog({ open ? path : null, ); const save = useSaveSkillFile(); + const remove = useDeleteSkillFile(); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(""); @@ -77,6 +80,8 @@ export function SkillFilePreviewDialog({ }, [path, open]); const fileEditable = !!data?.editable && treeEditable; + const isRootSkillFile = path?.replace(/\\/g, "/").toLowerCase() === "skill.md"; + const fileDeletable = !!data && treeEditable && !isRootSkillFile; const isMarkdown = useMemo(() => { if (!path) return false; const lower = path.toLowerCase(); @@ -115,6 +120,18 @@ export function SkillFilePreviewDialog({ } } + async function handleDelete() { + if (!skillName || !path) return; + if (!window.confirm(tx.filesDeleteConfirm.replace("{path}", path))) return; + try { + await remove.mutateAsync({ skillName, path }); + toast.success(tx.filesDeleteSuccess); + onOpenChange(false); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } + async function handleCopyPath() { if (!path) return; await navigator.clipboard.writeText(path); @@ -158,6 +175,18 @@ export function SkillFilePreviewDialog({ {tx.filesEditAction} )} + {fileDeletable && !editing && ( + + )} {editing && ( <>