Compare commits

..
Author SHA1 Message Date
wxq21 6bc78e5892 test: add codex test content 2026-08-13 10:57:25 +08:00
Administrator e189349eeb fix: sync llmwiki embed theme 2026-08-13 10:25:49 +08:00
吉鑫升 c4fd9dd7da Merge remote-tracking branch 'origin/master' into ji
# Conflicts:
#	.gitignore
#	frontend-web/src/components/workspace/input-box.tsx
#	frontend-web/src/core/threads/types.ts
#	frontend-web/src/pages/ChatPage.tsx
2026-08-12 15:55:51 +08:00
吉鑫升 b4d076a2d4 fix: harden WeKnora knowledge permissions and details 2026-08-12 15:45:28 +08:00
董宏凯 bc82072710 Merge branch 'master-nj' into 'master'
feat: 管理员可删除内置技能并管理技能内文件

See merge request knowledge-base-bj/deerflow-code!21
2026-08-12 07:08:19 +00:00
董宏凯andCursor bf023d746a feat: 管理员可删除内置技能并管理技能内文件
新增内置技能删除与技能文件删除 API,允许管理员编辑/删除内置技能文件;技能设置页与文件预览弹窗同步支持删除操作。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 15:05:29 +08:00
董宏凯 fb5024ea82 Merge branch 'master-nj' into 'master'
feat: 模型配置支持删除,岗位行动规划对接任务系统字典与批量新增 Skill

See merge request knowledge-base-bj/deerflow-code!20
2026-08-12 06:48:24 +00:00
董宏凯andCursor c9c2dc7ea8 feat: 模型配置支持删除,岗位行动规划对接任务系统字典与批量新增 Skill
模型管理新增 DELETE 接口与前端删除能力(支持含斜杠的模型名);行动规划改为 9 字段 JSON,prepare 传入 taskId/字典并绑定 position-roundtable-action-create Skill。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 14:47:14 +08:00
董宏凯 2a869f4ff6 Merge branch 'master-nj' into 'master'
feat: 添加管理员模型配置页面与 config.yaml 模型管理 API

See merge request knowledge-base-bj/deerflow-code!19
2026-08-12 06:44:18 +00:00
fengxiangdebaozi af509eb670 Merge branch 'master-nj' of http://192.168.0.200/knowledge-base-bj/deerflow-code into master-nj 2026-08-12 14:32:57 +08:00
fengxiangdebaozi 783e698b6e 代码提交 2026-08-12 14:32:47 +08:00
董宏凯andCursor ba912acf72 feat: 添加管理员模型配置页面与 config.yaml 模型管理 API
新增 /api/admin/models 路由(读写 config.yaml models 段、连接测试、写前备份),以及工作区「模型配置」管理页与侧栏入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 21:45:56 +08:00
吉鑫升 e0c09f6704 feat: integrate WeKnora llmwiki knowledge flow 2026-08-11 18:13:30 +08:00
董宏凯 17a12bb7b6 Merge branch 'master-nj' into 'master'
feat: 岗位会商独立意图智能体与四项意图 schema

See merge request knowledge-base-bj/deerflow-code!18
2026-08-11 05:00:55 +00:00
董宏凯andCursor f8757d1a43 feat: 岗位会商独立意图智能体与四项意图 schema
岗位会商 Step 1 改用 position-roundtable-intent 智能体,输出 coreGoals/riskWarnings/keyPoints/strategicSignificance 四项数组;支持单轮多张协助卡、按轮次计澄清上限,并兼容旧会话 objective/constraints 映射。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 13:00:16 +08:00
董宏凯 69b95f0d6d Merge branch 'master-nj' into 'master'
feat(frontend): 岗位会商页面布局与意图理解交互优化

See merge request knowledge-base-bj/deerflow-code!17
2026-08-11 04:59:37 +00:00
董宏凯 ca32a22c0d 代码提交 2026-08-10 15:11:47 +08:00
董宏凯 3eb307c5c5 代码提交 2026-08-10 14:26:49 +08:00
董宏凯 49af497085 代码修改 2026-08-05 21:15:36 +08:00
董宏凯andCursor 8b732b43ee fix(frontend): 同路径二次 write_file 时重挂岗位会商沙箱
按 tool-call 身份区分预览,避免同路径重写复用旧 previewKey 导致沙箱不刷新。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 00:54:15 +08:00
董宏凯andCursor 69e455274a fix(frontend): 岗位会商沙箱在 write_file 流式阶段实时打开并刷新
content 先于 path 到达时用临时文件名开沙箱,流式快照改走外部 store 避免整页重渲染卡顿。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 00:51:38 +08:00
董宏凯andCursor e939dc4548 feat(frontend): 岗位会商页面布局与意图理解交互优化
完善 PositionRoundtable 样式与输入组件,补充 SVG 资源,并同步 Step1 意图相关逻辑。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 21:52:54 +08:00
董宏凯 1ffead7ee2 岗位会商样式修改 2026-08-02 15:33:26 +08:00
董宏凯 a0a75a9964 Merge branch 'dhk-1' into 'master'
fix: avoid MySQL thread_meta refresh races and harden roundtable bootstrap

See merge request knowledge-base-bj/deerflow-code!16
2026-07-31 13:11:29 +00:00
董宏凯 37964d1f95 代码修改 2026-07-31 21:09:54 +08:00
董宏凯andCursor a9d66536c3 fix: avoid MySQL thread_meta refresh races and harden roundtable bootstrap
Skip post-commit ORM refresh on threads_meta creates so concurrent MySQL
read/write-split deployments no longer fail with Could not refresh instance;
clean up partial roundtable threads on checkpoint failure, and ship related
position-roundtable/frontend build fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 21:00:56 +08:00
董宏凯 a968c3b5cd Merge branch 'dhk-1' into 'master'
代码修改

See merge request knowledge-base-bj/deerflow-code!15
2026-07-29 14:27:46 +00:00
董宏凯 e52261fc3d fix: preserve full chat embed mode navigation 2026-07-29 22:25:42 +08:00
董宏凯 4450f342d8 页面嵌套修改 2026-07-29 21:53:32 +08:00
董宏凯 213dd841b5 Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-code into dhk-1 2026-07-28 19:18:52 +08:00
董宏凯 816c46d447 代码提交 2026-07-28 19:17:40 +08:00
g zy db857ddd4d Merge branch 'gzy_dev_new' into 'master'
fix: fix iframe agent selector width

See merge request knowledge-base-bj/deerflow-code!14
2026-07-28 03:30:12 +00:00
g zy e475c37f1d Merge branch 'gzy_dev_new' into 'master'
Gzy dev new

See merge request knowledge-base-bj/deerflow-code!13
2026-07-28 03:14:47 +00:00
董宏凯 151ecceb85 feat: 更新主题配置与系统设置 2026-07-27 21:31:09 +08:00
董宏凯 1555d5344b feat: 优化8BF业务链流程图与深色配置弹窗 2026-07-27 21:30:57 +08:00
329 changed files with 24413 additions and 1891 deletions

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
import os
import paramiko
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(
"119.254.86.251",
port=22322,
username="root",
password=os.environ["TARGET_SSH_PASSWORD"],
timeout=15,
auth_timeout=15,
banner_timeout=15,
)
_, stdout, stderr = client.exec_command(os.environ["TARGET_SSH_COMMAND"], timeout=30)
print(stdout.read().decode(errors="replace"), end="")
print(stderr.read().decode(errors="replace"), end="")
client.close()
+35
View File
@@ -0,0 +1,35 @@
# Never send source-control history, local dependencies, runtime state, or
# credentials to the Docker daemon. The offline images are built from the
# repository root by deploy/1panel-offline/Dockerfile.*.
.git
.runtime
.history
tmp
dist
# Credentials and environment-specific configuration are supplied at runtime
# through the offline delivery package, never copied into an image layer.
**/.env
**/.env.*
offline-backend-20260512/backend/.deer-flow
offline-backend-20260512/backend/logs
offline-backend-20260512/backend/docker-data
offline-backend-20260512/backend/dist
offline-backend-20260512/backend/wheelhouse
offline-backend-20260512/backend/offline-wheels
offline-backend-20260512/backend/.venv
offline-backend-20260512/backend/.pytest_cache
offline-backend-20260512/backend/.ruff_cache
# Reproducibly install frontend dependencies inside the image instead.
frontend-web/node_modules
frontend-web/dist
frontend-web/.vite
frontend-web/.offline-pack-build
frontend-web/.offline-extract-test
frontend-web/node_modules-offline-*.tar.gz
# Editor and operating-system metadata.
**/__pycache__
**/*.py[cod]
**/.DS_Store
+25 -14
View File
@@ -1,6 +1,9 @@
# Workspace/editor # Workspace/editor
.vscode/ .vscode/*
frontend-web/.vscode/ !.vscode/launch.json
!.vscode/tasks.json
!.vscode/extensions.json
frontend-web/.vscode/
node_modules/ node_modules/
# Claude Code local-only settings (per-user permissions, not shared) # Claude Code local-only settings (per-user permissions, not shared)
.claude/settings.local.json .claude/settings.local.json
@@ -52,8 +55,9 @@ offline-backend-20260505/bundle/start_backend.sh
offline-backend-20260505/bundle/stop_backend.sh offline-backend-20260505/bundle/stop_backend.sh
# offline-backend-20260512 runtime artifacts (large files, local config, data) # offline-backend-20260512 runtime artifacts (large files, local config, data)
offline-backend-20260512/.env.example offline-backend-20260512/.env.example
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md offline-backend-20260512/.env
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
offline-backend-20260512/SHA256SUMS offline-backend-20260512/SHA256SUMS
offline-backend-20260512/config.yaml.example offline-backend-20260512/config.yaml.example
offline-backend-20260512/data/ offline-backend-20260512/data/
@@ -61,12 +65,14 @@ offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
offline-backend-20260512/extensions_config.json offline-backend-20260512/extensions_config.json
offline-backend-20260512/skills/ offline-backend-20260512/skills/
offline-backend-20260512/wheelhouse/ offline-backend-20260512/wheelhouse/
offline-backend-20260512/backend/.deer-flow/data/*.db offline-backend-20260512/backend/.deer-flow/data/*.db
offline-backend-20260512/backend/.deer-flow/data/*.db-* offline-backend-20260512/backend/.deer-flow/data/*.db-*
offline-backend-20260512/backend/.deer-flow/* offline-backend-20260512/backend/.deer-flow/*
offline-backend-20260512/start_backend.sh offline-backend-20260512/backend/.env
offline-backend-20260512/stop_backend.sh offline-backend-20260512/backend/.env.local
offline-backend-20260512/check_backend.sh offline-backend-20260512/start_backend.sh
offline-backend-20260512/stop_backend.sh
offline-backend-20260512/check_backend.sh
hermes-agent-main/ hermes-agent-main/
@@ -77,11 +83,16 @@ offline-backend-20260512/backend/.deer-flow/*
# Per-user skill_evolution overrides — runtime data written by the API # Per-user skill_evolution overrides — runtime data written by the API
offline-backend-20260512/backend/skill_evolution.json offline-backend-20260512/backend/skill_evolution.json
skills/ skills/
hermes-agent-main/ hermes-agent-main/
.gitignore .gitignore
offline-backend-20260512/backend/tests/* offline-backend-20260512/backend/tests/*
deploy !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 .history
# Cloned third-party repos (local only) # Cloned third-party repos (local only)
+6
View File
@@ -0,0 +1,6 @@
{
"recommendations": [
"ms-python.python",
"ms-python.debugpy"
]
}
+66
View File
@@ -0,0 +1,66 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "DeerFlow Backend FastAPI (8001)",
"type": "debugpy",
"request": "launch",
"module": "uvicorn",
"args": [
"app.gateway.app:app",
"--host",
"0.0.0.0",
"--port",
"8001",
"--reload"
],
"cwd": "${workspaceFolder}/offline-backend-20260512/backend",
"python": "${workspaceFolder}/offline-backend-20260512/backend/.venv/Scripts/python.exe",
"envFile": "${workspaceFolder}/offline-backend-20260512/.env",
"env": {
"PYTHONPATH": "${workspaceFolder}/offline-backend-20260512/backend;${workspaceFolder}/offline-backend-20260512/backend/.deer-flow/python-vendor",
"GATEWAY_CORS_ORIGINS": "http://localhost:5174,http://127.0.0.1:5174,http://localhost:8080,http://127.0.0.1:8080"
},
"console": "integratedTerminal",
"justMyCode": false,
"subProcess": true
},
{
"name": "DeerFlow Frontend Vite (5174)",
"type": "node",
"request": "launch",
"runtimeExecutable": "pnpm",
"runtimeArgs": [
"exec",
"vite",
"--host",
"0.0.0.0",
"--port",
"5174"
],
"cwd": "${workspaceFolder}/frontend-web",
"env": {
"VITE_BACKEND_BASE_URL": "http://127.0.0.1:8001",
"VITE_LANGGRAPH_BASE_URL": "http://127.0.0.1:8001/api"
},
"console": "integratedTerminal",
"outputCapture": "std",
"serverReadyAction": {
"pattern": "Local:\\s+(https?://[^\\s]+)",
"uriFormat": "%s",
"action": "debugWithChrome",
"webRoot": "${workspaceFolder}/frontend-web"
}
}
],
"compounds": [
{
"name": "DeerFlow Full Stack (Backend + Frontend)",
"configurations": [
"DeerFlow Backend FastAPI (8001)",
"DeerFlow Frontend Vite (5174)"
],
"stopAll": true
}
]
}
+52
View File
@@ -0,0 +1,52 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "DeerFlow:检查本地开发环境",
"type": "shell",
"command": "$backendPython = '${workspaceFolder}\\offline-backend-20260512\\backend\\.venv\\Scripts\\python.exe'; if (-not (Test-Path -LiteralPath $backendPython)) { throw '后端虚拟环境不存在,请先在 backend 目录执行 uv sync' }; & $backendPython -c \"import uvicorn; print('Backend Python OK')\"; pnpm --dir '${workspaceFolder}\\frontend-web' exec vite --version",
"options": {
"shell": {
"executable": "powershell.exe",
"args": [
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command"
]
}
},
"problemMatcher": []
},
{
"label": "DeerFlow:前端生产构建",
"type": "process",
"command": "pnpm",
"args": [
"build"
],
"options": {
"cwd": "${workspaceFolder}/frontend-web"
},
"problemMatcher": []
},
{
"label": "DeerFlow:WeKnora 专项测试",
"type": "process",
"command": "${workspaceFolder}/offline-backend-20260512/backend/.venv/Scripts/python.exe",
"args": [
"-m",
"pytest",
"tests/test_llmwiki_weknora.py",
"-v"
],
"options": {
"cwd": "${workspaceFolder}/offline-backend-20260512/backend",
"env": {
"PYTHONPATH": "."
}
},
"problemMatcher": []
}
]
}
+3
View File
@@ -0,0 +1,3 @@
# Codex Test Content
This file is a small test-only change created to verify branch and commit workflow.
@@ -0,0 +1,15 @@
# 岗位会商沙箱文档
本目录存放**岗位会商文件预览沙箱**的对接与排障说明,独立于 `src/position-roundtable/docs/`,避免与模块内其它文档互相覆盖。
| 文档 | 说明 |
| --- | --- |
| [沙箱打开关闭与步骤条.md](./沙箱打开关闭与步骤条.md) | **详细版**:打开/关闭完整时序、步骤条关系、对接接口字段说明、必守规则、历史 bug 根因、排障手册、自测清单、接线示例与代码索引 |
模块总览仍见:`frontend-web/src/position-roundtable/README.md`。
建议读法:
- **从零对接新页面**:正文第 3、5、6、7、8、12、15、16 节
- **线上排障**:第 13、14 节
- **只要一页纸**:文末「附录 A」
@@ -0,0 +1,988 @@
# 岗位会商 · 沙箱打开 / 关闭与步骤条(详细对接与排障指南)
> **文档位置**:`frontend-web/docs/position-roundtable-sandbox/`
> **读者**:下次重做或对接岗位会商页面的同事;线上「沙箱不实时 / 卡死 / 第二次不刷新」时的排障同学。
> **最后依据**:当前仓库 `PositionRoundtablePage` + `PositionNodeChat` 实现(含临时名打开、外部 store、前沿节流、按 tool-call 重挂)。
---
## 目录
1. [文档用途与读法](#1-文档用途与读法)
2. [背景:为什么岗位会商要单独做沙箱](#2-背景为什么岗位会商要单独做沙箱)
3. [用户可见的正确行为(验收标准)](#3-用户可见的正确行为验收标准)
4. [整体架构与数据流](#4-整体架构与数据流)
5. [职责划分:谁拥有什么状态](#5-职责划分谁拥有什么状态)
6. [对接接口详解](#6-对接接口详解)
7. [打开沙箱的完整步骤](#7-打开沙箱的完整步骤)
8. [关闭沙箱的完整步骤](#8-关闭沙箱的完整步骤)
9. [步骤条与沙箱的关系](#9-步骤条与沙箱的关系)
10. [流式内容如何进入沙箱(性能关键)](#10-流式内容如何进入沙箱性能关键)
11. [与普通对话页的对照](#11-与普通对话页的对照)
12. [重做页面时的必守规则](#12-重做页面时的必守规则)
13. [历史问题与根因(务必读)](#13-历史问题与根因务必读)
14. [排障手册(按症状)](#14-排障手册按症状)
15. [对接自测清单](#15-对接自测清单)
16. [最小接线示例](#16-最小接线示例)
17. [关键代码索引](#17-关键代码索引)
18. [术语表](#18-术语表)
---
## 1. 文档用途与读法
### 1.1 这份文档解决什么问题
岗位会商右侧「文件预览沙箱」看起来像普通对话页的产物预览,但实现路径完全不同。历史上这块反复出过:
- 沙箱不实时打开,要等全文写完才弹;
- 打开了但内容冻住,生成完才一口气灌进去;
- 整页卡顿,连消息列表文字都不实时;
- 同轮「重新生成」第二次写入时,步骤条变了,沙箱却不动;
- 第一次正常,关掉后再问就不实时。
这些 bug **特别容易在「重做页面 / 换布局 / 自己重写开沙箱逻辑」时复现**。本文把正确对接方式、时序、接口、禁区和排障步骤写全,方便下一位同事直接按文档接线,而不是再靠猜。
### 1.2 建议读法
| 你的目标 | 先读哪些章节 |
| --- | --- |
| 从零接一个新页面壳 | 第 3、5、6、7、8、12、15、16 节 |
| 只改对话子组件探测逻辑 | 第 7、9、12、13 节 |
| 线上排障 | 第 13、14 节,必要时回看第 10 节 |
| 理解为什么不能用页面 state | 第 2、10、13 节 |
### 1.3 对照代码(以仓库为准)
| 角色 | 路径 |
| --- | --- |
| 页面壳(唯一沙箱宿主) | `frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx` |
| 节点对话(探测 write_file、上报预览) | `frontend-web/src/position-roundtable/components/PositionNodeChat.tsx` |
| 收口对话 | `frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx` |
| 预览 UI(共享组件,勿分叉逻辑) | `frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx` |
| 流式正文读取 | `frontend-web/src/core/artifacts/loader.ts` → `loadArtifactContentFromToolCall` |
| 普通对话页对照(步骤条自动开) | `frontend-web/src/components/workspace/messages/message-group.tsx` |
| 模块总览 | `frontend-web/src/position-roundtable/README.md` |
---
## 2. 背景:为什么岗位会商要单独做沙箱
### 2.1 普通对话页怎么开沙箱
普通对话页(`ChatPage`)大致是:
```text
ArtifactsProvider(页面级)
└─ ChatBox / MessageList
└─ message-group 渲染 write_file 步骤
└─ 发现 path 且 autoOpen → artifacts.select + setOpen(true)
└─ 右侧 ArtifactFileDetail 读同一套 ThreadContext
```
要点:
- 对话和预览共享同一套 `ThreadContext`(同一个 `useThreadStream`)。
- 步骤条组件自己就能触发打开。
- 流式 `content` 本来就在当前 React 树的 thread 里,不需要「把 thread 镜像到别的地方」。
### 2.2 岗位会商为什么不能照搬
岗位会商页面同时有:
- 左侧任务 / 意图 / 流程图;
- 中间某个节点的对话(`PositionNodeChat`);
- 右侧配置抽屉;
- **以及一个页面级单例沙箱**,它是对话区的**兄弟节点**,不在 `PositionNodeChat` 的 ThreadContext 里面。
因此:
1. 沙箱拿不到节点对话内部的 `thread`,除非节点主动上报;
2. 若把每次流式 thread 写进**页面级 `useState`**,整页(任务卡、意图、流程图、对话)都会按流式频率重渲染,主线程会被打爆;
3. 页面必须自己决定「开 / 关 / 抑制自动再开 / 切节点时清理」,不能让多个子组件各自 `setOpen`。
所以岗位会商采用:
```text
对话子组件探测 + 上报
→ 页面壳统一打开唯一 ArtifactFileDetail
→ 流式 thread 走外部 store,只让沙箱子树订阅
```
### 2.3 和「多智能体会商 Step2 沙箱」的区别
仓库里还有 `frontend-web/docs/roundtable-step2-sandbox-progress.md`,那是另一套圆桌 Step2 沙箱进度文档,**不是**岗位会商这条链路。对接岗位会商时以本文为准。
---
## 3. 用户可见的正确行为(验收标准)
重做或对接完成后,下列行为必须全部成立(用户感知对齐普通对话页):
| # | 行为 | 说明 |
| --- | --- | --- |
| A | 步骤条刚出现「写入文件」时,沙箱应已打开 | **不必等**步骤条上出现文件名 |
| B | 沙箱源码边写边滚;消息列表也实时出字 | 不能整页卡死、不能「生成完一口气刷」 |
| C | content 先于 path 时:先临时名打开,path 到了再改名 | 不闪屏、不重挂 |
| D | 同轮第二次同路径 `write_file`(重新生成) | 沙箱清空并重新流式 |
| E | 整轮结束后自动切 Markdown 预览 | 「正在生成,完成后自动预览」消失 |
| F | 用户手动关闭后,同一次写入不再自动弹 | 下一轮新写入仍可自动开 |
| G | 切岗位 / 切节点 / 新会话 / 切历史 | 沙箱关掉且不串内容 |
**不要误判的正常现象:**
- 流式期间沙箱停在「源码 / 编辑」视图,结束后才切「预览」——这是 `ArtifactFileDetail` 对 markdown/html 的既定行为,与普通对话页相同,**不是**打开时机 bug。
- 步骤条长时间显示「写入文件」、没有路径,但沙箱已经打开并在滚动——content-first 模型下这是**预期**。
---
## 4. 整体架构与数据流
```text
┌──────────────────────────────────────────────────────────────────────────┐
│ PositionRoundtablePage(页面壳) │
│ │
│ state: sandboxArtifact | null │
│ ref: sandboxArtifactRef / closedSandboxKeyRef / latestSnapshotRef │
│ store: createSandboxThreadStore() ←── 禁止改成 useState │
│ │
│ handlePreviewArtifact(artifact) ←──────────────────────────────────┐ │
│ handleNodeThreadSnapshot(...) ←───────────────────────────────┐ │ │
│ closeSandbox() │ │ │
│ │ │ │
│ {sandboxOpen && ( │ │ │
│ SandboxThreadProvider(store) │ │ │
│ └─ ArtifactFileDetail key=sandboxCloseKey(...) │ │ │
│ )} │ │ │
└───────────────────────────────────────────────────────────────────│──│───┘
│ │
┌───────────────────────────────────────────────────────────────────│──│───┐
│ PositionNodeChat / PositionBuiltinAgentChat │ │ │
│ │ │ │
│ useThreadStream → thread.messages / thread.isLoading │ │ │
│ │ │ │ │
│ ├─ MessageList → 步骤条(只展示 tool call) │ │ │
│ │ │ │ │
│ ├─ latestStreamingWriteFile(messages) │ │ │
│ │ → onPreviewArtifact({..., auto:true}) ───────────────────┘ │
│ │ │
│ └─ useEffect → onThreadSnapshot({threadId, thread}) ───────────────┘
└──────────────────────────────────────────────────────────────────────────┘
```
一句话:
- **谁打开**:对话子组件决定「该不该开、开哪次交付」;
- **谁真正开面板**:页面壳;
- **内容怎么更新**:子组件持续上报 thread 快照 → 页面壳 store → 只有沙箱子树重渲染。
---
## 5. 职责划分:谁拥有什么状态
### 5.1 页面壳必须拥有
| 状态 / 能力 | 为什么必须在页面壳 |
| --- | --- |
| `sandboxArtifact` | 唯一预览目标;切节点时要清 |
| `closedSandboxKeyRef` | 用户关过后的抑制;跨子组件生命周期 |
| `sandboxThreadStore` | 流式内容通道;不能进大页面 state |
| `closeSandbox()` | 切岗位 / 切节点 / 新会话统一入口 |
| 唯一 `ArtifactFileDetail` | 避免双实例、关闭失效、深度异常 |
### 5.2 对话子组件必须拥有
| 状态 / 能力 | 作用 |
| --- | --- |
| `autoPreviewRunArmedRef`(armed) | 只有本轮用户发送后才允许自动开 |
| `autoPreviewBaselineCallKeysRef`(baseline) | 忽略发送前已有的历史 write_file |
| `activeWriteFilePreviewRef` | 记录当前打开的 previewKey / path / toolCallId |
| `autoPreviewedArtifactKeyRef` | 同一次 identity 去重,避免每 chunk 重复上报 |
| `latestStreamingWriteFile` | 从 streaming messages 探测可预览的 write |
| `onThreadSnapshot` 上报 | 把 thread 喂给页面壳 |
### 5.3 明确禁止
1. 在 `PositionNodeChat` 内再挂一套 `ArtifactFileDetail`。
2. 让步骤条组件直接 `setOpen(true)` 作为岗位会商主路径(对照普通页可以,岗位会商不行)。
3. 把 `onThreadSnapshot` 落到 `PositionRoundtablePage` 的 `useState`。
4. 用「整个 threadId」做关闭抑制。
5. 用「path 相同」复用 `previewKey`。
---
## 6. 对接接口详解
### 6.1 `PositionArtifactPreview`
```ts
type PositionArtifactPreview = {
threadId: string; // 必填:LangGraph thread id
path: string; // 必填:真实路径,或 write-file: 虚拟 URL,或临时文件名路径
name: string; // 展示名(文件名)
mimeType: string | null; // 如 text/markdown;帮助预览语言判断
previewKey?: string; // 强烈建议:一次交付身份,推荐 `${messageId}:${toolCallId}`
auto?: boolean; // true = 流式自动打开;缺省/false = 用户点击打开
};
```
字段说明:
| 字段 | 详细含义 |
| --- | --- |
| `threadId` | 沙箱用来匹配 `onThreadSnapshot` 的线程;也对关闭抑制键有贡献 |
| `path` | 传给 `ArtifactFileDetail.filepath`。流式阶段通常是 `write-file:...` 虚拟 URL;落盘后的手动打开可以是真实 artifacts 路径 |
| `name` | UI 展示用;临时名阶段可能是 `未命名文件.md` |
| `mimeType` | 当 path 还不稳定时,帮助把语言判成 markdown/html,避免落成 TEXT |
| `previewKey` | **一次交付的身份**。同一次 tool call 的 path 升级要保持不变;新的 tool call 必须变 |
| `auto` | 自动打开才走关闭抑制;手动打开应清抑制 |
### 6.2 `onPreviewArtifact(artifact)` —— 对话 → 页面
页面壳 `handlePreviewArtifact` 当前逻辑顺序:
1. 校验 `threadId`、`path`,缺则 toast 并 return。
2. 计算 `artifactKey = sandboxCloseKey(artifact)`。
3. 若 `artifact.auto === true` 且 `closedSandboxKeyRef.current === artifactKey` → **抑制,直接 return**。
4. 清 `closedSandboxKeyRef`(手动打开或新的自动打开)。
5. `ArtifactsContext.select(path, true)` + `setOpen(true)`。
6. 判断是否同一预览 / 是否仅 path upgrade:
- `isSamePreview`:threadId + path + previewKey 全同 → 不重复 setState。
- `isPathUpgrade`:previewKey 相同、path 不同 → 更新 artifact,**不要**再折叠右侧抽屉。
7. 若不是 same preview:
- `setSandboxArtifact(artifact)`;
- **同步** `sandboxArtifactRef.current = artifact`(关键!flush 会立刻读 ref);
- `sandboxSnapshotAppliedAtRef.current = 0`(允许立刻推首帧内容)。
8. 若 store 里已有别的 thread 快照,先 `publish(null)`。
9. 若 `latestNodeThreadSnapshotRef` 的 threadId 匹配,立刻 `queueSandboxThreadSnapshot`。
10. 非 path upgrade 时 `setRightPanelOpen(false)`。
`sandboxCloseKey`:
```ts
if (artifact.previewKey) {
return `${artifact.threadId}:write:${artifact.previewKey}`;
}
return `${artifact.threadId}:${normalizePreviewArtifactPath(artifact.path)}`;
```
`ArtifactFileDetail` 的 React `key` 必须用这个 key:
**新 previewKey → 组件重挂 → 视觉上「清空再生成」。**
### 6.3 `onThreadSnapshot({ threadId, thread })` —— 对话 → 页面
每次 `thread` 引用变化(消息 chunk 到达)子组件都会调:
```ts
onThreadSnapshot?.({ threadId: activeThreadId, thread });
```
页面壳应:
1. 计算 `signature = sandboxThreadSignature(thread)`(用 isLoading、messages 长度、最近 write_file 的 path/content 长度/尾部等拼签名,用于去重)。
2. 写入 `latestNodeThreadSnapshotRef`(即使沙箱还没开,打开瞬间也要用)。
3. 若当前没有打开的沙箱,或打开的沙箱 threadId 不一致 → **只更新 ref,不 publish**。
4. 若一致 → `queueSandboxThreadSnapshot`(前沿节流后 `store.publish`)。
### 6.4 虚拟 URL 与正文读取
流式阶段 path 形如:
```text
write-file:${sourcePath}?message_id=${messageId}&tool_call_id=${toolCallId}&language=markdown
```
`loadArtifactContentFromToolCall`:
1. 解析 URL 的 `message_id`、`tool_call_id`;
2. 在 `thread.messages` 里找对应 AI message 的 tool call;
3. 返回 `toolCall.args.content`。
结论:
- 沙箱跟的是 **tool call 身份**,不是磁盘文件是否已经写完;
- 没有真实 path 也能显示正文;
- 临时文件名只影响展示名和语言猜测。
### 6.5 对话子组件 → 页面壳的其它相关回调
| 回调 | 用途 |
| --- | --- |
| `onPreviewArtifact` | 打开 / 更新沙箱 |
| `onThreadSnapshot` | 推送流式 thread |
| 页面壳 `closeSandbox` 传给 `ArtifactFileDetail.onClose` | 用户点关闭 |
手动从流程图 / 产物列表打开时,也可以直接调 `handlePreviewArtifact`(通常不带 `auto`,或不带 previewKey)。
---
## 7. 打开沙箱的完整步骤
### 7.1 武装(arm):只有「本轮用户提交」才允许自动开
在 `PositionNodeChat.handleSubmit` 真正 `sendMessage` 之前:
```text
activeWriteFilePreviewRef = null
autoPreviewBaselineCallKeysRef = writeFileCallKeys(thread.messages) // 历史 write 的 messageId:toolCallId
autoPreviewRunArmedRef = true
→ sendMessage(...)
```
发送失败要把 `armed` 设回 `false`。
为什么要 baseline?
线程里往往已经有上几次问答写过的 `write_file`。如果只看「messages 里有没有 write_file」,一提问就会把旧文件再打开一次。baseline 的含义是:**只有这次提交之后新出现的 tool call 才能自动开沙箱**。
### 7.2 探测 `write_file`:不要等完整 path
`write_file` 参数是一整段 JSON 流,**键顺序由模型决定**。
| 模型吐参顺序 | 步骤条表现 | 错误做法的后果 |
| --- | --- | --- |
| path / description 先到,再 content | 很快显示描述和文件名 | 普通对话页常见;等 path 也能开 |
| **content 先到,最后才 path** | 长时间只有「写入文件」,文件名很晚才出现 | 若硬等完整 path,沙箱会晚几十秒甚至一分钟 |
岗位会商报告类 agent **经常是第二种**。
`latestStreamingWriteFile` 规则:
1. 倒序扫描 AI messages 的 `tool_calls`;
2. 工具名是 `write_file` 或 `str_replace`;
3. **有完整扩展名的 path**(`hasCompleteArtifactExtension`)→ 用真实路径;
4. **尚无可用 path,但 `write_file` 的 content 已有非空字符** → 临时文件名:
- 正文头像 HTML → `未命名文件.html`
- 否则 → `未命名文件.md`
5. `str_replace` 必须等真实 path(它改已有文件);
6. 组装:
- `key = messageId:toolCallId`
- `path = write-file:虚拟URL`(write_file)或真实 path(str_replace)
- `mimeType`:markdown 则 `text/markdown`
为什么扩展名必须完整?
流式 path 会经历 `/a/b/report.m` → `/a/b/report.md`。若在 `.m` 时就当真实文件打开,预览语言可能被永久判成 TEXT。
### 7.3 上报 effect 的四个门闩
自动上报 `onPreviewArtifact` 前必须同时满足:
1. `thread.isLoading === true`
2. 探测到 streaming artifact,且其 key **不在** baseline
3. 已有 `previewThreadId`
4. `autoPreviewRunArmedRef === true`
然后处理 previewKey:
```text
若与 activePreview 是同一次 tool call(含 tool-start 前缀兼容)
→ 复用旧 previewKey // 用于临时名 → 真实 path
否则
→ previewKey = 新的 messageId:toolCallId
```
去重键:
```text
`${previewThreadId}:${previewKey}:${sourcePath}`
```
把 `sourcePath` 算进去,是为了:**同 previewKey 下临时名变成真实 path 时,还要再报一次**(path upgrade),但不会每来一个 content chunk 都报。
### 7.4 线程绑定竞态(不要误 disarm)
首轮对话常出现:
1. 用户发送(本地 draft threadId / 稍后 SDK 创建真实 id);
2. 流已经开始、armed=true;
3. 会话 API 把 `node.threadId` 从 `null` 写成真实 id。
这是「本节点绑定自己的新线程」,**不是**切节点。若绑定 effect 无脑清空 armed / baseline,首轮会永远开不了沙箱。
正确:识别 `isOwnThreadBinding`(同 nodeKey、旧 threadId 为空、新 threadId 等于当前 boundThreadId)时 **直接 return,不清 armed**。
### 7.5 path upgrade(改名)≠ 第二次交付
同一次 tool call:
```text
T1 content 到达 → path=未命名文件.md, previewKey=msg:tc1 → 打开
T2 真实 path 到达 → path=/mnt/.../报告.md, previewKey=msg:tc1 → 只改名
```
页面壳看到相同 previewKey、不同 path → `isPathUpgrade`:
- 更新 `sandboxArtifact`;
- **不**重挂(React key 仍基于 previewKey);
- **不**再折叠右侧抽屉。
### 7.6 同路径二次写入(重新生成)
同一次问答里模型可能:
```text
1) write_file → 步骤条「写入文件」→ 打开沙箱
2) 再 write_file 同 path,description=「重新生成优化版…」
```
这是**两次 tool call**,必须两个 previewKey。
若错误地「path 相同就复用 previewKey」:
- 步骤条出现第二条;
- 沙箱 React key 不变;
- 用户看到「重新生成了,但沙箱没变化」。
正确:身份只看 tool call。
### 7.7 时序总图(content-first)
```text
T0 用户发送 → armed=true,拍 baseline
T1 步骤条出现「写入文件」(尚无 path)
T1+ args.content 首字符到达 → 临时名打开沙箱,源码开始滚动
… store 约每 160ms 推一次 thread 快照 …
T2 args.path 拼完整(含 .md)→ path upgrade,标题改真实文件名(不重挂)
T3 write 结束 / isLoading=false → 自动切 Markdown 预览
```
### 7.8 时序总图(同轮二次写入)
```text
T1 write_file#1 → previewKey=msg:tc1 → 打开沙箱 A
T2 #1 结束(可能短暂切预览)
T3 write_file#2(同 path,新 description)→ previewKey=msg:tc2
→ key 变 → 组件重挂清空 → 跟 #2 重新流式
```
---
## 8. 关闭沙箱的完整步骤
### 8.1 用户手动关闭
`closeSandbox()` 应做:
1. `closedSandboxKeyRef = sandboxCloseKey(current)` —— 抑制**这一次**自动再开;
2. 关闭 `ArtifactsContext.open`(若开着);
3. 清节流定时器、pending 快照;
4. `sandboxSnapshotAppliedAtRef = 0`;
5. `sandboxArtifactRef = null` + `setSandboxArtifact(null)`;
6. `sandboxThreadStore.publish(null)`。
抑制与 path upgrade 的关系:
- 关掉临时名面板后,同一次 tool call 升级真实 path **仍被抑制**(同一 previewKey)——符合「用户不要看这次」;
- 下一次新的 `write_file`(新 previewKey)**可以**再自动开。
### 8.2 场景切换时强制关闭
以下操作应先调用 `closeSandbox()`,避免串台:
- 切岗位;
- 切节点;
- 切收口阶段;
- 新建会话;
- 切换历史会话。
是否写入 `closedSandboxKeyRef` 可按产品决定;至少必须清面板和 store。
### 8.3 错误的抑制粒度(历史坑)
若做成「关过一次就整个 thread 不再自动开」:
- 第一次实时开正常;
- 用户关掉;
- 再问一次 → 同 thread 被永久抑制 → 看起来像「第二次不实时 / 要等生成完」。
正确粒度:**单次交付的 `sandboxCloseKey`(含 previewKey)**。
---
## 9. 步骤条与沙箱的关系
### 9.1 步骤条从哪来
步骤条由 `MessageList` → `message-group.tsx` 渲染 tool call:
- 标题:优先 `args.description`;没有则 i18n 兜底「写入文件」;
- 子行:有 `args.path` 才显示路径;
- 普通对话页还会在「有 path + autoOpen」时自己 `select/setOpen`。
岗位会商里,步骤条**仍然只负责展示**;打开沙箱走 `onPreviewArtifact`,不要让两套逻辑抢。
### 9.2 对照表
| 你看到的步骤条 | 含义 | 沙箱应处于 |
| --- | --- | --- |
| 仅「写入文件」,无路径 | tool call 已开始;path/description 可能还没流到 | **应已打开**(临时名),源码在滚 |
| 「写入文件」+ 路径 | path 已可用 | 已打开;若刚从临时名升级则只改名 |
| 带自定义 description 的标题 + 路径 | 模型给了 description | 同上 |
| 第二条「重新生成…」+ 同路径 | 新的 write_file(新 toolCallId) | **应重挂**并重新流式 |
| 顶部「正在生成,完成后自动预览」 | 仍在 code 视图 | 正常,等 isLoading 结束切预览 |
| 生成结束,出现文件卡片 / present_files | 工具结果已落 | 通常已是预览模式;可手动再点开 |
### 9.3 常见误解
**误解 1:**「步骤条出现文件名 = 打开沙箱的正确时机」
→ 错。那是 path 到达的时机。content-first 时正确打开时机更早:content 首字节 / 工具调用已开始。
**误解 2:**「步骤条还在写『写入文件』,沙箱却开了,是 bug」
→ 通常不是。两边数据源相同,但沙箱允许无 path 打开,步骤条标题可能还没 description。
**误解 3:**「等生成完从编辑切预览 = 沙箱打开晚了」
→ 错。那是 `ArtifactFileDetail` 的 viewMode 策略。
---
## 10. 流式内容如何进入沙箱(性能关键)
### 10.1 为什么不能用页面 `useState` 存线程快照
错误写法:
```ts
setSandboxThreadSnapshot(snapshot) // 页面级 useState
```
后果:
- 消息 chunk 约每 30ms 一次,节流后仍约 6 次/秒;
- 每次都重渲染整页:任务卡、意图结果、流程图、节点对话……;
- 主线程占满 → 消息列表停绘、沙箱冻住;
- 纯尾随 `setTimeout(160)` 会被饿死,十几秒不 flush。
这同时解释了两个历史现象:
1. 「沙箱打开了但内容不更新」;
2. 「修完节流后整页更卡、消息也不实时」——因为节流终于开始真正 setState 了。
### 10.2 正确做法:外部 store + 子树订阅
```ts
createSandboxThreadStore()
subscribe / getSnapshot / publish
SandboxThreadProvider
useSyncExternalStore(store.subscribe, store.getSnapshot)
→ ThreadContext.Provider value={{ thread, isMock:false }}
→ ArtifactFileDetail
```
页面壳本身**不**因 snapshot 变化而重渲染;只有 Provider 及其子树重渲染。
### 10.3 节流必须是前沿(leading-edge)
`SANDBOX_SNAPSHOT_MIN_INTERVAL_MS = 160`:
```text
queue(snapshot):
pending = snapshot
if 距上次 publish >= 160ms:
立刻 flush(同步 publish)
else:
若没有在途 timer,则 setTimeout(剩余时间) 做补尾
flush:
清 timer
校验 pending.threadId === sandboxArtifactRef.threadId
publish(pending)
```
**不要**改回:
```ts
if (timer != null) return;
timer = setTimeout(flush, 160);
```
那是纯尾随;忙主线程下会假死。
### 10.4 signature 去重
`sandboxThreadSignature` 用最近 write_file 的 path、content 长度、content 尾 32 字等拼签名。
store.publish 时若 threadId+signature 未变,则不通知订阅者,减少无效渲染。
### 10.5 打开瞬间的首帧
`handlePreviewArtifact` 里往往会立刻 `queueSandboxThreadSnapshot(latestSnapshot)`。
此时 React 的 `setSandboxArtifact` 可能还没提交,若 flush 只读旧的 `sandboxArtifactRef`,会因 threadId 不匹配把首帧丢掉。
所以打开新预览时必须:
```ts
sandboxArtifactRef.current = artifact; // 同步
sandboxSnapshotAppliedAtRef.current = 0;
```
---
## 11. 与普通对话页的对照
| 点 | 普通对话页 | 岗位会商 |
| --- | --- | --- |
| 谁触发打开 | `message-group` 内 `artifacts.select` | 子组件 `onPreviewArtifact` → 页面壳 |
| 是否要求 path | 是(有 path 才自动开) | 否(write_file + content 可临时名开) |
| thread 从哪来 | 同树 ThreadContext | `onThreadSnapshot` + 外部 store |
| 关闭抑制 | 主要靠 ArtifactsContext / 用户操作 | `closedSandboxKeyRef` 按 previewKey |
| 同 path 重写 | 依赖新的 tool call / 选择逻辑 | **必须**新 previewKey 以重挂 |
| 结束后切预览 | ArtifactFileDetail 同一套 | 同一套 |
对齐目标是**用户感知一致**,不是代码路径完全相同。
普通对话页自动开的核心条件(对照用):
```ts
artifacts && isLoading && isLast && autoOpen && autoSelect && path && !result
```
它也要求 path,所以 content-first 时普通页同样会偏晚——只是很多主聊天示例 agent 先吐 path,观感更好。岗位会商报告 agent 更常 content-first,所以必须做临时名提前开。
---
## 12. 重做页面时的必守规则
按优先级:
1. **流式 thread 快照禁止进页面级 `useState`** → 用 store + `useSyncExternalStore`。
2. **节流用前沿 + 补尾**,不要纯尾随 timer。
3. **不要等完整 path 才开**;`write_file` + content 即可临时名打开。
4. **扩展名完整前不要把 path 当最终 path**。
5. **previewKey 按 tool call,不按 path**。
6. **打开时同步写 `sandboxArtifactRef`,并重置节流时间戳**。
7. **主路径扫描 `thread.messages`,不要只靠 `onToolStart`**。
8. **path upgrade 保持 previewKey,不要当第二次交付**。
9. **关闭抑制按 sandboxCloseKey,不要按整 thread**。
10. **沙箱单例在页面壳;子组件只上报**。
11. **本节点 threadId 回写不要 disarm**。
12. **「结束后才切预览」不要当打开 bug 去「修」掉共享组件**,除非产品明确要求边生成边渲染 Markdown(需单独评估性能)。
---
## 13. 历史问题与根因(务必读)
下面每个都在真实联调里出现过。重做时若踩中,优先回来对照。
### 13.1 沙箱要等文件名出来才开(晚几十秒)
**现象:** 步骤条早就「写入文件」,沙箱很晚才开;普通对话页对比显得「实时」。
**根因:** 探测逻辑硬等完整 `path`;而该 agent 先流 `content`、最后才流 `path`。
**修复:** content 首批字符即可用临时名打开;path 到了再 upgrade。
### 13.2 沙箱打开了,内容冻住,生成完才灌满
**现象:** 面板开了,但里面一直是空/旧内容;日志里 queue 很多,flush 很少。
**根因:** 纯尾随 160ms 定时器在忙主线程下被饿死。
**修复:** 前沿节流——到期就在流回调里同步 flush。
### 13.3 修完「内容更新」后,整页卡顿、消息也不实时
**现象:** 沙箱开始更新了,但页面更卡,消息列表也「攒到最后刷」。
**根因:** flush 走的是页面 `useState`,每 160ms 整页重渲染。以前 timer 饿死时反而「不卡」。
**修复:** 快照改外部 store,只让沙箱子树订阅。
### 13.4 第二次「重新生成」步骤有了,沙箱不变
**现象:** 同轮第二条二条 write 步骤,沙箱仍显示第一次完整结果。
**根因:** 用「path 相同」复用了第一次 previewKey,React key 不变,组件不重挂。
**修复:** previewKey 只按 tool call;同 path 新 call 必须新 key。
### 13.5 第一次实时,关掉后再问就不实时
**现象:** 关过沙箱后,下一轮要等生成完才开,或不开。
**根因:** 抑制绑在整个 threadId,或把 path upgrade / 新 call 误判成同一次。
**修复:** 抑制键 = `sandboxCloseKey(previewKey)`;仅抑制那一次交付。
### 13.6 首轮永远不开
**现象:** 新建节点第一次提问,armed 后很快又不满足条件。
**根因:** `node.threadId` 从 null 回写真实 id 时,绑定 effect 误 disarm。
**修复:** `isOwnThreadBinding` 时不清 armed。
### 13.7 预览变成纯文本
**现象:** 明明是 md,却按 TEXT 打开。
**根因:** 在 `.m` 阶段就当完整 path;或临时名没有 `.md` / 没传 language、mimeType;或 path 带空格。
**修复:** `hasCompleteArtifactExtension`;临时名带扩展名;URL 加 `language=markdown`;path trim。
### 13.8 依赖 onToolStart,线上偶发不开
**现象:** 本地偶发正常,联调经常不开。
**根因:** 后端 StreamBridge / worker 可能过滤 tools/events 类 stream mode。
**修复:** 主路径必须是 messages 里渐进出现的 `tool_calls`;onToolStart 只能当补充。
---
## 14. 排障手册(按症状)
排障时建议先看 Chrome Performance / React 重渲染是否整页抖,再看消息里 tool_calls 的 args 是 path-first 还是 content-first。
### 14.1 沙箱开得很晚
检查顺序:
1. `latestStreamingWriteFile` 是否仍要求完整 path?
2. armed 是否被 threadId 回写清掉?
3. 是否命中 `closedSandboxKey`?
4. 新 call 是否被 baseline 当成历史?
5. `previewThreadId` 是否为空(线程还没绑上)?
### 14.2 打开了但内容空白 / 冻住
1. 快照是否进了页面 useState?
2. 节流是否纯尾随?
3. `SandboxThreadProvider` 是否拿到带 messages 的 thread?
4. 虚拟 URL 的 message_id / tool_call_id 是否对得上?
5. 打开时 `sandboxArtifactRef` 是否同步?(首帧是否被 drop)
6. signature 是否因实现错误一直不变,导致 publish 被去重?
### 14.3 整页卡、消息列表也不实时
1. 搜页面壳是否还有 `setSandboxThreadSnapshot` / 类似大对象 state。
2. 是否在每 chunk `console.debug` 打大对象(DevTools 打开时更卡)。
3. 是否错误地让页面壳订阅了 store(页面壳不应 `useSyncExternalStore` 这份 store;只有 Provider 订)。
### 14.4 重新生成不刷新
1. 第二次 write 的 toolCallId 是否变了?
2. previewKey 是否仍用 path 复用?
3. `ArtifactFileDetail` 的 `key=` 是否基于 sandboxCloseKey/previewKey?
### 14.5 关后再问异常
1. 打印 `closedSandboxKeyRef` 与新 artifact 的 sandboxCloseKey。
2. 确认新一轮 send 时 armed/baseline 已重置。
3. 确认抑制不是整 thread。
### 14.6 语言 / 预览模式不对
1. 看打开时的 path / mimeType / language 查询参数。
2. 确认不是在扩展名不完整时打开。
3. 确认「源码→预览」发生在 isLoading false 之后(属正常)。
---
## 15. 对接自测清单
重做页面后至少跑完:
### 功能
- [ ] 首轮:步骤条「写入文件」出现时,沙箱是否已开并开始出字?
- [ ] content-first agent:无文件名阶段是否也能开?
- [ ] path 到达后:是否只改名、不整页闪、不重挂?
- [ ] 同轮第二次同路径 write:是否清空并重新流式?
- [ ] 结束后是否自动切 Markdown 预览?
- [ ] 手动点步骤条路径 / 产物卡片:能否打开正确文件?
### 关闭与抑制
- [ ] 流式中手动关闭:本次是否不再自动弹?
- [ ] 关闭后再发一轮新问题:是否仍可自动开?
- [ ] path upgrade 是否不会绕过「用户刚关闭」的抑制?
### 场景切换
- [ ] 切节点:沙箱关闭,旧内容不残留。
- [ ] 切岗位:同上。
- [ ] 新建会话 / 切历史:同上。
### 性能
- [ ] 流式中消息列表是否实时出字?
- [ ] 页面是否明显掉帧 / 卡死?
- [ ] React DevTools 中,大页面壳是否在每 160ms 整页 update?(不应)
---
## 16. 最小接线示例
### 16.1 页面壳(示意)
```tsx
const [sandboxArtifact, setSandboxArtifact] = useState<PositionArtifactPreview | null>(null);
const sandboxArtifactRef = useRef<PositionArtifactPreview | null>(null);
const closedSandboxKeyRef = useRef<string | null>(null);
const latestNodeThreadSnapshotRef = useRef<SandboxThreadSnapshot | null>(null);
const store = useMemo(createSandboxThreadStore, []);
// pending + timer + appliedAt 用于前沿节流(见 PositionRoundtablePage)
function handlePreviewArtifact(artifact: PositionArtifactPreview) {
if (!artifact.threadId || !artifact.path) return;
const key = sandboxCloseKey(artifact);
if (artifact.auto && closedSandboxKeyRef.current === key) return;
closedSandboxKeyRef.current = null;
artifactsCtx?.select(artifact.path, true);
artifactsCtx?.setOpen(true);
const prev = sandboxArtifactRef.current;
const isSame =
prev?.threadId === artifact.threadId &&
prev.path === artifact.path &&
prev.previewKey === artifact.previewKey;
const isPathUpgrade =
!isSame &&
Boolean(artifact.previewKey) &&
prev?.threadId === artifact.threadId &&
prev.previewKey === artifact.previewKey;
if (!isSame) {
sandboxArtifactRef.current = artifact; // 必须同步
setSandboxArtifact(artifact);
sandboxSnapshotAppliedAtRef.current = 0;
}
if (store.getSnapshot()?.threadId !== artifact.threadId) {
store.publish(null);
}
const latest = latestNodeThreadSnapshotRef.current;
if (latest?.threadId === artifact.threadId) {
queueSandboxThreadSnapshot(latest);
}
if (!isPathUpgrade) setRightPanelOpen(false);
}
function handleNodeThreadSnapshot(snapshot: { threadId: string; thread: BaseStream<AgentThreadState> }) {
const next = { ...snapshot, signature: sandboxThreadSignature(snapshot.thread) };
latestNodeThreadSnapshotRef.current = next;
if (sandboxArtifactRef.current?.threadId !== snapshot.threadId) return;
queueSandboxThreadSnapshot(next);
}
function closeSandbox() {
if (sandboxArtifactRef.current) {
closedSandboxKeyRef.current = sandboxCloseKey(sandboxArtifactRef.current);
}
// clear timer / pending / appliedAt
sandboxArtifactRef.current = null;
setSandboxArtifact(null);
store.publish(null);
artifactsCtx?.setOpen(false);
}
// 渲染
{sandboxArtifact && (
<SandboxThreadProvider
store={store}
threadId={sandboxArtifact.threadId}
fallbackThread={stub}
>
<ArtifactFileDetail
key={sandboxCloseKey(sandboxArtifact)}
filepath={sandboxArtifact.path}
threadId={sandboxArtifact.threadId}
mimeType={sandboxArtifact.mimeType}
onClose={closeSandbox}
allowVirtualMarkdownEdit
/>
</SandboxThreadProvider>
)}
```
### 16.2 对话子组件(示意)
```tsx
// send 前
activeWriteFilePreviewRef.current = null;
autoPreviewBaselineCallKeysRef.current = writeFileCallKeys(thread.messages);
autoPreviewRunArmedRef.current = true;
await sendMessage(...);
// 探测
const streaming = thread.isLoading ? latestStreamingWriteFile(thread.messages) : null;
const next =
streaming && !autoPreviewBaselineCallKeysRef.current.has(streaming.key)
? streaming
: null;
useEffect(() => {
if (!thread.isLoading || !next || !previewThreadId || !autoPreviewRunArmedRef.current) return;
// previewKey:同 tool call 复用,否则用 next.key
// artifactKey = threadId:previewKey:sourcePath 去重
onPreviewArtifact?.({
threadId: previewThreadId,
path: next.path,
name: next.name,
mimeType: next.mimeType,
previewKey,
auto: true,
});
}, [next, previewThreadId, thread.isLoading, onPreviewArtifact]);
useEffect(() => {
onThreadSnapshot?.({ threadId: activeThreadId, thread });
}, [activeThreadId, thread, onThreadSnapshot]);
```
**强烈建议:** 探测 / armed / baseline / 临时名逻辑直接复用或抽取自 `PositionNodeChat`,不要从零重写。
---
## 17. 关键代码索引
| 主题 | 位置 |
| --- | --- |
| 临时名 / 完整扩展名 / latestStreamingWriteFile | `PositionNodeChat.tsx` 中 `provisionalWriteFilePath`、`hasCompleteArtifactExtension`、`latestStreamingWriteFile` |
| armed / baseline / 上报 effect | `PositionNodeChat.tsx` `handleSubmit` + streaming preview `useEffect` |
| 同 tool call 判断 / 二次写入重挂 | 同上,`isSameToolCall` / `previewKey` |
| 线程绑定保护 | `PositionNodeChat.tsx` node binding `useEffect` 中 `isOwnThreadBinding` |
| sandboxCloseKey / store / 前沿节流 | `PositionRoundtablePage.tsx` `sandboxCloseKey`、`createSandboxThreadStore`、`queueSandboxThreadSnapshot` |
| 打开 / path upgrade / 抑制 | `PositionRoundtablePage.tsx` `handlePreviewArtifact` |
| 关闭 | `PositionRoundtablePage.tsx` `closeSandbox` |
| 虚拟 URL 读 content | `core/artifacts/loader.ts` `loadArtifactContentFromToolCall` |
| 源码/预览切换 | `artifact-file-detail.tsx` 中 `viewMode` + `thread.isLoading` |
| 普通页步骤条自动开 | `message-group.tsx` `write_file` 分支 |
相关提交(背景):
- `fix(frontend): 岗位会商沙箱在 write_file 流式阶段实时打开并刷新`
— 临时名打开、外部 store、前沿节流
- `fix(frontend): 同路径二次 write_file 时重挂岗位会商沙箱`
— previewKey 按 tool call,禁止按 path 复用
---
## 18. 术语表
| 术语 | 含义 |
| --- | --- |
| armed | 本轮用户发送后,允许自动打开沙箱的开关 |
| baseline | send 前已有的 write tool call 集合,用于忽略历史产物 |
| previewKey | 一次交付的稳定身份,推荐 `messageId:toolCallId` |
| sandboxCloseKey | 关闭抑制键,同时用作 `ArtifactFileDetail` 的 React key |
| provisional path / 临时名 | 尚无真实 path 时的 `未命名文件.md` / `.html` |
| path upgrade | 同 previewKey 下临时名升级为真实 path |
| sandboxThreadStore | 流式线程外部 store,避免整页重渲染 |
| write-file: URL | 用 message_id + tool_call_id 定位流式 content 的虚拟路径 |
| leading-edge throttle / 前沿节流 | 到期立即在回调里 publish,而不是只挂尾随 timer |
| content-first / path-first | 模型流式 JSON 参数时 content 与 path 哪个先到 |
| isOwnThreadBinding | 本节点把自己的新 threadId 写回 props,不是切节点 |
---
## 附录 A:给对接同学的一页纸摘要
1. 页面壳只挂一个沙箱;子组件只调用 `onPreviewArtifact` / `onThreadSnapshot`。
2. 自动打开时机 ≈ 步骤条出现「写入文件」时,而不是出现文件名时。
3. `write_file` 可以没有 path:临时名打开,path 到了再改名。
4. 流式 thread 进外部 store,进页面 state 会卡死。
5. 节流要前沿;previewKey 看 tool call;关闭抑制看 sandboxCloseKey。
6. 同路径重新生成 = 新 previewKey = 沙箱重挂。
7. 出问题先对照第 13、14 节,再看代码索引第 17 节。
+1 -1
View File
@@ -99,7 +99,7 @@ window.__CM_RUNTIME_CONFIG__ = {
VITE_AIPI_KNOWES_URL: "http://ch1.b.uat.4.cn/knowes", VITE_AIPI_KNOWES_URL: "http://ch1.b.uat.4.cn/knowes",
// MaxKey 鉴权地址:onlineTicket 换 token 使用。 // MaxKey 鉴权地址:onlineTicket 换 token 使用。
VITE_AUTH_BASE_URL: "https://auth.y.sit.4.cn", VITE_AUTH_BASE_URL: "https://auth.y.uat.4.cn",
// PG 中任务 iframe 地址。 // PG 中任务 iframe 地址。
VITE_AIPI_PGLINKTO_URL: "https://pg.b.uat.4.cn", VITE_AIPI_PGLINKTO_URL: "https://pg.b.uat.4.cn",
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b{fill:none;stroke:#fff;stroke-linecap:round;stroke-linejoin:round;}</style></defs><rect class="a" width="16" height="16"/><path class="b" d="M24.036,10l-.012,9.333" transform="translate(-16.016 -6.667)"/><path class="b" d="M10,24h9.333" transform="translate(-6.667 -16)"/></svg>

After

Width:  |  Height:  |  Size: 401 B

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

After

Width:  |  Height:  |  Size: 2.6 KiB

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

After

Width:  |  Height:  |  Size: 670 B

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

After

Width:  |  Height:  |  Size: 753 B

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

After

Width:  |  Height:  |  Size: 735 B

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

After

Width:  |  Height:  |  Size: 545 B

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

After

Width:  |  Height:  |  Size: 410 B

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

After

Width:  |  Height:  |  Size: 418 B

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

After

Width:  |  Height:  |  Size: 422 B

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

After

Width:  |  Height:  |  Size: 504 B

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

After

Width:  |  Height:  |  Size: 473 B

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

After

Width:  |  Height:  |  Size: 320 B

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

After

Width:  |  Height:  |  Size: 320 B

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

After

Width:  |  Height:  |  Size: 321 B

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

After

Width:  |  Height:  |  Size: 321 B

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

After

Width:  |  Height:  |  Size: 320 B

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

After

Width:  |  Height:  |  Size: 321 B

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

After

Width:  |  Height:  |  Size: 618 B

@@ -607,6 +607,27 @@ html.lightyellow .light-topnav-item--active::after {
} }
/* ── 侧边栏:二级图标卡片列(96px)+ 右侧三级列(展开时整体变宽,带过渡)──────── */ /* ── 侧边栏:二级图标卡片列(96px)+ 右侧三级列(展开时整体变宽,带过渡)──────── */
/* Runtime themes use their configurable header gradient rather than the
red/blue banner artwork. Navigation copy follows an automatically chosen
contrasting foreground so both pale and deep brand colours remain legible. */
html[data-custom-theme="true"] .light-topnav-item,
html[data-custom-theme="true"] .top-submenu-item {
color: var(--navigation-foreground);
}
html[data-custom-theme="true"] .light-topnav-item:hover,
html[data-custom-theme="true"] .light-topnav-item--active,
html[data-custom-theme="true"] .light-topnav-item--active:hover,
html[data-custom-theme="true"] .top-submenu-item:hover,
html[data-custom-theme="true"] .top-submenu-item--active {
color: var(--navigation-foreground);
text-shadow: none;
}
html[data-custom-theme="true"] .light-topnav-item--active::after {
background: var(--navigation-foreground);
}
.light-sidebar { .light-sidebar {
position: fixed; position: fixed;
left: 0; left: 0;
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import { import {
Bell, Bell,
Cpu,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
ChevronsUpDown, ChevronsUpDown,
@@ -10,6 +11,7 @@ import {
Gauge, Gauge,
ListTree, ListTree,
MousePointerClick, MousePointerClick,
BriefcaseBusiness,
Settings, Settings,
ShieldAlert, ShieldAlert,
ShieldCheck, ShieldCheck,
@@ -235,6 +237,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
按钮管理 按钮管理
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/position-roles")}
>
<BriefcaseBusiness className="size-4" />
岗位角色配置
</DropdownMenuItem>
)}
{isAdmin && ( {isAdmin && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => navigate("/page/strategy/admin/login-whitelist")} onClick={() => navigate("/page/strategy/admin/login-whitelist")}
@@ -243,6 +253,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
白名单管理 白名单管理
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/models")}
>
<Cpu className="size-4" />
模型配置
</DropdownMenuItem>
)}
{isAdmin && ( {isAdmin && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => navigate("/page/strategy/settings/model-concurrency")} onClick={() => navigate("/page/strategy/settings/model-concurrency")}
@@ -540,6 +558,18 @@ function LegacyAccordionSidebar({
按钮管理 按钮管理
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/position-roles")}>
<BriefcaseBusiness className="size-4" />
岗位角色配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/workspace/admin/models")}>
<Cpu className="size-4" />
模型配置
</DropdownMenuItem>
)}
{isAdmin && ( {isAdmin && (
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}> <DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}>
<ShieldCheck className="size-4" /> <ShieldCheck className="size-4" />
@@ -8,6 +8,7 @@ import {
SlidersHorizontal, SlidersHorizontal,
Upload, Upload,
} from "lucide-react"; } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -18,7 +19,7 @@ function HeaderButton({
variant = "outline", variant = "outline",
className, className,
}: { }: {
icon: React.ReactNode; icon: ReactNode;
label: string; label: string;
onClick: () => void; onClick: () => void;
variant?: "outline" | "primary"; variant?: "outline" | "primary";
@@ -52,6 +53,7 @@ export function WikiHeader({
onCreate, onCreate,
onManageFolders, onManageFolders,
onManageTemplates, onManageTemplates,
extraActions,
}: { }: {
onToggleSidebar: () => void; onToggleSidebar: () => void;
onImportFile: () => void; onImportFile: () => void;
@@ -61,6 +63,7 @@ export function WikiHeader({
onCreate: () => void; onCreate: () => void;
onManageFolders: () => void; onManageFolders: () => void;
onManageTemplates: () => void; onManageTemplates: () => void;
extraActions?: ReactNode;
}) { }) {
return ( return (
<header className="flex h-14 shrink-0 items-center justify-between border-b border-slate-100 bg-white px-4 sm:px-6 dark:border-slate-800 dark:bg-slate-900"> <header className="flex h-14 shrink-0 items-center justify-between border-b border-slate-100 bg-white px-4 sm:px-6 dark:border-slate-800 dark:bg-slate-900">
@@ -79,6 +82,7 @@ export function WikiHeader({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{extraActions}
<HeaderButton <HeaderButton
icon={<Upload className="size-4" />} icon={<Upload className="size-4" />}
label="导入文件" label="导入文件"
@@ -3,6 +3,7 @@
import { import {
BotIcon, BotIcon,
CheckIcon, CheckIcon,
DatabaseIcon,
GlobeIcon, GlobeIcon,
InfoIcon, InfoIcon,
Loader2Icon, Loader2Icon,
@@ -43,6 +44,7 @@ import {
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor"; import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor";
import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
import { AgentRecentChats } from "@/components/workspace/agents/agent-recent-chats"; import { AgentRecentChats } from "@/components/workspace/agents/agent-recent-chats";
import { useArtifactsOptional } from "@/components/workspace/artifacts"; import { useArtifactsOptional } from "@/components/workspace/artifacts";
import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list"; import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list";
@@ -80,6 +82,7 @@ interface AgentDraft {
templateName: string; templateName: string;
/** 会商席位「技能识别强化」开关。 */ /** 会商席位「技能识别强化」开关。 */
seatSkillDirective: boolean; seatSkillDirective: boolean;
knowledgeBaseIds: string[];
} }
function draftFromAgent(agent: Agent): AgentDraft { function draftFromAgent(agent: Agent): AgentDraft {
@@ -93,6 +96,7 @@ function draftFromAgent(agent: Agent): AgentDraft {
featured: agent.featured_order != null, featured: agent.featured_order != null,
templateName: agent.template_name ?? "", templateName: agent.template_name ?? "",
seatSkillDirective: agent.seat_skill_directive ?? false, seatSkillDirective: agent.seat_skill_directive ?? false,
knowledgeBaseIds: agent.llmwiki_knowledge_base_ids ?? [],
}; };
} }
@@ -100,15 +104,17 @@ function Section({
icon, icon,
title, title,
extra, extra,
className,
children, children,
}: { }: {
icon?: ReactNode; icon?: ReactNode;
title: string; title: string;
extra?: ReactNode; extra?: ReactNode;
className?: string;
children: ReactNode; children: ReactNode;
}) { }) {
return ( return (
<section className="bg-card/60 rounded-xl border p-4"> <section className={cn("bg-card/60 rounded-xl border p-4", className)}>
<div className="mb-3 flex items-center justify-between gap-2"> <div className="mb-3 flex items-center justify-between gap-2">
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold"> <h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
{icon} {icon}
@@ -139,6 +145,15 @@ export function AgentDetailPanel({
defaultCollapsed, defaultCollapsed,
showRecentChats, showRecentChats,
compactAppearanceActions, compactAppearanceActions,
avatarSrc,
positionName,
skillsIcon,
skillsTitle,
showCommonQuestions = false,
panelTitle,
embedded,
hideStatusBadges = false,
editIcon,
}: { }: {
agent: Agent | null | undefined; agent: Agent | null | undefined;
agentName: string; agentName: string;
@@ -150,6 +165,19 @@ export function AgentDetailPanel({
showRecentChats?: boolean; showRecentChats?: boolean;
/** 简洁模式下挂在右上角收起/展开按钮左侧的外观控件。 */ /** 简洁模式下挂在右上角收起/展开按钮左侧的外观控件。 */
compactAppearanceActions?: ReactNode; 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 router = useRouter();
const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false); const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false);
@@ -310,6 +338,11 @@ export function AgentDetailPanel({
request.template_name = draft.templateName.trim(); request.template_name = draft.templateName.trim();
if (draft.seatSkillDirective !== (agent.seat_skill_directive ?? false)) if (draft.seatSkillDirective !== (agent.seat_skill_directive ?? false))
request.seat_skill_directive = draft.seatSkillDirective; request.seat_skill_directive = draft.seatSkillDirective;
if (
JSON.stringify(draft.knowledgeBaseIds) !==
JSON.stringify(agent.llmwiki_knowledge_base_ids ?? [])
)
request.llmwiki_knowledge_base_ids = draft.knowledgeBaseIds;
if (nextPublished !== agent.published) request.published = nextPublished; if (nextPublished !== agent.published) request.published = nextPublished;
if (JSON.stringify(draft.skills) !== JSON.stringify(agent.skills ?? [])) if (JSON.stringify(draft.skills) !== JSON.stringify(agent.skills ?? []))
request.skills = draft.skills; request.skills = draft.skills;
@@ -472,11 +505,25 @@ export function AgentDetailPanel({
</div> </div>
) : ( ) : (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button size="sm" variant="outline" onClick={beginEdit}> {embedded ? (
<SquarePenIcon className="size-3.5" /> <Tooltip content="编辑智能体">
编辑 <Button
</Button> type="button"
{canTogglePublish && ( size="icon-sm"
variant="ghost"
onClick={beginEdit}
aria-label="编辑智能体"
>
{editIcon ?? <SquarePenIcon className="size-3.5" />}
</Button>
</Tooltip>
) : (
<Button size="sm" variant="outline" onClick={beginEdit}>
<SquarePenIcon className="size-3.5" />
编辑
</Button>
)}
{!embedded && canTogglePublish && (
<Tooltip content={agent?.published ? "下架" : "发布"}> <Tooltip content={agent?.published ? "下架" : "发布"}>
<Button <Button
size="icon-sm" size="icon-sm"
@@ -493,17 +540,19 @@ export function AgentDetailPanel({
</Button> </Button>
</Tooltip> </Tooltip>
)} )}
<Tooltip content="删除智能体"> {!embedded && (
<Button <Tooltip content="删除智能体">
size="icon-sm" <Button
variant="ghost" size="icon-sm"
className="text-destructive hover:text-destructive" variant="ghost"
onClick={() => setDeleteOpen(true)} className="text-destructive hover:text-destructive"
aria-label="删除智能体" onClick={() => setDeleteOpen(true)}
> aria-label="删除智能体"
<Trash2Icon className="size-3.5" /> >
</Button> <Trash2Icon className="size-3.5" />
</Tooltip> </Button>
</Tooltip>
)}
</div> </div>
) )
) : null; ) : null;
@@ -521,7 +570,11 @@ export function AgentDetailPanel({
<section className="bg-card/60 rounded-xl border p-4"> <section className="bg-card/60 rounded-xl border p-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="agent-avatar bg-primary/10 ring-primary/15 flex size-12 shrink-0 items-center justify-center rounded-2xl ring-1"> <div className="agent-avatar bg-primary/10 ring-primary/15 flex size-12 shrink-0 items-center justify-center rounded-2xl ring-1">
<BotIcon className="text-primary size-6" /> {avatarSrc ? (
<img src={avatarSrc} alt="" aria-hidden="true" className="size-9" />
) : (
<BotIcon className="text-primary size-6" />
)}
</div> </div>
<div className="min-w-0 flex-1 space-y-1.5"> <div className="min-w-0 flex-1 space-y-1.5">
{editing && draft ? ( {editing && draft ? (
@@ -541,9 +594,16 @@ export function AgentDetailPanel({
{displayName} {displayName}
</div> </div>
)} )}
<div className="flex flex-wrap items-center gap-1"> {!editing && positionName && (
{statusBadges} <div className="position-agent-role text-sm">
</div> 岗位:{desensitize(positionName)}
</div>
)}
{!hideStatusBadges && (
<div className="flex flex-wrap items-center gap-1">
{statusBadges}
</div>
)}
{!editing && agent.owner_username && !agent.builtin && ( {!editing && agent.owner_username && !agent.builtin && (
<div className="text-muted-foreground flex items-center gap-1 text-xs"> <div className="text-muted-foreground flex items-center gap-1 text-xs">
<UserRoundIcon className="size-3 shrink-0" /> <UserRoundIcon className="size-3 shrink-0" />
@@ -579,6 +639,13 @@ export function AgentDetailPanel({
</div> </div>
)} )}
{!editing && (agent.llmwiki_knowledge_base_ids?.length ?? 0) > 0 && (
<div className="text-muted-foreground mt-2 flex items-center gap-1 text-xs">
<DatabaseIcon className="size-3 shrink-0" />
LLMWiki 知识库:{agent.llmwiki_knowledge_base_ids?.length ?? 0} 个
</div>
)}
{/* Publish / featured toggles — only meaningful in edit mode. */} {/* Publish / featured toggles — only meaningful in edit mode. */}
{editing && draft && ( {editing && draft && (
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
@@ -596,6 +663,14 @@ export function AgentDetailPanel({
配置后,对话生成的 Markdown 文件可一键发布到知识入库接口。 配置后,对话生成的 Markdown 文件可一键发布到知识入库接口。
</p> </p>
</div> </div>
<LlmWikiKnowledgeSelector
source="all"
value={draft.knowledgeBaseIds}
onChange={(knowledgeBaseIds) =>
setDraft({ ...draft, knowledgeBaseIds })
}
variant="panel"
/>
<div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2"> <div className="bg-background/60 flex items-center justify-between rounded-lg border px-3 py-2">
<div className="pr-3"> <div className="pr-3">
<p className="text-sm font-medium">会商技能识别强化</p> <p className="text-sm font-medium">会商技能识别强化</p>
@@ -646,7 +721,7 @@ export function AgentDetailPanel({
{/* ── 常用问题 ───────────────────────────────────────────────────── */} {/* ── 常用问题 ───────────────────────────────────────────────────── */}
{(editing || questions.length > 0 || canEdit) && ( {(showCommonQuestions || editing || questions.length > 0 || canEdit) && (
<Section <Section
icon={<MessageCircleQuestionIcon className="size-3.5" />} icon={<MessageCircleQuestionIcon className="size-3.5" />}
title="常用问题" title="常用问题"
@@ -787,8 +862,9 @@ export function AgentDetailPanel({
{/* ── 技能管理 ───────────────────────────────────────────────────── */} {/* ── 技能管理 ───────────────────────────────────────────────────── */}
<Section <Section
icon={<SparklesIcon className="size-3.5" />} icon={skillsIcon ?? <SparklesIcon className="size-3.5" />}
title={`已启用技能 ${skillNamesShown.length} 个`} title={skillsTitle ?? `已启用技能 ${skillNamesShown.length} 个`}
className="position-roundtable-skills-section"
extra={ extra={
editing ? ( editing ? (
<Button <Button
@@ -830,7 +906,9 @@ export function AgentDetailPanel({
const panelChrome = (collapseButton: ReactNode) => ( const panelChrome = (collapseButton: ReactNode) => (
<> <>
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4"> <div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<span className="shrink-0 whitespace-nowrap text-sm font-semibold">智能体信息</span> <span className="shrink-0 whitespace-nowrap text-sm font-semibold">
{panelTitle ?? "智能体信息"}
</span>
<div className="flex min-w-0 items-center gap-1"> <div className="flex min-w-0 items-center gap-1">
{headerActions} {headerActions}
{compactAppearanceActions} {compactAppearanceActions}
@@ -844,7 +922,11 @@ export function AgentDetailPanel({
return ( return (
<> <>
{/* ── Desktop (lg+): inline aside, collapsible to a rail ───────────── */} {/* ── Desktop (lg+): inline aside, collapsible to a rail ───────────── */}
{collapsed ? ( {embedded ? (
<aside className="bg-card/40 flex h-full w-full min-w-0 shrink-0 flex-col border-l">
{panelChrome(null)}
</aside>
) : collapsed ? (
<div className="hidden shrink-0 items-start border-l px-2 pt-3 lg:flex"> <div className="hidden shrink-0 items-start border-l px-2 pt-3 lg:flex">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{compactAppearanceActions} {compactAppearanceActions}
@@ -877,30 +959,34 @@ export function AgentDetailPanel({
</aside> </aside>
)} )}
{/* ── Narrow screens (<lg): rail trigger + drawer with the same body ── */} {!embedded && (
<div className="flex shrink-0 flex-col items-center border-l pt-3 lg:hidden"> <>
<Tooltip content="智能体信息"> {/* ── Narrow screens (<lg): rail trigger + drawer with the same body ── */}
<Button <div className="flex shrink-0 flex-col items-center border-l pt-3 lg:hidden">
variant="ghost" <Tooltip content="智能体信息">
size="icon-sm" <Button
onClick={() => setMobileOpen(true)} variant="ghost"
aria-label="打开智能体信息" size="icon-sm"
> onClick={() => setMobileOpen(true)}
<InfoIcon className="size-4" /> aria-label="打开智能体信息"
</Button> >
</Tooltip> <InfoIcon className="size-4" />
</div> </Button>
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}> </Tooltip>
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-md"> </div>
<SheetHeader className="border-b px-4 pb-3 pt-4"> <Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTitle className="flex items-center justify-between text-sm"> <SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-md">
智能体信息 <SheetHeader className="border-b px-4 pb-3 pt-4">
<span className="mr-6">{headerActions}</span> <SheetTitle className="flex items-center justify-between text-sm">
</SheetTitle> 智能体信息
</SheetHeader> <span className="mr-6">{headerActions}</span>
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div> </SheetTitle>
</SheetContent> </SheetHeader>
</Sheet> <div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
</SheetContent>
</Sheet>
</>
)}
{/* ── Secondary surfaces ───────────────────────────────────────────── */} {/* ── Secondary surfaces ───────────────────────────────────────────── */}
<SkillPickerSheet <SkillPickerSheet
@@ -26,7 +26,7 @@ import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
* - "banner-dark" / "banner-light":Header 落在主题 banner 上(白字 / 浅色 hover)。 * - "banner-dark" / "banner-light":Header 落在主题 banner 上(白字 / 浅色 hover)。
* - "content":简洁模式聊天页浅底顶栏,走 foreground / muted-foreground。 * - "content":简洁模式聊天页浅底顶栏,走 foreground / muted-foreground。
*/ */
export type AppearanceControlTone = "banner-dark" | "banner-light" | "content"; export type AppearanceControlTone = "banner-dark" | "banner-light" | "banner-custom" | "content";
// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),冰川蓝=柔和浅蓝主题(iceblue),浅黄色=柔和黄主题(lightyellow),深蓝色=深色主题(dark) // 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),冰川蓝=柔和浅蓝主题(iceblue),浅黄色=柔和黄主题(lightyellow),深蓝色=深色主题(dark)
export const THEME_SWATCHES: { id: ThemeType; label: string; color: string }[] = [ export const THEME_SWATCHES: { id: ThemeType; label: string; color: string }[] = [
@@ -43,6 +43,8 @@ const triggerToneClass = (tone: AppearanceControlTone) =>
? "text-slate-400 hover:bg-slate-800/50 hover:text-slate-400" ? "text-slate-400 hover:bg-slate-800/50 hover:text-slate-400"
: tone === "banner-light" : tone === "banner-light"
? "text-white hover:bg-[var(--brand-950)] hover:text-white active:bg-[#8a0618]" ? "text-white hover:bg-[var(--brand-950)] hover:text-white active:bg-[#8a0618]"
: tone === "banner-custom"
? "text-[var(--navigation-foreground)] hover:bg-white/20 hover:text-[var(--navigation-foreground)] active:bg-white/30"
: "text-muted-foreground hover:bg-muted hover:text-foreground"; : "text-muted-foreground hover:bg-muted hover:text-foreground";
export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }) { export function CompactShortcutButtons({ tone }: { tone: AppearanceControlTone }) {
@@ -121,8 +121,9 @@ function findScrollableArtifactElement(root: HTMLElement | null) {
export function ArtifactFileDetail({ export function ArtifactFileDetail({
className, className,
filepath: filepathFromProps, filepath: filepathFromProps,
threadId, threadId,
mimeType = null,
onClose, onClose,
allowVirtualMarkdownEdit = false, allowVirtualMarkdownEdit = false,
disableDesensitize = false, disableDesensitize = false,
@@ -131,6 +132,8 @@ export function ArtifactFileDetail({
className?: string; className?: string;
filepath: string; filepath: string;
threadId: string; threadId: string;
/** Server/tool supplied type wins over path guessing when available. */
mimeType?: string | null;
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */ /** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
onClose?: () => void; onClose?: () => void;
/** /**
@@ -150,16 +153,38 @@ export function ArtifactFileDetail({
const { t } = useI18n(); const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts(); const { artifacts, setOpen, select } = useArtifacts();
const { isMock, thread, publicShareCode } = useThread(); const { isMock, thread, publicShareCode } = useThread();
const isWriteFileVirtual = useMemo(() => { const isWriteFileVirtual = useMemo(() => {
return filepathFromProps.startsWith("write-file:"); return filepathFromProps.startsWith("write-file:");
}, [filepathFromProps]); }, [filepathFromProps]);
const filepath = useMemo(() => { // A streamed tool call can be opened before the path has been normalized by
if (isWriteFileVirtual) { // the artifacts API. Keep the format supplied by the writer so Markdown is
const url = new URL(filepathFromProps); // rendered as Markdown instead of falling back to a TEXT source preview.
return decodeURIComponent(url.pathname); const writeFileLanguageHint = useMemo(() => {
} if (!isWriteFileVirtual) return null;
return filepathFromProps; try {
}, [filepathFromProps, isWriteFileVirtual]); 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(() => { const isSkillFile = useMemo(() => {
return filepath.endsWith(".skill"); return filepath.endsWith(".skill");
}, [filepath]); }, [filepath]);
@@ -186,17 +211,22 @@ export function ArtifactFileDetail({
const useWriteFileStreaming = useMemo(() => { const useWriteFileStreaming = useMemo(() => {
return isWriteFileVirtual && !isImageFile(filepath) && !editVirtualMarkdown; return isWriteFileVirtual && !isImageFile(filepath) && !editVirtualMarkdown;
}, [filepath, isWriteFileVirtual, editVirtualMarkdown]); }, [filepath, isWriteFileVirtual, editVirtualMarkdown]);
const { isCodeFile, language } = useMemo(() => { const { isCodeFile, language } = useMemo(() => {
if (useWriteFileStreaming) { if (useWriteFileStreaming) {
let nextLanguage = checkCodeFile(filepath).language; let nextLanguage =
nextLanguage ??= "text"; mimeLanguageHint ?? writeFileLanguageHint ?? checkCodeFile(filepath).language;
return { isCodeFile: true, language: nextLanguage }; nextLanguage ??= "text";
return { isCodeFile: true, language: nextLanguage };
} }
if (isSkillFile) { if (isSkillFile) {
return { isCodeFile: true, language: "markdown" }; return { isCodeFile: true, language: "markdown" };
} }
return checkCodeFile(filepath); const detected = checkCodeFile(filepath);
}, [filepath, useWriteFileStreaming, isSkillFile]); if (mimeLanguageHint) {
return { isCodeFile: true as const, language: mimeLanguageHint };
}
return detected;
}, [filepath, useWriteFileStreaming, isSkillFile, mimeLanguageHint, writeFileLanguageHint]);
const isSupportPreview = useMemo(() => { const isSupportPreview = useMemo(() => {
return ( return (
language === "html" || language === "html" ||
@@ -209,7 +239,8 @@ export function ArtifactFileDetail({
// editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟 // editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟
// URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容 // URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容
// 不会被旧的内存 stub 值覆盖回退。 // 不会被旧的内存 stub 值覆盖回退。
filepath: editVirtualMarkdown ? filepath : filepathFromProps, filepath:
editVirtualMarkdown || !isWriteFileVirtual ? filepath : filepathFromProps,
enabled: isCodeFile && !useWriteFileStreaming, enabled: isCodeFile && !useWriteFileStreaming,
}); });
@@ -15,6 +15,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { apiFetch } from "@/core/api"; import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
import { useSkills } from "@/core/skills/hooks"; import { useSkills } from "@/core/skills/hooks";
import type { SkillDisplayConfig } from "@/core/skills/type"; import type { SkillDisplayConfig } from "@/core/skills/type";
import { import {
@@ -92,6 +93,13 @@ const ChatBox: React.FC<{
const [settings] = useLocalSettings(); const [settings] = useLocalSettings();
const ragModeEnabled = const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); 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 { skills } = useSkills();
const { const {
@@ -106,11 +114,11 @@ const ChatBox: React.FC<{
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
const skillDisplayMap = useMemo( const skillDisplayMap = useMemo(
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), () => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
[ragModeEnabled, skills], [referencesEnabled, skills],
); );
const fallbackReferenceBatches = useMemo(() => { const fallbackReferenceBatches = useMemo(() => {
if (!ragModeEnabled) { if (!referencesEnabled) {
return []; return [];
} }
return buildTurnReferenceBatches( return buildTurnReferenceBatches(
@@ -118,9 +126,9 @@ const ChatBox: React.FC<{
skillDisplayMap, skillDisplayMap,
thread.isLoading, thread.isLoading,
); );
}, [ragModeEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]); }, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
useEffect(() => { useEffect(() => {
if (!ragModeEnabled) { if (!referencesEnabled) {
setBackendReferenceBatches([]); setBackendReferenceBatches([]);
return; return;
} }
@@ -156,7 +164,7 @@ const ChatBox: React.FC<{
} }
}); });
return () => controller.abort(); return () => controller.abort();
}, [ragModeEnabled, thread.isLoading, threadId, thread.values.messages]); }, [referencesEnabled, thread.isLoading, threadId, thread.values.messages]);
const adaptedBackendReferenceBatches = useMemo( const adaptedBackendReferenceBatches = useMemo(
() => () =>
adaptBackendReferenceBatches( adaptBackendReferenceBatches(
@@ -175,7 +183,7 @@ const ChatBox: React.FC<{
[adaptedBackendReferenceBatches, fallbackReferenceBatches], [adaptedBackendReferenceBatches, fallbackReferenceBatches],
); );
useEffect(() => { useEffect(() => {
if (!ragModeEnabled) { if (!referencesEnabled) {
return; return;
} }
const signature = JSON.stringify({ const signature = JSON.stringify({
@@ -202,7 +210,7 @@ const ChatBox: React.FC<{
fallbackBatches: debugReferenceBatches(fallbackReferenceBatches), fallbackBatches: debugReferenceBatches(fallbackReferenceBatches),
finalBatches: debugReferenceBatches(referenceBatches), finalBatches: debugReferenceBatches(referenceBatches),
}); });
}, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, ragModeEnabled, referenceBatches, threadId]); }, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, referencesEnabled, referenceBatches, threadId]);
const latestReferenceBatch = const latestReferenceBatch =
referenceBatches.length > 0 referenceBatches.length > 0
? referenceBatches[referenceBatches.length - 1]! ? referenceBatches[referenceBatches.length - 1]!
@@ -213,7 +221,7 @@ const ChatBox: React.FC<{
const referenceSources = activeReferenceBatch?.sources ?? []; const referenceSources = activeReferenceBatch?.sources ?? [];
const hasCitationReferences = referenceSources.length > 0; const hasCitationReferences = referenceSources.length > 0;
useEffect(() => { useEffect(() => {
if (!ragModeEnabled || !activeReferenceBatch) { if (!referencesEnabled || !activeReferenceBatch) {
return; return;
} }
logReferenceDebug("frontend-active-reference-batch-payload", threadId, { logReferenceDebug("frontend-active-reference-batch-payload", threadId, {
@@ -222,7 +230,7 @@ const ChatBox: React.FC<{
sourceCount: activeReferenceBatch.sources.length, sourceCount: activeReferenceBatch.sources.length,
sources: activeReferenceBatch.sources.map(debugReferenceSource), sources: activeReferenceBatch.sources.map(debugReferenceSource),
}); });
}, [activeReferenceBatch, ragModeEnabled, threadId]); }, [activeReferenceBatch, referencesEnabled, threadId]);
useEffect(() => { useEffect(() => {
if (hasCitationReferences) { if (hasCitationReferences) {
setReferencesOpen(true); setReferencesOpen(true);
@@ -83,6 +83,7 @@ import {
sortThreadsPinnedFirst, sortThreadsPinnedFirst,
titleOfThread, titleOfThread,
} from "@/core/threads/utils"; } from "@/core/threads/utils";
import { buildFullChatNavigationPath } from "@/core/page-layout/chats-iframe-route";
import { env } from "@/env"; import { env } from "@/env";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { isIMEComposing } from "@/lib/ime"; import { isIMEComposing } from "@/lib/ime";
@@ -185,15 +186,24 @@ export function GeneralQaSidebar() {
// 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。 // 当前嵌入的智能体(URL 上的 ?agent=):用于高亮对应卡片。
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const activeAgentId = searchParams.get("agent"); const activeAgentId = searchParams.get("agent");
const currentSearch = searchParams.toString();
const newChatHref = buildFullChatNavigationPath(NEW_CHAT_PATH, currentSearch);
const moreAgentsHref = buildFullChatNavigationPath(MORE_AGENTS_HREF, currentSearch);
const moreChatsHref = buildFullChatNavigationPath(MORE_CHATS_HREF, currentSearch);
// 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new, // 点击常用智能体 = 在通用问答右侧**原地嵌入**该智能体的对话页:停留在 /chats/new,
// 仅给 URL 加 ?agent=<id>,由 NewChatContent 把右侧问答区换成 AgentChatPage。 // 仅给 URL 加 ?agent=<id>,由 NewChatContent 把右侧问答区换成 AgentChatPage。
// 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。 // 不跳转独立页面、左侧栏不变。每次点击都重开一个干净会话(NewChatContent 的 key)。
const handleSelectAgent = useCallback( const handleSelectAgent = useCallback(
(agentId: string) => { (agentId: string) => {
navigate(`/page/workspace/chats/new?agent=${encodeURIComponent(agentId)}`); navigate(
buildFullChatNavigationPath(
`${NEW_CHAT_PATH}?agent=${encodeURIComponent(agentId)}`,
currentSearch,
),
);
}, },
[navigate], [currentSearch, navigate],
); );
// ── 最近的对话取数 + 搜索 ── // ── 最近的对话取数 + 搜索 ──
@@ -241,7 +251,7 @@ export function GeneralQaSidebar() {
const handleNewChatClick = (event: React.MouseEvent<HTMLAnchorElement>) => { const handleNewChatClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return; if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) return;
event.preventDefault(); event.preventDefault();
navigate(NEW_CHAT_PATH, { replace: true, state: { resetAt: Date.now() } }); navigate(newChatHref, { replace: true, state: { resetAt: Date.now() } });
}; };
const handleDelete = useCallback( const handleDelete = useCallback(
@@ -257,10 +267,17 @@ export function GeneralQaSidebar() {
nextThreadPath = listPathOfThread(threads[threadIndex - 1]!); nextThreadPath = listPathOfThread(threads[threadIndex - 1]!);
} }
} }
void router.push(nextThreadPath); void router.push(buildFullChatNavigationPath(nextThreadPath, currentSearch));
} }
}, },
[agentIdFromPath, deleteThread, router, threadIdFromPath, threads], [
agentIdFromPath,
currentSearch,
deleteThread,
router,
threadIdFromPath,
threads,
],
); );
const handleRenameClick = useCallback((threadId: string, currentTitle: string) => { const handleRenameClick = useCallback((threadId: string, currentTitle: string) => {
@@ -314,7 +331,7 @@ export function GeneralQaSidebar() {
底色用「主色混入透明」=主色低透明度叠在当前侧栏底色上,浅/红/蓝/深蓝主题都自适应 底色用「主色混入透明」=主色低透明度叠在当前侧栏底色上,浅/红/蓝/深蓝主题都自适应
(不要混 white,否则深蓝主题下会变成浅色块)。 */} (不要混 white,否则深蓝主题下会变成浅色块)。 */}
<Link <Link
href={NEW_CHAT_PATH} href={newChatHref}
onClick={handleNewChatClick} onClick={handleNewChatClick}
style={{ borderColor: "var(--primary)", color: "var(--primary)" }} style={{ borderColor: "var(--primary)", color: "var(--primary)" }}
className={cn( className={cn(
@@ -415,7 +432,7 @@ export function GeneralQaSidebar() {
); );
})} })}
<Link <Link
href={MORE_AGENTS_HREF} href={moreAgentsHref}
className="flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground" className="flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground"
> >
<span>{t.common.more}</span> <span>{t.common.more}</span>
@@ -454,7 +471,10 @@ export function GeneralQaSidebar() {
</div> </div>
) : ( ) : (
filteredThreads.map((thread) => { filteredThreads.map((thread) => {
const href = listPathOfThread(thread); const href = buildFullChatNavigationPath(
listPathOfThread(thread),
currentSearch,
);
const isActive = href === pathname; const isActive = href === pathname;
const isBoot = isBootstrapThread(thread); const isBoot = isBootstrapThread(thread);
const pinned = isThreadPinned(thread); const pinned = isThreadPinned(thread);
@@ -576,7 +596,7 @@ export function GeneralQaSidebar() {
)} )}
{!query && ( {!query && (
<Link <Link
href={MORE_CHATS_HREF} href={moreChatsHref}
className="mt-0.5 flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground" className="mt-0.5 flex items-center justify-between rounded-lg px-3 py-1.5 text-[12px] text-muted-foreground/70 transition-colors hover:bg-accent/50 hover:text-foreground"
> >
<span>更多最近对话</span> <span>更多最近对话</span>
@@ -22,9 +22,10 @@ import {
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
type ComponentProps, type ComponentProps,
} from "react"; type ReactNode,
} from "react";
import { import {
PromptInput, PromptInput,
@@ -64,6 +65,7 @@ import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { isHiddenFromUIMessage } from "@/core/messages/utils"; import { isHiddenFromUIMessage } from "@/core/messages/utils";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import type { Model } from "@/core/models/types";
import type { AgentThreadContext } from "@/core/threads"; import type { AgentThreadContext } from "@/core/threads";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
@@ -132,9 +134,11 @@ export function InputBox({
onFollowupsVisibilityChange, onFollowupsVisibilityChange,
onSubmit, onSubmit,
onStop, onStop,
promptPrefix, promptPrefix,
agentSelector, agentSelector,
assistantId, knowledgeSelector,
extraModels = [],
assistantId,
hideAdminRecommendedQuestions, hideAdminRecommendedQuestions,
compact, compact,
...props ...props
@@ -190,16 +194,25 @@ export function InputBox({
* homepage (``ChatPage``) passes this; ``AgentChatPage`` omits it because the * homepage (``ChatPage``) passes this; ``AgentChatPage`` omits it because the
* thread is already bound to one agent. * thread is already bound to one agent.
*/ */
agentSelector?: { agentSelector?: {
selectedAgentId: string | null; selectedAgentId: string | null;
onAgentChange: (agentId: string | null) => void; onAgentChange: (agentId: string | null) => void;
}; };
}) { /** 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 { t } = useI18n();
const [localSettings, setLocalSettings] = useLocalSettings(); const [localSettings, setLocalSettings] = useLocalSettings();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [modelDialogOpen, setModelDialogOpen] = useState(false); const [modelDialogOpen, setModelDialogOpen] = useState(false);
const { models } = useModels(); 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 { thread, isMock } = useThread();
const { textInput } = usePromptInputController(); const { textInput } = usePromptInputController();
const promptRootRef = useRef<HTMLDivElement | null>(null); const promptRootRef = useRef<HTMLDivElement | null>(null);
@@ -223,34 +236,40 @@ export function InputBox({
recommendedQuestions.length > 0; recommendedQuestions.length > 0;
useEffect(() => { useEffect(() => {
if (models.length === 0) { if (modelOptions.length === 0) {
return; return;
} }
const currentModel = models.find((m) => m.name === context.model_name); const currentModel = modelOptions.find((m) => m.name === context.model_name);
const fallbackModel = currentModel ?? models[0]!; const fallbackModel = currentModel ?? modelOptions[0]!;
const supportsThinking = fallbackModel.supports_thinking ?? false; const supportsThinking = fallbackModel.supports_thinking ?? false;
const nextModelName = fallbackModel.name; const nextModelName = fallbackModel.name;
const nextMode = getResolvedMode(context.mode, supportsThinking); const nextMode = getResolvedMode(context.mode, supportsThinking);
if (context.model_name === nextModelName && context.mode === nextMode) { if (
return; context.model_name === nextModelName &&
} context.mode === nextMode &&
context.execution_profile === fallbackModel.execution_profile
) {
return;
}
onContextChange?.({ onContextChange?.({
...context, ...context,
model_name: nextModelName, model_name: nextModelName,
mode: nextMode, mode: nextMode,
}); execution_profile: fallbackModel.execution_profile,
}, [context, models, onContextChange]); });
}, [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(() => { const resolvedModelName = selectedModel?.name;
if (models.length === 0) { const selectedSingleRoundRag = singleRoundRagActive;
return undefined;
}
return models.find((m) => m.name === context.model_name) ?? models[0];
}, [context.model_name, models]);
const resolvedModelName = selectedModel?.name;
const supportThinking = useMemo( const supportThinking = useMemo(
() => selectedModel?.supports_thinking ?? false, () => selectedModel?.supports_thinking ?? false,
@@ -264,38 +283,65 @@ export function InputBox({
const handleModelSelect = useCallback( const handleModelSelect = useCallback(
(model_name: string) => { (model_name: string) => {
const model = models.find((m) => m.name === model_name); const model = modelOptions.find((m) => m.name === model_name);
if (!model) { if (!model) {
return; return;
} }
onContextChange?.({ onContextChange?.({
...context, ...context,
model_name, model_name,
mode: getResolvedMode(context.mode, model.supports_thinking ?? false), mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
reasoning_effort: context.reasoning_effort, reasoning_effort: context.reasoning_effort,
}); execution_profile: model.execution_profile,
setModelDialogOpen(false); });
}, setModelDialogOpen(false);
[onContextChange, context, models], },
[onContextChange, context, modelOptions],
); );
const handleModeSelect = useCallback( const handleModeSelect = useCallback(
(mode: InputMode) => { (mode: InputMode) => {
onContextChange?.({ const ordinaryModel =
...context, models.find((model) => model.name === context.model_name) ?? models[0];
mode: getResolvedMode(mode, supportThinking), onContextChange?.({
reasoning_effort: ...context,
mode === "ultra" model_name: ordinaryModel?.name ?? context.model_name,
? "high" mode: getResolvedMode(
mode,
ordinaryModel?.supports_thinking ?? supportThinking,
),
reasoning_effort:
mode === "ultra"
? "high"
: mode === "pro" : mode === "pro"
? "medium" ? "medium"
: mode === "thinking" : mode === "thinking"
? "low" ? "low"
: "minimal", : "minimal",
}); execution_profile: undefined,
}, });
[onContextChange, context, supportThinking], },
); [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( const handleReasoningEffortSelect = useCallback(
(effort: "minimal" | "low" | "medium" | "high") => { (effort: "minimal" | "low" | "medium" | "high") => {
@@ -361,10 +407,11 @@ export function InputBox({
onContextChange?.({ onContextChange?.({
...context, ...context,
model_name: resolvedModelName, model_name: resolvedModelName,
mode: getResolvedMode( mode: getResolvedMode(
context.mode, context.mode,
selectedModel?.supports_thinking ?? false, selectedModel?.supports_thinking ?? false,
), ),
execution_profile: selectedModel?.execution_profile,
}); });
setTimeout(() => onSubmit?.(message), 0); setTimeout(() => onSubmit?.(message), 0);
return; return;
@@ -379,7 +426,8 @@ export function InputBox({
onStop, onStop,
resolvedModelName, resolvedModelName,
ragModeEnabled, ragModeEnabled,
selectedModel?.supports_thinking, selectedModel?.supports_thinking,
selectedModel?.execution_profile,
status, status,
threadId, threadId,
], ],
@@ -437,8 +485,9 @@ export function InputBox({
}, [pendingSuggestion, requestFormSubmit, textInput]); }, [pendingSuggestion, requestFormSubmit, textInput]);
const showFollowups = const showFollowups =
!disabled && !disabled &&
!isNewThread && !isNewThread &&
!selectedSingleRoundRag &&
!followupsHidden && !followupsHidden &&
(followupsLoading || followups.length > 0); (followupsLoading || followups.length > 0);
@@ -464,7 +513,7 @@ export function InputBox({
return; return;
} }
if (disabled || isMock) { if (disabled || isMock || selectedSingleRoundRag) {
return; return;
} }
@@ -526,7 +575,7 @@ export function InputBox({
}); });
return () => controller.abort(); return () => controller.abort();
}, [context.model_name, disabled, isMock, status, thread.messages, threadId]); }, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
return ( return (
<div ref={promptRootRef} className="relative flex flex-col gap-6"> <div ref={promptRootRef} className="relative flex flex-col gap-6">
@@ -782,54 +831,64 @@ export function InputBox({
</PromptInputButton> </PromptInputButton>
</Tooltip> </Tooltip>
<PromptInputActionMenu> <PromptInputActionMenu>
<ModeHoverGuide <ModeHoverGuide
mode={ mode={
context.mode === "flash" || context.mode === "flash" ||
context.mode === "thinking" || context.mode === "thinking" ||
context.mode === "pro" || context.mode === "pro" ||
context.mode === "ultra" context.mode === "ultra"
? context.mode ? context.mode
: "flash" : "flash"
} }
> tooltipContent={
selectedSingleRoundRag
? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料"
: undefined
}
>
<PromptInputActionMenuTrigger <PromptInputActionMenuTrigger
className={cn( className={cn(
"gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100", "gap-1.5! rounded-full border-0 bg-transparent px-3! text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium", compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
compact && compact &&
(context.mode === "thinking" (selectedSingleRoundRag
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200" ? "border-emerald-200 bg-emerald-50/60 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
: context.mode === "thinking"
? "border-indigo-200 bg-indigo-50/60 text-indigo-700 hover:bg-indigo-100 dark:border-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-200"
: context.mode === "pro" : context.mode === "pro"
? "border-violet-200 bg-violet-50/60 text-violet-700 hover:bg-violet-100 dark:border-violet-700 dark:bg-violet-950/40 dark:text-violet-200" ? "border-violet-200 bg-violet-50/60 text-violet-700 hover:bg-violet-100 dark:border-violet-700 dark:bg-violet-950/40 dark:text-violet-200"
: context.mode === "ultra" : context.mode === "ultra"
? "border-amber-200 bg-amber-50/60 text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200" ? "border-amber-200 bg-amber-50/60 text-amber-700 hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-950/40 dark:text-amber-200"
: "border-transparent bg-muted/40 hover:bg-muted hover:text-foreground"), : "border-transparent bg-muted/40 hover:bg-muted hover:text-foreground"),
)} )}
> >
<div> <div>
{context.mode === "flash" && <ZapIcon className="size-3" />} {selectedSingleRoundRag ? (
{context.mode === "thinking" && ( <BookMarkedIcon className="size-3" />
<LightbulbIcon className="size-3" /> ) : context.mode === "flash" ? (
)} <ZapIcon className="size-3" />
{context.mode === "pro" && ( ) : context.mode === "thinking" ? (
<GraduationCapIcon className="size-3" /> <LightbulbIcon className="size-3" />
)} ) : context.mode === "pro" ? (
{context.mode === "ultra" && ( <GraduationCapIcon className="size-3" />
<RocketIcon className="size-3 text-[#dabb5e]" /> ) : context.mode === "ultra" ? (
)} <RocketIcon className="size-3 text-[#dabb5e]" />
</div> ) : null}
</div>
<div <div
className={cn( className={cn(
"text-xs font-normal", "text-xs font-normal",
context.mode === "ultra" ? "golden-text" : "", context.mode === "ultra" ? "golden-text" : "",
)} )}
> >
{(context.mode === "flash" && t.inputBox.flashMode) || {selectedSingleRoundRag
(context.mode === "thinking" && ? "单轮 RAG"
t.inputBox.reasoningMode) || : (context.mode === "flash" && t.inputBox.flashMode) ||
(context.mode === "pro" && t.inputBox.proMode) || (context.mode === "thinking" &&
(context.mode === "ultra" && t.inputBox.ultraMode)} t.inputBox.reasoningMode) ||
</div> (context.mode === "pro" && t.inputBox.proMode) ||
(context.mode === "ultra" && t.inputBox.ultraMode)}
</div>
</PromptInputActionMenuTrigger> </PromptInputActionMenuTrigger>
</ModeHoverGuide> </ModeHoverGuide>
<PromptInputActionMenuContent className="w-80"> <PromptInputActionMenuContent className="w-80">
@@ -838,11 +897,11 @@ export function InputBox({
{t.inputBox.mode} {t.inputBox.mode}
</DropdownMenuLabel> </DropdownMenuLabel>
<PromptInputActionMenu> <PromptInputActionMenu>
<PromptInputActionMenuItem <PromptInputActionMenuItem
className={cn( className={cn(
context.mode === "flash" !selectedSingleRoundRag && context.mode === "flash"
? "text-accent-foreground" ? "text-accent-foreground"
: "text-muted-foreground/65", : "text-muted-foreground/65",
)} )}
onSelect={() => handleModeSelect("flash")} onSelect={() => handleModeSelect("flash")}
> >
@@ -850,9 +909,9 @@ export function InputBox({
<div className="flex items-center gap-1 font-bold"> <div className="flex items-center gap-1 font-bold">
<ZapIcon <ZapIcon
className={cn( className={cn(
"mr-2 size-4", "mr-2 size-4",
context.mode === "flash" && !selectedSingleRoundRag && context.mode === "flash" &&
"text-accent-foreground", "text-accent-foreground",
)} )}
/> />
{t.inputBox.flashMode} {t.inputBox.flashMode}
@@ -861,7 +920,7 @@ export function InputBox({
{t.inputBox.flashModeDescription} {t.inputBox.flashModeDescription}
</div> </div>
</div> </div>
{context.mode === "flash" ? ( {!selectedSingleRoundRag && context.mode === "flash" ? (
<CheckIcon className="ml-auto size-4" /> <CheckIcon className="ml-auto size-4" />
) : ( ) : (
<div className="ml-auto size-4" /> <div className="ml-auto size-4" />
@@ -869,9 +928,9 @@ export function InputBox({
</PromptInputActionMenuItem> </PromptInputActionMenuItem>
{supportThinking && ( {supportThinking && (
<PromptInputActionMenuItem <PromptInputActionMenuItem
className={cn( className={cn(
context.mode === "thinking" !selectedSingleRoundRag && context.mode === "thinking"
? "text-accent-foreground" ? "text-accent-foreground"
: "text-muted-foreground/65", : "text-muted-foreground/65",
)} )}
onSelect={() => handleModeSelect("thinking")} onSelect={() => handleModeSelect("thinking")}
@@ -880,9 +939,9 @@ export function InputBox({
<div className="flex items-center gap-1 font-bold"> <div className="flex items-center gap-1 font-bold">
<LightbulbIcon <LightbulbIcon
className={cn( className={cn(
"mr-2 size-4", "mr-2 size-4",
context.mode === "thinking" && !selectedSingleRoundRag && context.mode === "thinking" &&
"text-accent-foreground", "text-accent-foreground",
)} )}
/> />
{t.inputBox.reasoningMode} {t.inputBox.reasoningMode}
@@ -891,7 +950,7 @@ export function InputBox({
{t.inputBox.reasoningModeDescription} {t.inputBox.reasoningModeDescription}
</div> </div>
</div> </div>
{context.mode === "thinking" ? ( {!selectedSingleRoundRag && context.mode === "thinking" ? (
<CheckIcon className="ml-auto size-4" /> <CheckIcon className="ml-auto size-4" />
) : ( ) : (
<div className="ml-auto size-4" /> <div className="ml-auto size-4" />
@@ -899,8 +958,8 @@ export function InputBox({
</PromptInputActionMenuItem> </PromptInputActionMenuItem>
)} )}
<PromptInputActionMenuItem <PromptInputActionMenuItem
className={cn( className={cn(
context.mode === "pro" !selectedSingleRoundRag && context.mode === "pro"
? "text-accent-foreground" ? "text-accent-foreground"
: "text-muted-foreground/65", : "text-muted-foreground/65",
)} )}
@@ -910,8 +969,8 @@ export function InputBox({
<div className="flex items-center gap-1 font-bold"> <div className="flex items-center gap-1 font-bold">
<GraduationCapIcon <GraduationCapIcon
className={cn( className={cn(
"mr-2 size-4", "mr-2 size-4",
context.mode === "pro" && !selectedSingleRoundRag && context.mode === "pro" &&
"text-accent-foreground", "text-accent-foreground",
)} )}
/> />
@@ -921,15 +980,15 @@ export function InputBox({
{t.inputBox.proModeDescription} {t.inputBox.proModeDescription}
</div> </div>
</div> </div>
{context.mode === "pro" ? ( {!selectedSingleRoundRag && context.mode === "pro" ? (
<CheckIcon className="ml-auto size-4" /> <CheckIcon className="ml-auto size-4" />
) : ( ) : (
<div className="ml-auto size-4" /> <div className="ml-auto size-4" />
)} )}
</PromptInputActionMenuItem> </PromptInputActionMenuItem>
<PromptInputActionMenuItem <PromptInputActionMenuItem
className={cn( className={cn(
context.mode === "ultra" !selectedSingleRoundRag && context.mode === "ultra"
? "text-accent-foreground" ? "text-accent-foreground"
: "text-muted-foreground/65", : "text-muted-foreground/65",
)} )}
@@ -939,13 +998,17 @@ export function InputBox({
<div className="flex items-center gap-1 font-bold"> <div className="flex items-center gap-1 font-bold">
<RocketIcon <RocketIcon
className={cn( className={cn(
"mr-2 size-4", "mr-2 size-4",
context.mode === "ultra" && "text-[#dabb5e]", !selectedSingleRoundRag &&
context.mode === "ultra" &&
"text-[#dabb5e]",
)} )}
/> />
<div <div
className={cn( className={cn(
context.mode === "ultra" && "golden-text", !selectedSingleRoundRag &&
context.mode === "ultra" &&
"golden-text",
)} )}
> >
{t.inputBox.ultraMode} {t.inputBox.ultraMode}
@@ -955,13 +1018,46 @@ export function InputBox({
{t.inputBox.ultraModeDescription} {t.inputBox.ultraModeDescription}
</div> </div>
</div> </div>
{context.mode === "ultra" ? ( {!selectedSingleRoundRag && context.mode === "ultra" ? (
<CheckIcon className="ml-auto size-4" /> <CheckIcon className="ml-auto size-4" />
) : ( ) : (
<div className="ml-auto size-4" /> <div className="ml-auto size-4" />
)} )}
</PromptInputActionMenuItem> </PromptInputActionMenuItem>
</PromptInputActionMenu> {extraModels.length > 0 && (
<>
<DropdownMenuSeparator className="my-2" />
<PromptInputActionMenuItem
className={cn(
selectedSingleRoundRag
? "bg-emerald-50 text-emerald-800 hover:bg-emerald-100 focus:bg-emerald-100 dark:bg-emerald-950/40 dark:text-emerald-200 dark:hover:bg-emerald-950/60 dark:focus:bg-emerald-950/60"
: "text-muted-foreground/65",
)}
onSelect={handleSingleRoundRagSelect}
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1 font-bold">
<BookMarkedIcon
className={cn(
"mr-2 size-4",
selectedSingleRoundRag && "text-emerald-700 dark:text-emerald-200",
)}
/>
单轮 RAG 问答
</div>
<div className="pl-7 text-xs">
基于知识库检索生成答案,并展示思考内容与参考资料
</div>
</div>
{selectedSingleRoundRag ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</PromptInputActionMenuItem>
</>
)}
</PromptInputActionMenu>
</DropdownMenuGroup> </DropdownMenuGroup>
</PromptInputActionMenuContent> </PromptInputActionMenuContent>
</PromptInputActionMenu> </PromptInputActionMenu>
@@ -1081,9 +1177,10 @@ export function InputBox({
</PromptInputActionMenuContent> </PromptInputActionMenuContent>
</PromptInputActionMenu> </PromptInputActionMenu>
)} )}
</PromptInputTools> </PromptInputTools>
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}> <PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
{agentSelector && ( {knowledgeSelector}
{agentSelector && (
<AgentSelector <AgentSelector
selectedAgentId={agentSelector.selectedAgentId} selectedAgentId={agentSelector.selectedAgentId}
onAgentChange={agentSelector.onAgentChange} onAgentChange={agentSelector.onAgentChange}
@@ -1146,7 +1243,7 @@ export function InputBox({
<ModelSelectorContent> <ModelSelectorContent>
<ModelSelectorInput placeholder={t.inputBox.searchModels} /> <ModelSelectorInput placeholder={t.inputBox.searchModels} />
<ModelSelectorList> <ModelSelectorList>
{models.map((m) => ( {modelOptions.map((m) => (
<ModelSelectorItem <ModelSelectorItem
key={m.name} key={m.name}
value={m.name} value={m.name}
@@ -1154,9 +1251,11 @@ export function InputBox({
> >
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<ModelSelectorName>{m.display_name}</ModelSelectorName> <ModelSelectorName>{m.display_name}</ModelSelectorName>
<span className="text-muted-foreground truncate text-[10px]"> <span className="text-muted-foreground truncate text-[10px]">
{m.model} {selectedSingleRoundRag
</span> ? `单轮知识库问答 · ${m.name}`
: m.model}
</span>
</div> </div>
{m.name === context.model_name ? ( {m.name === context.model_name ? (
<CheckIcon className="ml-auto size-4" /> <CheckIcon className="ml-auto size-4" />
@@ -1164,8 +1263,8 @@ export function InputBox({
<div className="ml-auto size-4" /> <div className="ml-auto size-4" />
)} )}
</ModelSelectorItem> </ModelSelectorItem>
))} ))}
</ModelSelectorList> </ModelSelectorList>
</ModelSelectorContent> </ModelSelectorContent>
</ModelSelector> </ModelSelector>
<PromptInputSubmit <PromptInputSubmit
@@ -0,0 +1,353 @@
"use client";
import {
Check,
Database,
Folder,
Globe2,
Loader2,
Search,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import {
type LlmWikiKnowledgeBase,
useAgentLlmWikiKnowledgeBases,
useLlmWikiKnowledgeBases,
useLlmWikiRuntime,
} from "@/strategy-components/api/llmwiki";
type SelectorSource = "all" | "agent";
type SelectorVariant = "popover" | "panel";
function groupRows(rows: LlmWikiKnowledgeBase[]) {
return [
{
key: "personal",
label: "我的知识空间",
rows: rows.filter((item) => item.publication_status !== "published" || item.is_owner),
},
{
key: "public",
label: "公共知识空间",
rows: rows.filter((item) => item.publication_status === "published" && !item.is_owner),
},
].filter((group) => group.rows.length > 0);
}
function SpaceIcon({ item }: { item: LlmWikiKnowledgeBase }) {
return (
<span
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-md",
item.publication_status === "published"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-primary/10 text-primary",
)}
>
{item.publication_status === "published" ? (
<Globe2 className="size-3.5" />
) : (
<Folder className="size-3.5" />
)}
</span>
);
}
function SelectionContent({
rows,
value,
onChange,
search,
onSearchChange,
loading,
error,
source,
variant,
}: {
rows: LlmWikiKnowledgeBase[];
value: string[];
onChange: (value: string[]) => void;
search: string;
onSearchChange: (value: string) => void;
loading: boolean;
error: unknown;
source: SelectorSource;
variant: SelectorVariant;
}) {
const selected = useMemo(() => new Set(value), [value]);
const filtered = useMemo(() => {
const query = search.trim().toLocaleLowerCase();
if (!query) return rows;
return rows.filter((item) =>
`${item.name} ${item.description}`.toLocaleLowerCase().includes(query),
);
}, [rows, search]);
const groups = useMemo(() => groupRows(filtered), [filtered]);
const toggle = (id: string) => {
onChange(selected.has(id) ? value.filter((item) => item !== id) : [...value, id]);
};
return (
<>
<div className="border-b p-2.5">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(event) => onSearchChange(event.target.value)}
onKeyDown={(event) => event.stopPropagation()}
placeholder="搜索知识空间"
className="h-8 bg-muted/40 pl-8 text-xs"
/>
</div>
</div>
<div className={cn("overflow-y-auto p-1.5", variant === "popover" ? "max-h-64" : "max-h-80")}>
{loading ? (
<div className="flex h-28 items-center justify-center text-xs text-muted-foreground">
<Loader2 className="mr-2 size-4 animate-spin" />正在读取知识空间
</div>
) : error ? (
<div className="m-2 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
{error instanceof Error ? error.message : "知识空间读取失败"}
</div>
) : groups.length === 0 ? (
<div className="flex h-28 items-center justify-center px-4 text-center text-xs text-muted-foreground">
{search
? "没有匹配的知识空间"
: source === "agent"
? "该智能体尚未绑定可用知识空间"
: "当前账号暂无可用知识空间"}
</div>
) : (
groups.map((group, groupIndex) => (
<div key={group.key}>
{groupIndex > 0 && (
variant === "popover"
? <DropdownMenuSeparator className="my-1.5" />
: <div className="my-1.5 border-t" />
)}
{variant === "popover" ? (
<DropdownMenuLabel className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
{group.key === "personal" ? <Folder className="size-3" /> : <Globe2 className="size-3" />}{group.label}
</DropdownMenuLabel>
) : (
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-semibold text-muted-foreground">
{group.key === "personal" ? <Folder className="size-3" /> : <Globe2 className="size-3" />}{group.label}
</div>
)}
{group.rows.map((item) => {
const rowContent = (
<>
<SpaceIcon item={item} />
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-medium">{item.name}</div>
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">
{item.type === "faq" ? "FAQ" : "文档库"} · {item.knowledge_count ?? 0} 项知识
</div>
</div>
{item.publication_status === "published" && (
<Badge variant="outline" className="h-4 px-1 text-[9px]">公共</Badge>
)}
</>
);
return variant === "popover" ? (
<DropdownMenuCheckboxItem
key={item.id}
checked={selected.has(item.id)}
onCheckedChange={() => toggle(item.id)}
onSelect={(event) => event.preventDefault()}
className={cn(
"my-0.5 min-h-10 rounded-md py-1.5 pr-2 pl-7",
selected.has(item.id) && "bg-primary/8 focus:bg-primary/12",
)}
>
{rowContent}
</DropdownMenuCheckboxItem>
) : (
<button
key={item.id}
type="button"
role="checkbox"
aria-checked={selected.has(item.id)}
onClick={() => toggle(item.id)}
className={cn(
"my-0.5 flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-primary",
selected.has(item.id) && "bg-primary/8",
)}
>
<span className={cn(
"flex size-4 shrink-0 items-center justify-center rounded border",
selected.has(item.id) && "border-primary bg-primary text-primary-foreground",
)}>
{selected.has(item.id) && <Check className="size-3" />}
</span>
{rowContent}
</button>
);
})}
</div>
))
)}
</div>
<div className="flex items-center justify-between gap-2 border-t bg-muted/20 px-2.5 py-2">
<span className="text-[11px] text-muted-foreground">已选择 {value.length} 个</span>
<div className="flex items-center gap-1">
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange(filtered.map((item) => item.id))}>
全选
</Button>
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => onChange([])}>
清空
</Button>
</div>
</div>
</>
);
}
export function LlmWikiKnowledgeSelector({
value,
onChange,
source = "all",
agentId,
autoSelectAll = false,
disabled = false,
className,
label = "知识空间",
variant = "popover",
}: {
value: string[];
onChange: (value: string[]) => void;
source?: SelectorSource;
agentId?: string | null;
autoSelectAll?: boolean;
disabled?: boolean;
className?: string;
label?: string;
variant?: SelectorVariant;
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const runtime = useLlmWikiRuntime();
const all = useLlmWikiKnowledgeBases(
"all",
runtime.data?.provider === "weknora",
);
const agent = useAgentLlmWikiKnowledgeBases(
agentId,
runtime.data?.provider === "weknora" && source === "agent" && !!agentId,
);
const rows = all.data ?? [];
const defaultIds = useMemo(() => {
if (source !== "agent") {
return rows.map((item) => item.id);
}
return (agent.data ?? []).map((item) => item.id);
}, [agent.data, rows, source]);
const loading = all.isLoading || (source === "agent" && !!agentId && agent.isLoading);
const error = all.error ?? (source === "agent" ? agent.error : null);
const availableIds = useMemo(() => new Set(rows.map((item) => item.id)), [rows]);
const initializationKey = `${source}:${agentId ?? "all"}:${defaultIds.join(",")}`;
const initialized = useRef<string | null>(null);
useEffect(() => {
if (!all.data || (source === "agent" && agentId && !agent.data) || initialized.current === initializationKey) return;
initialized.current = initializationKey;
const valid = value.filter((id) => availableIds.has(id));
const next = autoSelectAll && valid.length === 0 && defaultIds.length > 0
? defaultIds.filter((id) => availableIds.has(id))
: valid;
if (next.length !== value.length || next.some((id, index) => id !== value[index])) {
onChange(next);
}
}, [agent.data, agentId, all.data, autoSelectAll, availableIds, defaultIds, initializationKey, onChange, source, value]);
useEffect(() => {
if (!open) setSearch("");
}, [open]);
if (runtime.isError || runtime.data?.provider !== "weknora") return null;
const content = (
<SelectionContent
rows={rows}
value={value}
onChange={onChange}
search={search}
onSearchChange={setSearch}
loading={loading}
error={error}
source={source}
variant={variant}
/>
);
if (variant === "panel") {
return (
<div className={cn("overflow-hidden rounded-xl border bg-background shadow-sm", className)}>
<div className="flex items-start justify-between gap-4 border-b px-4 py-3">
<div>
<div className="flex items-center gap-2 text-sm font-semibold">
<Database className="size-4 text-primary" />绑定知识空间
</div>
<p className="mt-1 text-xs text-muted-foreground">
新建该智能体对话时,只能从已绑定空间中选择本次检索范围。
</p>
</div>
<Badge variant="secondary" className="shrink-0">{value.length} 个</Badge>
</div>
{content}
</div>
);
}
const selectedName = value.length === 1
? rows.find((item) => item.id === value[0])?.name
: null;
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
"h-8 max-w-52 gap-1.5 rounded-full border-0 bg-transparent px-2.5 text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground",
value.length > 0 && "bg-primary/8 text-primary hover:bg-primary/12 hover:text-primary",
className,
)}
disabled={disabled || loading}
>
{loading ? <Loader2 className="size-3.5 animate-spin" /> : <Database className="size-3.5" />}
<span className="truncate text-xs font-normal">{selectedName || label}</span>
{value.length > 0 && (
<span className="flex size-4 items-center justify-center rounded-full bg-primary text-[9px] font-semibold text-primary-foreground">
{value.length}
</span>
)}
{value.length > 0 && value.length === rows.length && <Check className="size-3" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={8} className="w-80 overflow-hidden rounded-xl p-0 shadow-xl">
{content}
</DropdownMenuContent>
</DropdownMenu>
);
}
export default LlmWikiKnowledgeSelector;
@@ -33,7 +33,8 @@ import {
} from "@/core/skill-display/runtime"; } from "@/core/skill-display/runtime";
import type { Subtask } from "@/core/tasks"; import type { Subtask } from "@/core/tasks";
import { useUpdateSubtask } from "@/core/tasks/context"; import { useUpdateSubtask } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads"; import type { AgentThreadState } from "@/core/threads";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification"; import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
@@ -235,12 +236,17 @@ export function MessageList({
}, [messages, askTimes]); }, [messages, askTimes]);
const [settings] = useLocalSettings(); const [settings] = useLocalSettings();
const ragModeEnabled = const ragModeEnabled =
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
const { skills } = useSkills(); const hasSingleRoundRag = useMemo(
const skillDisplayMap = useMemo( () => messages.some(isSingleRoundRagMessage),
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), [messages],
[ragModeEnabled, skills], );
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 // Same per-turn reference batches the right-side panel renders (backend
// batches preferred). Inline [n] tokens must resolve against these so badge // 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") { if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => { return group.messages.map((msg) => {
const isAssistantMessage = const isAssistantMessage =
ragModeEnabled && referencesEnabled &&
group.type === "assistant" && group.type === "assistant" &&
msg.type === "ai"; msg.type === "ai";
const batchSources = isAssistantMessage const batchSources = isAssistantMessage
@@ -46,20 +46,23 @@ function getModeDescriptionKey(
} }
} }
export function ModeHoverGuide({ export function ModeHoverGuide({
mode, mode,
children, children,
showTitle = true, showTitle = true,
}: { tooltipContent,
mode: AgentMode; }: {
children: React.ReactNode; mode: AgentMode;
/** When true, tooltip shows "ModeName: Description". When false, only description. */ children: React.ReactNode;
showTitle?: boolean; /** When true, tooltip shows "ModeName: Description". When false, only description. */
}) { showTitle?: boolean;
const { t } = useI18n(); /** Overrides the standard mode tooltip for page-specific execution modes. */
const label = t.inputBox[getModeLabelKey(mode)]; tooltipContent?: string;
const description = t.inputBox[getModeDescriptionKey(mode)]; }) {
const content = showTitle ? `${label}: ${description}` : description; const { t } = useI18n();
const label = t.inputBox[getModeLabelKey(mode)];
const description = t.inputBox[getModeDescriptionKey(mode)];
const content = tooltipContent ?? (showTitle ? `${label}: ${description}` : description);
return <Tooltip content={content}>{children}</Tooltip>; return <Tooltip content={content}>{children}</Tooltip>;
} }
@@ -70,6 +70,18 @@ const ADVANCED_COLOR_FIELDS: Array<{
description: "主按钮悬停时的背景色", description: "主按钮悬停时的背景色",
token: "--theme-button-hover", token: "--theme-button-hover",
}, },
{
key: "navigation_gradient_start",
label: "导航渐变起点",
description: "全量模式顶部导航栏左侧的渐变色",
token: "--navigation-gradient-start",
},
{
key: "navigation_gradient_end",
label: "导航渐变终点",
description: "全量模式顶部导航栏右侧的渐变色",
token: "--navigation-gradient-end",
},
{ {
key: "selected_background", key: "selected_background",
label: "选中背景", label: "选中背景",
@@ -268,14 +280,20 @@ function CustomThemePreview({ theme }: { theme: CustomThemePreset }) {
style={tokens} style={tokens}
aria-label={`${theme.name} 的颜色预览`} aria-label={`${theme.name} 的颜色预览`}
> >
<div className="flex items-center gap-2 border-b border-[var(--border)] bg-[var(--background)] px-3 py-2"> <div
className="flex items-center gap-2 border-b border-[var(--border)] px-3 py-2"
style={{
backgroundImage:
"linear-gradient(105deg, var(--navigation-gradient-start) 0%, var(--primary) 52%, var(--navigation-gradient-end) 100%)",
}}
>
<span <span
className="flex size-5 items-center justify-center rounded-md bg-[var(--primary)] text-[10px] font-bold text-[var(--primary-foreground)] ring-2 ring-[var(--ring)]" className="flex size-5 items-center justify-center rounded-md bg-white/25 text-[10px] font-bold text-[var(--navigation-foreground)] ring-2 ring-white/35"
aria-hidden aria-hidden
> >
C C
</span> </span>
<span className="text-xs font-semibold text-[var(--foreground)]"> <span className="text-xs font-semibold text-[var(--navigation-foreground)]">
实时预览 实时预览
</span> </span>
<span <span
@@ -71,8 +71,9 @@ import { useTheme } from "@/contexts/ThemeContext";
import { getStoredAuth } from "@/core/auth"; import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import type { ValidateSkillUploadResponse } from "@/core/skills/api"; import type { ValidateSkillUploadResponse } from "@/core/skills/api";
import { import {
useDeleteCustomSkill, useDeleteBuiltinSkill,
useDeleteCustomSkill,
useDuplicateSkill, useDuplicateSkill,
useGenerateMissingSkillNameZh, useGenerateMissingSkillNameZh,
useGenerateSkillNameZh, useGenerateSkillNameZh,
@@ -616,9 +617,10 @@ export function SkillRow({
const { data: cardPublishSquares } = usePublishSquares(); const { data: cardPublishSquares } = usePublishSquares();
const setPinned = useSetSkillPinned(); const setPinned = useSetSkillPinned();
const setFeatured = useSetSkillFeatured(); const setFeatured = useSetSkillFeatured();
const setAlwaysOn = useSetSkillAlwaysOn(); const setAlwaysOn = useSetSkillAlwaysOn();
const { data: compression } = useSkillCompressionSettings(); const { data: compression } = useSkillCompressionSettings();
const deleteSkill = useDeleteCustomSkill(); const deleteCustom = useDeleteCustomSkill();
const deleteBuiltin = useDeleteBuiltinSkill();
const duplicateSkillMutation = useDuplicateSkill(); const duplicateSkillMutation = useDuplicateSkill();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [tagPickerOpen, setTagPickerOpen] = useState(false); const [tagPickerOpen, setTagPickerOpen] = useState(false);
@@ -634,8 +636,9 @@ export function SkillRow({
const isOwned = const isOwned =
isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId; isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
const isFromOthers = isCustom && !isBuiltin && !isOwned; const isFromOthers = isCustom && !isBuiltin && !isOwned;
// Admin can edit/delete/unpublish anyone's skill; non-admin only their own. // Admin can manage custom skills from any owner; non-admin only their own.
const canManage = isCustom && (isOwned || isAdmin); const canManage = isCustom && (isOwned || isAdmin);
const canDelete = isAdmin || isOwned;
const canTogglePublish = canManage && !isBuiltin; const canTogglePublish = canManage && !isBuiltin;
// 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。 // 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。
const canSetAlwaysOn = isAdmin || isOwned; const canSetAlwaysOn = isAdmin || isOwned;
@@ -686,11 +689,15 @@ export function SkillRow({
} }
} }
async function handleDelete() { async function handleDelete() {
try { try {
await deleteSkill.mutateAsync(skill.name); if (isBuiltin) {
toast.success(t.settings.skills.deleteSuccess); await deleteBuiltin.mutateAsync(skill.name);
setConfirmDeleteOpen(false); } else {
await deleteCustom.mutateAsync(skill.name);
}
toast.success(t.settings.skills.deleteSuccess);
setConfirmDeleteOpen(false);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : String(err)); toast.error(err instanceof Error ? err.message : String(err));
} }
@@ -918,7 +925,7 @@ export function SkillRow({
> >
<TagsIcon className="size-3.5" /> <TagsIcon className="size-3.5" />
</Button> </Button>
{canManage && ( {canManage && (
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
@@ -939,12 +946,12 @@ export function SkillRow({
/> />
</Button> </Button>
)} )}
{canManage && ( {canManage && (
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className={cn( className={cn(
"h-8 w-8 shrink-0", "h-8 w-8 shrink-0",
skill.pinned && "text-emerald-600 hover:text-emerald-700", skill.pinned && "text-emerald-600 hover:text-emerald-700",
)} )}
disabled={setPinned.isPending} disabled={setPinned.isPending}
@@ -1011,11 +1018,11 @@ export function SkillRow({
> >
<CopyIcon className="size-3.5" /> <CopyIcon className="size-3.5" />
</Button> </Button>
{canManage && ( {canDelete && (
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
className="text-destructive hover:text-destructive h-8 w-8 shrink-0" className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
onClick={() => setConfirmDeleteOpen(true)} onClick={() => setConfirmDeleteOpen(true)}
title={t.settings.skills.deleteAction} title={t.settings.skills.deleteAction}
> >
@@ -1109,16 +1116,18 @@ export function SkillRow({
<Button <Button
variant="outline" variant="outline"
onClick={() => setConfirmDeleteOpen(false)} onClick={() => setConfirmDeleteOpen(false)}
disabled={deleteSkill.isPending} disabled={deleteCustom.isPending || deleteBuiltin.isPending}
> >
{t.common.cancel} {t.common.cancel}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
onClick={() => void handleDelete()} onClick={() => void handleDelete()}
disabled={deleteSkill.isPending} disabled={deleteCustom.isPending || deleteBuiltin.isPending}
> >
{deleteSkill.isPending ? t.common.loading : t.common.delete} {deleteCustom.isPending || deleteBuiltin.isPending
? t.common.loading
: t.common.delete}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -92,7 +92,7 @@ export function SourceCard({
knowledge_base_id: previewTarget.knowledgeBaseId, knowledge_base_id: previewTarget.knowledgeBaseId,
chunk_id: previewTarget.chunkId, chunk_id: previewTarget.chunkId,
}); });
void apiFetch(`${getBackendBaseURL()}/api/weknora/source-context?${params}`) void apiFetch(`${getBackendBaseURL()}/api/llmwiki/sources/context?${params}`)
.then(async (response) => { .then(async (response) => {
const payload = (await response.json().catch(() => null)) as unknown; const payload = (await response.json().catch(() => null)) as unknown;
if (!response.ok || !isWeKnoraSourceContextResponse(payload)) { if (!response.ok || !isWeKnoraSourceContextResponse(payload)) {
@@ -240,7 +240,18 @@ type WeKnoraSourceContext = {
knowledge_base_name?: string; knowledge_base_name?: string;
document_title?: string; document_title?: string;
document_file_name?: string; document_file_name?: string;
display_mode?: "wiki" | "chunks";
focus_chunk_id: string; focus_chunk_id: string;
wiki_page?: {
id?: string;
slug?: string;
title?: string;
page_type?: string;
status?: string;
summary?: string;
content?: string;
source_refs?: string[];
} | null;
chunks: Array<{ chunks: Array<{
id: string; id: string;
chunk_index?: number; chunk_index?: number;
@@ -312,6 +323,33 @@ function WeKnoraSourceContextPanel({
if (!context) { if (!context) {
return null; return null;
} }
const wikiPage = context.wiki_page;
const wikiContent = cleanReferenceText(
[wikiPage?.summary, wikiPage?.content].filter(Boolean).join("\n\n"),
[],
);
if (wikiPage && wikiContent) {
return (
<div className="max-h-[55vh] space-y-3 overflow-y-auto pr-1">
<div className="bg-muted/60 text-muted-foreground rounded-lg px-3 py-2 text-xs">
<span>{context.knowledge_base_name || "WeKnora 知识库"}</span>
<span> · LLMWiki</span>
{wikiPage.page_type && <span> · {wikiPage.page_type}</span>}
</div>
<section className="border-primary/40 bg-primary/5 rounded-lg border p-3 text-sm leading-relaxed shadow-sm">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="text-foreground text-sm font-medium">
{wikiPage.title || wikiPage.slug || "LLMWiki 页面"}
</span>
<span className="bg-primary text-primary-foreground rounded px-1.5 py-0.5 text-xs font-medium">
清洗页
</span>
</div>
<div className="space-y-3 whitespace-pre-wrap">{wikiContent}</div>
</section>
</div>
);
}
return ( return (
<div className="max-h-[55vh] space-y-3 overflow-y-auto pr-1"> <div className="max-h-[55vh] space-y-3 overflow-y-auto pr-1">
<div className="bg-muted/60 text-muted-foreground rounded-lg px-3 py-2 text-xs"> <div className="bg-muted/60 text-muted-foreground rounded-lg px-3 py-2 text-xs">
@@ -5,6 +5,7 @@ import {
DownloadIcon, DownloadIcon,
Loader2, Loader2,
PencilIcon, PencilIcon,
Trash2Icon,
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useMemo, useState } from "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 { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { getSkillFileDownloadURL } from "@/core/skills/api"; import { getSkillFileDownloadURL } from "@/core/skills/api";
import { import {
useDeleteSkillFile,
useSaveSkillFile, useSaveSkillFile,
useSkillFileContent, useSkillFileContent,
} from "@/core/skills/hooks"; } from "@/core/skills/hooks";
@@ -66,6 +68,7 @@ export function SkillFilePreviewDialog({
open ? path : null, open ? path : null,
); );
const save = useSaveSkillFile(); const save = useSaveSkillFile();
const remove = useDeleteSkillFile();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
@@ -77,6 +80,8 @@ export function SkillFilePreviewDialog({
}, [path, open]); }, [path, open]);
const fileEditable = !!data?.editable && treeEditable; const fileEditable = !!data?.editable && treeEditable;
const isRootSkillFile = path?.replace(/\\/g, "/").toLowerCase() === "skill.md";
const fileDeletable = !!data && treeEditable && !isRootSkillFile;
const isMarkdown = useMemo(() => { const isMarkdown = useMemo(() => {
if (!path) return false; if (!path) return false;
const lower = path.toLowerCase(); 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() { async function handleCopyPath() {
if (!path) return; if (!path) return;
await navigator.clipboard.writeText(path); await navigator.clipboard.writeText(path);
@@ -158,6 +175,18 @@ export function SkillFilePreviewDialog({
{tx.filesEditAction} {tx.filesEditAction}
</Button> </Button>
)} )}
{fileDeletable && !editing && (
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs text-destructive hover:text-destructive"
onClick={() => void handleDelete()}
disabled={remove.isPending}
>
{remove.isPending ? <Loader2 className="size-3.5 animate-spin" /> : <Trash2Icon className="size-3.5" />}
{tx.filesDeleteAction}
</Button>
)}
{editing && ( {editing && (
<> <>
<Button <Button
@@ -184,7 +184,7 @@ export function WorkspaceNavChatList({
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
<SidebarMenuItem> {/* <SidebarMenuItem>
<SidebarMenuButton <SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/roundtable/position")} isActive={pathname.startsWith("/page/workspace/roundtable/position")}
asChild asChild
@@ -195,7 +195,7 @@ export function WorkspaceNavChatList({
<span>岗位会商</span> <span>岗位会商</span>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem> */}
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton <SidebarMenuButton
isActive={pathname === "/page/workspace/scheduled/tasks"} isActive={pathname === "/page/workspace/scheduled/tasks"}
@@ -234,6 +234,14 @@ export function WorkspaceNavMenu() {
岗位管理 岗位管理
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/position-roles")}
>
<BriefcaseIcon />
岗位角色配置
</DropdownMenuItem>
)}
{isAdmin && ( {isAdmin && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
+4 -1
View File
@@ -229,6 +229,8 @@ function markExplicitChoice(): void {
// 主题上下文 // 主题上下文
interface ThemeContextType { interface ThemeContextType {
theme: ThemeType; theme: ThemeType;
/** Resolved visual mode; custom themes keep their own light/dark setting. */
baseMode: 'light' | 'dark';
themeConfig: ThemeConfig; themeConfig: ThemeConfig;
toggleTheme: () => void; toggleTheme: () => void;
setTheme: (theme: ThemeType) => void; setTheme: (theme: ThemeType) => void;
@@ -309,6 +311,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const theme: ThemeType = isBuiltinTheme(requestedTheme) const theme: ThemeType = isBuiltinTheme(requestedTheme)
? requestedTheme ? requestedTheme
: customTheme?.id ?? 'light'; : customTheme?.id ?? 'light';
const baseMode: 'light' | 'dark' = customTheme?.mode ?? (theme === 'dark' ? 'dark' : 'light');
useEffect(() => { useEffect(() => {
if (typeof document === 'undefined') return; if (typeof document === 'undefined') return;
@@ -377,7 +380,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
return ( return (
<ThemeContext.Provider <ThemeContext.Provider
value={{ theme, themeConfig, toggleTheme, setTheme, mounted }} value={{ theme, baseMode, themeConfig, toggleTheme, setTheme, mounted }}
> >
{children} {children}
</ThemeContext.Provider> </ThemeContext.Provider>
+8 -4
View File
@@ -61,7 +61,9 @@ export interface Agent {
/** 会商席位「技能识别强化」开关。作为会商席位运行时,是否把本智能体配置的技能 /** 会商席位「技能识别强化」开关。作为会商席位运行时,是否把本智能体配置的技能
* (名称 + SKILL.md 路径 + 必须先 read_file 的硬指令) 拼进本轮任务以补偿弱模型。 * (名称 + SKILL.md 路径 + 必须先 read_file 的硬指令) 拼进本轮任务以补偿弱模型。
* 与业务链条同名开关取 OR。null/false = 关 (默认)。 */ * 与业务链条同名开关取 OR。null/false = 关 (默认)。 */
seat_skill_directive?: boolean | null; seat_skill_directive?: boolean | null;
/** DeerFlow LLMWiki mapping ids this agent allows conversations to select. */
llmwiki_knowledge_base_ids?: string[] | null;
tags?: Tag[]; tags?: Tag[];
featured_order?: number | null; featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */ /** Whether the current user has favorited (收藏) this agent. */
@@ -96,7 +98,8 @@ export interface CreateAgentRequest {
common_questions?: AgentQuestion[] | null; common_questions?: AgentQuestion[] | null;
template_name?: string | null; template_name?: string | null;
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */ /** 会商席位「技能识别强化」开关;缺省/false = 关。 */
seat_skill_directive?: boolean | null; seat_skill_directive?: boolean | null;
llmwiki_knowledge_base_ids?: string[] | null;
} }
export interface UpdateAgentRequest { export interface UpdateAgentRequest {
@@ -113,5 +116,6 @@ export interface UpdateAgentRequest {
/** 入库模板名;传空字符串表示清除。 */ /** 入库模板名;传空字符串表示清除。 */
template_name?: string | null; template_name?: string | null;
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */ /** 会商席位「技能识别强化」开关;缺省/false = 关。 */
seat_skill_directive?: boolean | null; seat_skill_directive?: boolean | null;
} llmwiki_knowledge_base_ids?: string[] | null;
}
+36 -7
View File
@@ -5,11 +5,25 @@ import { readRuntimeEnv } from "@/env";
* *
* Add a new entry here when an external system needs a short ``routeKey``. * Add a new entry here when an external system needs a short ``routeKey``.
* Targets must remain internal SPA routes; LoginPage validates them again * Targets must remain internal SPA routes; LoginPage validates them again
* before navigating. * before navigating. The optional display settings are converted into the
* existing route parameters on the destination URL.
*/ */
export const ROUTE_KEY_MAPPINGS: Readonly<Record<string, string>> = { export interface RouteKeyMapping {
a: "/page/canvas/ai-writing", /** Internal SPA route to open after sign-in. */
"8bf": "/page/strategy/qa/8bf-roundtable", path: string;
/** Add `hideChrome=1` to hide the global header and page sidebar. */
hideChrome?: boolean;
/** Add the existing `theme` route parameter and apply it during sign-in. */
theme?: string;
}
export const ROUTE_KEY_MAPPINGS: Readonly<Record<string, RouteKeyMapping>> = {
a: { path: "/page/canvas/ai-writing" },
"8bf": {
path: "/page/strategy/qa/8bf-roundtable",
hideChrome: true,
theme: "dark-blue",
},
}; };
/** Login URL parameter that selects a route from ``ROUTE_KEY_MAPPINGS``. */ /** Login URL parameter that selects a route from ``ROUTE_KEY_MAPPINGS``. */
@@ -26,8 +40,23 @@ export function getRouteKeyLoginPassword(): string {
return readRuntimeEnv("VITE_ROUTE_LOGIN_PASSWORD") ?? ""; return readRuntimeEnv("VITE_ROUTE_LOGIN_PASSWORD") ?? "";
} }
export function resolveRouteKey(routeKey: string | null | undefined): string { export function resolveRouteKeyMapping(
routeKey: string | null | undefined,
): RouteKeyMapping | undefined {
const key = routeKey?.trim(); const key = routeKey?.trim();
if (!key) return ""; if (!key) return undefined;
return ROUTE_KEY_MAPPINGS[key] ?? ""; return ROUTE_KEY_MAPPINGS[key];
}
export function resolveRouteKey(routeKey: string | null | undefined): string {
const mapping = resolveRouteKeyMapping(routeKey);
if (!mapping) return "";
const [path, ownQuery = ""] = mapping.path.split("?");
const query = new URLSearchParams(ownQuery);
if (mapping.hideChrome) query.set("hideChrome", "1");
if (mapping.theme) query.set("theme", mapping.theme);
const serializedQuery = query.toString();
return serializedQuery ? `${path}?${serializedQuery}` : path;
} }
+23 -9
View File
@@ -13,19 +13,33 @@ export function buildIntranetSourceUrl(recUuid?: string | null): string {
return `${INTRANET_SOURCE_URL_BASE}${encodeURIComponent(recUuid)}`; return `${INTRANET_SOURCE_URL_BASE}${encodeURIComponent(recUuid)}`;
} }
function getBaseOrigin() { function getBaseOrigin() {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
return window.location.origin; return window.location.origin;
} }
// Fallback for SSR // Fallback for SSR
return "http://localhost:2026"; return "http://localhost:2026";
} }
export function getBackendBaseURL() { function alignLoopbackHostname(url: URL): URL {
if (env.NEXT_PUBLIC_BACKEND_BASE_URL) { if (typeof window === "undefined") return url;
return new URL(env.NEXT_PUBLIC_BACKEND_BASE_URL, getBaseOrigin()) const loopbackHosts = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
.toString() if (
.replace(/\/+$/, ""); loopbackHosts.has(url.hostname) &&
loopbackHosts.has(window.location.hostname)
) {
url.hostname = window.location.hostname;
}
return url;
}
export function getBackendBaseURL() {
if (env.NEXT_PUBLIC_BACKEND_BASE_URL) {
return alignLoopbackHostname(
new URL(env.NEXT_PUBLIC_BACKEND_BASE_URL, getBaseOrigin()),
)
.toString()
.replace(/\/+$/, "");
} else { } else {
return ""; return "";
} }
@@ -0,0 +1,24 @@
/**
* Whether the application is currently hosted by another page.
*
* This intentionally covers both a native `<iframe>` and Wujie's child-app
* runtime. Accessing `window.top` is safe across origins for identity
* comparison, but the guard keeps the login/route bootstrap resilient to
* restrictive browser hosts.
*/
export function isEmbeddedHost(): boolean {
if (typeof window === "undefined") return false;
const hostWindow = window as Window & {
__POWERED_BY_WUJIE__?: boolean;
};
if (hostWindow.__POWERED_BY_WUJIE__) return true;
try {
return window.self !== window.top;
} catch {
// Some sandboxed or cross-origin hosts deny top-window access. They are
// still embedded contexts and should use the same safe entry route.
return true;
}
}
+7 -4
View File
@@ -545,10 +545,13 @@ export const enUS: Translations = {
filesSaveAction: "Save", filesSaveAction: "Save",
filesCancelEditAction: "Cancel", filesCancelEditAction: "Cancel",
filesDownloadAction: "Download", filesDownloadAction: "Download",
filesCopyPathAction: "Copy path", filesCopyPathAction: "Copy path",
filesDiscardConfirm: "Discard unsaved changes?", filesDiscardConfirm: "Discard unsaved changes?",
filesSaveSuccess: "File saved", filesSaveSuccess: "File saved",
filesReadOnlyHint: "Built-in skills are read-only.", filesDeleteAction: "Delete",
filesDeleteConfirm: "Delete \"{path}\"? This cannot be undone.",
filesDeleteSuccess: "File deleted",
filesReadOnlyHint: "Only administrators can edit or delete built-in skill files.",
viewDetailAction: "View skill details", viewDetailAction: "View skill details",
stateSheet: { stateSheet: {
archivedTitle: "Archived skills", archivedTitle: "Archived skills",
+7 -4
View File
@@ -475,10 +475,13 @@ export interface Translations {
filesSaveAction: string; filesSaveAction: string;
filesCancelEditAction: string; filesCancelEditAction: string;
filesDownloadAction: string; filesDownloadAction: string;
filesCopyPathAction: string; filesCopyPathAction: string;
filesDiscardConfirm: string; filesDiscardConfirm: string;
filesSaveSuccess: string; filesSaveSuccess: string;
filesReadOnlyHint: string; filesDeleteAction: string;
filesDeleteConfirm: string;
filesDeleteSuccess: string;
filesReadOnlyHint: string;
viewDetailAction: string; viewDetailAction: string;
stateSheet: { stateSheet: {
archivedTitle: string; archivedTitle: string;
+5 -2
View File
@@ -524,8 +524,11 @@ export const zhCN: Translations = {
filesDownloadAction: "下载", filesDownloadAction: "下载",
filesCopyPathAction: "复制路径", filesCopyPathAction: "复制路径",
filesDiscardConfirm: "有未保存的修改,确定放弃吗?", filesDiscardConfirm: "有未保存的修改,确定放弃吗?",
filesSaveSuccess: "文件已保存", filesSaveSuccess: "文件已保存",
filesReadOnlyHint: "内置技能为只读,仅可查看。", filesDeleteAction: "删除",
filesDeleteConfirm: "确定删除“{path}”?该操作不可撤销。",
filesDeleteSuccess: "文件已删除",
filesReadOnlyHint: "仅管理员可以编辑或删除内置技能文件。",
viewDetailAction: "查看技能详情", viewDetailAction: "查看技能详情",
stateSheet: { stateSheet: {
archivedTitle: "归档的技能", archivedTitle: "归档的技能",
@@ -0,0 +1,66 @@
import type { Message } from "@langchain/langgraph-sdk";
import { textOfMessage } from "@/core/threads/utils";
import { createLlmWikiConversationDeposit } from "@/strategy-components/api/llmwiki";
function isHidden(message: Message): boolean {
const extra = (message as { additional_kwargs?: Record<string, unknown> })
.additional_kwargs;
return extra?.hide_from_ui === true;
}
function messageText(message: Message | undefined): string {
return (message ? textOfMessage(message) : "")?.trim() ?? "";
}
function hasText(message: Message | undefined): message is Message {
return !!message && messageText(message).length > 0;
}
export function queueLlmWikiConversationDeposit({
threadId,
messages,
assistantId,
knowledgeBaseIds,
}: {
threadId: string | null | undefined;
messages: Message[] | undefined;
assistantId?: string | null;
knowledgeBaseIds?: string[];
}) {
if (!threadId || !messages?.length) {
return;
}
const visible = messages.filter((message) => !isHidden(message));
const assistantIndex = [...visible]
.map((message, index) => ({ message, index }))
.reverse()
.find(({ message }) => message.type === "ai" && hasText(message))?.index;
if (assistantIndex === undefined) {
return;
}
const assistantMessage = visible[assistantIndex];
const humanMessage = [...visible.slice(0, assistantIndex)]
.reverse()
.find((message) => message.type === "human" && hasText(message));
if (!assistantMessage?.id || !humanMessage) {
return;
}
const question = messageText(humanMessage);
const answer = messageText(assistantMessage);
if (!question || !answer) {
return;
}
void createLlmWikiConversationDeposit({
thread_id: threadId,
question,
answer,
human_message_id: humanMessage.id ?? null,
assistant_message_id: assistantMessage.id,
assistant_id: assistantId ?? null,
knowledge_base_ids: knowledgeBaseIds ?? [],
}).catch((error) => {
console.warn("Failed to queue LLMWiki conversation deposit", error);
});
}
@@ -0,0 +1,114 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
export interface ManagedModel {
name: string;
display_name: string | null;
description: string | null;
use: string;
model: string;
base_url: string | null;
request_timeout: number | null;
max_retries: number | null;
max_tokens: number | null;
temperature: number | null;
stream_usage: boolean | null;
supports_thinking: boolean;
supports_reasoning_effort: boolean;
supports_vision: boolean;
when_thinking_enabled: Record<string, unknown> | null;
when_thinking_disabled: Record<string, unknown> | null;
thinking: Record<string, unknown> | null;
use_responses_api: boolean | null;
output_version: string | null;
extra_settings: Record<string, unknown>;
api_key_configured: boolean;
api_key_source: string | null;
source: "config.yaml";
}
export interface ManagedModelsResponse {
models: ManagedModel[];
config_path: string;
updated_at: string | null;
}
export interface ModelSavePayload {
name: string;
display_name?: string | null;
description?: string | null;
use: string;
model: string;
api_key?: string;
clear_api_key?: boolean;
base_url?: string | null;
request_timeout?: number | null;
max_retries?: number | null;
max_tokens?: number | null;
temperature?: number | null;
stream_usage?: boolean | null;
supports_thinking: boolean;
supports_reasoning_effort: boolean;
supports_vision: boolean;
when_thinking_enabled?: Record<string, unknown> | null;
when_thinking_disabled?: Record<string, unknown> | null;
thinking?: Record<string, unknown> | null;
use_responses_api?: boolean | null;
output_version?: string | null;
extra_settings: Record<string, unknown>;
}
export interface ModelConnectionTestResult {
success: boolean;
latency_ms: number;
message: string;
preview?: string | null;
}
async function readError(response: Response, fallback: string): Promise<never> {
const payload = await response.json().catch(() => null) as { detail?: string } | null;
throw new Error(payload?.detail || fallback);
}
export async function loadManagedModels(): Promise<ManagedModelsResponse> {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models`);
if (!response.ok) return readError(response, "加载模型配置失败");
return response.json() as Promise<ManagedModelsResponse>;
}
export async function createManagedModel(payload: ModelSavePayload): Promise<ManagedModel> {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) return readError(response, "创建模型失败");
return response.json() as Promise<ManagedModel>;
}
export async function updateManagedModel(name: string, payload: ModelSavePayload): Promise<ManagedModel> {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models/${encodeURIComponent(name)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) return readError(response, "保存模型配置失败");
return response.json() as Promise<ManagedModel>;
}
export async function deleteManagedModel(name: string): Promise<void> {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models/${encodeURIComponent(name)}`, {
method: "DELETE",
});
if (!response.ok) return readError(response, "删除模型失败");
}
export async function testManagedModel(model: ModelSavePayload): Promise<ModelConnectionTestResult> {
const response = await apiFetch(`${getBackendBaseURL()}/api/admin/models/test`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model }),
});
if (!response.ok) return readError(response, "模型连接测试失败");
return response.json() as Promise<ModelConnectionTestResult>;
}
@@ -0,0 +1,52 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createManagedModel,
deleteManagedModel,
loadManagedModels,
testManagedModel,
updateManagedModel,
type ModelSavePayload,
} from "./api";
export const MANAGED_MODELS_QUERY_KEY = ["admin", "model-config"] as const;
export function useManagedModels() {
return useQuery({
queryKey: MANAGED_MODELS_QUERY_KEY,
queryFn: loadManagedModels,
staleTime: 10_000,
refetchOnWindowFocus: false,
});
}
export function useSaveManagedModel() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ name, payload, isNew }: { name: string; payload: ModelSavePayload; isNew: boolean }) =>
isNew ? createManagedModel(payload) : updateManagedModel(name, payload),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: MANAGED_MODELS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ["models"] }),
]);
},
});
}
export function useTestManagedModel() {
return useMutation({ mutationFn: testManagedModel });
}
export function useDeleteManagedModel() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteManagedModel,
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: MANAGED_MODELS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ["models"] }),
]);
},
});
}
@@ -0,0 +1,2 @@
export * from "./api";
export * from "./hooks";
@@ -0,0 +1,21 @@
import type { Model } from "./types";
export const SINGLE_ROUND_RAG_MODELS: Model[] = [
{ id: "single-round-rag-openai3", name: "OpenAI3", model: "OpenAI3", display_name: "Qwen3-32B", supports_thinking: true, execution_profile: "single_round_rag" },
{ id: "single-round-rag-openai4", name: "OpenAI4", model: "OpenAI4", display_name: "Qwen3-235B", supports_thinking: true, execution_profile: "single_round_rag" },
{ id: "single-round-rag-openai5", name: "OpenAI5", model: "OpenAI5", display_name: "GLM-5", supports_thinking: true, execution_profile: "single_round_rag" },
{ id: "single-round-rag-openai6", name: "OpenAI6", model: "OpenAI6", display_name: "Qwen3.5-27B", supports_thinking: true, execution_profile: "single_round_rag" },
{ id: "single-round-rag-openai7", name: "OpenAI7", model: "OpenAI7", display_name: "Qwen3-8B", supports_thinking: true, execution_profile: "single_round_rag" },
{ id: "single-round-rag-openai9", name: "OpenAI9", model: "OpenAI9", display_name: "四季7B", supports_thinking: true, execution_profile: "single_round_rag" },
];
export function isSingleRoundRagProfile(
context: unknown,
): boolean {
return (
typeof context === "object" &&
context !== null &&
(context as { execution_profile?: unknown }).execution_profile ===
"single_round_rag"
);
}
+5 -3
View File
@@ -4,9 +4,11 @@ export interface Model {
model: string; model: string;
display_name: string; display_name: string;
description?: string | null; description?: string | null;
supports_thinking?: boolean; supports_thinking?: boolean;
supports_reasoning_effort?: boolean; supports_reasoning_effort?: boolean;
} /** Execution profiles handled by the frontend instead of DeerFlow. */
execution_profile?: "single_round_rag";
}
export interface TokenUsageSettings { export interface TokenUsageSettings {
enabled: boolean; enabled: boolean;
@@ -2,13 +2,19 @@ import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { import {
buildStandardChatRedirectPath,
buildFullChatNavigationPath,
buildEmbeddedIframeRedirectPath,
buildChatsIframeRedirectPath, buildChatsIframeRedirectPath,
buildChatsIframeThreadPath, buildChatsIframeThreadPath,
hasIframePresentationOverride,
isExplicitBrainyIframeMode, isExplicitBrainyIframeMode,
isChatsIframeWorkspacePath, isChatsIframeWorkspacePath,
resolveChatsIframeEntryRedirect,
resolvePostLoginRedirectPath, resolvePostLoginRedirectPath,
resolveForcedThemeForPath, resolveForcedThemeForPath,
resolveStandardChatFallback, resolveStandardChatFallback,
shouldUseChatsIframeProtocol,
} from "./chats-iframe-route.ts"; } from "./chats-iframe-route.ts";
test("matches only workspace chats_iframe routes", () => { test("matches only workspace chats_iframe routes", () => {
@@ -37,7 +43,7 @@ test("forces light theme for landing and chats_iframe routes only", () => {
assert.equal(resolveForcedThemeForPath("/page/workspace/chats/new"), undefined); assert.equal(resolveForcedThemeForPath("/page/workspace/chats/new"), undefined);
}); });
test("requires an explicit chatMode=iframe opt-in for the Brainy page", () => { test("recognizes an explicit chatMode=iframe independently of the legacy marker", () => {
assert.equal(isExplicitBrainyIframeMode(""), false); assert.equal(isExplicitBrainyIframeMode(""), false);
assert.equal(isExplicitBrainyIframeMode("?from=iframe"), false); assert.equal(isExplicitBrainyIframeMode("?from=iframe"), false);
assert.equal(isExplicitBrainyIframeMode("?chatMode=full"), false); assert.equal(isExplicitBrainyIframeMode("?chatMode=full"), false);
@@ -45,34 +51,72 @@ test("requires an explicit chatMode=iframe opt-in for the Brainy page", () => {
assert.equal(isExplicitBrainyIframeMode("chatMode=IFRAME"), true); assert.equal(isExplicitBrainyIframeMode("chatMode=IFRAME"), true);
}); });
test("upgrades legacy iframe-source routes to the iframe protocol", () => { test("keeps bare from=iframe on the original dedicated iframe protocol", () => {
assert.equal(
resolveStandardChatFallback(
"/page/workspace/chats_iframe/new",
"?from=iframe",
),
null,
);
assert.equal(shouldUseChatsIframeProtocol("?from=iframe"), true);
assert.equal(hasIframePresentationOverride("?from=iframe"), false);
});
test("uses the dedicated AgentSelector route for a bare embedded host", () => {
assert.equal(shouldUseChatsIframeProtocol("", true), true);
assert.equal(
buildEmbeddedIframeRedirectPath("?ticket=secret&theme=dark-blue"),
"/page/workspace/chats_iframe/new?from=iframe&theme=dark-blue",
);
assert.equal(
resolveChatsIframeEntryRedirect(
"/page/workspace/chats/new",
"",
true,
),
"/page/workspace/chats_iframe/new?from=iframe",
);
assert.equal(
resolveChatsIframeEntryRedirect(
"/page/workspace/chats/thread-123",
"",
true,
),
"/page/workspace/chats_iframe/thread-123?from=iframe",
);
assert.equal(shouldUseChatsIframeProtocol("?chatMode=full", true), false);
assert.equal(shouldUseChatsIframeProtocol("?hideChrome=1", true), false);
assert.equal(
buildStandardChatRedirectPath("?hideChrome=1"),
"/page/workspace/chats/new?hideChrome=1&chatMode=full",
);
});
test("routes legacy iframe links with standard presentation flags to full chat", () => {
assert.equal(
resolveStandardChatFallback(
"/page/workspace/chats_iframe/new",
"?from=iframe&hideHeader=1",
),
"/page/workspace/chats/new?hideHeader=1&chatMode=full",
);
assert.equal( assert.equal(
resolveStandardChatFallback( resolveStandardChatFallback(
"/page/workspace/chats_iframe/new", "/page/workspace/chats_iframe/new",
"?from=iframe&hideChrome=1", "?from=iframe&hideChrome=1",
), ),
"/page/workspace/chats_iframe/new?from=iframe&hideChrome=1&chatMode=iframe", "/page/workspace/chats/new?hideChrome=1&chatMode=full",
); );
});
test("honors an explicit chat mode over the legacy iframe source", () => {
assert.equal( assert.equal(
resolveStandardChatFallback( resolveStandardChatFallback(
"/page/workspace/chats_iframe/new", "/page/workspace/chats_iframe/new",
"?from=iframe&chatMode=full", "?from=iframe&embed=1&theme=dark-blue",
), ),
"/page/workspace/chats/new?chatMode=full", "/page/workspace/chats/new?embed=1&theme=dark-blue&chatMode=full",
);
});
test("treats a blank chat mode as absent for the legacy iframe source", () => {
assert.equal(
resolveStandardChatFallback(
"/page/workspace/chats_iframe/new",
"?from=iframe&chatMode=%20%20",
),
"/page/workspace/chats_iframe/new?from=iframe&chatMode=iframe",
); );
assert.equal(shouldUseChatsIframeProtocol("?from=iframe&hideChrome=1"), false);
assert.equal(hasIframePresentationOverride("?from=iframe&embed=1"), true);
}); });
test("redirects chats_iframe routes without the iframe source to standard full chat", () => { test("redirects chats_iframe routes without the iframe source to standard full chat", () => {
@@ -139,32 +183,53 @@ test("forces the iframe theme on the login route without changing normal login",
), ),
undefined, undefined,
); );
assert.equal(
resolveForcedThemeForPath(
"/login",
"?from=iframe&hideChrome=1&theme=dark-blue",
),
undefined,
);
}); });
test("preserves the iframe theme in the post-login redirect", () => { test("does not add chatMode=iframe to the legacy post-login redirect", () => {
assert.equal( assert.equal(
buildChatsIframeRedirectPath("?from=iframe&theme=dark-blue"), buildChatsIframeRedirectPath("?from=iframe&theme=dark-blue"),
"/page/workspace/chats_iframe/new?from=iframe&chatMode=iframe&theme=dark-blue", "/page/workspace/chats_iframe/new?from=iframe&theme=dark-blue",
); );
assert.equal( assert.equal(
buildChatsIframeRedirectPath("?from=iframe"), buildChatsIframeRedirectPath("?from=iframe"),
"/page/workspace/chats_iframe/new?from=iframe&chatMode=iframe", "/page/workspace/chats_iframe/new?from=iframe",
); );
assert.equal( assert.equal(
buildChatsIframeRedirectPath( buildChatsIframeRedirectPath(
"?from=iframe&theme=dark-blue&ticket=secret&authToken=secret", "?from=iframe&theme=dark-blue&ticket=secret&authToken=secret",
), ),
"/page/workspace/chats_iframe/new?from=iframe&chatMode=iframe&theme=dark-blue", "/page/workspace/chats_iframe/new?from=iframe&theme=dark-blue",
); );
}); });
test("keeps the iframe redirect ahead of login-method fallbacks", () => { test("uses full chat after login when a legacy iframe link sets presentation", () => {
assert.equal( assert.equal(
resolvePostLoginRedirectPath( resolvePostLoginRedirectPath(
"?from=iframe&theme=dark-blue&yUserId=user-1", "?from=iframe&theme=dark-blue&yUserId=user-1",
"/page/workspace/chats/new", "/page/workspace/chats/new",
), ),
"/page/workspace/chats_iframe/new?from=iframe&chatMode=iframe&theme=dark-blue", "/page/workspace/chats_iframe/new?from=iframe&theme=dark-blue",
);
assert.equal(
resolvePostLoginRedirectPath(
"?from=iframe&hideChrome=1&theme=dark-blue",
"/page/workspace/chats/new",
),
"/page/workspace/chats/new?hideChrome=1&chatMode=full",
);
assert.equal(
resolvePostLoginRedirectPath(
"?from=iframe&embed=1&theme=dark-blue",
"/page/workspace/chats/new",
),
"/page/workspace/chats/new?embed=1&theme=dark-blue&chatMode=full",
); );
assert.equal( assert.equal(
resolvePostLoginRedirectPath("?yUserId=user-1", "/page/workspace/chats/new"), resolvePostLoginRedirectPath("?yUserId=user-1", "/page/workspace/chats/new"),
@@ -179,7 +244,61 @@ test("preserves iframe theme when a new chat becomes a thread", () => {
"thread-123", "thread-123",
"?from=iframe&theme=dark-blue&ticket=secret", "?from=iframe&theme=dark-blue&ticket=secret",
), ),
"/page/workspace/chats_iframe/thread-123?from=iframe&chatMode=iframe&theme=dark-blue", "/page/workspace/chats_iframe/thread-123?from=iframe&theme=dark-blue",
);
});
test("normalizes direct regular-chat legacy links according to the same split", () => {
assert.equal(
resolveChatsIframeEntryRedirect(
"/page/workspace/chats/new",
"?from=iframe",
),
"/page/workspace/chats_iframe/new?from=iframe",
);
assert.equal(
resolveChatsIframeEntryRedirect(
"/page/workspace/chats/thread-123",
"?from=iframe&hideChrome=1",
),
"/page/workspace/chats/thread-123?hideChrome=1&chatMode=full",
);
assert.equal(
resolveChatsIframeEntryRedirect(
"/page/workspace/chats/new",
"?from=iframe&embed=1&theme=dark-blue",
),
"/page/workspace/chats/new?embed=1&theme=dark-blue&chatMode=full",
);
});
test("builds a standard full-chat URL without leaking the legacy marker", () => {
assert.equal(
buildStandardChatRedirectPath(
"?from=iframe&hideHeader=1&ticket=secret",
),
"/page/workspace/chats/new?hideHeader=1&chatMode=full",
);
});
test("keeps full-chat presentation flags when the sidebar opens an agent or thread", () => {
assert.equal(
buildFullChatNavigationPath(
"/page/workspace/chats/new?agent=agent-1",
"?hideChrome=1&chatMode=full",
),
"/page/workspace/chats/new?agent=agent-1&hideChrome=1&chatMode=full",
);
assert.equal(
buildFullChatNavigationPath(
"/page/workspace/chats/thread-123",
"?embed=1&theme=dark-blue&chatMode=full",
),
"/page/workspace/chats/thread-123?embed=1&theme=dark-blue&chatMode=full",
);
assert.equal(
buildFullChatNavigationPath("/page/workspace/chats/new", ""),
"/page/workspace/chats/new",
); );
}); });
@@ -2,15 +2,131 @@ import { resolveEmbedThemeMode } from "../embed/theme-mode.mjs";
const CHATS_IFRAME_PATH = "/page/workspace/chats_iframe/new"; const CHATS_IFRAME_PATH = "/page/workspace/chats_iframe/new";
const IFRAME_LOGIN_SOURCE = "iframe"; const IFRAME_LOGIN_SOURCE = "iframe";
const STANDARD_CHATS_PATH = "/page/workspace/chats";
function toSearchParams(search: string): URLSearchParams { function toSearchParams(search: string): URLSearchParams {
return new URLSearchParams(search.startsWith("?") ? search : `?${search}`); return new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
} }
/** Keep the accepted URL booleans aligned with embed-session.ts. */
function isEnabledPresentationParam(search: string, key: string): boolean {
const value = toSearchParams(search).get(key)?.trim().toLowerCase();
return value === "1" || value === "true" || value === "yes";
}
export function isChatsIframeLogin(search: string): boolean { export function isChatsIframeLogin(search: string): boolean {
return toSearchParams(search).get("from") === IFRAME_LOGIN_SOURCE; return toSearchParams(search).get("from") === IFRAME_LOGIN_SOURCE;
} }
function isExplicitFullChatMode(search: string): boolean {
return (
toSearchParams(search).get("chatMode")?.trim().toLowerCase() === "full"
);
}
/**
* A bare login URL embedded by an external host has no query parameter to
* survive the login redirect. Add the legacy source marker before building
* the private chat URL so refreshes and subsequent thread navigation retain
* the selected dedicated iframe workspace.
*/
export function buildEmbeddedIframeRedirectPath(search: string): string {
const source = toSearchParams(search);
if (!source.has("from")) {
source.set("from", IFRAME_LOGIN_SOURCE);
}
return buildChatsIframeRedirectPath(source.toString());
}
/**
* `from=iframe` predates the regular embedded-chat presentation flags. Keep
* the two concerns separate: this only answers whether the caller explicitly
* asked PageLayout to alter global chrome, not whether it is an iframe page.
*/
export function hasIframePresentationOverride(search: string): boolean {
return (
isEnabledPresentationParam(search, "hideHeader") ||
isEnabledPresentationParam(search, "hideChrome") ||
isEnabledPresentationParam(search, "embed")
);
}
/**
* The private `chats_iframe` transport is the legacy default for
* `from=iframe`. An explicit `chatMode=iframe` always wins, while a caller
* that also supplies one of the standard presentation flags deliberately uses
* the ordinary full chat workspace instead.
*/
export function shouldUseChatsIframeProtocol(
search: string,
embeddedHost = false,
): boolean {
return (
!hasIframePresentationOverride(search) &&
(isExplicitBrainyIframeMode(search) ||
isChatsIframeLogin(search) ||
// The historical bare iframe integration enters `/login/admin` without
// any route parameters. Treat that embedded context as the legacy
// dedicated AgentSelector page, unless the caller explicitly selected
// the standard full-chat mode.
(embeddedHost && !isExplicitFullChatMode(search)))
);
}
/**
* Build the regular full-chat destination for a legacy iframe entry that has
* opted into a standard presentation mode. Keeping the display flags in the
* address bar makes direct links and refreshes deterministic; `from` is
* intentionally omitted so the legacy protocol cannot take over again.
*/
export function buildStandardChatRedirectPath(
search: string,
fallbackPath = `${STANDARD_CHATS_PATH}/new`,
): string {
const source = toSearchParams(search);
const [pathname, existingQuery = ""] = fallbackPath.split("?");
const target = new URLSearchParams(existingQuery);
for (const key of ["hideHeader", "hideChrome", "embed"] as const) {
const value = source.get(key)?.trim();
if (value && !target.has(key)) {
target.set(key, value);
}
}
// `theme` is meaningful only for embed mode, but preserving it here keeps a
// refreshed embedded page on the same forced theme before session storage is
// restored.
const theme = source.get("theme")?.trim();
if (theme && target.get("embed")) {
target.set("theme", theme);
}
target.set("chatMode", "full");
const query = target.toString();
return query ? `${pathname}?${query}` : pathname;
}
/**
* Keep an explicitly selected standard-chat presentation while navigating
* between `/chats/new` and a concrete chat thread. Without this, a click in
* the full-chat sidebar drops `hideChrome=1&chatMode=full`; an iframe host
* then looks like the legacy bare integration and is incorrectly redirected
* to the private AgentSelector workspace.
*
* This deliberately whitelists presentation flags through
* `buildStandardChatRedirectPath` instead of copying arbitrary login/query
* values into user-facing chat URLs.
*/
export function buildFullChatNavigationPath(
destinationPath: string,
search: string,
): string {
return hasIframePresentationOverride(search) || isExplicitFullChatMode(search)
? buildStandardChatRedirectPath(search, destinationPath)
: destinationPath;
}
/** /**
* Preserve only the presentation parameters consumed by the iframe workspace. * Preserve only the presentation parameters consumed by the iframe workspace.
* Login credentials must never be copied into the post-login address bar. * Login credentials must never be copied into the post-login address bar.
@@ -24,13 +140,11 @@ export function buildChatsIframeRedirectPath(search: string): string {
} }
const requestedMode = source.get("chatMode")?.trim(); const requestedMode = source.get("chatMode")?.trim();
if (requestedMode) { if (
requestedMode?.toLowerCase() === "iframe" ||
(!isChatsIframeLogin(search) && requestedMode)
) {
target.set("chatMode", requestedMode); target.set("chatMode", requestedMode);
} else if (isChatsIframeLogin(search)) {
// Plugin links historically identify this protocol with `from=iframe`.
// Normalize that legacy marker into the explicit mode before handing the
// URL to the workspace route, so it does not fall back to the full chat.
target.set("chatMode", "iframe");
} }
const theme = source.get("theme")?.trim(); const theme = source.get("theme")?.trim();
@@ -57,8 +171,12 @@ export function resolvePostLoginRedirectPath(
search: string, search: string,
fallbackPath: string, fallbackPath: string,
): string { ): string {
return isChatsIframeLogin(search) if (shouldUseChatsIframeProtocol(search)) {
? buildChatsIframeRedirectPath(search) return buildChatsIframeRedirectPath(search);
}
return isChatsIframeLogin(search) && hasIframePresentationOverride(search)
? buildStandardChatRedirectPath(search, fallbackPath)
: fallbackPath; : fallbackPath;
} }
@@ -69,13 +187,88 @@ export function isChatsIframeWorkspacePath(pathname: string): boolean {
); );
} }
/**
* A `from=iframe` link can arrive directly at a regular chat URL instead of
* passing through LoginPage. Normalize both entry paths to the same dedicated
* protocol workspace, including an existing thread id when one is supplied.
*/
export function resolveChatsIframeEntryRedirect(
pathname: string,
search: string,
embeddedHost = false,
): string | null {
const isStandardChatPath =
pathname === STANDARD_CHATS_PATH ||
pathname === `${STANDARD_CHATS_PATH}/` ||
pathname.startsWith(`${STANDARD_CHATS_PATH}/`);
// Standard presentation flags deliberately bypass the private iframe
// protocol. Normalize direct legacy URLs once so the result stays on the
// normal full-chat workspace and retains its agent/history sidebar.
if (
isChatsIframeLogin(search) &&
hasIframePresentationOverride(search) &&
!isExplicitBrainyIframeMode(search)
) {
return isStandardChatPath
? buildStandardChatRedirectPath(search, pathname)
: null;
}
const inferredEmbeddedProtocol =
embeddedHost &&
!hasIframePresentationOverride(search) &&
!isExplicitFullChatMode(search) &&
!isChatsIframeLogin(search) &&
!isExplicitBrainyIframeMode(search);
const protocolSearch = inferredEmbeddedProtocol
? `?${new URLSearchParams({
...Object.fromEntries(toSearchParams(search)),
from: IFRAME_LOGIN_SOURCE,
}).toString()}`
: search;
const requestsIframeProtocol = shouldUseChatsIframeProtocol(
protocolSearch,
embeddedHost,
);
if (!requestsIframeProtocol) {
return null;
}
if (pathname === STANDARD_CHATS_PATH || pathname === `${STANDARD_CHATS_PATH}/`) {
return buildChatsIframeRedirectPath(protocolSearch);
}
if (!pathname.startsWith(`${STANDARD_CHATS_PATH}/`)) {
return null;
}
const routeSuffix = pathname.slice(`${STANDARD_CHATS_PATH}/`.length);
if (!routeSuffix || routeSuffix.includes("/")) {
return null;
}
if (routeSuffix === "new") {
return buildChatsIframeRedirectPath(protocolSearch);
}
try {
return buildChatsIframeThreadPath(
"/page/workspace",
decodeURIComponent(routeSuffix),
protocolSearch,
);
} catch {
return null;
}
}
/** /**
* The lightweight iframe page is a private transport for the Brainy * The lightweight iframe page is a private transport for the Brainy
* postMessage protocol, not a general-purpose embedded chat page. * postMessage protocol, not a general-purpose embedded chat page.
* *
* Requiring an explicit mode prevents legacy `from=iframe` links, ordinary * `from=iframe` is the legacy equivalent of the explicit mode. Ordinary
* iframes and micro-frontend containers from accidentally landing on the * iframe/Wujie embedding without either marker remains standard chat.
* protocol-only page.
*/ */
export function isExplicitBrainyIframeMode(search: string): boolean { export function isExplicitBrainyIframeMode(search: string): boolean {
const normalizedSearch = search.startsWith("?") ? search.slice(1) : search; const normalizedSearch = search.startsWith("?") ? search.slice(1) : search;
@@ -95,25 +288,32 @@ export function resolveStandardChatFallback(
pathname: string, pathname: string,
search: string, search: string,
): string | null { ): string | null {
if (!isChatsIframeWorkspacePath(pathname) || isExplicitBrainyIframeMode(search)) { if (!isChatsIframeWorkspacePath(pathname)) {
return null; return null;
} }
const normalizedSearch = search.startsWith("?") ? search.slice(1) : search; const normalizedSearch = search.startsWith("?") ? search.slice(1) : search;
const params = new URLSearchParams(normalizedSearch); const params = new URLSearchParams(normalizedSearch);
const requestedMode = params.get("chatMode")?.trim();
if (isChatsIframeLogin(search) && !requestedMode) {
params.set("chatMode", "iframe");
const targetSearch = params.toString();
return targetSearch ? `${pathname}?${targetSearch}` : pathname;
}
const routePrefix = "/page/workspace/chats_iframe/"; const routePrefix = "/page/workspace/chats_iframe/";
const routeSuffix = pathname.slice(routePrefix.length); const routeSuffix = pathname.slice(routePrefix.length);
const targetPath = const targetPath =
routeSuffix && routeSuffix !== "new" routeSuffix && routeSuffix !== "new"
? `/page/workspace/chats/${routeSuffix}` ? `/page/workspace/chats/${routeSuffix}`
: "/page/workspace/chats/new"; : "/page/workspace/chats/new";
if (
isChatsIframeLogin(search) &&
hasIframePresentationOverride(search) &&
!isExplicitBrainyIframeMode(search)
) {
return buildStandardChatRedirectPath(search, targetPath);
}
if (shouldUseChatsIframeProtocol(search)) {
// The legacy marker itself is enough. Do not add chatMode=iframe here: it
// is an implementation detail and adding it caused a self-redirect loop.
return null;
}
params.delete("from"); params.delete("from");
params.set("chatMode", "full"); params.set("chatMode", "full");
const targetSearch = params.toString(); const targetSearch = params.toString();
@@ -131,7 +331,7 @@ export function resolveForcedThemeForPath(
const isLoginPath = pathname === "/login" || pathname.startsWith("/login/"); const isLoginPath = pathname === "/login" || pathname.startsWith("/login/");
if ( if (
isChatsIframeWorkspacePath(pathname) || isChatsIframeWorkspacePath(pathname) ||
(isLoginPath && isChatsIframeLogin(search)) (isLoginPath && shouldUseChatsIframeProtocol(search))
) { ) {
const rawTheme = toSearchParams(search).get("theme")?.trim(); const rawTheme = toSearchParams(search).get("theme")?.trim();
return rawTheme ? resolveEmbedThemeMode(rawTheme) : "light"; return rawTheme ? resolveEmbedThemeMode(rawTheme) : "light";
@@ -270,6 +270,13 @@ export const sidebarMenuItems: MenuItem[] = [
path: "/page/strategy/admin/positions", path: "/page/strategy/admin/positions",
adminOnly: true, adminOnly: true,
}, },
{
id: "mgmt-position-roles",
label: "岗位角色配置",
icon: Briefcase,
path: "/page/strategy/admin/position-roles",
adminOnly: true,
},
{ {
id: "mgmt-admin-scheduled-tasks", id: "mgmt-admin-scheduled-tasks",
label: "定时任务管理", label: "定时任务管理",
+31 -7
View File
@@ -402,15 +402,26 @@ export async function getSkillContent(
return response.json() as Promise<SkillContentResponse>; return response.json() as Promise<SkillContentResponse>;
} }
export async function deleteCustomSkill(skillName: string): Promise<void> { export async function deleteCustomSkill(skillName: string): Promise<void> {
const response = await apiFetch( const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`, `${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
{ method: "DELETE" }, { method: "DELETE" },
); );
if (!response.ok) { if (!response.ok) {
throw await parseDetailedError(response, "Failed to delete skill"); throw await parseDetailedError(response, "Failed to delete skill");
} }
} }
/** Delete a built-in skill. The backend permits this only for administrators. */
export async function deleteBuiltinSkill(skillName: string): Promise<void> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`,
{ method: "DELETE" },
);
if (!response.ok) {
throw await parseDetailedError(response, "Failed to delete built-in skill");
}
}
export interface SkillFileNode { export interface SkillFileNode {
name: string; name: string;
@@ -479,7 +490,7 @@ export function getSkillFileDownloadURL(skillName: string, path: string): string
); );
} }
export async function saveSkillFile( export async function saveSkillFile(
skillName: string, skillName: string,
path: string, path: string,
content: string, content: string,
@@ -494,9 +505,22 @@ export async function saveSkillFile(
); );
if (!response.ok) { if (!response.ok) {
throw await parseDetailedError(response, "Failed to save skill file"); throw await parseDetailedError(response, "Failed to save skill file");
} }
return response.json() as Promise<SkillFileSaveResponse>; return response.json() as Promise<SkillFileSaveResponse>;
} }
export async function deleteSkillFile(
skillName: string,
path: string,
): Promise<void> {
const url =
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/content` +
`?path=${encodeURIComponent(path)}`;
const response = await apiFetch(url, { method: "DELETE" });
if (!response.ok) {
throw await parseDetailedError(response, "Failed to delete skill file");
}
}
export interface SkillNameZhResponse { export interface SkillNameZhResponse {
name: string; name: string;
+35 -5
View File
@@ -5,8 +5,10 @@ import {
useQueryClient, useQueryClient,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { import {
deleteCustomSkill, deleteBuiltinSkill,
deleteCustomSkill,
deleteSkillFile,
duplicateSkill, duplicateSkill,
enableSkill, enableSkill,
generateMissingSkillNameZh, generateMissingSkillNameZh,
@@ -318,7 +320,7 @@ export function useSkillFileContent(skillName: string | null, path: string | nul
}); });
} }
export function useSaveSkillFile() { export function useSaveSkillFile() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ mutationFn: ({
@@ -339,5 +341,33 @@ export function useSaveSkillFile() {
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] }); void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
} }
}, },
}); });
} }
export function useDeleteSkillFile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
skillName,
path,
}: {
skillName: string;
path: string;
}) => deleteSkillFile(skillName, path),
onSuccess: (_data, { skillName, path }) => {
void queryClient.invalidateQueries({ queryKey: ["skill-files", skillName] });
void queryClient.removeQueries({ queryKey: ["skill-file", skillName, path] });
},
});
}
export function useDeleteBuiltinSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteBuiltinSkill,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] });
void queryClient.invalidateQueries({ queryKey: ["admin-skills"] });
},
});
}
@@ -291,6 +291,8 @@ export type CustomThemeBaseMode = "light" | "dark";
export interface CustomThemeColorOverrides { export interface CustomThemeColorOverrides {
button_hover?: string | null; button_hover?: string | null;
navigation_gradient_start?: string | null;
navigation_gradient_end?: string | null;
selected_background?: string | null; selected_background?: string | null;
border?: string | null; border?: string | null;
muted?: string | null; muted?: string | null;
@@ -13,6 +13,8 @@ const BLACK: Rgb = { r: 0, g: 0, b: 0 };
export const CUSTOM_THEME_ADVANCED_KEYS = [ export const CUSTOM_THEME_ADVANCED_KEYS = [
"button_hover", "button_hover",
"navigation_gradient_start",
"navigation_gradient_end",
"selected_background", "selected_background",
"border", "border",
"muted", "muted",
@@ -146,6 +148,18 @@ export function getCustomThemeTokens(theme: CustomThemeTokenInput): Record<strin
const primaryHover = optionalHexColor(overrides.button_hover) const primaryHover = optionalHexColor(overrides.button_hover)
? toRgb(overrides.button_hover!) ? toRgb(overrides.button_hover!)
: defaultPrimaryHover; : defaultPrimaryHover;
// Keep the same left → main-colour → right treatment used by the built-in
// themes. These two values remain automatic unless the administrator opens
// “更多配色” and explicitly overrides one of them.
const defaultNavigationGradientStart = mix(primary, BLACK, isDark ? 0.46 : 0.3);
const defaultNavigationGradientEnd = mix(primary, WHITE, isDark ? 0.14 : 0.42);
const navigationGradientStart = optionalHexColor(overrides.navigation_gradient_start)
? toRgb(overrides.navigation_gradient_start!)
: defaultNavigationGradientStart;
const navigationGradientEnd = optionalHexColor(overrides.navigation_gradient_end)
? toRgb(overrides.navigation_gradient_end!)
: defaultNavigationGradientEnd;
const navigationForeground = readableForeground(mix(navigationGradientStart, navigationGradientEnd, 0.5));
const primaryActive = isDark ? mix(primary, WHITE, 0.2) : mix(primary, BLACK, 0.2); const primaryActive = isDark ? mix(primary, WHITE, 0.2) : mix(primary, BLACK, 0.2);
const primaryForeground = optionalHexColor(overrides.primary_foreground) ?? readableForeground(primary); const primaryForeground = optionalHexColor(overrides.primary_foreground) ?? readableForeground(primary);
const buttonForeground = optionalHexColor(theme.text_color) ?? primaryForeground; const buttonForeground = optionalHexColor(theme.text_color) ?? primaryForeground;
@@ -168,6 +182,8 @@ export function getCustomThemeTokens(theme: CustomThemeTokenInput): Record<strin
const secondaryHex = toHex(secondary); const secondaryHex = toHex(secondary);
const primaryHoverHex = toHex(primaryHover); const primaryHoverHex = toHex(primaryHover);
const primaryActiveHex = toHex(primaryActive); const primaryActiveHex = toHex(primaryActive);
const navigationGradientStartHex = toHex(navigationGradientStart);
const navigationGradientEndHex = toHex(navigationGradientEnd);
const sidebarHex = toHex(sidebar); const sidebarHex = toHex(sidebar);
return { return {
@@ -177,6 +193,9 @@ export function getCustomThemeTokens(theme: CustomThemeTokenInput): Record<strin
"--brand-900": actionHex, "--brand-900": actionHex,
"--brand-950": actionActiveHex, "--brand-950": actionActiveHex,
"--theme-button-hover": primaryHoverHex, "--theme-button-hover": primaryHoverHex,
"--navigation-gradient-start": navigationGradientStartHex,
"--navigation-gradient-end": navigationGradientEndHex,
"--navigation-foreground": navigationForeground,
"--theme-selected-background": accentHex, "--theme-selected-background": accentHex,
"--background": backgroundHex, "--background": backgroundHex,
"--foreground": foreground, "--foreground": foreground,
+67 -37
View File
@@ -25,10 +25,13 @@ import { promptInputFilePartToFile, uploadFiles } from "../uploads";
import type { AgentThread, AgentThreadState } from "./types"; import type { AgentThread, AgentThreadState } from "./types";
export type ToolEndEvent = { export type ToolEndEvent = {
name: string; name: string;
data: unknown; data: unknown;
}; toolCallId?: string;
};
export type ToolStartEvent = ToolEndEvent;
export type ThreadStreamOptions = { export type ThreadStreamOptions = {
threadId?: string | null | undefined; threadId?: string | null | undefined;
@@ -47,10 +50,11 @@ export type ThreadStreamOptions = {
/** 额外写到新建 thread 的 metadata(与 agent_id 合并)。 /** 额外写到新建 thread 的 metadata(与 agent_id 合并)。
* 任务深链用 `{ taskId }` 把对话挂到任务下(1:N),列表按 metadata.taskId 过滤。 */ * 任务深链用 `{ taskId }` 把对话挂到任务下(1:N),列表按 metadata.taskId 过滤。 */
threadMetadata?: Record<string, unknown>; threadMetadata?: Record<string, unknown>;
onStart?: (threadId: string) => void; onStart?: (threadId: string) => void;
onFinish?: (state: AgentThreadState) => void; onFinish?: (state: AgentThreadState) => void;
onToolEnd?: (event: ToolEndEvent) => void; onToolStart?: (event: ToolStartEvent) => void;
}; onToolEnd?: (event: ToolEndEvent) => void;
};
type SendMessageOptions = { type SendMessageOptions = {
additionalKwargs?: Record<string, unknown>; additionalKwargs?: Record<string, unknown>;
@@ -447,10 +451,11 @@ export function useThreadStream({
assistantId, assistantId,
runOptions, runOptions,
threadMetadata, threadMetadata,
onStart, onStart,
onFinish, onFinish,
onToolEnd, onToolStart,
}: ThreadStreamOptions) { onToolEnd,
}: ThreadStreamOptions) {
const { t } = useI18n(); const { t } = useI18n();
const [localSettings] = useLocalSettings(); const [localSettings] = useLocalSettings();
// Track the thread ID that is currently streaming to handle thread changes during streaming // Track the thread ID that is currently streaming to handle thread changes during streaming
@@ -466,16 +471,17 @@ export function useThreadStream({
lastStreamActivityAtRef.current = Date.now(); lastStreamActivityAtRef.current = Date.now();
}, []); }, []);
const listeners = useRef({ const listeners = useRef({
onStart, onStart,
onFinish, onFinish,
onToolEnd, onToolStart,
}); onToolEnd,
});
// Keep listeners ref updated with latest callbacks // Keep listeners ref updated with latest callbacks
useEffect(() => { useEffect(() => {
listeners.current = { onStart, onFinish, onToolEnd }; listeners.current = { onStart, onFinish, onToolStart, onToolEnd };
}, [onStart, onFinish, onToolEnd]); }, [onStart, onFinish, onToolStart, onToolEnd]);
useEffect(() => { useEffect(() => {
const normalizedThreadId = threadId ?? null; const normalizedThreadId = threadId ?? null;
@@ -545,12 +551,6 @@ export function useThreadStream({
}, },
onLangChainEvent(event) { onLangChainEvent(event) {
markStreamActivity(); markStreamActivity();
if (event.event === "on_tool_end") {
listeners.current.onToolEnd?.({
name: event.name,
data: event.data,
});
}
// 操作日志(Y-Log):每一次大模型调用结束都各上报一条(一次 LLM 调用 = 一条), // 操作日志(Y-Log):每一次大模型调用结束都各上报一条(一次 LLM 调用 = 一条),
// 让问答日志尽量「多、全」——一个问答轮次里 lead agent / 子代理的每次模型调用 // 让问答日志尽量「多、全」——一个问答轮次里 lead agent / 子代理的每次模型调用
// 都会落一条。门控与失败静默都在 writeYLog 内部处理,绝不影响问答。 // 都会落一条。门控与失败静默都在 writeYLog 内部处理,绝不影响问答。
@@ -572,8 +572,32 @@ export function useThreadStream({
} catch { } catch {
/* 上报绝不影响问答 */ /* 上报绝不影响问答 */
} }
} }
}, },
// The SDK emits UI tool lifecycle events on the dedicated `tools` stream
// mode. `events` is a separate LangChain trace channel; using it for UI
// reactions can miss a tool that the conversation has already rendered.
onToolEvent:
onToolStart || onToolEnd
? (event) => {
markStreamActivity();
if (event.event === "on_tool_start") {
listeners.current.onToolStart?.({
name: event.name,
data: event.input,
toolCallId: event.toolCallId,
});
return;
}
if (event.event === "on_tool_end") {
listeners.current.onToolEnd?.({
name: event.name,
data: event.output,
toolCallId: event.toolCallId,
});
}
}
: undefined,
onUpdateEvent(data) { onUpdateEvent(data) {
markStreamActivity(); markStreamActivity();
const updates: Array<Partial<AgentThreadState> | null> = Object.values( const updates: Array<Partial<AgentThreadState> | null> = Object.values(
@@ -1122,20 +1146,26 @@ export function useThreadStream({
}), }),
); );
const ragModeEnabled = const mergedContext = {
localSettings.display.ragModeEnabled || isThreadRagModeLocked(threadId); ...context,
if (ragModeEnabled) { ...extraContext,
lockThreadRagMode(threadId); };
} const selectedLlmWikiIds = Array.isArray(
mergedContext.llmwiki_knowledge_base_ids,
)
? mergedContext.llmwiki_knowledge_base_ids
: [];
const ragModeEnabled =
localSettings.display.ragModeEnabled ||
isThreadRagModeLocked(threadId) ||
selectedLlmWikiIds.length > 0;
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
const contextCompressionEnabled = const contextCompressionEnabled =
localSettings.display.contextCompressionEnabled ?? false; localSettings.display.contextCompressionEnabled ?? false;
const mergedContext = {
...context,
...extraContext,
};
const hiddenContextMessages = normalizeHiddenContextMessages(options); const hiddenContextMessages = normalizeHiddenContextMessages(options);
const submittedMessages: Message[] = [ const submittedMessages: Message[] = [
...hiddenContextMessages.map((item) => ({ ...hiddenContextMessages.map((item) => ({
+7 -3
View File
@@ -23,9 +23,13 @@ export interface AgentThreadContext extends Record<string, unknown> {
rag_mode_enabled?: boolean; rag_mode_enabled?: boolean;
/** Per-request switch for context compression (上下文压缩). Default off. */ /** Per-request switch for context compression (上下文压缩). Default off. */
summarization_enabled?: boolean; summarization_enabled?: boolean;
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */ /** Per-conversation switch for knowledge-base RAG injection (phase 2). */
knowledge_rag_enabled?: boolean; knowledge_rag_enabled?: boolean;
agent_id?: string; /** DeerFlow LLMWiki mapping ids selected for this conversation. */
llmwiki_knowledge_base_ids?: string[];
/** Direct knowledge-base RAG mode; this request never enters Lead Agent. */
execution_profile?: "single_round_rag";
agent_id?: string;
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */ /** @deprecated kept for reading legacy thread metadata; prefer agent_id */
agent_name?: string; agent_name?: string;
} }
@@ -0,0 +1,378 @@
import type { Message } from "@langchain/langgraph-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { readRuntimeEnv } from "@/env";
const STORAGE_PREFIX = "workspace.single-round-rag.";
type KnowledgeBaseChunk = {
answer?: unknown;
docs?: unknown;
};
type RagSource = {
id?: string;
title: string;
type: string;
snippet: string;
content: string;
source?: string;
time?: string;
url?: string;
raw: Record<string, unknown>;
};
/** Same JSON-object framing used by the established GeneralQA single-RAG flow. */
function extractJsonObjects(raw: string): { objects: string[]; rest: string } {
const objects: string[] = [];
let depth = 0;
let stringQuote: '"' | "'" | null = null;
let escaped = false;
let start = -1;
for (let index = 0; index < raw.length; index += 1) {
const char = raw[index]!;
if (stringQuote) {
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === stringQuote) stringQuote = null;
continue;
}
if (char === '"' || char === "'") {
stringQuote = char;
continue;
}
if (char === "{") {
if (depth === 0) start = index;
depth += 1;
} else if (char === "}") {
depth -= 1;
if (depth === 0 && start !== -1) {
objects.push(raw.slice(start, index + 1));
start = -1;
}
}
}
return depth > 0 && start !== -1
? { objects, rest: raw.slice(start) }
: { objects, rest: "" };
}
/** Same `<think>` split semantics as GeneralQA, kept local to avoid loading its demo data. */
function parseSingleRagStream(chunks: { answer: string }[]) {
let thinkingMarkdown = "";
let answerMarkdown = "";
let inThink = false;
let thinkingComplete = false;
for (const { answer } of chunks) {
let remaining = answer;
while (remaining) {
if (inThink) {
const endIndex = remaining.indexOf("</think>");
if (endIndex === -1) {
thinkingMarkdown += remaining;
remaining = "";
} else {
thinkingMarkdown += remaining.slice(0, endIndex);
remaining = remaining.slice(endIndex + "</think>".length);
inThink = false;
thinkingComplete = true;
}
} else {
const startIndex = remaining.indexOf("<think>");
if (startIndex === -1) {
answerMarkdown += remaining;
remaining = "";
} else {
answerMarkdown += remaining.slice(0, startIndex);
remaining = remaining.slice(startIndex + "<think>".length);
inThink = true;
}
}
}
}
return { thinkingMarkdown, answerMarkdown, thinkingComplete };
}
function storageKey(threadId: string) {
return `${STORAGE_PREFIX}${threadId}`;
}
function readStoredMessages(threadId: string): Message[] {
if (typeof window === "undefined" || !threadId) return [];
try {
const raw = window.sessionStorage.getItem(storageKey(threadId));
const parsed: unknown = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? (parsed as Message[]) : [];
} catch {
return [];
}
}
function writeStoredMessages(threadId: string, messages: Message[]) {
if (typeof window === "undefined" || !threadId) return;
try {
window.sessionStorage.setItem(storageKey(threadId), JSON.stringify(messages));
} catch {
// Quota errors must never interrupt an already-running answer.
}
}
function parseLooseStringValue(rawValue: string): string {
try {
return JSON.parse(rawValue) as string;
} catch {
const value = rawValue.trim();
if (value.startsWith("'") && value.endsWith("'")) {
return value
.slice(1, -1)
.replace(/\\n/g, "\n")
.replace(/\\r/g, "\r")
.replace(/\\t/g, "\t")
.replace(/\\'/g, "'")
.replace(/\\\\/g, "\\");
}
throw new Error(`无法解析知识库流片段:${rawValue}`);
}
}
function parseKnowledgeBaseChunk(jsonText: string): KnowledgeBaseChunk {
try {
return JSON.parse(jsonText) as KnowledgeBaseChunk;
} catch {
const objectText = jsonText.trim();
const answerMatch = objectText.match(/^\{\s*answer\s*:\s*([\s\S]*)\s*\}$/);
if (answerMatch) {
return { answer: parseLooseStringValue(answerMatch[1]!.trim()) };
}
const docsMatch = objectText.match(/^\{\s*docs\s*:\s*([\s\S]*)\s*\}$/);
if (docsMatch) {
const rawValue = docsMatch[1]!.trim();
try {
return { docs: JSON.parse(rawValue) };
} catch {
return { docs: parseLooseStringValue(rawValue) };
}
}
const normalizedText = objectText.replace(
/([{,]\s*)(answer|docs)(\s*:)/g,
'$1"$2"$3',
);
return JSON.parse(normalizedText) as KnowledgeBaseChunk;
}
}
function flattenDocs(value: unknown): Record<string, unknown>[] {
if (Array.isArray(value)) {
return value.flatMap((item) => flattenDocs(item));
}
if (!value || typeof value !== "object") return [];
const record = value as Record<string, unknown>;
if ("docs" in record) return flattenDocs(record.docs);
return [record];
}
function normalizeSources(value: unknown): RagSource[] {
return flattenDocs(value).map((item, index) => {
const title = String(item.m_title ?? item.title ?? item.source ?? `参考资料 ${index + 1}`);
const content = String(item.m_content ?? item.content ?? item.snippet ?? "");
const source = item.source1 ?? item.source;
const rawUrl = item.url ?? item.link ?? item.href;
return {
id: String(item.recUuid ?? item.rec_uuid ?? item.uuid ?? item.id ?? "") || undefined,
title,
type: String(item.source1 ?? item.type ?? "知识库"),
snippet: content,
content,
source: typeof source === "string" ? source : undefined,
time: typeof item.m_publish === "string" ? item.m_publish : undefined,
url: typeof rawUrl === "string" && /^https?:\/\//i.test(rawUrl) ? rawUrl : undefined,
raw: item,
};
});
}
function createId(prefix: string) {
return `${prefix}-${crypto.randomUUID()}`;
}
function isAbortError(error: unknown) {
return error instanceof DOMException && error.name === "AbortError";
}
export function isSingleRoundRagMessage(message: Message): boolean {
return message.additional_kwargs?.single_round_rag === true;
}
/**
* Frontend-only adapter for the existing general-QA single-round endpoint.
* It deliberately never submits a run to DeerFlow or mutates the Lead Agent.
*/
export function useSingleRoundRagMessages(threadId: string) {
const [messages, setMessages] = useState<Message[]>(() => readStoredMessages(threadId));
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const controllerRef = useRef<AbortController | null>(null);
useEffect(() => {
setMessages(readStoredMessages(threadId));
setError(null);
}, [threadId]);
useEffect(() => () => controllerRef.current?.abort(), []);
const updateMessages = useCallback(
(targetThreadId: string, updater: (current: Message[]) => Message[]) => {
setMessages((current) => {
const next = updater(current);
writeStoredMessages(targetThreadId, next);
return next;
});
},
[],
);
const stop = useCallback(() => controllerRef.current?.abort(), []);
const submit = useCallback(
async ({
targetThreadId,
query,
modelName,
}: {
targetThreadId: string;
query: string;
modelName: string;
}) => {
if (isLoading) return;
const question = query.trim();
if (!question) return;
const humanId = createId("single-rag-human");
const assistantId = createId("single-rag-ai");
const sourceId = createId("single-rag-sources");
const clientTs = new Date().toISOString();
const userMessage: Message = {
id: humanId,
type: "human",
content: [{ type: "text", text: question }],
additional_kwargs: { client_ts: clientTs, single_round_rag: true },
};
const initialAssistant: Message = {
id: assistantId,
type: "ai",
content: "",
additional_kwargs: { single_round_rag: true },
};
updateMessages(targetThreadId, (current) => [...current, userMessage, initialAssistant]);
setError(null);
setIsLoading(true);
const controller = new AbortController();
controllerRef.current = controller;
const answerChunks: { answer: string }[] = [];
let sources: RagSource[] = [];
const refreshAssistant = () => {
const trace = parseSingleRagStream(answerChunks);
updateMessages(targetThreadId, (current) => {
const next = current.filter((message) => message.id !== sourceId);
const assistantIndex = next.findIndex((message) => message.id === assistantId);
if (assistantIndex < 0) return current;
next[assistantIndex] = {
...next[assistantIndex],
content: trace.answerMarkdown,
additional_kwargs: {
...next[assistantIndex]!.additional_kwargs,
single_round_rag: true,
reasoning_content: trace.thinkingMarkdown || null,
},
} as Message;
if (sources.length > 0) {
next.splice(assistantIndex, 0, {
id: sourceId,
type: "tool",
name: "knowledge_base_search",
content: { results: sources },
additional_kwargs: {
hide_from_ui: true,
single_round_rag: true,
},
} as unknown as Message);
}
return next;
});
};
try {
const chatUrl = readRuntimeEnv("VITE_AIPI_CHAT_URL") || "";
if (!chatUrl) {
throw new Error("未配置 VITE_AIPI_CHAT_URL,无法发起单轮 RAG 问答。");
}
const response = await fetch(
`${chatUrl.replace(/\/$/, "")}/chat/knowledge_base_chat_tiaoshi`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
signal: controller.signal,
body: JSON.stringify({
query: question,
knowledge_base_name: [],
top_k: 5,
score_threshold: 0.5,
history: [],
stream: true,
model_name: modelName,
temperature: 0.3,
prompt_name: "default",
local_doc_url: false,
}),
},
);
if (!response.ok) throw new Error(`知识库服务请求失败(${response.status})`);
if (!response.body) throw new Error("知识库服务未返回流式响应。");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const extracted = extractJsonObjects(buffer);
buffer = extracted.rest;
for (const jsonText of extracted.objects) {
const chunk = parseKnowledgeBaseChunk(jsonText);
if (typeof chunk.answer === "string") answerChunks.push({ answer: chunk.answer });
if (chunk.docs !== undefined) sources = normalizeSources(chunk.docs);
refreshAssistant();
}
}
} catch (caught) {
if (!isAbortError(caught)) {
const message = caught instanceof Error ? caught.message : "单轮 RAG 问答失败。";
setError(message);
updateMessages(targetThreadId, (current) =>
current.map((item) =>
item.id === assistantId
? ({
...item,
content: answerChunks.length > 0 ? item.content : message,
additional_kwargs: {
...item.additional_kwargs,
single_round_rag: true,
},
} as Message)
: item,
),
);
}
} finally {
if (controllerRef.current === controller) controllerRef.current = null;
setIsLoading(false);
}
},
[isLoading, updateMessages],
);
return { messages, isLoading, error, submit, stop };
}
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Check, Loader2, Save, Settings2, X } from "lucide-react"; import { Loader2, Save, Settings2, X } from "lucide-react";
import { getStoredAuth } from "@/core/auth"; import { getAccountDisplayName, getStoredAuth } from "@/core/auth";
import { PositionBusinessChainFlow } from "@/position-roundtable/components/PositionBusinessChainFlow";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage"; import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import { listChains } from "@/roundtable-planning/api/chains"; import { listChains } from "@/roundtable-planning/api/chains";
import type { BusinessChain } from "@/roundtable-planning/lib/business-chain"; import type { BusinessChain } from "@/roundtable-planning/lib/business-chain";
@@ -25,7 +26,9 @@ const BUSINESS_CODE = "8BF";
*/ */
export default function EightBfRoundtablePage({ compactChrome = false }: { compactChrome?: boolean }) { export default function EightBfRoundtablePage({ compactChrome = false }: { compactChrome?: boolean }) {
const auth = getStoredAuth(); const auth = getStoredAuth();
const isAdmin = auth?.system_role === "admin"; // 8BF 公共业务链由指定账号维护,不依赖外部透传的管理员角色。
// 与敏感词管理使用同一可信账户展示名规则(登录邮箱 @ 前缀)。
const canConfigureChain = getAccountDisplayName(auth).toLowerCase() === "lqq";
const storageTaskId = useMemo(() => `8bf:${auth?.user_id || "anonymous"}`, [auth?.user_id]); const storageTaskId = useMemo(() => `8bf:${auth?.user_id || "anonymous"}`, [auth?.user_id]);
const [mapping, setMapping] = useState<BusinessMapping | null>(null); const [mapping, setMapping] = useState<BusinessMapping | null>(null);
const [chains, setChains] = useState<BusinessChain[]>([]); const [chains, setChains] = useState<BusinessChain[]>([]);
@@ -34,6 +37,10 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
const [selectedChainId, setSelectedChainId] = useState(""); const [selectedChainId, setSelectedChainId] = useState("");
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const configuredChain = useMemo(
() => chains.find((chain) => chain.id === mapping?.chainId) ?? null,
[chains, mapping?.chainId],
);
const loadConfiguration = useCallback(async () => { const loadConfiguration = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
@@ -41,7 +48,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
try { try {
const [mappings, availableChains] = await Promise.all([ const [mappings, availableChains] = await Promise.all([
listBusinessMappings(), listBusinessMappings(),
listChains(isAdmin ? "all" : "public"), listChains("public"),
]); ]);
const nextMapping = mappings.find((item) => item.businessCode === BUSINESS_CODE) ?? null; const nextMapping = mappings.find((item) => item.businessCode === BUSINESS_CODE) ?? null;
// 8BF 只允许绑定已发布业务链;后端同样会进行二次校验。 // 8BF 只允许绑定已发布业务链;后端同样会进行二次校验。
@@ -54,13 +61,17 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [isAdmin]); }, []);
useEffect(() => { useEffect(() => {
void loadConfiguration(); void loadConfiguration();
}, [loadConfiguration]); }, [loadConfiguration]);
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
if (!canConfigureChain) {
setError("仅 lqq 账号可以配置 8BF 业务链条");
return;
}
const selected = chains.find((chain) => chain.id === selectedChainId); const selected = chains.find((chain) => chain.id === selectedChainId);
if (!selected) { if (!selected) {
setError("请选择一条已发布的业务链"); setError("请选择一条已发布的业务链");
@@ -81,17 +92,11 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}, [chains, selectedChainId]); }, [canConfigureChain, chains, selectedChainId]);
const toolbarSlot = ( const toolbarSlot = (
<> <>
<div className="eightbf-roundtable__chain-status" aria-live="polite"> {canConfigureChain && (
<span className="eightbf-roundtable__chain-status-label">8BF 业务链</span>
<span className="eightbf-roundtable__chain-status-value">
{isLoading ? "加载中…" : mapping?.chainTitle || "待管理员配置"}
</span>
</div>
{isAdmin && (
<button <button
type="button" type="button"
className="eightbf-roundtable__chain-settings-button" className="eightbf-roundtable__chain-settings-button"
@@ -101,7 +106,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
配置业务链 配置业务链
</button> </button>
)} )}
{isSettingsOpen && ( {canConfigureChain && isSettingsOpen && (
<div className="eightbf-roundtable__settings-mask" role="presentation"> <div className="eightbf-roundtable__settings-mask" role="presentation">
<section <section
className="eightbf-roundtable__settings-dialog" className="eightbf-roundtable__settings-dialog"
@@ -137,7 +142,7 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
))} ))}
</select> </select>
</label> </label>
{error && <p className="eightbf-roundtable__settings-error">{error}</p>} {error && <p className="eightbf-roundtable__settings-error" role="alert">{error}</p>}
<div className="eightbf-roundtable__settings-actions"> <div className="eightbf-roundtable__settings-actions">
<button type="button" onClick={() => setIsSettingsOpen(false)}>取消</button> <button type="button" onClick={() => setIsSettingsOpen(false)}>取消</button>
<button type="button" onClick={() => void handleSave()} disabled={isSaving}> <button type="button" onClick={() => void handleSave()} disabled={isSaving}>
@@ -148,7 +153,6 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
</section> </section>
</div> </div>
)} )}
{!isLoading && mapping?.chainId && <Check className="eightbf-roundtable__configured-icon h-4 w-4" aria-label="已配置" />}
</> </>
); );
@@ -159,6 +163,13 @@ export default function EightBfRoundtablePage({ compactChrome = false }: { compa
lockBusinessChain lockBusinessChain
storageTaskId={storageTaskId} storageTaskId={storageTaskId}
toolbarSlot={toolbarSlot} toolbarSlot={toolbarSlot}
entryRailBusinessChainSlot={
<PositionBusinessChainFlow
chain={configuredChain}
loading={isLoading}
className="eightbf-roundtable__business-chain-flow"
/>
}
rootClassName="eightbf-roundtable" rootClassName="eightbf-roundtable"
/> />
); );
@@ -21,4 +21,10 @@
.eightbf-roundtable .eightbf-roundtable__chain-status-value { color: #134e4a; font-weight: 600; } .eightbf-roundtable .eightbf-roundtable__chain-status-value { color: #134e4a; font-weight: 600; }
.eightbf-roundtable .eightbf-roundtable__configured-icon { color: var(--eightbf-accent); } .eightbf-roundtable .eightbf-roundtable__configured-icon { color: var(--eightbf-accent); }
/* 8BF 左上业务链区域:复用岗位会商的纵向节点、点阵画布与放大查看交互。 */
.eightbf-roundtable .eightbf-roundtable__business-chain-flow {
height: 500px;
min-height: 460px;
}
/* 可在此继续覆盖第 1 步的任务理解面板与业务链流程图样式。 */ /* 可在此继续覆盖第 1 步的任务理解面板与业务链流程图样式。 */
@@ -31,4 +31,49 @@
.eightbf-roundtable .eightbf-roundtable__settings-actions button:last-child { display: inline-flex; align-items: center; gap: 6px; background: #0f766e; color: #fff; } .eightbf-roundtable .eightbf-roundtable__settings-actions button:last-child { display: inline-flex; align-items: center; gap: 6px; background: #0f766e; color: #fff; }
.eightbf-roundtable .eightbf-roundtable__settings-actions button:disabled { cursor: not-allowed; opacity: .65; } .eightbf-roundtable .eightbf-roundtable__settings-actions button:disabled { cursor: not-allowed; opacity: .65; }
/* 管理员业务链配置弹窗:深色主题下与会商工作区使用同一套深蓝表面与高对比文字。 */
.dark .eightbf-roundtable .eightbf-roundtable__settings-mask {
background: rgb(2 12 30 / 72%);
backdrop-filter: blur(3px);
}
.dark .eightbf-roundtable .eightbf-roundtable__settings-dialog {
border: 1px solid rgb(69 195 243 / 42%);
background: #082b57;
box-shadow: 0 24px 56px rgb(0 0 0 / 45%);
}
.dark .eightbf-roundtable .eightbf-roundtable__settings-kicker { color: #65d6c6; }
.dark .eightbf-roundtable .eightbf-roundtable__settings-heading h2 { color: #f1f7ff; }
.dark .eightbf-roundtable .eightbf-roundtable__settings-close { color: #a9bddb; }
.dark .eightbf-roundtable .eightbf-roundtable__settings-close:hover { color: #fff; }
.dark .eightbf-roundtable .eightbf-roundtable__settings-tip { color: #b9c9df; }
.dark .eightbf-roundtable .eightbf-roundtable__settings-field { color: #e6effc; }
.dark .eightbf-roundtable .eightbf-roundtable__settings-field select {
border-color: rgb(143 196 255 / 48%);
background: #0d3768;
color: #f1f7ff;
color-scheme: dark;
}
.dark .eightbf-roundtable .eightbf-roundtable__settings-field select:focus {
border-color: #65d6c6;
outline: 2px solid rgb(101 214 198 / 28%);
outline-offset: 1px;
}
.dark .eightbf-roundtable .eightbf-roundtable__settings-field option { background: #0d3768; color: #f1f7ff; }
.dark .eightbf-roundtable .eightbf-roundtable__settings-error { color: #fca5a5; }
.dark .eightbf-roundtable .eightbf-roundtable__settings-actions button:first-child {
color: #b9c9df;
}
.dark .eightbf-roundtable .eightbf-roundtable__settings-actions button:first-child:hover {
background: rgb(143 196 255 / 12%);
color: #fff;
}
.dark .eightbf-roundtable .eightbf-roundtable__settings-actions button:last-child {
background: #167d74;
}
.dark .eightbf-roundtable .eightbf-roundtable__settings-actions button:last-child:hover {
background: #21958a;
}
/* 可在此继续覆盖第 3 步报告、流程图与放大弹窗的样式。 */ /* 可在此继续覆盖第 3 步报告、流程图与放大弹窗的样式。 */
+7
View File
@@ -20,6 +20,13 @@ export function readRuntimeFlag(key: string): boolean {
return readRuntimeEnv(key) === "true"; return readRuntimeEnv(key) === "true";
} }
/**
* Read an integer setting supplied by ``runtime-config.js``.
*
* Runtime configuration is editable after the frontend is built, so validate
* numeric values here instead of letting an invalid deployment value bypass a
* UI constraint that the backend cannot accept.
*/
export const env = { export const env = {
NEXT_PUBLIC_BACKEND_BASE_URL: readRuntimeEnv("VITE_BACKEND_BASE_URL"), NEXT_PUBLIC_BACKEND_BASE_URL: readRuntimeEnv("VITE_BACKEND_BASE_URL"),
NEXT_PUBLIC_LANGGRAPH_BASE_URL: readRuntimeEnv("VITE_LANGGRAPH_BASE_URL"), NEXT_PUBLIC_LANGGRAPH_BASE_URL: readRuntimeEnv("VITE_LANGGRAPH_BASE_URL"),
+139 -4
View File
@@ -16,6 +16,7 @@ import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { ChatBox, useThreadChat } from "@/components/workspace/chats"; import { ChatBox, useThreadChat } from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger"; import { ExportTrigger } from "@/components/workspace/export-trigger";
import { InputBox } from "@/components/workspace/input-box"; import { InputBox } from "@/components/workspace/input-box";
import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
import { MessageList } from "@/components/workspace/messages"; import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context"; import { ThreadContext } from "@/components/workspace/messages/context";
import { QuestionNavigator } from "@/components/workspace/question-navigator"; import { QuestionNavigator } from "@/components/workspace/question-navigator";
@@ -24,7 +25,9 @@ import { TodoList } from "@/components/workspace/todo-list";
import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator"; import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicator";
import { Tooltip } from "@/components/workspace/tooltip"; import { Tooltip } from "@/components/workspace/tooltip";
import { normalizeAgentQuestions, useAgent } from "@/core/agents"; import { normalizeAgentQuestions, useAgent } from "@/core/agents";
import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks"; import { useNotification } from "@/core/notification/hooks";
import { saveLastAgentId, useThreadSettings } from "@/core/settings"; import { saveLastAgentId, useThreadSettings } from "@/core/settings";
@@ -69,6 +72,26 @@ const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder";
const RWFX_SQ_REPORT_CONTEXT_MAX_CHARS = 12000; const RWFX_SQ_REPORT_CONTEXT_MAX_CHARS = 12000;
const RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS = 2000; const RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS = 2000;
function readPendingKnowledgeSelection(agentId: string | undefined): string[] | null {
if (typeof window === "undefined" || !agentId) return null;
try {
const raw = sessionStorage.getItem("pendingAgentMessage");
if (!raw) return null;
const parsed = JSON.parse(raw) as {
agentId?: string;
knowledgeBaseIds?: unknown;
};
if (parsed.agentId !== agentId || !Array.isArray(parsed.knowledgeBaseIds)) {
return null;
}
return parsed.knowledgeBaseIds
.map((item) => String(item).trim())
.filter(Boolean);
} catch {
return null;
}
}
function stringifyForHiddenContext(value: unknown): string { function stringifyForHiddenContext(value: unknown): string {
if (typeof value === "string") return value; if (typeof value === "string") return value;
try { try {
@@ -244,6 +267,79 @@ export default function AgentChatPage({
useThreadChat(); useThreadChat();
const [settings, setSettings] = useThreadSettings(threadId); const [settings, setSettings] = useThreadSettings(threadId);
const { tokenUsageEnabled } = useModels(); const { tokenUsageEnabled } = useModels();
const pendingKnowledgeSelection = useRef(
readPendingKnowledgeSelection(agent_id),
);
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>(
() => pendingKnowledgeSelection.current ?? [],
);
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(
() => pendingKnowledgeSelection.current !== null,
);
const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false);
const knowledgeSelectionThread = useRef<string | null>(null);
const knowledgeSelectionAgent = useRef(agent_id);
const activeThreadIdRef = useRef<string | null | undefined>(threadId);
const changeKnowledgeSelection = useCallback((ids: string[]) => {
setSelectedKnowledgeBaseIds(ids);
setKnowledgeSelectionReady(true);
}, []);
useEffect(() => {
activeThreadIdRef.current = threadId;
}, [threadId]);
useEffect(() => {
if (knowledgeSelectionAgent.current === agent_id) return;
knowledgeSelectionAgent.current = agent_id;
const pending = readPendingKnowledgeSelection(agent_id);
pendingKnowledgeSelection.current = pending;
knowledgeSelectionThread.current = null;
setSelectedKnowledgeBaseIds(pending ?? []);
setKnowledgeSelectionReady(pending !== null);
}, [agent_id]);
useEffect(() => {
if (isNewThread) {
if (knowledgeSelectionThread.current !== "__new__") {
setSelectedKnowledgeBaseIds(pendingKnowledgeSelection.current ?? []);
setKnowledgeSelectionReady(pendingKnowledgeSelection.current !== null);
}
knowledgeSelectionThread.current = "__new__";
setKnowledgeSelectionLoading(false);
return;
}
if (!threadId || knowledgeSelectionThread.current === threadId) return;
knowledgeSelectionThread.current = threadId;
setKnowledgeSelectionLoading(true);
void getAPIClient()
.threads.get(threadId)
.then((record) => {
if (knowledgeSelectionThread.current !== threadId) return;
const metadata = record.metadata as Record<string, unknown> | undefined;
const raw = metadata?.llmwiki_knowledge_base_ids;
if (Array.isArray(raw)) {
setSelectedKnowledgeBaseIds(
raw.map((item) => String(item).trim()).filter(Boolean),
);
setKnowledgeSelectionReady(true);
} else {
setSelectedKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
}
})
.catch(() => {
if (knowledgeSelectionThread.current === threadId) {
setKnowledgeSelectionReady(false);
}
})
.finally(() => {
if (knowledgeSelectionThread.current === threadId) {
setKnowledgeSelectionLoading(false);
}
});
}, [isNewThread, threadId]);
useEffect(() => { useEffect(() => {
if (isNewThread) { if (isNewThread) {
@@ -253,14 +349,34 @@ export default function AgentChatPage({
}, [isNewThread, threadId, agent_id]); }, [isNewThread, threadId, agent_id]);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const conversationMetadata = useMemo(
() => ({
...(taskId ? taskThreadMetadata(taskContext) : {}),
...(knowledgeSelectionReady
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
: {}),
}),
[knowledgeSelectionReady, selectedKnowledgeBaseIds, taskContext, taskId],
);
const [thread, sendMessage] = useThreadStream({ const [thread, sendMessage] = useThreadStream({
threadId: isNewThread ? undefined : threadId, threadId: isNewThread ? undefined : threadId,
assistantId: agent_id, assistantId: agent_id,
context: { ...settings.context, agent_id }, context: {
...settings.context,
agent_id,
...(knowledgeSelectionReady
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
: {}),
},
// 任务闸门:新建 thread 打 metadata.taskId 标签,供任务对话列表(1:N)按任务过滤; // 任务闸门:新建 thread 打 metadata.taskId 标签,供任务对话列表(1:N)按任务过滤;
// xdfx 额外打 metadata.actionId 标签,按「行动id」划分聊天记录(见 taskThreadMetadata)。 // xdfx 额外打 metadata.actionId 标签,按「行动id」划分聊天记录(见 taskThreadMetadata)。
threadMetadata: taskId ? taskThreadMetadata(taskContext) : undefined, threadMetadata:
Object.keys(conversationMetadata).length > 0
? conversationMetadata
: undefined,
onStart: (createdThreadId) => { onStart: (createdThreadId) => {
knowledgeSelectionThread.current = createdThreadId;
activeThreadIdRef.current = createdThreadId;
setThreadId(createdThreadId); setThreadId(createdThreadId);
setIsNewThread(false); setIsNewThread(false);
// Keep the current component mounted while updating the hash route. // Keep the current component mounted while updating the hash route.
@@ -286,6 +402,12 @@ export default function AgentChatPage({
} catch { } catch {
/* 上报绝不影响问答 */ /* 上报绝不影响问答 */
} }
queueLlmWikiConversationDeposit({
threadId: activeThreadIdRef.current,
messages: state.messages,
assistantId: agent_id,
knowledgeBaseIds: selectedKnowledgeBaseIds,
});
if (document.hidden || !document.hasFocus()) { if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished"; let body = "Conversation finished";
const lastMessage = state.messages[state.messages.length - 1]; const lastMessage = state.messages[state.messages.length - 1];
@@ -581,10 +703,23 @@ export default function AgentChatPage({
autoFocus autoFocus
status={status} status={status}
context={settings.context} context={settings.context}
disabled={isDemoMode} disabled={isDemoMode || knowledgeSelectionLoading}
onContextChange={(context) => onContextChange={(context) =>
setSettings("context", context) setSettings("context", context)
} }
knowledgeSelector={
<LlmWikiKnowledgeSelector
source="agent"
agentId={agent_id}
value={selectedKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
autoSelectAll={
isNewThread && pendingKnowledgeSelection.current === null
}
disabled={isDemoMode || knowledgeSelectionLoading}
label="知识空间"
/>
}
onFollowupsVisibilityChange={setShowFollowups} onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onStop={handleStop} onStop={handleStop}
@@ -636,7 +771,7 @@ export default function AgentChatPage({
assistantId={agent_id} assistantId={agent_id}
status={status} status={status}
context={settings.context} context={settings.context}
disabled={isDemoMode} disabled={isDemoMode || knowledgeSelectionLoading}
onContextChange={(context) => onContextChange={(context) =>
setSettings("context", context) setSettings("context", context)
} }
+190 -12
View File
@@ -16,6 +16,7 @@ import {
} from "@/components/workspace/chats"; } from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger"; import { ExportTrigger } from "@/components/workspace/export-trigger";
import { KnowledgeSaveTrigger } from "@/components/workspace/knowledge-save-trigger"; import { KnowledgeSaveTrigger } from "@/components/workspace/knowledge-save-trigger";
import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
import { InputBox } from "@/components/workspace/input-box"; import { InputBox } from "@/components/workspace/input-box";
import { import {
MessageList, MessageList,
@@ -44,6 +45,10 @@ import { PanelLeftOpen } from "lucide-react";
import { useAgentSelector, useAgentsPaginated } from "@/core/agents"; import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
import {
isSingleRoundRagProfile,
SINGLE_ROUND_RAG_MODELS,
} from "@/core/models/single-round-rag";
import { useNotification } from "@/core/notification/hooks"; import { useNotification } from "@/core/notification/hooks";
import { import {
getLastAgentId, getLastAgentId,
@@ -52,6 +57,8 @@ import {
useThreadSettings, useThreadSettings,
} from "@/core/settings"; } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks"; import { useThreadStream } from "@/core/threads/hooks";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useSingleRoundRagMessages } from "@/core/threads/use-single-round-rag";
import { useLayoutMode } from "@/core/system-settings/hooks"; import { useLayoutMode } from "@/core/system-settings/hooks";
import { useRouteBase } from "@/core/route-base"; import { useRouteBase } from "@/core/route-base";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
@@ -121,12 +128,29 @@ export default function ChatPage() {
// In-memory only — explicitly overrides any value persisted by the old // In-memory only — explicitly overrides any value persisted by the old
// per-thread header toggle so stale localStorage can't re-enable it. // per-thread header toggle so stale localStorage can't re-enable it.
const [knowledgeRagEnabled] = useKnowledgeRagEnabled(); const [knowledgeRagEnabled] = useKnowledgeRagEnabled();
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]);
const [knowledgeSelectionReady, setKnowledgeSelectionReady] = useState(false);
const [knowledgeSelectionLoading, setKnowledgeSelectionLoading] = useState(false);
const knowledgeSelectionThread = useRef<string | null>(null);
const activeThreadIdRef = useRef<string | null | undefined>(threadId);
const changeKnowledgeSelection = useCallback((ids: string[]) => {
setSelectedKnowledgeBaseIds(ids);
setKnowledgeSelectionReady(true);
}, []);
const streamContext = useMemo( const streamContext = useMemo(
() => ({ () => ({
...settings.context, ...settings.context,
knowledge_rag_enabled: knowledgeRagEnabled, knowledge_rag_enabled: knowledgeRagEnabled,
...(knowledgeSelectionReady
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
: {}),
}), }),
[settings.context, knowledgeRagEnabled], [
settings.context,
knowledgeRagEnabled,
knowledgeSelectionReady,
selectedKnowledgeBaseIds,
],
); );
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const { tokenUsageEnabled } = useModels(); const { tokenUsageEnabled } = useModels();
@@ -150,6 +174,10 @@ export default function ChatPage() {
const { selectedArtifact } = useArtifacts(); const { selectedArtifact } = useArtifacts();
useSpecificChatMode(); useSpecificChatMode();
useEffect(() => {
activeThreadIdRef.current = threadId;
}, [threadId]);
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
}, []); }, []);
@@ -158,12 +186,54 @@ export default function ChatPage() {
if (isNewThread) { if (isNewThread) {
setHasStartedConversation(false); setHasStartedConversation(false);
setPromptPrefixOn(false); setPromptPrefixOn(false);
setSelectedKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
setKnowledgeSelectionLoading(false);
knowledgeSelectionThread.current = "__new__";
// Pre-select the agent the user last started a conversation with so a // Pre-select the agent the user last started a conversation with so a
// fresh chat defaults to it. Validated against the known agent list below. // fresh chat defaults to it. Validated against the known agent list below.
setSelectedAgentId(getLastAgentId()); setSelectedAgentId(getLastAgentId());
} }
}, [isNewThread, threadId]); }, [isNewThread, threadId]);
// The knowledge-base choice belongs to the conversation, not to a browser
// component instance. Restore it from LangGraph thread metadata whenever an
// existing conversation is opened. An explicit empty array means the user
// deliberately disabled LLMWiki retrieval for that conversation.
useEffect(() => {
if (isNewThread || !threadId || knowledgeSelectionThread.current === threadId) {
return;
}
knowledgeSelectionThread.current = threadId;
setKnowledgeSelectionLoading(true);
void getAPIClient(isMock)
.threads.get(threadId)
.then((record) => {
if (knowledgeSelectionThread.current !== threadId) return;
const metadata = record.metadata as Record<string, unknown> | undefined;
const raw = metadata?.llmwiki_knowledge_base_ids;
if (Array.isArray(raw)) {
setSelectedKnowledgeBaseIds(
raw.map((item) => String(item).trim()).filter(Boolean),
);
setKnowledgeSelectionReady(true);
} else {
setSelectedKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
}
})
.catch(() => {
if (knowledgeSelectionThread.current === threadId) {
setKnowledgeSelectionReady(false);
}
})
.finally(() => {
if (knowledgeSelectionThread.current === threadId) {
setKnowledgeSelectionLoading(false);
}
});
}, [isMock, isNewThread, threadId]);
// Drop a restored last-used agent that is no longer in the selector list // Drop a restored last-used agent that is no longer in the selector list
// (deleted / unpublished / no longer curated), so we never hand off to a // (deleted / unpublished / no longer curated), so we never hand off to a
// dead agent. Wait for the list to load before deciding. // dead agent. Wait for the list to load before deciding.
@@ -175,6 +245,8 @@ export default function ChatPage() {
if (known.length === 0) return; if (known.length === 0) return;
if (!known.some((a) => a.id === selectedAgentId)) { if (!known.some((a) => a.id === selectedAgentId)) {
setSelectedAgentId(null); setSelectedAgentId(null);
setSelectedKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
} }
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]); }, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
@@ -183,8 +255,13 @@ export default function ChatPage() {
const [thread, sendMessage, isUploading] = useThreadStream({ const [thread, sendMessage, isUploading] = useThreadStream({
threadId: isNewThread ? undefined : threadId, threadId: isNewThread ? undefined : threadId,
context: streamContext, context: streamContext,
threadMetadata: knowledgeSelectionReady
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
: undefined,
isMock, isMock,
onStart: (createdThreadId) => { onStart: (createdThreadId) => {
knowledgeSelectionThread.current = createdThreadId;
activeThreadIdRef.current = createdThreadId;
setThreadId(createdThreadId); setThreadId(createdThreadId);
setIsNewThread(false); setIsNewThread(false);
// Keep the current component mounted while updating the hash route. // Keep the current component mounted while updating the hash route.
@@ -210,6 +287,12 @@ export default function ChatPage() {
} catch { } catch {
/* 上报绝不影响问答 */ /* 上报绝不影响问答 */
} }
queueLlmWikiConversationDeposit({
threadId: activeThreadIdRef.current,
messages: state.messages,
assistantId: selectedAgentId,
knowledgeBaseIds: selectedKnowledgeBaseIds,
});
if (document.hidden || !document.hasFocus()) { if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished"; let body = "Conversation finished";
const lastMessage = state.messages.at(-1); const lastMessage = state.messages.at(-1);
@@ -226,6 +309,20 @@ export default function ChatPage() {
} }
}, },
}); });
const singleRoundRag = useSingleRoundRagMessages(threadId);
const singleRoundRagSelected = isSingleRoundRagProfile(settings.context);
const displayThread = useMemo(() => {
if (singleRoundRag.messages.length === 0 && !singleRoundRag.isLoading) {
return thread;
}
const messages = [...thread.messages, ...singleRoundRag.messages];
return {
...thread,
values: { ...thread.values, messages },
messages,
isLoading: thread.isLoading || singleRoundRag.isLoading,
} as typeof thread;
}, [singleRoundRag.isLoading, singleRoundRag.messages, thread]);
const writingArtifactPath = useMemo(() => { const writingArtifactPath = useMemo(() => {
const isMarkdown = (path: string | null | undefined) => const isMarkdown = (path: string | null | undefined) =>
@@ -240,6 +337,46 @@ export default function ChatPage() {
const handleSubmit = useCallback( const handleSubmit = useCallback(
(message: PromptInputMessage) => { (message: PromptInputMessage) => {
if (singleRoundRagSelected) {
if (message.files && message.files.length > 0) {
toast.error("单轮 RAG 问答暂不支持附件,请移除附件后重试。");
return;
}
setHasStartedConversation(true);
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void (async () => {
try {
let targetThreadId = threadId;
if (isNewThread) {
const created = await getAPIClient(isMock).threads.create({});
targetThreadId = created.thread_id;
setThreadId(targetThreadId);
setIsNewThread(false);
history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}#${routeBase}/chats/${targetThreadId}`,
);
const title = message.text.trim().replace(/\s+/g, " ").slice(0, 60);
if (title) {
void getAPIClient(isMock).threads
.updateState(targetThreadId, { values: { title } })
.catch(() => undefined);
}
}
await singleRoundRag.submit({
targetThreadId,
query: message.text,
modelName: settings.context.model_name ?? "OpenAI3",
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "单轮 RAG 问答失败。");
}
})();
return;
}
// If the user picked an agent before sending the opening turn, hand off // If the user picked an agent before sending the opening turn, hand off
// to AgentChatPage so the conversation runs against that agent. We stash // to AgentChatPage so the conversation runs against that agent. We stash
// the pending text in sessionStorage because the next/navigation shim // the pending text in sessionStorage because the next/navigation shim
@@ -262,7 +399,12 @@ export default function ChatPage() {
void (async () => { void (async () => {
try { try {
const created = await getAPIClient(isMock).threads.create({ const created = await getAPIClient(isMock).threads.create({
metadata: { agent_id: selectedAgentId }, metadata: {
agent_id: selectedAgentId,
...(knowledgeSelectionReady
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
: {}),
},
}); });
sessionStorage.setItem( sessionStorage.setItem(
"pendingAgentMessage", "pendingAgentMessage",
@@ -270,6 +412,9 @@ export default function ChatPage() {
agentId: selectedAgentId, agentId: selectedAgentId,
threadId: created.thread_id, threadId: created.thread_id,
text, text,
...(knowledgeSelectionReady
? { knowledgeBaseIds: selectedKnowledgeBaseIds }
: {}),
}), }),
); );
saveLastAgentId(selectedAgentId); saveLastAgentId(selectedAgentId);
@@ -328,11 +473,19 @@ export default function ChatPage() {
hideAdminPrompt, hideAdminPrompt,
preferencesLoading, preferencesLoading,
selectedAgentId, selectedAgentId,
selectedKnowledgeBaseIds,
knowledgeSelectionReady,
agentHandoffPending, agentHandoffPending,
isMock, isMock,
router, router,
settings.context.writing_mode, settings.context.writing_mode,
settings.context.model_name,
writingArtifactPath, writingArtifactPath,
singleRoundRag,
singleRoundRagSelected,
routeBase,
setIsNewThread,
setThreadId,
], ],
); );
@@ -418,11 +571,17 @@ export default function ChatPage() {
// Passing null (the ✕ clear) wipes the stored preference. // Passing null (the ✕ clear) wipes the stored preference.
const handleAgentChange = useCallback((agentId: string | null) => { const handleAgentChange = useCallback((agentId: string | null) => {
setSelectedAgentId(agentId); setSelectedAgentId(agentId);
setSelectedKnowledgeBaseIds([]);
setKnowledgeSelectionReady(false);
saveLastAgentId(agentId); saveLastAgentId(agentId);
}, []); }, []);
const handleStop = useCallback(async () => { const handleStop = useCallback(async () => {
if (singleRoundRag.isLoading) {
singleRoundRag.stop();
return;
}
await thread.stop(); await thread.stop();
}, [thread]); }, [singleRoundRag, thread]);
const promptPrefixProp = useMemo(() => { const promptPrefixProp = useMemo(() => {
if (!promptPrefixSettings?.enabled) return undefined; if (!promptPrefixSettings?.enabled) return undefined;
@@ -468,7 +627,7 @@ export default function ChatPage() {
const compactActionsHidden = isHideChromeActive(); const compactActionsHidden = isHideChromeActive();
return ( return (
<ThreadContext.Provider value={{ thread, isMock }}> <ThreadContext.Provider value={{ thread: displayThread, isMock }}>
<ChatBox threadId={threadId}> <ChatBox threadId={threadId}>
<div <div
className={cn( className={cn(
@@ -486,7 +645,7 @@ export default function ChatPage() {
)} )}
> >
<div className="flex w-full items-center text-sm font-medium"> <div className="flex w-full items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} /> <ThreadTitle threadId={threadId} thread={displayThread} />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */} {/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
@@ -505,7 +664,7 @@ export default function ChatPage() {
)} )}
<TokenUsageIndicator <TokenUsageIndicator
enabled={tokenUsageEnabled} enabled={tokenUsageEnabled}
messages={thread.messages} messages={displayThread.messages}
/> />
<ExportTrigger threadId={threadId} /> <ExportTrigger threadId={threadId} />
<KnowledgeSaveTrigger threadId={threadId} /> <KnowledgeSaveTrigger threadId={threadId} />
@@ -543,7 +702,7 @@ export default function ChatPage() {
<MessageList <MessageList
className={cn("size-full", "pt-10")} className={cn("size-full", "pt-10")}
threadId={threadId} threadId={threadId}
thread={thread} thread={displayThread}
paddingBottom={messageListPaddingBottom} paddingBottom={messageListPaddingBottom}
tokenUsageEnabled={tokenUsageEnabled} tokenUsageEnabled={tokenUsageEnabled}
// 输入框为底部绝对浮层(约 160px 高),按钮上移到其上方居中。 // 输入框为底部绝对浮层(约 160px 高),按钮上移到其上方居中。
@@ -585,9 +744,9 @@ export default function ChatPage() {
<div className="absolute right-0 bottom-0 left-0"> <div className="absolute right-0 bottom-0 left-0">
<TodoList <TodoList
className="bg-background/5" className="bg-background/5"
todos={thread.values.todos ?? []} todos={displayThread.values.todos ?? []}
hidden={ hidden={
!thread.values.todos || thread.values.todos.length === 0 !displayThread.values.todos || displayThread.values.todos.length === 0
} }
/> />
</div> </div>
@@ -612,15 +771,18 @@ export default function ChatPage() {
status={ status={
thread.error thread.error
? "error" ? "error"
: thread.isLoading : displayThread.isLoading
? "streaming" ? "streaming"
: "ready" : "ready"
} }
context={settings.context} context={settings.context}
extraModels={SINGLE_ROUND_RAG_MODELS}
disabled={ disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading || isUploading ||
agentHandoffPending agentHandoffPending ||
knowledgeSelectionLoading ||
singleRoundRag.isLoading
} }
onContextChange={(context) => onContextChange={(context) =>
setSettings("context", context) setSettings("context", context)
@@ -634,6 +796,22 @@ export default function ChatPage() {
} }
: undefined : undefined
} }
knowledgeSelector={
isLandingState ? (
<LlmWikiKnowledgeSelector
source={selectedAgentId ? "agent" : "all"}
agentId={selectedAgentId}
value={selectedKnowledgeBaseIds}
onChange={changeKnowledgeSelection}
autoSelectAll={!!selectedAgentId}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
knowledgeSelectionLoading
}
label="知识空间"
/>
) : undefined
}
onFollowupsVisibilityChange={setShowFollowups} onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onStop={handleStop} onStop={handleStop}
@@ -664,7 +842,7 @@ export default function ChatPage() {
*/} */}
{!isLandingState && ( {!isLandingState && (
<QuestionNavigator <QuestionNavigator
messages={thread.messages} messages={displayThread.messages}
className="top-16 right-0 bottom-48 left-0" className="top-16 right-0 bottom-48 left-0"
/> />
)} )}
+8 -96
View File
@@ -2,14 +2,12 @@
import { import {
BookMarkedIcon, BookMarkedIcon,
BotIcon,
BrainCircuitIcon, BrainCircuitIcon,
FileTextIcon, FileTextIcon,
GraduationCapIcon, GraduationCapIcon,
ShrinkIcon, ShrinkIcon,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input"; import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { useArtifacts } from "@/components/workspace/artifacts"; import { useArtifacts } from "@/components/workspace/artifacts";
@@ -27,7 +25,7 @@ import { TodoList } from "@/components/workspace/todo-list";
import { Tooltip } from "@/components/workspace/tooltip"; import { Tooltip } from "@/components/workspace/tooltip";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { useAgentSelector, useAgentsPaginated } from "@/core/agents"; import { useAgentSelector } from "@/core/agents";
import { resolveIncomingIframeSelection } from "@/core/plugin-management/domain-agent"; import { resolveIncomingIframeSelection } from "@/core/plugin-management/domain-agent";
import { getAccessToken } from "@/core/auth"; import { getAccessToken } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
@@ -45,7 +43,6 @@ import { usePluginManagementSettings } from "@/core/system-settings/hooks";
import { useThreadStream } from "@/core/threads/hooks"; import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env"; import { env } from "@/env";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
type BrainyChatSubmitMessage, type BrainyChatSubmitMessage,
@@ -130,8 +127,6 @@ function postBrainyAuthToken(accessToken: string) {
export default function IframeChatPage() { export default function IframeChatPage() {
const routeBase = useRouteBase(); const routeBase = useRouteBase();
const location = useLocation();
const navigate = useNavigate();
const { t } = useI18n(); const { t } = useI18n();
const [hasStartedConversation, setHasStartedConversation] = useState(false); const [hasStartedConversation, setHasStartedConversation] = useState(false);
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } = const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
@@ -147,34 +142,6 @@ export default function IframeChatPage() {
useState<PendingIframeSubmit | null>(null); useState<PendingIframeSubmit | null>(null);
const { agents: selectorAgents, isLoading: selectorLoading } = const { agents: selectorAgents, isLoading: selectorLoading } =
useAgentSelector(); useAgentSelector();
// The protocol page must still be escapable when no curated selector list
// has been configured. Include the user's own and public agents in this
// page's native fallback picker. A native select also remains interactive
// in iframe/micro-frontend environments where a portal dialog can fail.
const { agents: mineAgents } = useAgentsPaginated({
scope: "mine",
page: 1,
pageSize: 100,
});
const { agents: squareAgents } = useAgentsPaginated({
scope: "square",
page: 1,
pageSize: 100,
});
const fallbackAgents = useMemo(() => {
const uniqueAgents = new Map<
string,
(typeof selectorAgents)[number]
>();
for (const agent of [
...selectorAgents,
...mineAgents,
...squareAgents,
]) {
uniqueAgents.set(agent.id, agent);
}
return [...uniqueAgents.values()];
}, [mineAgents, selectorAgents, squareAgents]);
const { data: pluginManagementSettings } = usePluginManagementSettings(); const { data: pluginManagementSettings } = usePluginManagementSettings();
const pendingRequestIdRef = useRef<string | null>(null); const pendingRequestIdRef = useRef<string | null>(null);
const handleSubmitRef = useRef< const handleSubmitRef = useRef<
@@ -383,30 +350,6 @@ export default function IframeChatPage() {
saveLastAgentId(agentId); saveLastAgentId(agentId);
}, []); }, []);
/**
* Manual fallback for accidental protocol-page visits.
*
* A real Brainy integration drives this page with postMessage. A human who
* reaches it directly should not be trapped here: move them to the standard
* chat workspace, optionally opening the selected agent.
*/
const enterStandardChat = useCallback(
(agentId?: string) => {
const params = new URLSearchParams(location.search);
params.delete("from");
params.set("chatMode", "full");
params.delete("agent");
if (agentId) {
params.set("agent", agentId);
saveLastAgentId(agentId);
}
navigate(`${routeBase}/chats/new?${params.toString()}`, {
replace: true,
});
},
[location.search, navigate, routeBase],
);
useEffect(() => { useEffect(() => {
handleSubmitRef.current = handleSubmit; handleSubmitRef.current = handleSubmit;
}, [handleSubmit]); }, [handleSubmit]);
@@ -705,46 +648,15 @@ export default function IframeChatPage() {
</div> </div>
</div> </div>
{isLandingState && mounted && ( {isLandingState && mounted && (
<div className="mb-6 flex min-h-40 flex-col items-center justify-center px-4 text-center"> <div className="mb-6 flex min-h-40 flex-col items-center justify-center">
<div className="text-2xl font-medium">你好</div> <div className="text-2xl font-medium">你好</div>
<div className="mt-2 text-sm text-muted-foreground"> <div className="mt-4">
选择智能体进入问答 <AgentSelector
selectedAgentId={selectedAgentId}
onAgentChange={handleAgentChange}
disabled={isUploading}
/>
</div> </div>
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
<label className="relative inline-flex h-9 w-40 shrink-0 items-center rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3 text-[#7e6a57] shadow-sm transition-colors hover:bg-white">
<BotIcon className="mr-2 size-3.5 shrink-0" />
<select
aria-label="选择智能体并进入问答"
defaultValue=""
disabled={isUploading || selectorLoading}
onChange={(event) => {
const agentId = event.target.value;
if (agentId) enterStandardChat(agentId);
}}
className="min-w-0 flex-1 cursor-pointer appearance-none bg-transparent pr-5 text-xs outline-none disabled:cursor-wait"
>
<option value="" disabled>
{selectorLoading ? "正在加载智能体…" : "选择 Agent"}
</option>
{fallbackAgents.map((agent) => (
<option key={agent.id} value={agent.id}>
{desensitize(agent.name)}
</option>
))}
</select>
<span
aria-hidden
className="pointer-events-none absolute right-3 text-[10px]"
>
▾
</span>
</label>
</div>
{!selectorLoading && fallbackAgents.length === 0 && (
<div className="mt-3 text-xs text-muted-foreground">
暂无可选智能体
</div>
)}
</div> </div>
)} )}
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && ( {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
+7 -2
View File
@@ -1,5 +1,5 @@
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react"; import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -530,7 +530,11 @@ function VaultPageDialog({
); );
} }
export default function KnowledgeBasePage() { export default function KnowledgeBasePage({
headerExtraActions,
}: {
headerExtraActions?: ReactNode;
} = {}) {
const navigate = useNavigate(); const navigate = useNavigate();
const { noteId } = useParams<{ noteId: string }>(); const { noteId } = useParams<{ noteId: string }>();
@@ -653,6 +657,7 @@ export default function KnowledgeBasePage() {
onCreate={() => setCreateOpen(true)} onCreate={() => setCreateOpen(true)}
onManageFolders={() => setFoldersOpen(true)} onManageFolders={() => setFoldersOpen(true)}
onManageTemplates={() => setTemplatesOpen(true)} onManageTemplates={() => setTemplatesOpen(true)}
extraActions={headerExtraActions}
/> />
<div <div
@@ -0,0 +1,91 @@
import { Loader2, Settings2 } from "lucide-react";
import { useState } from "react";
import { Navigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { getStoredAuth } from "@/core/auth";
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
import WeKnoraKnowledgeWorkspace, {
ConnectionSettings,
} from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeWorkspace";
import WeKnoraKnowledgeDetailPage from "@/strategy-components/components/resource-management/llmwiki/WeKnoraKnowledgeDetailPage";
import KnowledgeBasePage from "./KnowledgeBasePage";
import KnowledgeGraphPage from "./KnowledgeGraphPage";
type LlmWikiKnowledgePageMode = "list" | "detail" | "graph";
function RuntimeLoading() {
return (
<div className="flex h-full min-h-64 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 size-5 animate-spin" />
正在读取 LLMWiki 运行模式
</div>
);
}
function AdminWeKnoraSettingsButton() {
const [open, setOpen] = useState(false);
return (
<>
<Button size="sm" variant="outline" onClick={() => setOpen(true)}>
<Settings2 className="mr-2 size-4" />
配置 WeKnora
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>LLMWiki 运行模式</DialogTitle>
</DialogHeader>
<ConnectionSettings />
</DialogContent>
</Dialog>
</>
);
}
function LegacyKnowledgePage() {
const isAdmin = getStoredAuth()?.system_role === "admin";
return (
<KnowledgeBasePage
headerExtraActions={isAdmin ? <AdminWeKnoraSettingsButton /> : undefined}
/>
);
}
export default function LlmWikiKnowledgePage({
mode = "list",
}: {
mode?: LlmWikiKnowledgePageMode;
}) {
const runtime = useLlmWikiRuntime();
if (runtime.isLoading) {
return <RuntimeLoading />;
}
if (runtime.isError || runtime.data?.provider !== "weknora") {
if (mode === "graph") {
return <KnowledgeGraphPage />;
}
return <LegacyKnowledgePage />;
}
if (mode === "graph") {
return <Navigate to="/page/workspace/knowledge" replace />;
}
if (mode === "detail") {
return <WeKnoraKnowledgeDetailPage />;
}
return <WeKnoraKnowledgeWorkspace />;
}
+81 -17
View File
@@ -10,6 +10,7 @@ import { setStoredAuth, setStoredUserName } from '@/core/auth';
import { import {
getRouteKeyLoginPassword, getRouteKeyLoginPassword,
resolveRouteKey, resolveRouteKey,
resolveRouteKeyMapping,
ROUTE_KEY_PARAM, ROUTE_KEY_PARAM,
} from '@/core/auth/route-key-login'; } from '@/core/auth/route-key-login';
import { import {
@@ -25,6 +26,7 @@ import {
} from '@/core/auth/task-deeplink'; } from '@/core/auth/task-deeplink';
import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login'; import { resolveUsernameByOnlineTicket } from '@/core/auth/wujie-ticket-login';
import { isWujieEnv, useWujieProps } from '@/core/embed/wujie'; import { isWujieEnv, useWujieProps } from '@/core/embed/wujie';
import { isEmbeddedHost } from '@/core/embed/frame-context';
import { readEmbedParam } from '@/core/embed/embed-session'; import { readEmbedParam } from '@/core/embed/embed-session';
import { import {
GENERAL_QA_ROUTE, GENERAL_QA_ROUTE,
@@ -33,9 +35,14 @@ import {
persistToolsToken, persistToolsToken,
} from '@/core/auth/yuser-login'; } from '@/core/auth/yuser-login';
import { import {
buildStandardChatRedirectPath,
buildEmbeddedIframeRedirectPath,
buildChatsIframeRedirectPath, buildChatsIframeRedirectPath,
hasIframePresentationOverride,
isChatsIframeLogin, isChatsIframeLogin,
resolveChatsIframeEntryRedirect,
resolvePostLoginRedirectPath, resolvePostLoginRedirectPath,
shouldUseChatsIframeProtocol,
} from '@/core/page-layout/chats-iframe-route'; } from '@/core/page-layout/chats-iframe-route';
import { import {
isSidebarEnabledFromSearch, isSidebarEnabledFromSearch,
@@ -101,22 +108,42 @@ export default function LoginPage() {
); );
const requestedChatMode = const requestedChatMode =
searchParams.get('chatMode')?.trim().toLowerCase() ?? ''; searchParams.get('chatMode')?.trim().toLowerCase() ?? '';
const brainyIframeRequested = requestedChatMode === IFRAME_CHAT_MODE; const iframePresentationOverride = useMemo(
// Wujie is an application container, not the Brainy iframe protocol. It () => hasIframePresentationOverride(searchParams.toString()),
// must therefore land on the regular full ChatPage. Plugin links with [searchParams],
// `from=iframe` are normalized into the explicit iframe protocol mode. );
const embeddedHost = useMemo(() => isEmbeddedHost(), []);
// `from=iframe` is the established entry contract for the dedicated iframe
// page. A standard presentation flag is the one intentional exception: it
// uses the normal full chat workspace so Header/PageSidebar visibility is
// controlled by hideHeader/hideChrome/embed instead.
const brainyIframeRequested = shouldUseChatsIframeProtocol(
searchParams.toString(),
embeddedHost,
);
// An explicit full-chat request and all standard presentation flags retain
// the regular workspace. Otherwise native iframe/Wujie hosts use the
// legacy dedicated AgentSelector entry, matching the established bare
// iframe login integration.
const fullChatRequested = useMemo( const fullChatRequested = useMemo(
() => () =>
!brainyIframeRequested && !brainyIframeRequested &&
(isWujieEnv() || (isWujieEnv() ||
requestedChatMode === FULL_CHAT_MODE), requestedChatMode === FULL_CHAT_MODE ||
[brainyIframeRequested, requestedChatMode, searchParams], // hideHeader/hideChrome/embed are explicit presentation contracts.
// They must retain their query parameters and enter the standard
// workspace even when a native iframe has no legacy `from` marker.
iframePresentationOverride),
[
brainyIframeRequested,
iframePresentationOverride,
requestedChatMode,
searchParams,
],
); );
const isIframeLogin = useMemo( const isIframeLogin = useMemo(
() => () => brainyIframeRequested,
brainyIframeRequested || [brainyIframeRequested],
(!fullChatRequested && isChatsIframeLogin(searchParams.toString())),
[brainyIframeRequested, fullChatRequested, searchParams],
); );
const resolvedUsername = useMemo(() => { const resolvedUsername = useMemo(() => {
const raw = username?.trim(); const raw = username?.trim();
@@ -127,22 +154,37 @@ export default function LoginPage() {
const yUserId = searchParams.get('yUserId')?.trim() ?? ''; const yUserId = searchParams.get('yUserId')?.trim() ?? '';
const redirectPath = useMemo( const redirectPath = useMemo(
() => { () => {
if (yUserId) return GENERAL_QA_ROUTE;
// Keep the mode on the destination URL so PageRoutes can retain the // Keep the mode on the destination URL so PageRoutes can retain the
// full chat workspace sidebar while hideChrome hides only the global // full chat workspace sidebar while hideChrome hides only the global
// application chrome. // application chrome.
if (brainyIframeRequested) { if (brainyIframeRequested) {
return buildChatsIframeRedirectPath(searchParams.toString()); return !isChatsIframeLogin(searchParams.toString()) &&
requestedChatMode !== IFRAME_CHAT_MODE
? buildEmbeddedIframeRedirectPath(searchParams.toString())
: buildChatsIframeRedirectPath(searchParams.toString());
} }
if (fullChatRequested) { if (fullChatRequested) {
return `${DEFAULT_REDIRECT_PATH}?chatMode=${FULL_CHAT_MODE}`; return buildStandardChatRedirectPath(
searchParams.toString(),
DEFAULT_REDIRECT_PATH,
);
} }
// External iframe callers frequently also provide yUserId for login.
// That identity parameter must not steal a deliberate iframe entry and
// send it to the generic full-chat home before the route split above.
if (yUserId) return GENERAL_QA_ROUTE;
return resolvePostLoginRedirectPath( return resolvePostLoginRedirectPath(
searchParams.toString(), searchParams.toString(),
DEFAULT_REDIRECT_PATH, DEFAULT_REDIRECT_PATH,
); );
}, },
[brainyIframeRequested, fullChatRequested, searchParams, yUserId], [
brainyIframeRequested,
fullChatRequested,
requestedChatMode,
searchParams,
yUserId,
],
); );
const toolToken = searchParams.get('token')?.trim() ?? ''; const toolToken = searchParams.get('token')?.trim() ?? '';
// 内网登录透传的访问令牌:存入 userInfo.accessToken,供 XK/PG 中任务 iframe 拼参 // 内网登录透传的访问令牌:存入 userInfo.accessToken,供 XK/PG 中任务 iframe 拼参
@@ -152,6 +194,10 @@ export default function LoginPage() {
// token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开) // token 换登录:?authToken= 传上游 token(与 tools_token 的 ?token= 区分开)
const authToken = searchParams.get('authToken')?.trim() ?? ''; const authToken = searchParams.get('authToken')?.trim() ?? '';
const routeKey = searchParams.get(ROUTE_KEY_PARAM)?.trim() ?? ''; const routeKey = searchParams.get(ROUTE_KEY_PARAM)?.trim() ?? '';
const routeKeyMapping = useMemo(
() => resolveRouteKeyMapping(routeKey),
[routeKey],
);
const routeKeyTarget = useMemo(() => resolveRouteKey(routeKey), [routeKey]); const routeKeyTarget = useMemo(() => resolveRouteKey(routeKey), [routeKey]);
const configuredRouteLoginPassword = useMemo( const configuredRouteLoginPassword = useMemo(
() => (routeKeyTarget ? getRouteKeyLoginPassword() : ''), () => (routeKeyTarget ? getRouteKeyLoginPassword() : ''),
@@ -183,6 +229,14 @@ export default function LoginPage() {
const normalized = raw.startsWith('/') ? raw : `/${raw}`; const normalized = raw.startsWith('/') ? raw : `/${raw}`;
const [pathOnly, ownQuery = ''] = normalized.split('?'); const [pathOnly, ownQuery = ''] = normalized.split('?');
const target = new URLSearchParams(ownQuery); const target = new URLSearchParams(ownQuery);
const iframeEntryTarget = resolveChatsIframeEntryRedirect(
pathOnly,
searchParams.toString(),
embeddedHost,
);
if (iframeEntryTarget) {
return iframeEntryTarget;
}
// Keep the Wujie/full-chat decision on the target URL. The destination // Keep the Wujie/full-chat decision on the target URL. The destination
// route uses it to retain the full agent/history workspace sidebar. // route uses it to retain the full agent/history workspace sidebar.
if ( if (
@@ -204,7 +258,7 @@ export default function LoginPage() {
}); });
const qs = target.toString(); const qs = target.toString();
return qs ? `${pathOnly}?${qs}` : pathOnly; return qs ? `${pathOnly}?${qs}` : pathOnly;
}, [fullChatRequested, routeKeyTarget, searchParams]); }, [embeddedHost, fullChatRequested, routeKeyTarget, searchParams]);
const externalParams = useMemo( const externalParams = useMemo(
() => ({ () => ({
theme: searchParams.get('theme') ?? '', theme: searchParams.get('theme') ?? '',
@@ -243,10 +297,20 @@ export default function LoginPage() {
// 跨标签污染正常系统页;iframe 主题改由 forcedTheme(按标签页、不持久化)接管。 // 跨标签污染正常系统页;iframe 主题改由 forcedTheme(按标签页、不持久化)接管。
useEffect(() => { useEffect(() => {
if (isEmbed || isIframeLogin) return; if (isEmbed || isIframeLogin) return;
const themeValue = wujieProps.theme || externalParams.theme; // routeKey 中显式配置的主题优先于调用方 URL,确保短链无需额外拼
// `theme=dark-blue` 也能在登录完成前切换到指定主题。
const themeValue =
routeKeyMapping?.theme || wujieProps.theme || externalParams.theme;
if (!themeValue) return; if (!themeValue) return;
setTheme(resolveThemeFromQuery(themeValue)); setTheme(resolveThemeFromQuery(themeValue));
}, [isEmbed, isIframeLogin, wujieProps.theme, externalParams.theme, setTheme]); }, [
isEmbed,
isIframeLogin,
routeKeyMapping?.theme,
wujieProps.theme,
externalParams.theme,
setTheme,
]);
// ticket / userId / env 持久化(与 login 请求并行做,互不阻塞) // ticket / userId / env 持久化(与 login 请求并行做,互不阻塞)
useEffect(() => { useEffect(() => {
@@ -0,0 +1,491 @@
import {
BotIcon,
ChevronDownIcon,
CircleAlertIcon,
CpuIcon,
EyeIcon,
KeyRoundIcon,
LoaderCircleIcon,
PlusIcon,
RefreshCwIcon,
Settings2Icon,
SparklesIcon,
Trash2Icon,
WrenchIcon,
} from "lucide-react";
import { useMemo, useState, type ReactNode } from "react";
import {
Button as TButton,
Dialog,
Input,
InputNumber,
MessagePlugin,
Select,
Switch,
Tag,
Textarea,
} from "tdesign-react";
import {
type ManagedModel,
type ModelSavePayload,
useDeleteManagedModel,
useManagedModels,
useSaveManagedModel,
useTestManagedModel,
} from "@/core/model-management";
import {
WorkspaceBody,
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
type ModelDraft = ModelSavePayload & {
api_key: string;
keep_existing_key: boolean;
extra_settings_text: string;
};
const PROVIDER_OPTIONS = [
{ label: "OpenAI 兼容接口", value: "langchain_openai:ChatOpenAI" },
{ label: "Anthropic", value: "langchain_anthropic:ChatAnthropic" },
{ label: "Google Gemini", value: "langchain_google_genai:ChatGoogleGenerativeAI" },
{ label: "Groq", value: "langchain_groq:ChatGroq" },
{ label: "Ollama", value: "langchain_ollama:ChatOllama" },
];
const emptyDraft = (): ModelDraft => ({
name: "",
display_name: "",
description: "",
use: "langchain_openai:ChatOpenAI",
model: "",
api_key: "",
keep_existing_key: false,
clear_api_key: false,
base_url: "",
request_timeout: 30,
max_retries: 2,
max_tokens: undefined,
temperature: undefined,
stream_usage: true,
supports_thinking: false,
supports_reasoning_effort: false,
supports_vision: false,
when_thinking_enabled: null,
when_thinking_disabled: null,
thinking: null,
use_responses_api: null,
output_version: null,
extra_settings: {},
extra_settings_text: "{}",
});
function draftFromModel(model: ManagedModel): ModelDraft {
return {
...model,
api_key: "",
keep_existing_key: model.api_key_configured,
clear_api_key: false,
extra_settings_text: JSON.stringify(model.extra_settings ?? {}, null, 2),
};
}
function optionalString(value: string | null | undefined): string | null {
const normalized = (value ?? "").trim();
return normalized || null;
}
function toNumber(value: number | string | null | undefined): number | undefined {
if (value === "" || value === null || value === undefined) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function buildPayload(draft: ModelDraft): ModelSavePayload {
let extraSettings: Record<string, unknown> = {};
try {
const parsed = JSON.parse(draft.extra_settings_text || "{}") as unknown;
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
throw new Error("额外参数必须是 JSON 对象");
}
extraSettings = parsed as Record<string, unknown>;
} catch (error) {
throw error instanceof Error ? error : new Error("额外参数不是有效 JSON");
}
const payload: ModelSavePayload = {
name: draft.name.trim(),
display_name: optionalString(draft.display_name),
description: optionalString(draft.description),
use: draft.use.trim(),
model: draft.model.trim(),
clear_api_key: Boolean(draft.clear_api_key),
base_url: optionalString(draft.base_url),
request_timeout: toNumber(draft.request_timeout),
max_retries: toNumber(draft.max_retries),
max_tokens: toNumber(draft.max_tokens),
temperature: toNumber(draft.temperature),
stream_usage: draft.stream_usage,
supports_thinking: draft.supports_thinking,
supports_reasoning_effort: draft.supports_reasoning_effort,
supports_vision: draft.supports_vision,
when_thinking_enabled: draft.when_thinking_enabled,
when_thinking_disabled: draft.when_thinking_disabled,
thinking: draft.thinking,
use_responses_api: draft.use_responses_api,
output_version: optionalString(draft.output_version),
extra_settings: extraSettings,
};
if (draft.api_key.trim()) payload.api_key = draft.api_key.trim();
return payload;
}
function StatCard({ icon: Icon, label, value }: { icon: typeof CpuIcon; label: string; value: number }) {
return (
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-sm">
<span className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary"><Icon className="size-5" /></span>
<div><div className="text-xl font-semibold tabular-nums">{value}</div><div className="text-xs text-muted-foreground">{label}</div></div>
</div>
);
}
function Field({ label, required = false, children }: { label: string; required?: boolean; children: ReactNode }) {
return <label className="space-y-1.5 text-sm font-medium">{label}{required && <span className="ml-1 text-destructive">*</span>}{children}</label>;
}
function ModelCapabilityBadge({
icon: Icon,
label,
className,
}: {
icon?: typeof KeyRoundIcon;
label: string;
className: string;
}) {
return (
<span className={`inline-flex h-7 items-center gap-1 whitespace-nowrap rounded px-2 text-xs font-medium leading-none ${className}`}>
{Icon && <Icon aria-hidden className="block size-3.5 shrink-0" />}
<span className="block leading-none">{label}</span>
</span>
);
}
function showGlobalMessage(
theme: "success" | "error" | "warning",
content: string,
) {
// Schedule after the modal state update so the message is rendered above
// the page, rather than inside or behind a closing dialog.
window.setTimeout(() => {
MessagePlugin.closeAll();
void MessagePlugin[theme]({
content,
duration: 5_000,
placement: "top",
zIndex: 10_000,
attach: () => document.body,
});
}, 0);
}
function ModelEditor({ model, onClose }: { model: ManagedModel | null; onClose: () => void }) {
const isNew = model === null;
const [draft, setDraft] = useState<ModelDraft>(() => model ? draftFromModel(model) : emptyDraft());
const [advanced, setAdvanced] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; latency: number; message: string; preview?: string | null } | null>(null);
const save = useSaveManagedModel();
const test = useTestManagedModel();
const patch = (next: Partial<ModelDraft>) => setDraft((current) => ({ ...current, ...next }));
const handleTest = async () => {
try {
const payload = buildPayload(draft);
const result = await test.mutateAsync(payload);
setTestResult({ success: result.success, latency: result.latency_ms, message: result.message, preview: result.preview });
if (!result.success) return void showGlobalMessage("error", `连接失败:${result.message}`);
showGlobalMessage("success", `连接成功${result.latency_ms ? `,${result.latency_ms} ms` : ""}`);
} catch (error) {
showGlobalMessage("error", error instanceof Error ? error.message : "连接测试失败");
}
};
const handleSave = async () => {
try {
const payload = buildPayload(draft);
if (!payload.name || !payload.use || !payload.model) {
return void showGlobalMessage("warning", "请填写模型标识、驱动与实际模型名");
}
await save.mutateAsync({ name: model?.name ?? payload.name, payload, isNew });
onClose();
showGlobalMessage(
"success",
isNew
? `模型“${payload.name}”已添加,所有页面的模型选择器已刷新。`
: `模型“${payload.name}”的配置已保存并立即生效。`,
);
} catch (error) {
showGlobalMessage("error", error instanceof Error ? error.message : "保存模型配置失败");
}
};
return (
<Dialog
visible
header={isNew ? "添加模型" : `编辑模型:${model?.display_name || model?.name}`}
placement="center"
width={860}
zIndex={3200}
confirmBtn={{ content: isNew ? "添加并启用" : "保存配置", theme: "primary" }}
cancelBtn="取消"
confirmLoading={save.isPending}
onClose={onClose}
onConfirm={() => void handleSave()}
onCancel={onClose}
>
<div className="max-h-[70vh] space-y-5 overflow-y-auto px-1 py-1 pr-3">
<div className="rounded-lg border border-primary/20 bg-primary/5 px-3 py-2 text-sm text-muted-foreground">
保存会直接更新当前部署的 <code className="rounded bg-background px-1 py-0.5">config.yaml</code> 模型段,自动备份并热刷新;不会中断已开始的会话。
</div>
<section className="grid gap-4 md:grid-cols-2">
<Field label="稳定标识" required><Input value={draft.name} disabled={!isNew} placeholder="例如 deepseek-chat" tips={isNew ? "保存后不可修改,用于智能体和运行配置引用。" : "稳定标识不可修改,避免已有配置失效。"} onChange={(value) => patch({ name: String(value) })} /></Field>
<Field label="显示名称"><Input value={draft.display_name ?? ""} placeholder="例如 DeepSeek Chat" onChange={(value) => patch({ display_name: String(value) })} /></Field>
<Field label="模型驱动" required><Select value={draft.use} options={PROVIDER_OPTIONS} filterable creatable inputProps={{ placeholder: "选择或输入 use 类路径" }} onChange={(value) => patch({ use: String(value) })} /></Field>
<Field label="实际模型名" required><Input value={draft.model} placeholder="例如 deepseek-chat" onChange={(value) => patch({ model: String(value) })} /></Field>
<Field label="Base URL"><Input value={draft.base_url ?? ""} type="url" placeholder="https://api.example.com/v1" onChange={(value) => patch({ base_url: String(value) })} /></Field>
<Field label="API Key"><Input value={draft.api_key} type="password" placeholder={draft.keep_existing_key && !draft.clear_api_key ? "已配置,留空则保持不变" : "可填写密钥或 $ENVIRONMENT_VARIABLE"} onChange={(value) => patch({ api_key: String(value), clear_api_key: false })} /></Field>
{draft.keep_existing_key && !draft.api_key && <div className="md:col-span-2 flex items-center gap-2 text-xs text-muted-foreground"><KeyRoundIcon className="size-3.5" />已配置密钥{model?.api_key_source ? `(环境变量 ${model.api_key_source})` : ""},不会回显。<TButton theme="default" variant="text" size="small" onClick={() => patch({ clear_api_key: !draft.clear_api_key })}>{draft.clear_api_key ? "撤销清除" : "清除密钥"}</TButton></div>}
<div className="md:col-span-2"><Field label="描述"><Textarea value={draft.description ?? ""} autosize={{ minRows: 2, maxRows: 4 }} placeholder="说明模型适用的任务与限制" onChange={(value) => patch({ description: String(value) })} /></Field></div>
</section>
<section className="rounded-lg border border-border p-4">
<div className="mb-2"><h3 className="font-medium">能力声明</h3><p className="mt-1 text-xs text-muted-foreground">这些标识决定哪些页面会向模型提供思考、视觉或工具调用入口。</p></div>
<div className="flex flex-wrap gap-x-5 gap-y-3 text-sm"><span className="flex items-center gap-2"><Switch value={draft.supports_thinking} onChange={(value) => patch({ supports_thinking: Boolean(value) })} />支持思考</span><span className="flex items-center gap-2"><Switch value={draft.supports_vision} onChange={(value) => patch({ supports_vision: Boolean(value) })} />支持视觉</span><span className="flex items-center gap-2"><Switch value={draft.supports_reasoning_effort} onChange={(value) => patch({ supports_reasoning_effort: Boolean(value) })} />推理档位</span></div>
</section>
<div className="flex items-center justify-between border-t pt-4"><TButton theme="default" variant="outline" loading={test.isPending} onClick={() => void handleTest()}><WrenchIcon className="mr-1.5 size-4" />测试连接</TButton><TButton theme="default" variant="text" onClick={() => setAdvanced((value) => !value)}><Settings2Icon className="mr-1.5 size-4" />高级配置 <ChevronDownIcon className={`ml-1 size-4 transition-transform ${advanced ? "rotate-180" : ""}`} /></TButton></div>
{testResult && <div className={`rounded-lg border px-3 py-2 text-sm ${testResult.success ? "border-primary/20 bg-primary/5 text-foreground" : "border-destructive/25 bg-destructive/5 text-destructive"}`}><div className="font-medium">{testResult.success ? "连接成功" : "连接失败"}{testResult.success && testResult.latency ? ` · ${testResult.latency} ms` : ""}</div><p className="mt-1 break-words text-xs opacity-80">{testResult.preview || testResult.message}</p></div>}
{advanced && <section className="space-y-4 rounded-lg border border-dashed border-border bg-muted/10 p-4"><div className="grid gap-4 md:grid-cols-3"><Field label="请求超时(秒)"><InputNumber value={draft.request_timeout ?? ""} min={1} max={600} onChange={(value) => patch({ request_timeout: toNumber(value) })} /></Field><Field label="最大重试次数"><InputNumber value={draft.max_retries ?? ""} min={0} max={20} onChange={(value) => patch({ max_retries: toNumber(value) })} /></Field><Field label="最大输出 Token"><InputNumber value={draft.max_tokens ?? ""} min={1} max={1_000_000} onChange={(value) => patch({ max_tokens: toNumber(value) })} /></Field><Field label="温度"><InputNumber value={draft.temperature ?? ""} min={0} max={2} step={0.1} decimalPlaces={1} onChange={(value) => patch({ temperature: toNumber(value) })} /></Field><span className="flex items-end gap-2 pb-1 text-sm font-medium"><Switch value={draft.stream_usage ?? true} onChange={(value) => patch({ stream_usage: Boolean(value) })} />流式 Token 统计</span></div><Field label="额外参数 JSON"><Textarea value={draft.extra_settings_text} autosize={{ minRows: 4, maxRows: 10 }} placeholder={'{\n "extra_body": {}\n}'} onChange={(value) => patch({ extra_settings_text: String(value) })} /><p className="text-xs font-normal text-muted-foreground">保留并编辑配置文件中未在基础表单展示的兼容参数。必须为 JSON 对象。</p></Field></section>}
</div>
</Dialog>
);
}
export default function ModelManagementPage() {
const { data, isLoading, error, refetch, isFetching } = useManagedModels();
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<"all" | "thinking" | "vision">("all");
const [editor, setEditor] = useState<ManagedModel | null | "new">(null);
const [deleteTarget, setDeleteTarget] = useState<ManagedModel | null>(null);
const remove = useDeleteManagedModel();
const models = useMemo(() => {
const keyword = search.trim().toLowerCase();
return (data?.models ?? []).filter((model) => {
const matchesFilter = filter === "all" || (filter === "thinking" ? model.supports_thinking : model.supports_vision);
const matchesSearch = !keyword || [model.name, model.display_name, model.model, model.use].filter(Boolean).join(" ").toLowerCase().includes(keyword);
return matchesFilter && matchesSearch;
});
}, [data?.models, filter, search]);
const stats = useMemo(() => ({ total: data?.models.length ?? 0, thinking: data?.models.filter((item) => item.supports_thinking).length ?? 0, vision: data?.models.filter((item) => item.supports_vision).length ?? 0 }), [data?.models]);
const handleDelete = async () => {
if (!deleteTarget)
return;
try {
await remove.mutateAsync(deleteTarget.name);
const deletedName = deleteTarget.name;
setDeleteTarget(null);
showGlobalMessage("success", `模型“${deletedName}”已删除,配置已立即生效。`);
}
catch (error) {
showGlobalMessage("error", error instanceof Error ? error.message : "删除模型失败,请稍后重试。");
}
};
return (
<WorkspaceContainer>
<WorkspaceHeader />
<WorkspaceBody>
<main className="flex size-full min-h-0 flex-col overflow-y-auto bg-background px-6 py-5 md:px-8">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<span className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
<CpuIcon className="size-5" />
</span>
<h1 className="text-2xl font-semibold tracking-tight">模型配置</h1>
</div>
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
管理当前部署 <code className="rounded bg-muted px-1 py-0.5">config.yaml</code> 中的对话模型。保存后所有页面立即可选;密钥仅可写入,永不回显。
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<TButton theme="default" variant="outline" loading={isFetching} onClick={() => void refetch()}>
<RefreshCwIcon className="mr-1.5 size-4" />刷新
</TButton>
<TButton theme="primary" onClick={() => setEditor("new")}>
<PlusIcon className="mr-1.5 size-4" />添加模型
</TButton>
</div>
</header>
<div className="grid gap-3 sm:grid-cols-3">
<StatCard icon={BotIcon} label="已配置模型" value={stats.total} />
<StatCard icon={SparklesIcon} label="支持思考" value={stats.thinking} />
<StatCard icon={EyeIcon} label="支持视觉" value={stats.vision} />
</div>
<section className="rounded-xl border border-border bg-card shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
{([
["all", "全部"],
["thinking", "支持思考"],
["vision", "支持视觉"],
] as const).map(([value, label]) => (
<TButton
key={value}
theme={filter === value ? "primary" : "default"}
variant={filter === value ? "base" : "text"}
size="small"
onClick={() => setFilter(value)}
>
{label}
</TButton>
))}
</div>
<Input
value={search}
placeholder="搜索名称、模型或驱动"
clearable
className="w-full sm:w-72"
onChange={(value) => setSearch(String(value))}
/>
</div>
{isLoading && (
<div className="flex h-52 items-center justify-center gap-2 text-sm text-muted-foreground">
<LoaderCircleIcon className="size-4 animate-spin" />加载模型配置…
</div>
)}
{error && (
<div className="m-4 flex items-start gap-2 rounded-lg border border-destructive/25 bg-destructive/5 p-3 text-sm text-destructive">
<CircleAlertIcon className="mt-0.5 size-4" />{error.message}
</div>
)}
{!isLoading && !error && models.length === 0 && (
<div className="flex h-52 flex-col items-center justify-center gap-2 text-sm text-muted-foreground">
<CpuIcon className="size-7" />没有匹配的模型配置
</div>
)}
{!isLoading && !error && models.length > 0 && (
<div className="grid gap-3 p-4 lg:grid-cols-2">
{models.map((model) => (
<article key={model.name} className="rounded-lg border border-border bg-background p-4 transition-colors hover:border-primary/35">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="truncate font-semibold">{model.display_name || model.name}</h2>
<Tag theme="success" variant="light" size="small">config.yaml</Tag>
</div>
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{model.name}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<TButton theme="default" variant="outline" size="small" onClick={() => setEditor(model)}>
配置
</TButton>
<TButton
theme="danger"
variant="text"
size="small"
aria-label={"删除模型 " + (model.display_name || model.name)}
onClick={() => setDeleteTarget(model)}
>
<Trash2Icon className="mr-1 size-3.5" />删除
</TButton>
</div>
</div>
{model.description ? (
<p className="mt-3 line-clamp-2 min-h-10 text-sm leading-5 text-muted-foreground">{model.description}</p>
) : (
<p className="mt-3 min-h-10 text-sm leading-5 text-muted-foreground">未填写描述</p>
)}
<div className="mt-3 grid gap-2 border-t pt-3 text-xs text-muted-foreground sm:grid-cols-2">
<span className="truncate" title={model.use}>驱动:{model.use}</span>
<span className="truncate" title={model.model}>模型:{model.model}</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{model.api_key_configured && (
<ModelCapabilityBadge
icon={KeyRoundIcon}
label="已配置密钥"
className="bg-muted text-foreground"
/>
)}
{model.supports_thinking && (
<ModelCapabilityBadge
icon={SparklesIcon}
label="思考"
className="bg-primary/10 text-primary"
/>
)}
{model.supports_vision && (
<ModelCapabilityBadge
icon={EyeIcon}
label="视觉"
className="bg-amber-500/10 text-amber-700 dark:text-amber-300"
/>
)}
{model.supports_reasoning_effort && (
<ModelCapabilityBadge
label="推理档位"
className="bg-muted text-muted-foreground"
/>
)}
</div>
</article>
))}
</div>
)}
</section>
{data?.config_path && (
<p className="text-xs text-muted-foreground">
当前配置文件:<code>{data.config_path}</code>
{data.updated_at ? " · 最近更新 " + new Date(data.updated_at).toLocaleString() : ""}
</p>
)}
</div>
</main>
</WorkspaceBody>
{editor !== null && (
<ModelEditor
key={editor === "new" ? "new" : editor.name}
model={editor === "new" ? null : editor}
onClose={() => setEditor(null)}
/>
)}
{deleteTarget && (
<Dialog
visible
header="删除模型"
placement="center"
width={440}
zIndex={3200}
confirmBtn={{ content: "删除", theme: "danger" }}
cancelBtn="取消"
confirmLoading={remove.isPending}
onClose={() => {
if (!remove.isPending) setDeleteTarget(null);
}}
onCancel={() => {
if (!remove.isPending) setDeleteTarget(null);
}}
onConfirm={() => void handleDelete()}
>
<p className="text-sm leading-6 text-muted-foreground">
确定要删除模型 <code className="rounded bg-muted px-1 py-0.5">{deleteTarget.name}</code> 吗?删除后会立即更新运行中的模型配置,依赖它的智能体或任务可能无法继续使用。
</p>
</Dialog>
)}
</WorkspaceContainer>
);
}
+12
View File
@@ -31,6 +31,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { ArtifactsProvider } from "@/components/workspace/artifacts"; import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
import { import {
MessageList, MessageList,
type SetupAgentApprovalPayload, type SetupAgentApprovalPayload,
@@ -95,6 +96,7 @@ export default function NewAgentPage() {
const [isCreatingAgent, setIsCreatingAgent] = useState(false); const [isCreatingAgent, setIsCreatingAgent] = useState(false);
const [isSavingDraft, setIsSavingDraft] = useState(false); const [isSavingDraft, setIsSavingDraft] = useState(false);
const [selectedSkills, setSelectedSkills] = useState<string[]>([]); const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]);
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false); const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
const [detailSkillName, setDetailSkillName] = useState<string | null>(null); const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
// 恢复已有 bootstrap 会话时该 thread 已存在,无需再 create(置为 true,跳过创建)。 // 恢复已有 bootstrap 会话时该 thread 已存在,无需再 create(置为 true,跳过创建)。
@@ -230,6 +232,7 @@ export default function NewAgentPage() {
model: modelOverride, model: modelOverride,
template_name: templateNameInput.trim(), template_name: templateNameInput.trim(),
seat_skill_directive: seatSkillDirectiveInput, seat_skill_directive: seatSkillDirectiveInput,
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
}); });
setAgent(updated); setAgent(updated);
return updated; return updated;
@@ -252,6 +255,7 @@ export default function NewAgentPage() {
model: modelOverride, model: modelOverride,
template_name: templateNameInput.trim(), template_name: templateNameInput.trim(),
seat_skill_directive: seatSkillDirectiveInput, seat_skill_directive: seatSkillDirectiveInput,
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
}); });
setAgentId(created.id); setAgentId(created.id);
setAgent(created); setAgent(created);
@@ -280,6 +284,7 @@ export default function NewAgentPage() {
descriptionInput, descriptionInput,
modelInput, modelInput,
selectedSkills, selectedSkills,
selectedKnowledgeBaseIds,
soulInput, soulInput,
templateNameInput, templateNameInput,
seatSkillDirectiveInput, seatSkillDirectiveInput,
@@ -590,6 +595,13 @@ export default function NewAgentPage() {
</p> </p>
</div> </div>
<LlmWikiKnowledgeSelector
source="all"
value={selectedKnowledgeBaseIds}
onChange={setSelectedKnowledgeBaseIds}
variant="panel"
/>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">SOUL.md</label> <label className="text-sm font-medium">SOUL.md</label>
<Textarea <Textarea
@@ -0,0 +1,362 @@
"use client";
/**
* 岗位角色配置(仅管理员)。
*
* 管理岗位会商使用的“工作视角”而非组织岗位/RBAC:一个启用的意图识别岗
* 承担第一阶段意图确认,并承载方案总结、行动规划两个收口智能体入口。
*/
import { useEffect, useMemo, useState } from "react";
import { Navigate } from "react-router-dom";
import {
ArrowDown,
ArrowUp,
Bot,
BriefcaseBusiness,
CircleDot,
Info,
ListChecks,
Loader2,
Plus,
Save,
ShieldCheck,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { getStoredAuth } from "@/core/auth";
import { cn } from "@/lib/utils";
import {
type PositionRole,
type PositionRoleType,
usePositionRoleActions,
usePositionRoles,
} from "@/strategy-components/api/position-roles";
let sequence = 0;
const newRoleId = () => `custom-${Date.now()}-${++sequence}`;
const ID_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,127}$/;
function serialize(rows: PositionRole[]): string {
return JSON.stringify(
rows.map((row, index) => ({
...row,
sortOrder: index,
name: row.name.trim(),
description: row.description.trim(),
})),
);
}
function makeNewRole(sortOrder: number): PositionRole {
return {
id: newRoleId(),
name: "新岗位",
description: "请填写该岗位在会商流程中的职责",
roleType: "standard",
enabled: true,
isBuiltin: false,
sortOrder,
};
}
export default function PositionRolesPage() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const { data: serverRows = [], isLoading, isError, error, refetch } = usePositionRoles();
const { save } = usePositionRoleActions();
const [rows, setRows] = useState<PositionRole[]>([]);
const [loadedKey, setLoadedKey] = useState<string | null>(null);
const sourceKey = useMemo(() => serialize(serverRows), [serverRows]);
useEffect(() => {
if (sourceKey !== loadedKey) {
setRows(serverRows.map((row, index) => ({ ...row, sortOrder: index })));
setLoadedKey(sourceKey);
}
}, [loadedKey, serverRows, sourceKey]);
const dirty = useMemo(() => serialize(rows) !== sourceKey, [rows, sourceKey]);
const enabledIntentId = rows.find((row) => row.enabled && row.roleType === "intent")?.id;
const usingFallback = serverRows.some((row) => row.isFallback);
const enabledCount = rows.filter((row) => row.enabled).length;
const intentRole = rows.find((row) => row.id === enabledIntentId);
if (!isAdmin) return <Navigate to="/page/workspace/chats/new" replace />;
const update = (id: string, patch: Partial<PositionRole>) =>
setRows((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row)));
const changeRoleType = (id: string, roleType: PositionRoleType) => {
setRows((current) =>
current.map((row) =>
row.id === id
? { ...row, roleType, enabled: roleType === "intent" ? true : row.enabled }
: roleType === "intent" && row.roleType === "intent"
? { ...row, roleType: "standard" }
: row,
),
);
};
const toggleEnabled = (id: string, enabled: boolean) => {
const target = rows.find((row) => row.id === id);
if (!enabled && target?.roleType === "intent" && enabledIntentId === id) {
toast.info("请先将另一个岗位设为“意图识别岗”,再停用当前岗位");
return;
}
update(id, { enabled });
};
const move = (id: string, direction: "up" | "down") => {
const index = rows.findIndex((row) => row.id === id);
const nextIndex = direction === "up" ? index - 1 : index + 1;
if (index < 0 || nextIndex < 0 || nextIndex >= rows.length) return;
const next = [...rows];
[next[index], next[nextIndex]] = [next[nextIndex], next[index]];
setRows(next.map((row, order) => ({ ...row, sortOrder: order })));
};
const remove = (row: PositionRole) => {
if (row.isBuiltin) {
toast.info("内置岗位为兼容历史业务链而保留;如不再使用可先停用它");
return;
}
if (row.id === enabledIntentId) {
toast.info("请先将另一个岗位设为“意图识别岗”后再删除");
return;
}
setRows((current) => current.filter((item) => item.id !== row.id).map((item, index) => ({ ...item, sortOrder: index })));
};
const validate = (): PositionRole[] | null => {
if (!rows.length) {
toast.error("至少需要保留一个岗位");
return null;
}
const normalized = rows.map((row, sortOrder) => ({
...row,
id: row.id.trim(),
name: row.name.trim(),
description: row.description.trim(),
sortOrder,
}));
if (normalized.some((row) => !ID_PATTERN.test(row.id))) {
toast.error("岗位标识须以字母开头,只能包含字母、数字、- 或 _");
return null;
}
if (normalized.some((row) => !row.name)) {
toast.error("请填写每个岗位的名称");
return null;
}
if (new Set(normalized.map((row) => row.id)).size !== normalized.length) {
toast.error("岗位标识不能重复");
return null;
}
if (new Set(normalized.map((row) => row.name.toLocaleLowerCase())).size !== normalized.length) {
toast.error("岗位名称不能重复");
return null;
}
if (normalized.filter((row) => row.enabled && row.roleType === "intent").length !== 1) {
toast.error("请保留且仅保留一个启用的意图识别岗");
return null;
}
return normalized;
};
const handleSave = async () => {
const payload = validate();
if (!payload) return;
try {
await save.mutateAsync(payload);
setRows(payload);
toast.success("岗位角色配置已保存,会商页面将按新配置显示");
} catch (reason) {
toast.error(reason instanceof Error ? reason.message : "保存岗位角色配置失败");
}
};
return (
<div className="flex size-full min-h-0 flex-col overflow-hidden bg-background">
<header className="border-border/70 flex flex-wrap items-center justify-between gap-4 border-b bg-gradient-to-r from-[var(--color-main,#0062D9)]/[0.08] via-background to-background px-6 py-5">
<div className="flex min-w-0 items-center gap-2.5">
<div className="flex size-10 shrink-0 items-center justify-center rounded-md bg-[var(--color-main,#0062D9)]/10 text-[var(--color-main,#0062D9)]">
<BriefcaseBusiness className="h-5 w-5" />
</div>
<div className="min-w-0">
<h1 className="text-foreground text-lg font-semibold tracking-tight">岗位角色配置</h1>
<p className="text-muted-foreground mt-1 text-sm">
管理岗位多智能体会商中的岗位名称、职责、性质、启停与顺序;不影响组织岗位、成员或权限。
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setRows((current) => [...current, makeNewRole(current.length)])}>
<Plus className="h-4 w-4" /> 添加岗位
</Button>
<Button size="sm" onClick={handleSave} disabled={!dirty || save.isPending || isLoading}>
{save.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{save.isPending ? "保存中…" : "保存配置"}
</Button>
</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto p-6">
<div className="mx-auto flex w-full max-w-6xl flex-col gap-5">
<section className="grid gap-3 md:grid-cols-3">
<div className="rounded-md border border-border/70 bg-card px-4 py-3.5">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground"><ListChecks className="size-4 text-[var(--color-main,#0062D9)]" />已启用岗位</div>
<div className="mt-2 flex items-baseline gap-1"><strong className="text-2xl font-semibold text-foreground">{enabledCount}</strong><span className="text-sm text-muted-foreground">个</span></div>
</div>
<div className="rounded-md border border-[var(--color-main,#0062D9)]/30 bg-[var(--color-main,#0062D9)]/[0.05] px-4 py-3.5">
<div className="flex items-center gap-2 text-xs font-medium text-[var(--color-main,#0062D9)]"><CircleDot className="size-4" />会商入口岗位</div>
<div className="mt-2 truncate text-base font-semibold text-foreground">{intentRole?.name ?? "尚未配置"}</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">意图识别与收口智能体入口</p>
</div>
<div className="rounded-md border border-border/70 bg-card px-4 py-3.5">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground"><ShieldCheck className="size-4 text-emerald-600 dark:text-emerald-400" />内置岗位</div>
<div className="mt-2 text-base font-semibold text-foreground">{rows.filter((row) => row.isBuiltin).length} 个稳定席位</div>
<p className="mt-0.5 text-xs text-muted-foreground">可改名、调整性质或停用,不可删除</p>
</div>
</section>
{usingFallback && (
<section className="flex items-start justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm">
<div className="flex gap-2.5 text-amber-900 dark:text-amber-200">
<Info className="mt-0.5 size-4 shrink-0" />
<p>当前显示的是内置岗位兜底数据。后端接口尚未返回目录;待后端升级并启动后,系统会自动把这四个岗位写入数据库。</p>
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={() => void refetch()}>重新连接</Button>
</section>
)}
<section className="border-border/70 bg-muted/35 flex gap-3 rounded-md border px-4 py-3 text-sm">
<Info className="mt-0.5 h-4 w-4 shrink-0 text-[var(--color-main,#0062D9)]" />
<div className="text-muted-foreground leading-6">
<span className="text-foreground font-medium">意图识别岗</span>
是会商入口:它负责任务意图识别,并显示“方案总结”“行动规划”两个收口智能体。系统必须保留且仅保留一个启用的意图识别岗。
内置岗位的稳定标识用于兼容历史业务链,不能删除,但可改名、调整性质或停用。
</div>
</section>
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 rounded-md border border-dashed py-14 text-sm">
<Loader2 className="h-4 w-4 animate-spin" /> 正在加载岗位角色配置…
</div>
) : isError ? (
<div className="flex flex-col items-center gap-3 rounded-md border border-dashed py-14 text-center text-sm">
<p className="text-destructive">{error instanceof Error ? error.message : "岗位角色配置加载失败"}</p>
<Button variant="outline" size="sm" onClick={() => void refetch()}>重新加载</Button>
</div>
) : (
<section className="rounded-md border border-border/70 bg-card p-4">
<div className="mb-4 flex flex-wrap items-end justify-between gap-2 border-b border-border/70 pb-3">
<div>
<h2 className="flex items-center gap-2 text-base font-semibold text-foreground"><Bot className="size-4 text-[var(--color-main,#0062D9)]" />岗位目录</h2>
<p className="mt-1 text-xs text-muted-foreground">拖动前可先用上下箭头调整顺序;保存后会立即影响岗位会商的选择与流程。</p>
</div>
<span className="text-xs text-muted-foreground">共 {rows.length} 个岗位</span>
</div>
<div className="grid gap-3">
{rows.map((row, index) => (
<article key={row.id} className={cn("grid grid-cols-[2.5rem_minmax(0,1fr)_11rem_8rem_auto] items-start gap-4 rounded-md border p-4 transition-colors max-lg:grid-cols-[2.5rem_minmax(0,1fr)_11rem] max-md:grid-cols-1 max-md:gap-3", row.roleType === "intent" ? "border-[var(--color-main,#0062D9)]/45 bg-[var(--color-main,#0062D9)]/[0.045]" : "border-border/70 bg-background hover:border-[var(--color-main,#0062D9)]/30")}>
<div className={cn("flex size-10 items-center justify-center rounded-md", row.roleType === "intent" ? "bg-[var(--color-main,#0062D9)]/15 text-[var(--color-main,#0062D9)]" : "bg-muted text-muted-foreground")}>
{row.roleType === "intent" ? <CircleDot className="size-5" /> : <BriefcaseBusiness className="size-5" />}
</div>
<div className="min-w-0 space-y-2.5">
<div className="flex items-center gap-2">
<label className="sr-only" htmlFor={`role-name-${row.id}`}>岗位名称</label>
<Input
id={`role-name-${row.id}`}
value={row.name}
onChange={(event) => update(row.id, { name: event.target.value })}
maxLength={128}
className="font-medium"
placeholder="岗位名称"
/>
{row.isBuiltin && <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">内置</span>}
</div>
<label className="sr-only" htmlFor={`role-description-${row.id}`}>岗位职责说明</label>
<Input
id={`role-description-${row.id}`}
value={row.description}
onChange={(event) => update(row.id, { description: event.target.value })}
maxLength={512}
className="text-sm"
placeholder="该岗位在会商流程中的职责说明"
/>
<div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<span className="shrink-0">岗位标识:</span>
{row.isBuiltin ? (
<code className="truncate rounded bg-muted px-1.5 py-0.5 text-[11px]">{row.id}</code>
) : (
<Input
aria-label={`${row.name || "新岗位"}的岗位标识`}
value={row.id}
onChange={(event) => update(row.id, { id: event.target.value })}
maxLength={128}
className="h-7 max-w-xs font-mono text-xs"
/>
)}
</div>
</div>
<div className="space-y-1.5 max-md:grid max-md:grid-cols-[6rem_minmax(0,1fr)] max-md:items-center">
<span className="text-xs text-muted-foreground md:hidden">岗位性质</span>
<Select value={row.roleType} onValueChange={(value) => changeRoleType(row.id, value as PositionRoleType)}>
<SelectTrigger aria-label={`${row.name || "岗位"}的岗位性质`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard">普通执行岗</SelectItem>
<SelectItem value="intent">意图识别岗(特殊)</SelectItem>
</SelectContent>
</Select>
{row.roleType === "intent" && (
<p className="text-[11px] leading-4 text-[var(--color-main,#0062D9)]">会商入口与收口智能体</p>
)}
</div>
<div className="flex min-h-9 items-center gap-2 max-lg:col-start-2 max-md:col-auto max-md:grid max-md:grid-cols-[6rem_minmax(0,1fr)]">
<span className="text-xs text-muted-foreground md:hidden">启用状态</span>
<Switch checked={row.enabled} onCheckedChange={(checked) => toggleEnabled(row.id, checked)} aria-label={`${row.name || "岗位"}${row.enabled ? "已启用" : "已停用"}`} />
<span className={cn("text-xs", row.enabled ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground")}>
{row.enabled ? "已启用" : "已停用"}
</span>
</div>
<div className="flex items-center justify-end gap-1 max-lg:col-start-3 max-md:col-auto max-md:justify-start">
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={index === 0} onClick={() => move(row.id, "up")} aria-label={`上移 ${row.name}`}>
<ArrowUp className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={index === rows.length - 1} onClick={() => move(row.id, "down")} aria-label={`下移 ${row.name}`}>
<ArrowDown className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" disabled={row.isBuiltin} onClick={() => remove(row)} aria-label={`删除 ${row.name}`} title={row.isBuiltin ? "内置岗位不能删除,可停用" : "删除岗位"}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</article>
))}
</div>
<footer className="border-t border-border/70 bg-muted/20 px-4 py-2.5 text-xs text-muted-foreground">
<ShieldCheck className="mr-1.5 inline h-3.5 w-3.5 align-[-2px] text-emerald-600 dark:text-emerald-400" />
当前已启用 {rows.filter((row) => row.enabled).length} 个岗位,其中意图识别岗:{rows.find((row) => row.id === enabledIntentId)?.name ?? "未配置"}。
</footer>
</section>
)}
</div>
</div>
</div>
);
}
+11
View File
@@ -45,6 +45,7 @@ import LoginWhitelistPage from "./LoginWhitelistPage";
import SensitiveWordsPage from "./SensitiveWordsPage"; import SensitiveWordsPage from "./SensitiveWordsPage";
import SkillAddressesPage from "./SkillAddressesPage"; import SkillAddressesPage from "./SkillAddressesPage";
import TaskButtonsPage from "./TaskButtonsPage"; import TaskButtonsPage from "./TaskButtonsPage";
import PositionRolesPage from "./PositionRolesPage";
import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage"; import RoundtableDiagnosticsPage from "./RoundtableDiagnosticsPage";
import LightAppNewNotePage from "./LightAppNewNotePage"; import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage"; import LightAppNoteListPage from "./LightAppNoteListPage";
@@ -437,6 +438,16 @@ export default function StrategyRoutes() {
) )
} }
/> />
<Route
path="admin/position-roles"
element={
getStoredAuth()?.system_role === "admin" ? (
<PositionRolesPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route <Route
path="admin/login-whitelist" path="admin/login-whitelist"
element={ element={
+125 -28
View File
@@ -2,7 +2,11 @@ import type { CSSProperties, ReactNode } from 'react';
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom'; import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom';
import { getStoredAuth } from '@/core/auth'; import { getStoredAuth } from '@/core/auth';
import { resolveStandardChatFallback } from '@/core/page-layout/chats-iframe-route'; import {
resolveChatsIframeEntryRedirect,
resolveStandardChatFallback,
} from '@/core/page-layout/chats-iframe-route';
import { isEmbeddedHost } from '@/core/embed/frame-context';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { PromptInputProvider } from '@/components/ai-elements/prompt-input'; import { PromptInputProvider } from '@/components/ai-elements/prompt-input';
@@ -31,14 +35,14 @@ import AdminScheduledTasksPage from './AdminScheduledTasksPage';
import AdminSkillsPage from './AdminSkillsPage'; import AdminSkillsPage from './AdminSkillsPage';
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage'; import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
import ScheduledTasksPage from './ScheduledTasksPage'; import ScheduledTasksPage from './ScheduledTasksPage';
import KnowledgeBasePage from './KnowledgeBasePage'; import LlmWikiKnowledgePage from './LlmWikiKnowledgePage';
import KnowledgeGraphPage from './KnowledgeGraphPage';
import LlmMetricsPage from './LlmMetricsPage'; import LlmMetricsPage from './LlmMetricsPage';
import CheckpointMigrationPage from './CheckpointMigrationPage'; import CheckpointMigrationPage from './CheckpointMigrationPage';
import MemoryPage from './MemoryPage'; import MemoryPage from './MemoryPage';
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage'; import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage'; import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
import ModelConcurrencySettingsPage from './ModelConcurrencySettingsPage'; import ModelConcurrencySettingsPage from './ModelConcurrencySettingsPage';
import ModelManagementPage from './ModelManagementPage';
import PublishSquaresSettingsPage from './PublishSquaresSettingsPage'; import PublishSquaresSettingsPage from './PublishSquaresSettingsPage';
import SkillCompressionSettingsPage from './SkillCompressionSettingsPage'; import SkillCompressionSettingsPage from './SkillCompressionSettingsPage';
import SkillEvolutionPage from './SkillEvolutionPage'; import SkillEvolutionPage from './SkillEvolutionPage';
@@ -49,6 +53,7 @@ import AdminMemoryPage from './AdminMemoryPage';
import AdminUserHubPage from './AdminUserHubPage'; import AdminUserHubPage from './AdminUserHubPage';
import TagsPage from './TagsPage'; import TagsPage from './TagsPage';
import PositionsPage from './PositionsPage'; import PositionsPage from './PositionsPage';
import PositionRolesPage from './PositionRolesPage';
import PluginManagementPage from './PluginManagementPage'; import PluginManagementPage from './PluginManagementPage';
import RecommendedQuestionsPage from './RecommendedQuestionsPage'; import RecommendedQuestionsPage from './RecommendedQuestionsPage';
import AIWritingPage from '@/open-canvas/pages/AIWritingPage'; import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
@@ -240,13 +245,20 @@ function NewChatEntryRoute({
forceWorkspaceSidebarCollapsed: boolean; forceWorkspaceSidebarCollapsed: boolean;
}; };
}) { }) {
// Do not infer the chat experience from window.self !== window.top here. const location = useLocation();
// A generic `/chats/new` deep link must render the same standard chat both const iframeEntryTarget = resolveChatsIframeEntryRedirect(
// top-level and inside an iframe. The dedicated iframe protocol page remains location.pathname,
// available only through its explicit `/chats_iframe/...` route (LoginPage location.search,
// selects it for `from=iframe`). Otherwise a normal deep link was silently isEmbeddedHost(),
// redirected to IframeChatPage, whose new-thread state intentionally has no );
// manually selected agent and therefore stopped at "选择 Agent".
if (iframeEntryTarget) {
return <Navigate to={iframeEntryTarget} replace />;
}
// A bare external iframe historically opens the dedicated AgentSelector
// page. `resolveChatsIframeEntryRedirect` records that inferred context as
// `from=iframe`; explicit full-chat/presentation parameters stay here.
return ( return (
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen> <WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
<ChatRuntime> <ChatRuntime>
@@ -256,12 +268,78 @@ function NewChatEntryRoute({
); );
} }
function ChatsIndexEntryRoute({
layoutProps,
compactChrome,
}: {
layoutProps: {
pageSidebarCollapsed: boolean;
pageSidebarHidden: boolean;
workspaceSidebarHidden: boolean;
headerHidden: boolean;
compactWorkspaceSidebar: boolean;
compactActionsHidden: boolean;
pageSidebarDark: boolean;
forceWorkspaceSidebarCollapsed: boolean;
};
compactChrome: boolean;
}) {
const location = useLocation();
const iframeEntryTarget = resolveChatsIframeEntryRedirect(
location.pathname,
location.search,
isEmbeddedHost(),
);
if (iframeEntryTarget) {
return <Navigate to={iframeEntryTarget} replace />;
}
return (
<WorkspaceLayout {...layoutProps}>
<ChatsPage compactChrome={compactChrome} />
</WorkspaceLayout>
);
}
function ChatThreadEntryRoute({
layoutProps,
}: {
layoutProps: {
pageSidebarCollapsed: boolean;
pageSidebarHidden: boolean;
workspaceSidebarHidden: boolean;
headerHidden: boolean;
compactWorkspaceSidebar: boolean;
compactActionsHidden: boolean;
pageSidebarDark: boolean;
forceWorkspaceSidebarCollapsed: boolean;
};
}) {
const location = useLocation();
const iframeEntryTarget = resolveChatsIframeEntryRedirect(
location.pathname,
location.search,
isEmbeddedHost(),
);
if (iframeEntryTarget) {
return <Navigate to={iframeEntryTarget} replace />;
}
return (
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
<ChatRuntime>
<ChatPage />
</ChatRuntime>
</WorkspaceLayout>
);
}
/** /**
* `chats_iframe` is reserved for the Brainy postMessage protocol. Some legacy * `chats_iframe` is reserved for the Brainy postMessage protocol. `from=iframe`
* Legacy links may still target it (or carry `from=iframe` during login), but * and `chatMode=iframe` opt into this page; ordinary iframe/Wujie embedding
* ordinary iframe/Wujie embedding needs the normal full chat workspace. * remains on the normal full chat workspace.
* `chatMode=iframe` is the only explicit opt-in for integrations that really
* implement the Brainy protocol.
*/ */
function IframeChatEntryRoute({ function IframeChatEntryRoute({
layoutProps, layoutProps,
@@ -341,9 +419,10 @@ export default function WorkspaceRoutes({
<Route <Route
path="chats" path="chats"
element={ element={
<WorkspaceLayout {...layoutProps}> <ChatsIndexEntryRoute
<ChatsPage compactChrome={headerHidden && !compactActionsHidden} /> layoutProps={layoutProps}
</WorkspaceLayout> compactChrome={headerHidden && !compactActionsHidden}
/>
} }
/> />
<Route <Route
@@ -352,13 +431,7 @@ export default function WorkspaceRoutes({
/> />
<Route <Route
path="chats/:thread_id" path="chats/:thread_id"
element={ element={<ChatThreadEntryRoute layoutProps={layoutProps} />}
<WorkspaceLayout {...layoutProps} defaultHistorySidebarOpen>
<ChatRuntime>
<ChatPage />
</ChatRuntime>
</WorkspaceLayout>
}
/> />
<Route <Route
path="chats_iframe/new" path="chats_iframe/new"
@@ -531,7 +604,7 @@ export default function WorkspaceRoutes({
path="knowledge" path="knowledge"
element={ element={
<WorkspaceLayout {...layoutProps}> <WorkspaceLayout {...layoutProps}>
<KnowledgeBasePage /> <LlmWikiKnowledgePage />
</WorkspaceLayout> </WorkspaceLayout>
} }
/> />
@@ -539,7 +612,7 @@ export default function WorkspaceRoutes({
path="knowledge/graph" path="knowledge/graph"
element={ element={
<WorkspaceLayout {...layoutProps}> <WorkspaceLayout {...layoutProps}>
<KnowledgeGraphPage /> <LlmWikiKnowledgePage mode="graph" />
</WorkspaceLayout> </WorkspaceLayout>
} }
/> />
@@ -547,7 +620,7 @@ export default function WorkspaceRoutes({
path="knowledge/:noteId" path="knowledge/:noteId"
element={ element={
<WorkspaceLayout {...layoutProps}> <WorkspaceLayout {...layoutProps}>
<KnowledgeBasePage /> <LlmWikiKnowledgePage mode="detail" />
</WorkspaceLayout> </WorkspaceLayout>
} }
/> />
@@ -665,6 +738,18 @@ export default function WorkspaceRoutes({
) )
} }
/> />
<Route
path="admin/position-roles"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<PositionRolesPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route <Route
path="settings/recommended-questions" path="settings/recommended-questions"
element={ element={
@@ -701,6 +786,18 @@ export default function WorkspaceRoutes({
) )
} }
/> />
<Route
path="admin/models"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<ModelManagementPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route <Route
path="settings/model-concurrency" path="settings/model-concurrency"
element={ element={
+57 -6
View File
@@ -24,11 +24,23 @@
| 参数 | 是否必填 | 作用 | | 参数 | 是否必填 | 作用 |
| --- | --- | --- | | --- | --- | --- |
| `taskId` | 否 | 外部任务 ID。缺失时使用本地 mock 任务,便于演示和开发。 | | `taskId` | 否 | 外部任务 ID。传入后它是岗位会商的共享存储作用域;缺失时保持原有的个人会话逻辑。mock 仅提供任务卡片的显示兜底,绝不替换路由传入的 ID。 |
| `sessionId` | 否 | 直接恢复指定岗位会商会话;缺失时页面会按 `taskId` 查找最近一条未归档会话。 | | `sessionId` | 否 | 直接恢复指定岗位会商会话;缺失时页面会按 `taskId` 查找最近一条未归档会话。 |
| `positionId` | 否 | 初始打开的岗位,同时同步顶部岗位选择器。无效值回退到 `intelligence`。 | | `positionId` | 否 | 初始打开的岗位,同时同步顶部岗位选择器。无效值回退到 `intelligence`。 |
| `new=1` | 否 | 强制不恢复历史会话,以新会话开始。 | | `new=1` | 否 | 强制不恢复历史会话,以新会话开始。 |
传入 `taskId` 时,同一个任务下可以有多条会商会话,所有用户共享同一份历史、节点快照与 LangGraph 线程;当前 `user_id` 仅保留创建审计,不参与这些任务会话的读写过滤。页面恢复 `sessionId` 时会核验其 `externalTaskId` 与路由 `taskId` 一致,不一致会丢弃该会话并按当前任务重新发现历史。未传 `taskId` 时,会继续只读取当前用户的原有个人会话。
### 外部系统按 taskId 对接
1. 进入页面时将业务侧任务的稳定主键原样放入 URL。工作区入口使用 `/#/page/workspace/roundtable/position?taskId=<taskId>`;策略页入口也兼容 `/#/page/strategy/qa/position-roundtable?taskId=<taskId>`。不要使用任务标题、序号或每次请求都变化的临时 ID。
2. 前端先调用 `GET /api/position-roundtable/sessions?external_task_id=<taskId>`;返回的是该任务下所有用户共享的会商记录,按最近更新时间排序。选中一条后再调用 `GET /sessions/{sessionId}` 恢复完整节点和聊天快照。
3. 没有可恢复记录时,调用 `POST /sessions`,至少传入 `{ external_task_id: taskId, task_snapshot: { ...任务展示字段 } }`。服务端将 `task_scoped` 设为 `true`,并固定快照中的 `id` / `externalTaskId` 为传入的 taskId。
4. 后续所有意图、链条、节点、收口智能体和历史删除 API 只使用返回的 `sessionId`;不要在每次消息请求中重新创建会话。一个 taskId 可以有多条 sessionId。
5. 不传 `taskId`(同时省略 `external_task_id`)就是兼容模式:后端只返回和操作当前用户自己的个人会话,绝不会把历史记录升级为共享。响应中的 `task_scoped` 可用来区分两种记录。
服务端从请求中的非空 `external_task_id` 判断是否开启共享,调用方无需也不应自行传 `task_scoped`。
当前岗位 ID 定义在 `lib/positions.ts`: 当前岗位 ID 定义在 `lib/positions.ts`:
| ID | 页面岗位 | 职责 | | ID | 页面岗位 | 职责 |
@@ -109,6 +121,31 @@ PositionRoundtablePage
后端会冻结链条快照并根据链条阶段创建节点。已启动后意图不可再修改;需要重新识别时必须新建会话。 后端会冻结链条快照并根据链条阶段创建节点。已启动后意图不可再修改;需要重新识别时必须新建会话。
The position workspace selects the dedicated built-in `position-roundtable-intent`
agent by sending `intent_agent_id` through the shared Step-1 init and stream
requests. The thread lifecycle, file upload, and SSE transport stay identical
to the generic multi-agent flow. The dedicated agent permits at most five
user-assistance rounds; one round may contain multiple independent
`ask_clarification` cards. `Step1Panel` renders every card separately and
submits the selected answers together, so cards must never be merged into one
numbered multi-question card. Only its final `[INTENT_READY]` schema changes to
the following four string arrays:
```json
{
"coreGoals": ["..."],
"riskWarnings": ["..."],
"keyPoints": ["..."],
"strategicSignificance": ["..."]
}
```
The position sidebar and edit dialog render these four fields. Business-chain
activation requires all four arrays and at least one core goal or strategic
significance. Historic `objective` / `constraints` / `assumptions` snapshots
are read through a compatibility mapping only; ordinary multi-agent Step 1 is
not changed.
### 4.2 普通节点问答 ### 4.2 普通节点问答
`PositionNodeChat` 在发送前调用 `getPositionNodeContext` 获取受长度控制的任务、意图和已交付上游内容,再以 `task_external_context` 作为隐藏上下文发送给当前智能体。 `PositionNodeChat` 在发送前调用 `getPositionNodeContext` 获取受长度控制的任务、意图和已交付上游内容,再以 `task_external_context` 作为隐藏上下文发送给当前智能体。
@@ -132,10 +169,16 @@ PositionRoundtablePage
页面只维护一个 `sandboxArtifact`。`PositionNodeChat` 和 `PositionBuiltinAgentChat` 通过 `onPreviewArtifact` 上报预览目标;父页面统一渲染 `ArtifactFileDetail`。 页面只维护一个 `sandboxArtifact`。`PositionNodeChat` 和 `PositionBuiltinAgentChat` 通过 `onPreviewArtifact` 上报预览目标;父页面统一渲染 `ArtifactFileDetail`。
**对接 / 重做页面 / 排障指南(打开关闭时机、步骤条、接口约定):**
[`../../docs/position-roundtable-sandbox/沙箱打开关闭与步骤条.md`](../../docs/position-roundtable-sandbox/沙箱打开关闭与步骤条.md)
(目录说明见 [`../../docs/position-roundtable-sandbox/README.md`](../../docs/position-roundtable-sandbox/README.md))
设计要点: 设计要点:
- 文件正在由 `write_file` 流式生成时,优先显示源码;完成后交由预览组件切换 Markdown 预览; - 文件正在由 `write_file` 流式生成时,优先显示源码;完成后交由预览组件切换 Markdown 预览;
- `sandboxThreadSnapshot` 有 160ms 合并节流,只保留当前打开文件所在线程的最新快照,避免流式时频繁 setState; - `write_file` 的参数是一整个 JSON 流,键顺序由模型决定。若 `content` 先于 `path` 到达,会先用临时文件名(`未命名文件.md`/`.html`,按正文首部嗅探)开沙箱——流式内容只按 `message_id` + `tool_call_id` 定位,与路径无关;真实 `path` 到达后沿用同一个 `previewKey` 改名,属于「更新」而非第二次交付;
- 流式快照**不能进页面 state**。本页很大(任务面板、意图结果、流程图、节点对话),按流式频率重渲染整页会把主线程占满,连消息列表都会停止绘制直到本轮结束。快照走 `createSandboxThreadStore()` + `SandboxThreadProvider`(`useSyncExternalStore`),只有沙箱子树重渲染;
- 快照的合并节流是**前沿**节流:距上次推送超过 160ms 就在流回调里同步推送,未满则挂一个补尾定时器。不能只靠尾随定时器——主线程繁忙时回调会被饿死,面板会一直冻在打开时的内容;
- 用户手动关闭后,`closedSandboxKeyRef` 与 `suppressedAutoPreviewThreadIdsRef` 阻止同一线程立即把沙箱重新打开; - 用户手动关闭后,`closedSandboxKeyRef` 与 `suppressedAutoPreviewThreadIdsRef` 阻止同一线程立即把沙箱重新打开;
- 切岗位、切节点、切收口阶段、新建会话和切换历史会话都会先调用 `closeSandbox()`。 - 切岗位、切节点、切收口阶段、新建会话和切换历史会话都会先调用 `closeSandbox()`。
@@ -158,8 +201,8 @@ PositionRoundtablePage
| 方法 | 路径 | 用途 | | 方法 | 路径 | 用途 |
| --- | --- | --- | | --- | --- | --- |
| `GET` | `/sessions?external_task_id=` | 按任务查询会话,用于恢复最近会话和历史记录。 | | `GET` | `/sessions?external_task_id=<taskId>` | 传入时按任务作用域查询共享会话;不传参数时查询当前用户的个人历史。 |
| `POST` | `/sessions` | 创建初始会话并保存任务快照。 | | `POST` | `/sessions` | 创建会话并保存任务快照;传入非空 `external_task_id` 时服务端会将快照 ID 固定为该值并启用共享;缺失或空值时创建原有个人会话。 |
| `GET` | `/sessions/{id}` | 恢复会话、节点和冻结快照。 | | `GET` | `/sessions/{id}` | 恢复会话、节点和冻结快照。 |
| `DELETE` | `/sessions/{id}` | 删除一条岗位会商历史及其节点;删除当前记录时前端会先屏蔽并发恢复请求。 | | `DELETE` | `/sessions/{id}` | 删除一条岗位会商历史及其节点;删除当前记录时前端会先屏蔽并发恢复请求。 |
| `PUT` | `/sessions/{id}/task` | 更新未冻结的任务快照。 | | `PUT` | `/sessions/{id}/task` | 更新未冻结的任务快照。 |
@@ -173,7 +216,7 @@ PositionRoundtablePage
| `POST` | `/sessions/{id}/summary/*` | 准备、发送和完成方案总结。 | | `POST` | `/sessions/{id}/summary/*` | 准备、发送和完成方案总结。 |
| `POST` | `/sessions/{id}/action-plan/*` | 准备、发送和完成行动规划。 | | `POST` | `/sessions/{id}/action-plan/*` | 准备、发送和完成行动规划。 |
| `PUT` | `/sessions/{id}/{summary|action-plan}/conversation` | 保存两个收口智能体的消息与未发送草稿。 | | `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`。 后端实现在 `offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py`。
@@ -207,7 +250,7 @@ src/position-roundtable/demo/position-roundtable-full.json
## 9. 开发与排查清单 ## 9. 开发与排查清单
1. 新增岗位时:仅补充 `POSITION_OPTIONS`,并确保业务链席位的 `position_id` 使用稳定 ID;不要以显示名称作持久化键。 1. 新增岗位时:通过「设置和更多 → 岗位角色配置」(管理员)保存到 `/api/position-roles`;`POSITION_OPTIONS` 仅作为接口暂不可用时的前端回退。业务链席位的 `position_id` 必须使用稳定 ID,不能使用显示名称。
2. 新增收口智能体时:同时补充前端 `PositionBuiltinAgentKind`、后端 prepare/complete API、解锁条件和 UI 切换项。 2. 新增收口智能体时:同时补充前端 `PositionBuiltinAgentKind`、后端 prepare/complete API、解锁条件和 UI 切换项。
3. 变更节点完成条件时:前后端必须一致。当前普通节点的完成条件是 Markdown 交付物,而不是“有一条回答”。 3. 变更节点完成条件时:前后端必须一致。当前普通节点的完成条件是 Markdown 交付物,而不是“有一条回答”。
4. 改预览逻辑时:始终通过父页面的 `onPreviewArtifact` / `closeSandbox` 入口;不要在子组件里新增沙箱状态。 4. 改预览逻辑时:始终通过父页面的 `onPreviewArtifact` / `closeSandbox` 入口;不要在子组件里新增沙箱状态。
@@ -226,3 +269,11 @@ pnpm build
```powershell ```powershell
git diff --check git diff --check
``` ```
## 11. 岗位角色配置
岗位角色配置只管理岗位会商页面的工作视角,不等同于组织岗位或权限。后端启动会幂等补齐
`intelligence`、`planning`、`solution`、`evaluation` 四个内置岗位;已保存的名称、说明、顺序、
启停和性质不会被种子覆盖。一个启用的“意图识别岗(特殊)”是必需的,负责第一阶段意图理解并
显示方案总结、行动规划两个收口智能体。内置岗位为兼容历史业务链而不可删除,但可以修改或停用;
自定义岗位可以新增、排序和删除。
@@ -68,7 +68,10 @@ export interface PositionRoundtableNode {
*/ */
export interface PositionRoundtableSession { export interface PositionRoundtableSession {
id: string; id: string;
/** Present only when the workspace was opened with a route `taskId`. */
externalTaskId: string | null; externalTaskId: string | null;
/** True only for sessions intentionally shared by an explicit route taskId. */
taskScoped: boolean;
taskSnapshot: Record<string, unknown>; taskSnapshot: Record<string, unknown>;
chainId: string | null; chainId: string | null;
chainSnapshot: Record<string, unknown> | null; chainSnapshot: Record<string, unknown> | null;
@@ -112,6 +115,7 @@ interface ServerNode {
interface ServerSession { interface ServerSession {
id: string; id: string;
external_task_id: string | null; external_task_id: string | null;
task_scoped?: boolean;
task_snapshot?: Record<string, unknown>; task_snapshot?: Record<string, unknown>;
chain_id: string | null; chain_id: string | null;
chain_snapshot?: Record<string, unknown> | null; chain_snapshot?: Record<string, unknown> | null;
@@ -130,10 +134,86 @@ interface ServerSession {
nodes?: ServerNode[]; nodes?: ServerNode[];
} }
const BASE = () => `${getBackendBaseURL()}/api/position-roundtable`; const PRIMARY_API_PATH = "/api/position-roundtable";
const COMPAT_API_PATH = "/api/multi-agent/position-roundtable";
const BASE = () => `${getBackendBaseURL()}${PRIMARY_API_PATH}`;
/**
* Some intranet gateways only forward the long-established `/api/multi-agent`
* namespace. The position workspace was added later under its own top-level
* prefix, so a frontend-only release can work locally but receive FastAPI's
* route-level `{"detail":"Not Found"}` after deployment.
*
* Retry only that exact "route is absent" response against the compatibility
* namespace. A domain 404 such as "Position roundtable session not found"
* remains a real 404 and is never hidden by this routing fallback.
*/
let resolvedApiPath: typeof PRIMARY_API_PATH | typeof COMPAT_API_PATH | null = null;
function replaceApiPath(url: string, apiPath: string): string {
return url.replace(PRIMARY_API_PATH, apiPath);
}
async function isMissingRoute(response: Response): Promise<boolean> {
if (response.status !== 404) return false;
const raw = await response.clone().text().catch(() => "");
try {
const parsed = JSON.parse(raw) as { detail?: unknown };
return parsed?.detail === "Not Found";
} catch {
const normalized = raw.trim();
return (
normalized === "Not Found" ||
/<title>\s*404 Not Found\s*<\/title>/i.test(normalized)
);
}
}
async function positionApiFetch(
input: string,
init?: RequestInit,
): Promise<Response> {
if (resolvedApiPath === COMPAT_API_PATH) {
const compatibilityResponse = await apiFetch(
replaceApiPath(input, COMPAT_API_PATH),
init,
);
if (!(await isMissingRoute(compatibilityResponse))) {
return compatibilityResponse;
}
// A gateway may be reloaded while the SPA remains open. Re-probe the
// canonical route instead of pinning the tab to a now-stale route choice.
resolvedApiPath = null;
}
const primaryResponse = await apiFetch(input, init);
if (!(await isMissingRoute(primaryResponse))) {
resolvedApiPath = PRIMARY_API_PATH;
return primaryResponse;
}
const compatibilityResponse = await apiFetch(
replaceApiPath(input, COMPAT_API_PATH),
init,
);
if (!(await isMissingRoute(compatibilityResponse))) {
resolvedApiPath = COMPAT_API_PATH;
return compatibilityResponse;
}
// Preserve the canonical response/error when neither route exists. This
// keeps diagnostics stable and identifies an outdated backend release.
return primaryResponse;
}
async function readJson<T>(response: Response, label: string): Promise<T> { async function readJson<T>(response: Response, label: string): Promise<T> {
if (!response.ok) { if (!response.ok) {
if (await isMissingRoute(response)) {
throw new Error(
`${label} failed (${response.status}): 岗位会商会话接口尚未部署或未被网关转发;` +
`已尝试 ${PRIMARY_API_PATH} 与 ${COMPAT_API_PATH}`,
);
}
const detail = await response.text().catch(() => ""); const detail = await response.text().catch(() => "");
throw new Error(`${label} failed (${response.status}): ${detail}`); throw new Error(`${label} failed (${response.status}): ${detail}`);
} }
@@ -194,6 +274,7 @@ function toSession(session: ServerSession): PositionRoundtableSession {
const result: PositionRoundtableSession = { const result: PositionRoundtableSession = {
id: session.id, id: session.id,
externalTaskId: session.external_task_id, externalTaskId: session.external_task_id,
taskScoped: session.task_scoped ?? false,
taskSnapshot: session.task_snapshot ?? {}, taskSnapshot: session.task_snapshot ?? {},
chainId: session.chain_id, chainId: session.chain_id,
chainSnapshot: session.chain_snapshot ?? null, chainSnapshot: session.chain_snapshot ?? null,
@@ -219,7 +300,7 @@ function toSession(session: ServerSession): PositionRoundtableSession {
export async function listPositionSessions(externalTaskId?: string): Promise<PositionRoundtableSession[]> { export async function listPositionSessions(externalTaskId?: string): Promise<PositionRoundtableSession[]> {
const params = externalTaskId ? `?external_task_id=${encodeURIComponent(externalTaskId)}` : ""; const params = externalTaskId ? `?external_task_id=${encodeURIComponent(externalTaskId)}` : "";
const result = await readJson<ServerSession[]>( const result = await readJson<ServerSession[]>(
await apiFetch(`${BASE()}/sessions${params}`), await positionApiFetch(`${BASE()}/sessions${params}`),
"list position sessions", "list position sessions",
); );
return result.map(toSession); return result.map(toSession);
@@ -230,7 +311,7 @@ export async function createPositionSession(input: {
taskSnapshot: Record<string, unknown>; taskSnapshot: Record<string, unknown>;
}): Promise<PositionRoundtableSession> { }): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>( const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions`, { await positionApiFetch(`${BASE()}/sessions`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
@@ -244,8 +325,8 @@ export async function createPositionSession(input: {
} }
export async function getPositionSession(sessionId: string): Promise<PositionRoundtableSession> { export async function getPositionSession(sessionId: string): Promise<PositionRoundtableSession> {
const response = await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`); const response = await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`);
if (response.status === 404) { if (response.status === 404 && !(await isMissingRoute(response))) {
// Consume the body so the connection can be reused, then expose a typed // Consume the body so the connection can be reused, then expose a typed
// error that the page can resolve by selecting another history record. // error that the page can resolve by selecting another history record.
await response.text().catch(() => ""); await response.text().catch(() => "");
@@ -260,7 +341,7 @@ export async function updatePositionTaskSnapshot(
taskSnapshot: Record<string, unknown>, taskSnapshot: Record<string, unknown>,
): Promise<PositionRoundtableSession> { ): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>( const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/task`, { await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/task`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ task_snapshot: taskSnapshot }), body: JSON.stringify({ task_snapshot: taskSnapshot }),
@@ -279,7 +360,7 @@ export async function savePositionIntent(
}, },
): Promise<PositionRoundtableSession> { ): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>( const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/intent`, { await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/intent`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
@@ -299,7 +380,7 @@ export async function activatePositionSession(
): Promise<PositionRoundtableSession> { ): Promise<PositionRoundtableSession> {
// 启动即冻结链条快照并创建节点;后续页面不得用当前编辑中的链条替换它。 // 启动即冻结链条快照并创建节点;后续页面不得用当前编辑中的链条替换它。
const result = await readJson<ServerSession>( const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/activate`, { await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/activate`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chain_id: chainId }), body: JSON.stringify({ chain_id: chainId }),
@@ -311,7 +392,7 @@ export async function activatePositionSession(
export async function archivePositionSession(sessionId: string): Promise<PositionRoundtableSession> { export async function archivePositionSession(sessionId: string): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>( const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/archive`, { await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/archive`, {
method: "POST", method: "POST",
}), }),
"archive position session", "archive position session",
@@ -320,12 +401,12 @@ export async function archivePositionSession(sessionId: string): Promise<Positio
} }
export async function deletePositionSession(sessionId: string): Promise<void> { export async function deletePositionSession(sessionId: string): Promise<void> {
const response = await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`, { const response = await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`, {
method: "DELETE", method: "DELETE",
}); });
// DELETE is deliberately idempotent from the UI's perspective. A stale // DELETE is deliberately idempotent from the UI's perspective. A stale
// history row that is already gone still needs to be removed locally. // history row that is already gone still needs to be removed locally.
if (response.status === 404) { if (response.status === 404 && !(await isMissingRoute(response))) {
await response.text().catch(() => ""); await response.text().catch(() => "");
return; return;
} }
@@ -338,7 +419,7 @@ export async function bindPositionNodeThread(
threadId: string, threadId: string,
): Promise<PositionRoundtableNode> { ): Promise<PositionRoundtableNode> {
const result = await readJson<ServerNode>( const result = await readJson<ServerNode>(
await apiFetch( await positionApiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/thread`, `${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/thread`,
{ {
method: "POST", method: "POST",
@@ -360,7 +441,7 @@ export async function savePositionNodeConversation(
}, },
): Promise<PositionRoundtableNode> { ): Promise<PositionRoundtableNode> {
const result = await readJson<ServerNode>( const result = await readJson<ServerNode>(
await apiFetch( await positionApiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/state`, `${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/state`,
{ {
method: "PUT", method: "PUT",
@@ -386,7 +467,7 @@ export async function savePositionBuiltinConversation(
): Promise<PositionRoundtableSession> { ): Promise<PositionRoundtableSession> {
const path = kind === "summary" ? "summary/state" : "action-plan/state"; const path = kind === "summary" ? "summary/state" : "action-plan/state";
const result = await readJson<ServerSession>( const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, { await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
@@ -416,7 +497,7 @@ export async function seedPositionDemoSession(
payload: PositionDemoSeedPayload, payload: PositionDemoSeedPayload,
): Promise<PositionRoundtableSession> { ): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>( const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/demo/seed`, { await positionApiFetch(`${BASE()}/demo/seed`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -437,7 +518,7 @@ export async function getPositionNodeContext(
nodeKey: string, nodeKey: string,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
return readJson<Record<string, unknown>>( return readJson<Record<string, unknown>>(
await apiFetch( await positionApiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/context`, `${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/context`,
{ method: "POST" }, { method: "POST" },
), ),
@@ -456,7 +537,7 @@ export async function completePositionNodeTurn(
): Promise<PositionRoundtableNode> { ): Promise<PositionRoundtableNode> {
// 后端依据 artifactManifest 判断是否有 Markdown 交付物,并在需要时解锁下游。 // 后端依据 artifactManifest 判断是否有 Markdown 交付物,并在需要时解锁下游。
const result = await readJson<ServerNode>( const result = await readJson<ServerNode>(
await apiFetch( await positionApiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/complete-turn`, `${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/complete-turn`,
{ {
method: "POST", method: "POST",
@@ -479,7 +560,7 @@ export async function rejectPositionNode(
input: { reason?: string; title?: string }, input: { reason?: string; title?: string },
): Promise<PositionRoundtableSession> { ): Promise<PositionRoundtableSession> {
const result = await readJson<ServerSession>( const result = await readJson<ServerSession>(
await apiFetch( await positionApiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/reject`, `${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/reject`,
{ {
method: "POST", method: "POST",
@@ -533,7 +614,7 @@ async function prepareBuiltinRun(
// prepare 阶段固定本轮的上游版本与隐藏提示词,避免总结/行动规划引用漂移数据。 // prepare 阶段固定本轮的上游版本与隐藏提示词,避免总结/行动规划引用漂移数据。
const path = kind === "summary" ? "summary/prepare" : "action-plan/prepare"; const path = kind === "summary" ? "summary/prepare" : "action-plan/prepare";
const result = await readJson<ServerBuiltinRunPreparation>( const result = await readJson<ServerBuiltinRunPreparation>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, { await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }), body: JSON.stringify({ message }),
@@ -557,7 +638,7 @@ async function completeBuiltinRun(
// complete 阶段会校验 sourceRevisions;上游已变化时不能把旧结果覆盖为最新交付。 // complete 阶段会校验 sourceRevisions;上游已变化时不能把旧结果覆盖为最新交付。
const path = kind === "summary" ? "summary/complete" : "action-plan/complete"; const path = kind === "summary" ? "summary/complete" : "action-plan/complete";
const result = await readJson<ServerSession>( const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, { await positionApiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
@@ -1,10 +1,30 @@
import { useEffect, useRef, type ComponentProps } from "react"; import { useEffect, useMemo, useRef, type ComponentProps } from "react";
import { InputBox } from "@/components/workspace/input-box"; import {
import { usePromptInputController } from "@/components/ai-elements/prompt-input"; usePromptInputController,
import { cn } from "@/lib/utils"; type PromptInputMessage,
} from "@/components/ai-elements/prompt-input";
import type { InputBox } from "@/components/workspace/input-box";
import { useModels } from "@/core/models/hooks";
import type { AgentQuestion } from "@/core/agents";
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
type PositionAgentInputProps = ComponentProps<typeof InputBox> & { type InputBoxProps = ComponentProps<typeof InputBox>;
type PositionAgentInputProps = Pick<
InputBoxProps,
| "className"
| "assistantId"
| "status"
| "disabled"
| "context"
| "onContextChange"
| "onSubmit"
| "onStop"
| "isNewThread"
| "threadId"
| "hideAdminRecommendedQuestions"
> & {
commonQuestions?: (string | AgentQuestion)[] | null;
/** Stable node/builtin key used to restore the matching SQL-backed draft. */ /** Stable node/builtin key used to restore the matching SQL-backed draft. */
draftKey?: string; draftKey?: string;
draftValue?: string; draftValue?: string;
@@ -18,18 +38,36 @@ type PositionAgentInputProps = ComponentProps<typeof InputBox> & {
* 只改这个组件即可,不会影响普通对话、工作区或其他业务页面。 * 只改这个组件即可,不会影响普通对话、工作区或其他业务页面。
*/ */
export function PositionAgentInput({ export function PositionAgentInput({
className, context,
disabled = false,
status = "ready",
onContextChange,
onSubmit,
onStop,
commonQuestions,
draftKey = "", draftKey = "",
draftValue = "", draftValue = "",
onDraftChange, onDraftChange,
...props
}: PositionAgentInputProps) { }: PositionAgentInputProps) {
const { textInput } = usePromptInputController(); const { textInput, attachments } = usePromptInputController();
const { models } = useModels();
const activeDraftKeyRef = useRef(""); const activeDraftKeyRef = useRef("");
const restoringValueRef = useRef<string | null>(null); const restoringValueRef = useRef<string | null>(null);
const textInputRef = useRef(textInput); const textInputRef = useRef(textInput);
textInputRef.current = textInput; textInputRef.current = textInput;
const modelOptions = useMemo(
() => models.map((model) => ({ label: model.display_name ?? model.name, value: model.name })),
[models],
);
const attachedFiles = useMemo(
() =>
attachments.files
.map((attachment) => attachment.file)
.filter((file): file is File => file instanceof File),
[attachments.files],
);
useEffect(() => { useEffect(() => {
activeDraftKeyRef.current = draftKey; activeDraftKeyRef.current = draftKey;
if (textInputRef.current.value !== draftValue) { if (textInputRef.current.value !== draftValue) {
@@ -51,13 +89,60 @@ export function PositionAgentInput({
onDraftChange?.(textInput.value); onDraftChange?.(textInput.value);
}, [draftKey, onDraftChange, textInput.value]); }, [draftKey, onDraftChange, textInput.value]);
// Keep the page-specific class on the wrapper rather than adding a variant const handleSend = () => {
// to InputBox. Node chat and built-in chat can evolve together while every if (status === "streaming") {
// other workspace keeps the global composer untouched. onStop?.();
return;
}
const text = textInput.value.trim();
if (!text && attachedFiles.length === 0) return;
const message: PromptInputMessage = {
text: textInput.value,
files: attachments.files,
};
void onSubmit?.(message);
textInput.clear();
attachments.clear();
};
const handleModelChange = (modelName: string) => {
const model = models.find((candidate) => candidate.name === modelName);
if (!model) return;
onContextChange?.({ ...context, model_name: model.name });
};
const selectedModel =
typeof context.model_name === "string"
? context.model_name
: modelOptions[0]?.value;
return ( return (
<InputBox <PositionIntentInput
{...props} value={textInput.value}
className={cn("position-roundtable-agent-input w-full", className)} onValueChange={textInput.setInput}
attachedFiles={attachedFiles}
onAddFiles={attachments.add}
onRemoveFile={(index) => {
const attachment = attachments.files[index];
if (attachment) attachments.remove(attachment.id);
}}
charactersLeft={textInput.value.length}
isSending={status === "streaming"}
canSend={!disabled && (Boolean(textInput.value.trim()) || attachedFiles.length > 0)}
pendingClarification={false}
onSend={handleSend}
onStop={() => onStop?.()}
onFollowUp={() => undefined}
onSkip={() => undefined}
onEnterRoundtable={() => undefined}
enterButtonLabel=""
intentReady={null}
hideEnterRoundtable
onManualSave={() => undefined}
onTriggerToast={() => undefined}
modelOptions={modelOptions}
selectedModel={selectedModel}
onSelectedModelChange={handleModelChange}
commonQuestions={commonQuestions}
/> />
); );
} }
@@ -716,7 +716,7 @@ export function PositionBuiltinAgentChat({
</div> </div>
)} )}
<div className="position-roundtable-agent-composer-bar shrink-0 border-t border-slate-100 px-4 py-3 dark:border-[#2b8ffe]/20"> <div className="position-roundtable-agent-composer-bar position-roundtable-node-composer shrink-0">
<PositionAgentInput <PositionAgentInput
className="w-full" className="w-full"
isNewThread={!boundThreadId} isNewThread={!boundThreadId}
@@ -0,0 +1,286 @@
/**
* 岗位会商风格的只读业务链流程图。
*
* 页面无关:8BF 等独立会商页可以直接复用岗位会商的纵向流程图视觉与交互,
* 不会读取或写入岗位会商的会话、岗位节点状态。
*/
import { useRef, useState } from "react";
import { CheckCircle2, Circle, Search } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
import { cn } from "@/lib/utils";
const NODE_COLORS = ["#4f7df3", "#a778f5", "#65b4e8", "#f0a245", "#e675a8"];
const FLOW_NODE_WIDTH = 184;
const FLOW_NODE_GAP = 12;
type FlowStage = {
goal: string | null;
seats: ChainSeat[];
};
export interface PositionBusinessChainFlowProps {
chain: BusinessChain | null;
loading?: boolean;
className?: string;
}
function getFlowStages(chain: BusinessChain): FlowStage[] {
const seatsById = new Map(chain.seats.map((seat) => [seat.agent_id, seat]));
if (chain.stages?.length) {
return chain.stages
.map((ids, index) => ({
goal: chain.stageGoals?.[index] ?? null,
seats: ids
.map((id) => seatsById.get(id))
.filter((seat): seat is ChainSeat => Boolean(seat)),
}))
.filter((stage) => stage.seats.length > 0);
}
return chain.seats.map((seat) => ({ goal: null, seats: [seat] }));
}
export function PositionBusinessChainFlow({
chain,
loading = false,
className,
}: PositionBusinessChainFlowProps) {
const [dialogOpen, setDialogOpen] = useState(false);
return (
<section
className={cn(
"position-business-chain-flow flex h-full min-h-[460px] w-full flex-col overflow-hidden rounded-2xl border border-[#ece7e4] bg-white p-3 shadow-[0_4px_20px_rgba(0,0,0,0.02)] dark:border-[#2b8ffe]/30 dark:bg-[#042b59]/55 dark:shadow-none",
className,
)}
>
<div className="mb-3 flex shrink-0 items-center justify-between gap-2 border-b border-slate-200 pb-3 dark:border-[#2b8ffe]/40">
<div className="flex min-w-0 items-center gap-2">
<span
aria-hidden="true"
className="h-6 w-1 shrink-0 rounded-l-[2px] rounded-br-[2px] bg-[linear-gradient(167deg,#f15959_0%,#cc1925_100%)] dark:bg-[linear-gradient(167deg,#a2e1f9_0%,#45c3f3_100%)]"
/>
<div className="min-w-0">
<h2 className="truncate text-base font-semibold text-slate-900 dark:text-white">业务链条</h2>
<p className="truncate text-[11px] text-slate-400 dark:text-[#8aa0c4]" aria-live="polite">
{chain?.title ?? (loading ? "正在加载管理员配置…" : "等待管理员配置")}
</p>
</div>
</div>
{chain && (
<button
type="button"
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-[#f0c8c8] bg-[#fff7f7] text-[#cc1925] transition-colors hover:border-[#cc1925] hover:bg-[#fff0f0] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cc1925]/30 dark:border-[#2b8ffe]/45 dark:bg-[#082d5f] dark:text-[#8fc4ff]"
title="查看完整流程图"
aria-label="查看完整流程图"
onClick={() => setDialogOpen(true)}
>
<Search className="size-[18px]" strokeWidth={2.5} />
</button>
)}
</div>
{!chain ? (
<div className="flex min-h-0 flex-1 items-center justify-center rounded-lg border border-dashed border-[#e8d0cc] px-5 text-center text-xs leading-6 text-slate-400 dark:border-[#2b8ffe]/30 dark:text-[#8aa0c4]">
{loading ? "正在加载业务链条…" : "管理员尚未配置 8BF 业务链条"}
</div>
) : (
<>
<BusinessChainCanvas chain={chain} className="min-h-0 flex-1" />
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="flex h-[86vh] w-[96vw] max-w-[1180px] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b px-5 py-4 dark:border-[#2b8ffe]/25">
<DialogTitle className="text-base font-semibold">{chain.title} · 业务链条流程图</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 p-4">
<BusinessChainCanvas chain={chain} expanded className="h-full" />
</div>
</DialogContent>
</Dialog>
</>
)}
</section>
);
}
function BusinessChainCanvas({
chain,
expanded = false,
className,
}: {
chain: BusinessChain;
expanded?: boolean;
className?: string;
}) {
const [view, setView] = useState({ tx: 0, ty: 0 });
const [panning, setPanning] = useState(false);
const dragRef = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
const stages = getFlowStages(chain);
const nodeWidth = expanded ? 250 : FLOW_NODE_WIDTH;
const nodeGap = expanded ? 18 : FLOW_NODE_GAP;
let colorIndex = 0;
const endPan = (event: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return;
dragRef.current = null;
setPanning(false);
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// The browser may have already released the pointer capture.
}
};
return (
<div
className={cn(
"relative overflow-hidden rounded-lg border border-slate-100 bg-slate-50/70 touch-none dark:border-[#2b8ffe]/25 dark:bg-[#032650]/75",
panning ? "cursor-grabbing" : "cursor-grab",
className,
)}
style={{
backgroundImage: "radial-gradient(rgba(148,163,184,.55) 1px, transparent 1px)",
backgroundSize: "16px 16px",
}}
title="按住鼠标拖动画布;双击复位"
onDoubleClick={() => setView({ tx: 0, ty: 0 })}
onPointerDown={(event) => {
if (event.button !== 0) return;
dragRef.current = { x: event.clientX, y: event.clientY, tx: view.tx, ty: view.ty };
setPanning(true);
event.currentTarget.setPointerCapture(event.pointerId);
}}
onPointerMove={(event) => {
const drag = dragRef.current;
if (!drag) return;
setView({ tx: drag.tx + event.clientX - drag.x, ty: drag.ty + event.clientY - drag.y });
}}
onPointerUp={endPan}
onPointerCancel={endPan}
>
<div
className="flex min-h-full min-w-max flex-col items-center px-5 py-6"
style={{ transform: `translate(${view.tx}px, ${view.ty}px)` }}
>
{stages.map((stage, stageIndex) => {
const stageWidth = Math.max(
nodeWidth,
stage.seats.length * (nodeWidth + nodeGap) - nodeGap,
);
return (
<div key={`${stage.goal ?? "stage"}-${stageIndex}`} className="flex flex-col items-center">
{stageIndex > 0 && (
<FlowConnector
branches={stage.seats.length}
width={stageWidth}
nodeWidth={nodeWidth}
nodeGap={nodeGap}
/>
)}
<div className="flex items-start justify-center" style={{ gap: nodeGap }}>
{stage.seats.map((seat) => {
const accent = NODE_COLORS[(colorIndex++ + 1) % NODE_COLORS.length]!;
return (
<FlowNode
key={seat.agent_id}
title={seat.name || seat.agent_id}
description={stage.goal?.trim() || seat.description?.trim() || "业务链席位"}
accent={accent}
width={nodeWidth}
/>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
}
function FlowConnector({
branches,
width,
nodeWidth,
nodeGap,
}: {
branches: number;
width: number;
nodeWidth: number;
nodeGap: number;
}) {
const connectorStyle = { backgroundColor: "rgba(148,163,184,.88)" };
if (branches <= 1) {
return (
<span className="relative mb-1 h-8 w-0.5 rounded-full" style={connectorStyle}>
<span className="absolute bottom-1 left-1/2 size-2 -translate-x-1/2 rotate-45 border-b-2 border-r-2 border-slate-400" />
</span>
);
}
const nodeCenter = nodeWidth / 2;
return (
<div className="relative mb-1 h-9" style={{ width }}>
<span className="absolute left-1/2 top-0 h-3.5 w-0.5 -translate-x-1/2 rounded-full" style={connectorStyle} />
<span className="absolute top-3.5 h-0.5 rounded-full" style={{ left: nodeCenter, right: nodeCenter, ...connectorStyle }} />
{Array.from({ length: branches }).map((_, index) => (
<span
key={index}
className="absolute top-3.5 bottom-0 w-0.5 -translate-x-1/2 rounded-full"
style={{
left: `${((index * (nodeWidth + nodeGap) + nodeCenter) / width) * 100}%`,
...connectorStyle,
}}
>
<span className="absolute bottom-1 left-1/2 size-2 -translate-x-1/2 rotate-45 border-b-2 border-r-2 border-slate-400" />
</span>
))}
</div>
);
}
function FlowNode({
title,
description,
accent,
completed = false,
width,
}: {
title: string;
description: string;
accent: string;
completed?: boolean;
width: number;
}) {
const StatusIcon = completed ? CheckCircle2 : Circle;
return (
<article
className="relative overflow-hidden rounded-xl border border-slate-200 bg-white p-3 pr-10 shadow-sm dark:border-[#2b8ffe]/40 dark:bg-[#0a2d58]"
style={{ width, borderLeftWidth: 5, borderLeftColor: accent }}
>
<span
className={cn(
"absolute right-2 top-2 flex size-6 items-center justify-center rounded-full border bg-white shadow-sm dark:bg-[#0d3768]",
completed
? "border-emerald-500 text-emerald-500"
: "border-slate-300 text-slate-400 dark:border-[#8aa0c4] dark:text-[#8aa0c4]",
)}
>
<StatusIcon className="size-3.5" strokeWidth={2.5} />
</span>
<p className="truncate text-sm font-extrabold leading-5 text-slate-900 dark:text-[#e8edf5]" title={title}>
{title}
</p>
<p className="mt-0.5 truncate text-xs font-semibold leading-4" style={{ color: accent }} title={description}>
{description}
</p>
</article>
);
}
@@ -1,12 +1,9 @@
import { useRef } from "react"; import { useRef, useState } from "react";
import { import {
FileText, FileText,
Loader2,
Paperclip,
Pause, Pause,
Repeat, Repeat,
Save, Save,
Send,
Smile, Smile,
Sparkles, Sparkles,
X, X,
@@ -14,7 +11,11 @@ import {
import { Select as TSelect } from "tdesign-react"; import { Select as TSelect } from "tdesign-react";
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea"; import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { normalizeAgentQuestions, type AgentQuestion } from "@/core/agents";
import type { Step1InputAreaProps } from "@/roundtable-planning/components/Step1Panel"; import type { Step1InputAreaProps } from "@/roundtable-planning/components/Step1Panel";
import moreTaskIcon from "@/assets/position-roundtable/more-task.svg";
import uploadFileIcon from "@/assets/position-roundtable/upload-file.svg";
import sendMessageIcon from "@/assets/position-roundtable/send-message.svg";
interface PositionIntentModelOption { interface PositionIntentModelOption {
label: string; label: string;
@@ -25,6 +26,7 @@ interface PositionIntentInputProps extends Step1InputAreaProps {
modelOptions?: PositionIntentModelOption[]; modelOptions?: PositionIntentModelOption[];
selectedModel?: string; selectedModel?: string;
onSelectedModelChange?: (model: string) => void; onSelectedModelChange?: (model: string) => void;
commonQuestions?: (string | AgentQuestion)[] | null;
} }
/** Keep attachment metadata presentation local to this position-only composer. */ /** Keep attachment metadata presentation local to this position-only composer. */
@@ -64,40 +66,25 @@ export function PositionIntentInput({
modelOptions = [], modelOptions = [],
selectedModel, selectedModel,
onSelectedModelChange, onSelectedModelChange,
commonQuestions,
}: PositionIntentInputProps) { }: PositionIntentInputProps) {
// Files are owned by Step1Panel. This ref only opens the native picker and // Files are owned by Step1Panel. This ref only opens the native picker and
// forwards its selection through onAddFiles, so replacement layouts do not // forwards its selection through onAddFiles, so replacement layouts do not
// accidentally create a second attachment state. // accidentally create a second attachment state.
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [showMoreQuestions, setShowMoreQuestions] = useState(false);
const normalizedCommonQuestions = normalizeAgentQuestions(commonQuestions);
const visibleCommonQuestions = normalizedCommonQuestions.slice(0, 2);
const extraCommonQuestions = normalizedCommonQuestions.slice(2);
const pickCommonQuestion = (question: AgentQuestion) => {
onValueChange(question.prompt);
setShowMoreQuestions(false);
};
return ( return (
<div className="position-roundtable-intent-input position-roundtable-intent-composer shrink-0 border-t border-slate-200 bg-[#f7faff] px-4 py-3 dark:border-[#2b8ffe]/35 dark:bg-[#071a38]"> <div className="position-roundtable-intent-input position-roundtable-intent-composer shrink-0 border-t border-slate-200 bg-[#f7faff] px-4 py-3 dark:border-[#2b8ffe]/35 dark:bg-[#071a38]">
<div className="position-roundtable-intent-composer-frame relative overflow-hidden rounded-[4px] border border-slate-300 bg-white shadow-[0_2px_10px_rgba(15,23,42,0.08)] transition-all focus-within:border-primary focus-within:ring-2 focus-within:ring-[var(--brand-200)]/45 dark:border-[#2b8ffe]/55 dark:bg-[#0d2855] dark:shadow-none dark:focus-within:border-[#56baff] dark:focus-within:ring-[#2b8ffe]/25"> <div className="position-roundtable-intent-composer-frame relative overflow-hidden rounded-[4px] border border-slate-300 bg-white shadow-[0_2px_10px_rgba(15,23,42,0.08)] transition-all focus-within:border-primary focus-within:ring-2 focus-within:ring-[var(--brand-200)]/45 dark:border-[#2b8ffe]/55 dark:bg-[#0d2855] dark:shadow-none dark:focus-within:border-[#56baff] dark:focus-within:ring-[#2b8ffe]/25">
{attachedFiles.length > 0 && (
<div className="position-roundtable-intent-attachments mx-3 mb-2 flex flex-wrap gap-1.5 border-b border-slate-100 pb-2 pt-2.5 dark:border-[#2b8ffe]/25">
{attachedFiles.map((file, index) => (
<span
key={`${file.name}-${file.size}-${index}`}
className="inline-flex max-w-[200px] items-center gap-1.5 rounded-[4px] border border-slate-200 bg-slate-50 py-1 pl-2 pr-1 text-[11px] text-slate-600 dark:border-[#2b8ffe]/30 dark:bg-[#102f63] dark:text-[#c4dcff]"
>
<FileText className="size-3 shrink-0 text-primary" />
<span className="truncate font-medium" title={file.name}>
{file.name}
</span>
<span className="shrink-0 text-slate-400">{formatFileSize(file.size)}</span>
<button
type="button"
onClick={() => onRemoveFile(index)}
className="shrink-0 rounded-[3px] p-0.5 text-slate-400 transition hover:bg-slate-200 hover:text-slate-600 dark:text-[#8aaee0] dark:hover:bg-[#21447c] dark:hover:text-white"
title="移除"
>
<X className="size-3" />
</button>
</span>
))}
</div>
)}
<AutoGrowTextarea <AutoGrowTextarea
id="input-text-area" id="input-text-area"
value={value} value={value}
@@ -144,7 +131,8 @@ export function PositionIntentInput({
title="上传参考文件(智能体将读取文件内容辅助理解任务)" title="上传参考文件(智能体将读取文件内容辅助理解任务)"
className="rounded-[3px] p-1.5 transition hover:bg-white hover:text-primary dark:hover:bg-[#16386e] dark:hover:text-[#73c8ff]" className="rounded-[3px] p-1.5 transition hover:bg-white hover:text-primary dark:hover:bg-[#16386e] dark:hover:text-[#73c8ff]"
> >
<Paperclip className="size-4" /> <img src={uploadFileIcon} alt="" aria-hidden="true" />
<span>上传附件</span>
</button> </button>
</div> </div>
<span className="font-mono text-[10px] text-slate-400">{charactersLeft}/500</span> <span className="font-mono text-[10px] text-slate-400">{charactersLeft}/500</span>
@@ -154,8 +142,79 @@ export function PositionIntentInput({
Parent pages can still hide optional actions by their stable ids Parent pages can still hide optional actions by their stable ids
without changing this component's layout or global input styles. without changing this component's layout or global input styles.
*/} */}
<div className="position-roundtable-intent-toolbar flex flex-wrap items-center gap-3 border-t border-slate-100 bg-white px-3 py-2.5 dark:border-[#2b8ffe]/25 dark:bg-[#0b234c]"> <div className="position-roundtable-intent-toolbar flex flex-wrap items-center gap-3 border-t border-slate-100 px-3 py-2.5 dark:border-[#2b8ffe]/25">
<div className="flex shrink-0 items-center"> <div className="position-roundtable-intent-common-slot">
{visibleCommonQuestions.length > 0 && (
<div className="position-roundtable-intent-common-questions">
{visibleCommonQuestions.map((question, index) => (
<button
key={`${question.prompt}-${index}`}
type="button"
className="position-roundtable-intent-common-question"
title={question.prompt}
onClick={() => pickCommonQuestion(question)}
>
<span aria-hidden="true" />
<span>{question.title}</span>
</button>
))}
{extraCommonQuestions.length > 0 && (
<div className="position-roundtable-intent-common-more">
<button
type="button"
className="position-roundtable-intent-common-more-button"
aria-label="更多常用问题"
aria-expanded={showMoreQuestions}
title="更多常用问题"
onClick={() => setShowMoreQuestions((visible) => !visible)}
>
<img src={moreTaskIcon} alt="" aria-hidden="true" />
</button>
{showMoreQuestions && (
<div className="position-roundtable-intent-common-more-menu">
{extraCommonQuestions.map((question, index) => (
<button
key={`${question.prompt}-${index}`}
type="button"
title={question.prompt}
onClick={() => pickCommonQuestion(question)}
>
{question.title}
</button>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
{attachedFiles.length > 0 && (
<div className="position-roundtable-intent-file-cards flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto">
{attachedFiles.map((file, index) => (
<span
key={`${file.name}-${file.size}-${index}`}
className="inline-flex max-w-[220px] shrink-0 items-center gap-1.5 rounded-[4px] border border-[#2b8ffe]/45 bg-[#0b3168] py-1 pl-2 pr-1 text-[11px] text-[#c4dcff]"
>
<FileText className="size-3 shrink-0 text-[#73c8ff]" />
<span className="truncate font-medium" title={file.name}>
{file.name}
</span>
<span className="shrink-0 text-[#8aaee0]">{formatFileSize(file.size)}</span>
<button
type="button"
onClick={() => onRemoveFile(index)}
className="shrink-0 cursor-pointer rounded-[3px] p-0.5 text-[#8aaee0] transition hover:bg-[#21447c] hover:text-white"
title="移除"
>
<X className="size-3" />
</button>
</span>
))}
</div>
)}
<div className="position-roundtable-intent-actions ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1.5">
<div className="position-roundtable-intent-upload-wrap flex shrink-0 items-center">
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@@ -169,28 +228,28 @@ export function PositionIntentInput({
<button <button
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
title="上传参考文件" title="上传附件"
aria-label="上传参考文件" aria-label="上传附件"
className="position-roundtable-intent-upload-btn flex size-10 items-center justify-center rounded-[4px] border border-slate-200 bg-white text-slate-500 transition hover:border-primary/45 hover:text-primary dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b] dark:hover:text-[#73c8ff]" className="position-roundtable-intent-upload-btn flex items-center justify-center rounded-[24px] border-0"
> >
<Paperclip className="size-4" /> <img src={uploadFileIcon} alt="" aria-hidden="true" />
<span>上传附件</span>
</button> </button>
</div> </div>
<div className="position-roundtable-intent-actions ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1.5">
{modelOptions.length > 0 && ( {modelOptions.length > 0 && (
<div <div
className="position-roundtable-intent-model-switch flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white pl-2 text-xs font-bold text-slate-700 shadow-sm dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7]" className="position-roundtable-intent-model-switch flex h-8 shrink-0 items-center gap-1.5 rounded-[20px] border border-[#2b8ffe]/55 bg-[#0b3168] px-2.5 text-xs font-bold text-[#dceaff]"
title="切换意图理解模型" title="鍒囨崲鎰忓浘鐞嗚В妯″瀷"
> >
<Sparkles className="size-3.5 shrink-0 text-primary dark:text-[#73c8ff]" /> <Sparkles className="size-3.5 shrink-0 text-[#73c8ff]" />
<TSelect <TSelect
size="small" size="small"
aria-label="切换意图理解模型" aria-label="鍒囨崲鎰忓浘鐞嗚В妯″瀷"
value={selectedModel} value={selectedModel}
options={modelOptions} options={modelOptions}
disabled={isSending} disabled={isSending}
onChange={(value) => onSelectedModelChange?.(String(value))} onChange={(value) => onSelectedModelChange?.(String(value))}
className="w-[150px] shrink-0 [&_.t-input]:!h-8 [&_.t-input]:!border-0 [&_.t-input]:!bg-transparent [&_.t-input]:!px-1 [&_.t-input]:!text-xs [&_.t-input]:!font-bold [&_.t-input]:!shadow-none dark:[&_.t-input]:!text-[#dceaff]" className="w-[150px] shrink-0 [&_.t-input]:!h-7 [&_.t-input]:!rounded-[4px] [&_.t-input]:!border-0 [&_.t-input]:!bg-transparent [&_.t-input]:!px-1 [&_.t-input]:!text-xs [&_.t-input]:!font-bold [&_.t-input]:!text-[#dceaff] [&_.t-input]:!shadow-none"
popupProps={{ overlayClassName: "z-[80]" }} popupProps={{ overlayClassName: "z-[80]" }}
/> />
</div> </div>
@@ -198,26 +257,20 @@ export function PositionIntentInput({
<button <button
id="send-message-btn" id="send-message-btn"
type="button" type="button"
onClick={onSend} onClick={isSending ? onStop : onSend}
disabled={!canSend} disabled={isSending ? false : !canSend}
className="position-roundtable-intent-send-btn flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] bg-primary px-3 text-xs font-bold text-white shadow-[0_2px_8px_rgba(245,34,45,0.15)] transition-all active:scale-[0.98] hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:bg-slate-300 dark:bg-[#1478e8] dark:hover:bg-[#2995ff] dark:disabled:bg-[#28476f]" aria-label={isSending ? "暂停生成" : "发送"}
title={isSending ? "暂停生成" : "发送"}
className={`position-roundtable-intent-send-btn flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] bg-primary px-3 text-xs font-bold text-white shadow-[0_2px_8px_rgba(245,34,45,0.15)] transition-all active:scale-[0.98] hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:bg-slate-300 dark:bg-[#1478e8] dark:hover:bg-[#2995ff] dark:disabled:bg-[#28476f]${isSending ? " position-roundtable-intent-send-btn--stopping" : ""}`}
> >
{isSending ? <Loader2 className="size-3 animate-spin" /> : <Send className="size-3" />} {isSending ? (
{isSending ? "发送中" : "发送"} <Pause className="size-4" aria-hidden="true" />
) : (
<img src={sendMessageIcon} alt="" aria-hidden="true" />
)}
<span className="sr-only">{isSending ? "暂停生成" : "发送"}</span>
</button> </button>
{isSending && (
<button
id="pause-intent-btn"
type="button"
onClick={onStop}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-amber-200/40 bg-amber-100 px-3 text-xs font-bold text-amber-800 shadow-sm transition active:scale-[0.98] hover:bg-amber-200 dark:border-amber-300/30 dark:bg-amber-400/15 dark:text-amber-200 dark:hover:bg-amber-400/25"
>
<Pause className="size-3" />
暂停
</button>
)}
<button <button
id="suggest-followup-btn" id="suggest-followup-btn"
type="button" type="button"
@@ -15,7 +15,11 @@ import { ThreadContext } from "@/components/workspace/messages/context";
import { MessageList } from "@/components/workspace/messages"; import { MessageList } from "@/components/workspace/messages";
import { useAgent } from "@/core/agents"; import { useAgent } from "@/core/agents";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import { useEditMessage, useThreadStream } from "@/core/threads/hooks"; import {
useEditMessage,
useThreadStream,
type ToolStartEvent,
} from "@/core/threads/hooks";
import type { AgentThreadState } from "@/core/threads/types"; import type { AgentThreadState } from "@/core/threads/types";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { uuid } from "@/core/utils/uuid"; import { uuid } from "@/core/utils/uuid";
@@ -40,7 +44,6 @@ import {
readConversationDraft, readConversationDraft,
readConversationMessages, readConversationMessages,
} from "@/position-roundtable/lib/conversation-snapshot"; } from "@/position-roundtable/lib/conversation-snapshot";
interface PositionNodeSwitchOption { interface PositionNodeSwitchOption {
node: PositionRoundtableNode; node: PositionRoundtableNode;
agentName?: string | null; agentName?: string | null;
@@ -164,17 +167,100 @@ function artifactDisplayName(pathOrUrl: string): string {
return path.split("/").filter(Boolean).at(-1) || "Markdown 报告"; return path.split("/").filter(Boolean).at(-1) || "Markdown 报告";
} }
function normalizeWriteFilePath(path: string): string {
let normalized = path.trim();
const first = normalized[0];
const last = normalized.at(-1);
if (
normalized.length >= 2 &&
((first === '"' && last === '"') ||
(first === "'" && last === "'") ||
(first === "`" && last === "`"))
) {
normalized = normalized.slice(1, -1).trim();
}
return normalized;
}
function isMarkdownPath(path: string): boolean {
const pathname = normalizeWriteFilePath(path).split(/[?#]/, 1)[0]?.toLowerCase() ?? "";
return pathname.endsWith(".md") || pathname.endsWith(".markdown");
}
function hasCompleteArtifactExtension(path: string): boolean {
const pathname = normalizeWriteFilePath(path).split(/[?#]/, 1)[0] ?? "";
const filename = pathname.split("/").filter(Boolean).at(-1) ?? "";
// During JSON argument streaming a Markdown path arrives as `.m` and then
// `.md`. Opening on the partial suffix permanently labels the preview TEXT.
// Extensionless files still open from on_tool_start once the full path is known.
return /\.[a-z0-9]{2,12}$/i.test(filename);
}
/**
* `write_file` arguments stream as one JSON object and the key order is up to
* the model. Agents that emit `content` before `path` leave the file nameless
* for as long as the body takes to write — a minute for a full report. The
* sandbox follows a streaming file by message/tool-call id, not by path, so it
* can open under a provisional name and adopt the real one later. Sniff the
* body so the provisional name at least carries the right extension.
*/
function provisionalWriteFilePath(content: string): string {
const head = content.slice(0, 256).trimStart().toLowerCase();
if (head.startsWith("<!doctype html") || head.startsWith("<html")) {
return "未命名文件.html";
}
return "未命名文件.md";
}
function writeFilePathFromToolEvent(data: unknown, depth = 0): string | null {
if (depth > 3 || data == null) return null;
if (typeof data === "string") {
const value = data.trim();
if (!value) return null;
if (value.startsWith("{") || value.startsWith("[")) {
try {
return writeFilePathFromToolEvent(JSON.parse(value), depth + 1);
} catch {
return null;
}
}
return null;
}
if (typeof data !== "object" || Array.isArray(data)) return null;
const record = data as Record<string, unknown>;
if (typeof record.path === "string") {
const path = normalizeWriteFilePath(record.path);
return path || null;
}
for (const key of ["input", "inputs", "args", "kwargs"]) {
const path = writeFilePathFromToolEvent(record[key], depth + 1);
if (path) return path;
}
return null;
}
function buildWriteFileVirtualUrl(path: string, messageId: string, toolCallId: string): string { function buildWriteFileVirtualUrl(path: string, messageId: string, toolCallId: string): string {
const language = isMarkdownPath(path) ? "&language=markdown" : "";
return new URL( return new URL(
`write-file:${path}?message_id=${encodeURIComponent( `write-file:${path}?message_id=${encodeURIComponent(
messageId, messageId,
)}&tool_call_id=${encodeURIComponent(toolCallId)}`, )}&tool_call_id=${encodeURIComponent(toolCallId)}${language}`,
).toString(); ).toString();
} }
function isArtifactWriteTool(name: string | undefined): name is "write_file" | "str_replace" {
return name === "write_file" || name === "str_replace";
}
function latestStreamingWriteFile( function latestStreamingWriteFile(
messages: AgentThreadState["messages"], messages: AgentThreadState["messages"],
): { key: string; path: string; name: string; mimeType: string | null } | null { ): {
key: string;
path: string;
sourcePath: string;
name: string;
mimeType: string | null;
} | null {
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) { for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
const message = messages[messageIndex] as (typeof messages)[number] & { const message = messages[messageIndex] as (typeof messages)[number] & {
id?: string; id?: string;
@@ -183,17 +269,33 @@ function latestStreamingWriteFile(
if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue; if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue;
for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) { for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) {
const toolCall = message.tool_calls[toolIndex]; const toolCall = message.tool_calls[toolIndex];
if (toolCall?.name !== "write_file" || !toolCall.id) continue; if (!isArtifactWriteTool(toolCall?.name) || !toolCall.id) continue;
const rawPath = toolCall.args?.path; const rawPath = typeof toolCall.args?.path === "string" ? toolCall.args.path : "";
if (typeof rawPath !== "string" || !rawPath.trim()) continue; const rawContent = typeof toolCall.args?.content === "string" ? toolCall.args.content : "";
const content = toolCall.args?.content; const normalizedPath = normalizeWriteFilePath(rawPath);
if (typeof content !== "string" || content.length === 0) continue; const hasUsablePath =
const virtualPath = buildWriteFileVirtualUrl(rawPath, message.id, toolCall.id); Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
if (!hasUsablePath) {
// `str_replace` edits an existing file, so it can only be previewed once
// its real path is known. A new file is identified by its tool call, so
// its body can be previewed as soon as the first characters arrive.
if (toolCall.name !== "write_file" || !rawContent.trim()) continue;
}
const sourcePath = hasUsablePath ? normalizedPath : provisionalWriteFilePath(rawContent);
// Do not wait for the complete `content` argument. LangGraph streams
// write_file arguments incrementally, and the virtual artifact loader
// will redraw as `content` arrives. Waiting for it turns this into a
// post-generation preview instead of a live sandbox.
const previewPath =
toolCall.name === "write_file"
? buildWriteFileVirtualUrl(sourcePath, message.id, toolCall.id)
: sourcePath;
return { return {
key: `${message.id}:${toolCall.id}`, key: `${message.id}:${toolCall.id}`,
path: virtualPath, path: previewPath,
name: artifactDisplayName(rawPath), sourcePath,
mimeType: rawPath.toLowerCase().endsWith(".md") ? "text/markdown" : null, name: artifactDisplayName(sourcePath),
mimeType: isMarkdownPath(sourcePath) ? "text/markdown" : null,
}; };
} }
} }
@@ -214,7 +316,7 @@ function writeFileCallKeys(messages: AgentThreadState["messages"]): Set<string>
}; };
if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue; if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue;
for (const toolCall of message.tool_calls) { for (const toolCall of message.tool_calls) {
if (toolCall?.name === "write_file" && toolCall.id) { if (isArtifactWriteTool(toolCall?.name) && toolCall.id) {
keys.add(`${message.id}:${toolCall.id}`); keys.add(`${message.id}:${toolCall.id}`);
} }
} }
@@ -265,11 +367,21 @@ export function PositionNodeChat({
); );
const [boundThreadId, setBoundThreadId] = useState<string | null>(node.threadId); const [boundThreadId, setBoundThreadId] = useState<string | null>(node.threadId);
const boundThreadIdRef = useRef<string | null>(node.threadId); const boundThreadIdRef = useRef<string | null>(node.threadId);
const previousNodeBindingRef = useRef({
nodeKey: node.nodeKey,
threadId: node.threadId,
});
const draftThreadIdRef = useRef(node.threadId ?? uuid()); const draftThreadIdRef = useRef(node.threadId ?? uuid());
const upstreamRevisionRef = useRef<Record<string, unknown> | null>(null); const upstreamRevisionRef = useRef<Record<string, unknown> | null>(null);
const autoPreviewedArtifactKeyRef = useRef<string | null>(null); const autoPreviewedArtifactKeyRef = useRef<string | null>(null);
const autoPreviewBaselineCallKeysRef = useRef<Set<string>>(new Set()); const autoPreviewBaselineCallKeysRef = useRef<Set<string>>(new Set());
const autoPreviewRunArmedRef = useRef(false); const autoPreviewRunArmedRef = useRef(false);
const writeFilePreviewSequenceRef = useRef(0);
const activeWriteFilePreviewRef = useRef<{
path: string;
previewKey: string;
toolCallId?: string;
} | null>(null);
const editMessage = useEditMessage(); const editMessage = useEditMessage();
const [messageContentOverrides, setMessageContentOverrides] = useState<Record<string, string>>({}); const [messageContentOverrides, setMessageContentOverrides] = useState<Record<string, string>>({});
const persistedMessages = useMemo( const persistedMessages = useMemo(
@@ -288,12 +400,28 @@ export function PositionNodeChat({
!node.invalidatedBy; !node.invalidatedBy;
useEffect(() => { useEffect(() => {
const previousBinding = previousNodeBindingRef.current;
previousNodeBindingRef.current = { nodeKey: node.nodeKey, threadId: node.threadId };
// `onStart` binds a newly created LangGraph thread before the session API
// persists that id back into `node.threadId`. That prop update happens
// while the first turn is already streaming; it is not a node switch and
// must not clear the preview run that has just been armed.
const isOwnThreadBinding =
previousBinding.nodeKey === node.nodeKey &&
previousBinding.threadId == null &&
node.threadId != null &&
node.threadId === boundThreadIdRef.current;
if (isOwnThreadBinding) return;
setBoundThreadId(node.threadId); setBoundThreadId(node.threadId);
boundThreadIdRef.current = node.threadId; boundThreadIdRef.current = node.threadId;
setInputDraft(readConversationDraft(node.conversationSnapshot)); setInputDraft(readConversationDraft(node.conversationSnapshot));
setMessageContentOverrides({}); setMessageContentOverrides({});
autoPreviewedArtifactKeyRef.current = null;
autoPreviewBaselineCallKeysRef.current = new Set(); autoPreviewBaselineCallKeysRef.current = new Set();
autoPreviewRunArmedRef.current = false; autoPreviewRunArmedRef.current = false;
writeFilePreviewSequenceRef.current = 0;
activeWriteFilePreviewRef.current = null;
// The component is keyed by nodeKey in the page. This reset is therefore // The component is keyed by nodeKey in the page. This reset is therefore
// only a defensive guard for callers that reuse the component instance. // only a defensive guard for callers that reuse the component instance.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -314,6 +442,43 @@ export function PositionNodeChat({
[node.nodeKey, onNodeUpdate, sessionId], [node.nodeKey, onNodeUpdate, sessionId],
); );
const handleToolStart = useCallback(
(event: ToolStartEvent) => {
if (!isArtifactWriteTool(event.name) || !autoPreviewRunArmedRef.current) return;
const path = writeFilePathFromToolEvent(event.data);
const threadId = boundThreadIdRef.current;
if (!path || !threadId) return;
// Message chunks and on_tool_start can describe the same invocation in
// either order. Reuse the first preview key so a manual close suppresses
// only this write, while a later rewrite receives a fresh key and opens.
const activePreview = activeWriteFilePreviewRef.current;
if (
activePreview?.path === path &&
(activePreview.toolCallId === event.toolCallId ||
(event.toolCallId != null && activePreview.previewKey.endsWith(`:${event.toolCallId}`)))
) {
return;
}
const previewKey = event.toolCallId
? `tool-start:${event.toolCallId}`
: `tool-start:${node.nodeKey}:${++writeFilePreviewSequenceRef.current}`;
activeWriteFilePreviewRef.current = { path, previewKey, toolCallId: event.toolCallId };
const artifactKey = `${threadId}:${previewKey}`;
if (autoPreviewedArtifactKeyRef.current === artifactKey) return;
autoPreviewedArtifactKeyRef.current = artifactKey;
onPreviewArtifact?.({
threadId,
path,
name: artifactDisplayName(path),
mimeType: isMarkdownPath(path) ? "text/markdown" : null,
previewKey,
auto: true,
});
},
[node.nodeKey, onPreviewArtifact],
);
const [thread, sendMessage, , actualThreadId] = useThreadStream({ const [thread, sendMessage, , actualThreadId] = useThreadStream({
threadId: boundThreadId, threadId: boundThreadId,
assistantId: node.agentId, assistantId: node.agentId,
@@ -334,15 +499,21 @@ export function PositionNodeChat({
agent_id: node.agentId, agent_id: node.agentId,
}, },
onStart: bindCreatedThread, onStart: bindCreatedThread,
onToolStart: handleToolStart,
onFinish: (state) => { onFinish: (state) => {
// A turn is not considered delivered by the UI alone. The persisted // A turn is not considered delivered by the UI alone. The persisted
// completion call records the answer, file manifest and upstream version // completion call records the answer, file manifest and upstream version
// so the service can decide whether this seat unlocks its downstream. // so the service can decide whether this seat unlocks its downstream.
const threadId = boundThreadIdRef.current; const threadId = boundThreadIdRef.current;
const latestAi = [...(state.messages ?? [])] // `present_files` is often the last AI tool call and has no prose of
// its own. Keep walking backwards to the last actual answer; otherwise
// a successful file delivery would return early here and never refresh
// the node's artifact cards.
const latestAnswer = [...(state.messages ?? [])]
.reverse() .reverse()
.find((message) => message.type === "ai"); .filter((message) => message.type === "ai")
const latestAnswer = latestAi ? textOfMessage(latestAi) ?? "" : ""; .map((message) => textOfMessage(message) ?? "")
.find((content) => content.trim()) ?? "";
if (!threadId || !latestAnswer.trim()) return; if (!threadId || !latestAnswer.trim()) return;
void (async () => { void (async () => {
let artifactManifest: Array<Record<string, unknown>> = []; let artifactManifest: Array<Record<string, unknown>> = [];
@@ -378,6 +549,10 @@ export function PositionNodeChat({
const activeThreadId = const activeThreadId =
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? draftThreadIdRef.current; boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? draftThreadIdRef.current;
// A sandbox must always receive the server thread id. In particular, do not
// attach a streaming preview to the provisional UUID used for a new node
// before `useThreadStream` has created and bound its thread.
const previewThreadId = boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? null;
const persistedThreadId = const persistedThreadId =
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? null; boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? null;
const recoveredMessages = useMemo( const recoveredMessages = useMemo(
@@ -566,32 +741,60 @@ export function PositionNodeChat({
() => (thread.isLoading ? latestStreamingWriteFile(thread.messages) : null), () => (thread.isLoading ? latestStreamingWriteFile(thread.messages) : null),
[thread.isLoading, thread.messages], [thread.isLoading, thread.messages],
); );
const newStreamingArtifact =
streamingArtifact && !autoPreviewBaselineCallKeysRef.current.has(streamingArtifact.key)
? streamingArtifact
: null;
useEffect(() => { useEffect(() => {
if ( if (
!thread.isLoading || !thread.isLoading ||
!streamingArtifact || !newStreamingArtifact ||
!autoPreviewRunArmedRef.current || !previewThreadId ||
autoPreviewBaselineCallKeysRef.current.has(streamingArtifact.key) !autoPreviewRunArmedRef.current
) { ) {
return; return;
} }
// One write_file call should open at most one preview. The parent further // One write_file call should open at most one preview. The parent further
// guards manual close state, because it owns the page-wide singleton sandbox. // guards manual close state, because it owns the page-wide singleton sandbox.
// A report is often rewritten to the same output path. The tool-call key // A report is often rewritten to the same output path — identity must be the
// (not just the path) makes that a new previewable delivery. // tool-call key, not the path, otherwise a second write keeps the old panel
const artifactKey = `${activeThreadId}:${streamingArtifact.key}`; // and the sandbox never clears / remounts for the rewrite.
const toolCallKey = newStreamingArtifact.key;
const toolCallId = toolCallKey.includes(":")
? toolCallKey.slice(toolCallKey.lastIndexOf(":") + 1)
: undefined;
const startedPreview = activeWriteFilePreviewRef.current;
const isSameToolCall =
startedPreview != null &&
(startedPreview.previewKey === toolCallKey ||
(toolCallId != null &&
(startedPreview.toolCallId === toolCallId ||
startedPreview.previewKey.endsWith(`:${toolCallId}`))));
// Same tool call may upgrade a provisional filename → real path; keep the
// open panel. A different tool call is a new delivery even on the same path.
const previewKey = isSameToolCall ? startedPreview.previewKey : toolCallKey;
activeWriteFilePreviewRef.current = {
path: newStreamingArtifact.sourcePath,
previewKey,
toolCallId: toolCallId ?? startedPreview?.toolCallId,
};
// The path takes part in the identity so that a preview opened under a
// provisional name is re-published once the real path streams in. The
// preview key stays the same, so the parent treats it as an update of the
// open panel rather than a second delivery.
const artifactKey = `${previewThreadId}:${previewKey}:${newStreamingArtifact.sourcePath}`;
if (autoPreviewedArtifactKeyRef.current === artifactKey) return; if (autoPreviewedArtifactKeyRef.current === artifactKey) return;
autoPreviewedArtifactKeyRef.current = artifactKey; autoPreviewedArtifactKeyRef.current = artifactKey;
onPreviewArtifact?.({ onPreviewArtifact?.({
threadId: activeThreadId, threadId: previewThreadId,
path: streamingArtifact.path, path: newStreamingArtifact.path,
name: streamingArtifact.name, name: newStreamingArtifact.name,
mimeType: streamingArtifact.mimeType, mimeType: newStreamingArtifact.mimeType,
previewKey: streamingArtifact.key, previewKey,
auto: true, auto: true,
}); });
}, [activeThreadId, onPreviewArtifact, streamingArtifact, thread.isLoading]); }, [newStreamingArtifact, onPreviewArtifact, previewThreadId, thread.isLoading]);
// SDK thread creation is asynchronous. ``onStart`` normally binds it, but // SDK thread creation is asynchronous. ``onStart`` normally binds it, but
// watching the actual stream id as well closes the last race where a page // watching the actual stream id as well closes the last race where a page
@@ -628,6 +831,7 @@ export function PositionNodeChat({
: null; : null;
// A thread retains historical write_file tool calls. Only files whose // A thread retains historical write_file tool calls. Only files whose
// call id appears after this exact submission may open the sandbox. // call id appears after this exact submission may open the sandbox.
activeWriteFilePreviewRef.current = null;
autoPreviewBaselineCallKeysRef.current = writeFileCallKeys(thread.messages); autoPreviewBaselineCallKeysRef.current = writeFileCallKeys(thread.messages);
autoPreviewRunArmedRef.current = true; autoPreviewRunArmedRef.current = true;
await sendMessage(boundThreadIdRef.current ?? draftThreadIdRef.current, message, { await sendMessage(boundThreadIdRef.current ?? draftThreadIdRef.current, message, {
@@ -811,7 +1015,7 @@ export function PositionNodeChat({
</div> </div>
)} )}
<div className="position-roundtable-agent-composer-bar shrink-0 border-t border-slate-100 px-4 py-3 dark:border-[#2b8ffe]/20"> <div className="position-roundtable-agent-composer-bar position-roundtable-node-composer shrink-0">
<PositionAgentInput <PositionAgentInput
className="w-full" className="w-full"
isNewThread={!boundThreadId} isNewThread={!boundThreadId}
@@ -825,6 +1029,7 @@ export function PositionNodeChat({
onDraftChange={setInputDraft} onDraftChange={setInputDraft}
hideAdminRecommendedQuestions hideAdminRecommendedQuestions
onContextChange={(context) => setSettings("context", context)} onContextChange={(context) => setSettings("context", context)}
commonQuestions={agent?.common_questions}
onSubmit={(message) => void handleSubmit(message)} onSubmit={(message) => void handleSubmit(message)}
onStop={() => void thread.stop()} onStop={() => void thread.stop()}
/> />
@@ -97,6 +97,7 @@ export function getPositionDemoTask(): PositionTask {
direction: String(task.direction ?? ""), direction: String(task.direction ?? ""),
source: String(task.source ?? "演示数据"), source: String(task.source ?? "演示数据"),
issuedAt: String(task.issuedAt ?? ""), issuedAt: String(task.issuedAt ?? ""),
priority: String(task.priority ?? "紧急"),
}; };
} }
@@ -0,0 +1,391 @@
# 岗位会商沙箱对接指南(重做页面 / 排障)
## 文档用途
给**下次重做或对接岗位会商页面**的同事用,也给线上「沙箱不实时 / 卡死 / 第二次不刷新」时排障用。
读完应能回答:
1. 新页面要接哪几个回调、传什么字段?
2. 打开 / 关闭沙箱各在什么时机?和步骤条是什么关系?
3. 哪些写法绝对不能用(会复现历史 bug)?
4. 出问题时按什么顺序查?
对照实现(当前生产路径):
| 角色 | 文件 |
| --- | --- |
| 页面壳(沙箱唯一宿主) | `pages/PositionRoundtablePage.tsx` |
| 节点对话(探测 write_file、上报预览) | `components/PositionNodeChat.tsx` |
| 收口对话 | `components/PositionBuiltinAgentChat.tsx` |
| 预览 UI(共享组件,勿分叉逻辑) | `components/workspace/artifacts/artifact-file-detail.tsx` |
| 流式正文读取 | `core/artifacts/loader.ts` → `loadArtifactContentFromToolCall` |
| 普通对话页对照(步骤条自动开) | `components/workspace/messages/message-group.tsx` |
模块总览见同目录上级 [`../README.md`](../README.md);本文只覆盖**文件预览沙箱**这一条链路。
---
## 1. 验收标准(对接完成即通过)
重做页面后,下列行为必须全部成立(与普通对话页用户感知一致):
| # | 行为 |
| --- | --- |
| A | 步骤条刚出现「写入文件」时,右侧沙箱应已打开(不必等文件名出现) |
| B | 沙箱内源码随模型输出边写边滚;消息列表文字也实时出现,不能整页卡死 |
| C | 模型若先吐正文、后吐 `path`:先用临时名打开,path 到了再改名,不闪屏重挂 |
| D | 同一次问答里第二次 `write_file` 写同一路径(「重新生成」):沙箱清空并重新流式 |
| E | 整轮 `isLoading=false` 后,自动从源码切到 Markdown 预览 |
| F | 用户手动关闭后,**同一次**写入不再自动弹;**下一轮**新写入仍可自动开 |
| G | 切岗位 / 切节点 / 新建或切换会话时,沙箱关掉且不串内容 |
---
## 2. 职责划分(谁干什么)
```text
┌─────────────────────────────────────────────────────────────┐
│ 页面壳(PositionRoundtablePage 或你的新页面) │
│ · 唯一 sandboxArtifact 状态 │
│ · handlePreviewArtifact / closeSandbox │
│ · 流式线程外部 store(禁止整页 useState) │
│ · 渲染唯一 ArtifactFileDetail │
└──────────────────────────▲──────────────────────────────────┘
│ onPreviewArtifact / onThreadSnapshot
┌──────────────────────────┴──────────────────────────────────┐
│ 对话子组件(PositionNodeChat 等) │
│ · useThreadStream 拉消息 │
│ · 渲染 MessageList / 步骤条(只展示,不直接开沙箱) │
│ · 探测 write_file → 组装 PositionArtifactPreview 上报 │
│ · 每次 thread 变化上报 onThreadSnapshot │
└─────────────────────────────────────────────────────────────┘
```
硬性规则:
1. **沙箱只有一个实例**,挂在页面壳上,不要在对话子组件里再挂 `ArtifactFileDetail`。
2. 对话子组件**不要**直接 `artifacts.setOpen(true)` 当作岗位会商的主路径(页面壳统一开合,才能做关闭抑制、切节点清理)。
3. 步骤条和沙箱是**两条线**:步骤条只反映 tool call 展示;开沙箱走 `onPreviewArtifact`。
---
## 3. 对接接口(重新接页面时照此接线)
### 3.1 对话子组件 → 页面壳
#### `onPreviewArtifact(artifact)`
请求打开或更新沙箱。载荷:
```ts
type PositionArtifactPreview = {
threadId: string; // 必填:LangGraph thread id
path: string; // 必填:真实路径,或 write-file: 虚拟 URL,或临时文件名
name: string; // 展示名
mimeType: string | null; // 如 text/markdown;帮助预览语言判断
previewKey?: string; // 强烈建议:一次交付的身份,推荐 `${messageId}:${toolCallId}`
auto?: boolean; // true = 流式自动打开;false/缺省 = 用户点击打开
};
```
页面壳收到后应:
1. `auto===true` 且 `closedSandboxKey === sandboxCloseKey(artifact)` → **直接 return**(用户刚关过这一次)。
2. 更新页面级 `sandboxArtifact`(并同步 ref,见下)。
3. 同步 `ArtifactsContext.select(path) + setOpen(true)`(与共享预览组件生命周期对齐)。
4. 若已有该 thread 的最新快照,立刻推进沙箱 store。
5. React `key` 用 `sandboxCloseKey(artifact)`(有 `previewKey` 时优先它),保证新交付会重挂。
`sandboxCloseKey` 约定:
```ts
// 有 previewKey:threadId:write:${previewKey}
// 无 previewKey:threadId:${normalizePath(path)}
```
#### `onThreadSnapshot({ threadId, thread })`
把当前 `useThreadStream` 的 thread 对象持续交给页面壳,供沙箱读流式 `content`。
页面壳应:
1. 始终写入 `latestNodeThreadSnapshotRef`(打开瞬间可能用到)。
2. **仅当**当前沙箱 `threadId` 与 snapshot 一致时,才 `queue` → store `publish`。
3. 节流:前沿 160ms(见第 5 节),不要纯尾随 `setTimeout`。
### 3.2 页面壳 → 预览组件
```tsx
{sandboxOpen && sandboxArtifact && (
<SandboxThreadProvider store={store} threadId={sandboxArtifact.threadId} fallbackThread={stub}>
<ArtifactFileDetail
key={sandboxCloseKey(sandboxArtifact)}
filepath={sandboxArtifact.path}
threadId={sandboxArtifact.threadId}
mimeType={sandboxArtifact.mimeType}
onClose={closeSandbox}
allowVirtualMarkdownEdit
// ...
/>
</SandboxThreadProvider>
)}
```
流式正文依赖虚拟 URL:
```text
write-file:${path}?message_id=...&tool_call_id=...&language=markdown
```
`loadArtifactContentFromToolCall` **只认** URL 里的 `message_id` + `tool_call_id`,从 `thread.messages` 对应 tool call 的 `args.content` 取值。
因此:**可以没有真实磁盘 path 就开沙箱**;path 主要用于文件名展示和语言猜测。
### 3.3 对话子组件内部必做状态(可复用现有逻辑)
重新实现探测时,至少保留这些语义(不必同名,语义要对):
| 状态 | 作用 |
| --- | --- |
| `armed` | 仅本轮用户 `send` 成功武装后才允许自动 `onPreviewArtifact` |
| `baselineCallKeys` | send 前已有的 `messageId:toolCallId` 集合;忽略历史 write,防止一提问就开旧文件 |
| `activePreview` | 当前已打开的 previewKey / path / toolCallId,用于 path 升级与同 call 去重 |
| 线程绑定保护 | `node.threadId` 从 null→本节点刚创建的 id 时,**不要** disarm |
发送时:
```text
baseline = 当前历史 write keys
armed = true
activePreview = null
→ sendMessage(...)
```
探测时(`isLoading`):
```text
找到最新 write_file / str_replace
若不在 baseline:
- 有完整扩展名 path → 用真实 path
- write_file 且仅有 content → 临时名(未命名文件.md / .html)
- str_replace 无 path → 先不报
previewKey = 同 tool call 复用,否则新 key
→ onPreviewArtifact({ ..., auto: true })
```
---
## 4. 打开 / 关闭时序(对接时按此实现)
### 4.1 打开(自动)
```text
用户发送
→ armed=true + 拍 baseline
→ 模型开始 write_file
→ 步骤条出现「写入文件」(可能还没有 path)
→ content 或完整 path 任一满足探测条件
→ onPreviewArtifact(auto)
→ 页面壳打开 ArtifactFileDetail + 推送 thread 快照
→ 源码视图滚动更新
→(可选)真实 path 到达:同 previewKey 改名(path upgrade,不重挂)
→ isLoading=false:共享组件自动切预览
```
### 4.2 同轮第二次写入(重新生成)
```text
write_file #1 → previewKey = msg:tc1 → 打开
write_file #2 同 path → previewKey = msg:tc2(必须变)
→ React key 变 → 组件重挂 → 视觉上清空再生成
```
**禁止**用「path 相同 ⇒ 复用 previewKey」。那会导致步骤条有「重新生成」、沙箱却不动。
### 4.3 关闭
| 触发 | 行为 |
| --- | --- |
| 用户点关闭 | `closedSandboxKeyRef = sandboxCloseKey(current)`;清 artifact / store / 定时器 |
| 切岗位、切节点、新会话、切历史 | 先 `closeSandbox()`(可按产品决定是否写入抑制键;至少清面板) |
| 自动打开被抑制 | 仅当 `auto && closedKey === 当前 key`;手动打开应清抑制 |
抑制粒度必须是**单次交付(previewKey)**,不能是「整个 thread 永远不再自动开」。
---
## 5. 重做页面时的必守规则(违反必出 bug)
1. **流式 thread 快照禁止进页面级 `useState`**
大页面每秒重渲染数次会占满主线程:消息列表停更、沙箱冻住、定时器饿死。
正确:外部 store + `useSyncExternalStore`,只让沙箱子树订阅。
2. **节流用前沿(leading-edge),不要纯尾随 `setTimeout`**
距上次 publish ≥ 160ms → 同步 publish;否则补尾定时器。
纯「有 timer 就 return」在忙主线程下会十几秒不 flush。
3. **不要等完整 `path` 才开沙箱**
报告类 agent 常先流 `content`、最后才流 `path`,硬等会晚几十秒。
`write_file` + 已有 content → 临时名打开;`str_replace` 仍等真实 path。
4. **扩展名要完整再当「真实 path」**
流式中 `.m` → `.md`,过早打开会把语言锁成 TEXT。
5. **previewKey 按 tool call,不按 path**
同 path 多次写入 = 多次交付。
6. **打开瞬间同步推进 `sandboxArtifactRef`**
若 flush 快照时只看 ref、而 setState 尚未提交,首帧会被「threadId 不匹配」丢掉。
7. **不要依赖 `onToolStart` 作为唯一打开手段**
当前后端对部分 stream mode 可能过滤;主路径必须是 `thread.messages` 里渐进出现的 `tool_calls`。
8. **path upgrade 不要当第二次打开**
同 previewKey 改 path:更新 artifact,不要乱关抽屉 / 不要重挂。
9. **「结束后才切预览」不是打开 bug**
`ArtifactFileDetail` 对 markdown/html 在 `isLoading` 期间强制源码视图,与普通对话页相同。
---
## 6. 步骤条怎么对接理解
步骤条来自消息里的 tool call 渲染(`message-group`),标题优先 `args.description`,否则文案「写入文件」;子行显示 `args.path`。
| 你看到的步骤条 | 含义 | 沙箱应处于 |
| --- | --- | --- |
| 仅「写入文件」,无路径 | tool call 已开始,path 可能还没流到 | **应已打开**(临时名) |
| 「写入文件」+ 路径 | path 已可用 | 已打开;若刚从临时名升级则只改名 |
| 第二条「重新生成…」+ 同路径 | 新的 write_file(新 toolCallId) | **应重挂**并重新流式 |
| 生成中顶部「正在生成,完成后自动预览」 | 仍在 code 视图 | 正常,等 isLoading 结束切预览 |
对接时不要把「步骤条出现文件名」当成「开沙箱的唯一信号」——那会在 content-first 模型上系统性偏晚。
---
## 7. 排障手册(按症状查)
### 7.1 沙箱开得很晚 / 要等文件名才开
检查顺序:
1. 探测是否仍要求「必须有完整 path」?→ 改为 content 可开临时名。
2. `armed` 是否在 `threadId` 回写时被误清?→ 保护「本节点绑定自己的新 thread」。
3. 是否被 `closedSandboxKey` 抑制?→ 看是否刚关过同 previewKey。
4. 新 call 是否落在 baseline 里?→ baseline 只能在 send 时拍一次。
### 7.2 沙箱打开了但内容空白 / 冻住,生成完才一口气出来
1. 快照是否写进了页面 `useState`?→ 改 store。
2. 节流是否纯尾随定时器?→ 改前沿。
3. `SandboxThreadProvider` 是否把带 `messages` 的 thread 传给了 `ArtifactFileDetail`?
4. 虚拟 URL 的 `tool_call_id` 是否与消息里一致?
5. 打开时 `sandboxArtifactRef` 是否已同步(避免首帧 flush 被丢)?
### 7.3 消息列表也不实时,整页很卡
优先怀疑页面壳在流式频率下整页重渲染。
确认没有把 `onThreadSnapshot` 落到大页面 state;临时 `console.debug` 每 chunk 打大对象也会加重(排障完删掉)。
### 7.4 第二次「重新生成」步骤有了,沙箱不变
previewKey 是否按 path 复用了?→ 必须按 `messageId:toolCallId`。
`ArtifactFileDetail` 的 `key` 是否跟 previewKey 走?否则不会重挂。
### 7.5 第一次实时开,关掉后再问就不实时 / 很晚才开
抑制是否绑在整个 `threadId` 上?→ 改为单次 `sandboxCloseKey(previewKey)`。
### 7.6 预览变成纯文本 / 不像 Markdown
1. 是否在 `.m` 阶段就当完整 path 打开了?
2. 临时名是否带 `.md` / URL 是否带 `language=markdown` / 是否传了 `mimeType`?
3. path 是否有首尾空格?
### 7.7 只靠 onToolStart,线上偶发永远不开
后端可能不推 tools/events。主路径改为扫描 `thread.messages` 的 streaming `tool_calls`。
---
## 8. 对接自测清单(重做页面后必跑)
- [ ] 首轮:步骤条「写入文件」出现时,沙箱是否已开并开始出字?
- [ ] content-first agent:无文件名阶段是否也能开?
- [ ] path 到达后:是否只改名、不整页闪?
- [ ] 同轮第二次同路径 write:是否清空并重新流式?
- [ ] 流式中消息列表是否仍实时?页面是否明显卡顿?
- [ ] 手动关闭后本次是否不再弹?下一轮是否仍可自动开?
- [ ] 结束后是否自动切 Markdown 预览?
- [ ] 切节点 / 切岗位 / 新会话:沙箱是否关闭且内容不串?
---
## 9. 最小接线伪代码(新页面可抄)
```tsx
// 页面壳
const [sandboxArtifact, setSandboxArtifact] = useState<PositionArtifactPreview | null>(null);
const sandboxArtifactRef = useRef(sandboxArtifact);
const closedSandboxKeyRef = useRef<string | null>(null);
const store = useMemo(createSandboxThreadStore, []);
function handlePreviewArtifact(artifact: PositionArtifactPreview) {
const key = sandboxCloseKey(artifact);
if (artifact.auto && closedSandboxKeyRef.current === key) return;
const prev = sandboxArtifactRef.current;
const sameKey = prev?.previewKey && prev.previewKey === artifact.previewKey
&& prev.threadId === artifact.threadId;
const pathOnlyUpgrade = sameKey && prev.path !== artifact.path;
sandboxArtifactRef.current = artifact;
setSandboxArtifact(artifact);
// publish latest thread snapshot into store (leading-edge throttle)
// ArtifactsContext.select + setOpen(true)
if (!pathOnlyUpgrade) closeRightDrawer();
}
function closeSandbox() {
if (sandboxArtifactRef.current) {
closedSandboxKeyRef.current = sandboxCloseKey(sandboxArtifactRef.current);
}
sandboxArtifactRef.current = null;
setSandboxArtifact(null);
store.publish(null);
}
// 对话子组件
<PositionNodeChat
onPreviewArtifact={handlePreviewArtifact}
onThreadSnapshot={handleNodeThreadSnapshot}
/>
```
对话侧探测逻辑优先**复用** `PositionNodeChat` 内的 `latestStreamingWriteFile` / armed / baseline,而不是从零再写一套——这是最容易写错的部分。
---
## 10. 术语
| 词 | 含义 |
| --- | --- |
| armed | 本轮用户发送后,允许自动打开沙箱 |
| baseline | send 前已有 write tool call 集合,用于忽略历史产物 |
| previewKey | 一次交付身份,推荐 `messageId:toolCallId` |
| sandboxCloseKey | 关闭抑制键 + React key |
| provisional path | 无真实 path 时的临时文件名 |
| path upgrade | 同 previewKey 下临时名 → 真实 path |
| sandboxThreadStore | 流式线程外部 store,避免整页重渲染 |
| write-file: URL | 用 message/tool call id 定位流式 content 的虚拟路径 |
---
## 11. 相关提交(背景)
若需对照修复动机,可查:
- `fix(frontend): 岗位会商沙箱在 write_file 流式阶段实时打开并刷新` — 临时名打开、store、前沿节流
- `fix(frontend): 同路径二次 write_file 时重挂岗位会商沙箱` — previewKey 按 tool call
排障时优先对照本文第 5、7 节,再对照上述提交的 diff。
@@ -13,6 +13,7 @@ export interface PositionTask {
direction: string; direction: string;
source: string; source: string;
issuedAt: string; issuedAt: string;
priority?: string;
} }
export const MOCK_POSITION_TASKS: readonly PositionTask[] = [ export const MOCK_POSITION_TASKS: readonly PositionTask[] = [
@@ -30,16 +31,21 @@ export const MOCK_POSITION_TASKS: readonly PositionTask[] = [
export const DEFAULT_MOCK_POSITION_TASK_ID = MOCK_POSITION_TASKS[0].id; export const DEFAULT_MOCK_POSITION_TASK_ID = MOCK_POSITION_TASKS[0].id;
export function getMockPositionTask(taskId?: string | null): PositionTask { export function getMockPositionTask(taskId?: string | null): PositionTask {
return ( const fixture =
MOCK_POSITION_TASKS.find((task) => task.id === taskId) ?? 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. */ /** Normalizes a persisted Session task snapshot without exposing source internals. */
export function taskFromPositionSnapshot( export function taskFromPositionSnapshot(
snapshot: Record<string, unknown> | null | undefined, snapshot: Record<string, unknown> | null | undefined,
fallback: PositionTask, fallback: PositionTask,
taskScopeId?: string,
): PositionTask { ): PositionTask {
if (!snapshot || Object.keys(snapshot).length === 0) return fallback; if (!snapshot || Object.keys(snapshot).length === 0) return fallback;
const value = (keys: string[], defaultValue: string) => { const value = (keys: string[], defaultValue: string) => {
@@ -49,10 +55,15 @@ export function taskFromPositionSnapshot(
return typeof found === "string" ? found : defaultValue; return typeof found === "string" ? found : defaultValue;
}; };
return { 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), title: value(["title", "taskName", "name"], fallback.title),
description: value(["description", "taskContent", "overview", "content"], fallback.description), description: value(["description", "taskContent", "overview", "content"], fallback.description),
direction: value(["direction", "taskDirection", "category"], fallback.direction), direction: value(["direction", "taskDirection", "category"], fallback.direction),
priority: value(["priority", "level", "urgency"], fallback.priority ?? "紧急"),
source: (() => { source: (() => {
const source = value(["source", "taskSource", "sourceName"], fallback.source); const source = value(["source", "taskSource", "sourceName"], fallback.source);
return source === "mock" ? "本系统创建" : source; return source === "mock" ? "本系统创建" : source;
@@ -1,39 +1,90 @@
/** /**
* 岗位协同的第一版岗位目录。 * 岗位会商的工作视角目录。
* *
* 当前岗位仅代表页面工作视角;后续接入组织岗位 / 权限系统时,保留这里的稳定 id, * 这不是组织/RBAC 岗位。稳定 id 会写入业务链节点,因此服务端的
* 只替换数据源即可。业务链条席位用 `position_id` 持久化这组 id。 * 「岗位角色配置」只允许变更展示属性;本模块保留一份内置回退,确保
* 后端短暂不可用时历史会商仍能正常展示。
*/ */
export const POSITION_OPTIONS = [
export type PositionId = string;
export interface PositionOption {
id: PositionId;
name: string;
description: string;
isIntentPosition: boolean;
enabled?: boolean;
isBuiltin?: boolean;
sortOrder?: number;
}
export const POSITION_OPTIONS: readonly PositionOption[] = [
{ {
id: "intelligence", id: "intelligence",
name: "测试分析岗", name: "测试分析岗",
description: "识别任务意图、补齐约束并确认业务链条", description: "识别任务意图、补齐约束并确认业务链条",
isIntentPosition: true, isIntentPosition: true,
enabled: true,
isBuiltin: true,
sortOrder: 0,
}, },
{ {
id: "planning", id: "planning",
name: "组织测试岗", name: "组织测试岗",
description: "承接上游信息并进行组织测试", description: "承接上游信息并进行组织测试",
isIntentPosition: false, isIntentPosition: false,
enabled: true,
isBuiltin: true,
sortOrder: 1,
}, },
{ {
id: "solution", id: "solution",
name: "方案设计岗", name: "方案设计岗",
description: "承接上游结果并形成方案产物", description: "承接上游结果并形成方案产物",
isIntentPosition: false, isIntentPosition: false,
enabled: true,
isBuiltin: true,
sortOrder: 2,
}, },
{ {
id: "evaluation", id: "evaluation",
name: "综合测试岗", name: "综合测试岗",
description: "测试链条产物并提出综合建议", description: "测试链条产物并提出综合建议",
isIntentPosition: false, isIntentPosition: false,
enabled: true,
isBuiltin: true,
sortOrder: 3,
}, },
] as const; ];
export type PositionId = (typeof POSITION_OPTIONS)[number]["id"];
export const DEFAULT_POSITION_ID: PositionId = "intelligence"; export const DEFAULT_POSITION_ID: PositionId = "intelligence";
let runtimePositionOptions: readonly PositionOption[] = POSITION_OPTIONS;
/**
* 供独立的流程图、产物抽屉等非页面级组件读取当前展示名。
* 调用方传入空数组时保留内置回退,避免接口暂时失败导致旧会话显示“未分配岗位”。
*/
export function setRuntimePositionOptions(options: readonly PositionOption[]): void {
runtimePositionOptions = options.length ? options : POSITION_OPTIONS;
}
export function getRuntimePositionOptions(): readonly PositionOption[] {
return runtimePositionOptions;
}
export function intentPositionIdFor(options: readonly PositionOption[] = runtimePositionOptions): PositionId {
return options.find((position) => position.isIntentPosition)?.id ?? DEFAULT_POSITION_ID;
}
export function resolvePositionId(
value: string | null | undefined,
options: readonly PositionOption[] = runtimePositionOptions,
): PositionId {
return options.some((position) => position.id === value)
? String(value)
: intentPositionIdFor(options);
}
export const positionNameById = (positionId: string | null | undefined): string => export const positionNameById = (positionId: string | null | undefined): string =>
POSITION_OPTIONS.find((position) => position.id === positionId)?.name ?? "未分配岗位"; runtimePositionOptions.find((position) => position.id === positionId)?.name ?? "未分配岗位";
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -4,9 +4,15 @@ import { getBackendBaseURL } from "@/core/config";
import type { ClarificationPayload } from "./multi-agent"; import type { ClarificationPayload } from "./multi-agent";
import { reportRoundtableError } from "./diagnostics-report"; import { reportRoundtableError } from "./diagnostics-report";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification"; import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { normalizeIntentReadySummary } from "@/roundtable-planning/lib/roundtable-constants";
export type { ClarificationPayload }; export type { ClarificationPayload };
/** Stable built-in agent used by the multi-agent roundtable Step 1 flow. */
export const INTENT_AGENT_ID = "roundtable-intent";
/** Dedicated Step 1 agent for the position-roundtable workspace. */
export const POSITION_INTENT_AGENT_ID = "position-roundtable-intent";
/** /**
* Task-intent agent (Step 1) API. Mirror of multi-agent.ts but with a single * Task-intent agent (Step 1) API. Mirror of multi-agent.ts but with a single
* built-in agent and no per-call coordinator: one /init creates a thread * built-in agent and no per-call coordinator: one /init creates a thread
@@ -100,13 +106,18 @@ function parseIntentErrorDetail(value: unknown): IntentErrorDetail | null {
export async function initIntent( export async function initIntent(
model?: string, model?: string,
taskContext?: Record<string, unknown> | null, taskContext?: Record<string, unknown> | null,
intentAgentId?: string,
): Promise<IntentInitResponse> { ): Promise<IntentInitResponse> {
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, { const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
// 岗位会商在创建意图线程时就写入父任务标识。这样即使用户尚未确认 // 岗位会商在创建意图线程时就写入父任务标识。这样即使用户尚未确认
// 业务链,第一阶段的对话也已经可以按 taskId 恢复。 // 业务链,第一阶段的对话也已经可以按 taskId 恢复。
body: JSON.stringify({ model, task_context: taskContext ?? undefined }), body: JSON.stringify({
model,
task_context: taskContext ?? undefined,
intent_agent_id: intentAgentId || undefined,
}),
}); });
if (!res.ok) { if (!res.ok) {
const raw = await res.text().catch(() => ""); const raw = await res.text().catch(() => "");
@@ -137,6 +148,19 @@ export interface IntentSummary {
constraints: string[]; constraints: string[];
/** Optional assumptions the agent is making on the user's behalf. */ /** Optional assumptions the agent is making on the user's behalf. */
assumptions?: string[]; assumptions?: string[];
/** Present only for the dedicated position-roundtable intent agent. */
coreGoals?: string[];
riskWarnings?: string[];
keyPoints?: string[];
strategicSignificance?: string[];
}
/** Final hand-off shape emitted only by the position-roundtable intent agent. */
export interface PositionIntentSummary {
coreGoals: string[];
riskWarnings: string[];
keyPoints: string[];
strategicSignificance: string[];
} }
/** /**
@@ -164,6 +188,8 @@ export interface StreamIntentRequest {
threadId: string; threadId: string;
message: string; message: string;
model?: string; model?: string;
/** Explicit built-in Step-1 agent. Omit to retain the multi-agent default. */
intentAgentId?: string;
/** Optional hidden business/task context injected into the model run. */ /** Optional hidden business/task context injected into the model run. */
taskContext?: Record<string, unknown> | null; taskContext?: Record<string, unknown> | null;
/** Step 1 用户上传的参考文件(可选)。 */ /** Step 1 用户上传的参考文件(可选)。 */
@@ -186,8 +212,7 @@ export interface StreamIntentRequest {
* Fires for every `ask_clarification` tool message parsed out of the live * Fires for every `ask_clarification` tool message parsed out of the live
* stream (the intent agent may emit several in a single turn). Lets the UI * stream (the intent agent may emit several in a single turn). Lets the UI
* surface each assistance question in real-time instead of waiting for the * surface each assistance question in real-time instead of waiting for the
* terminal frame — which only carries the *first* question. `index` is the * terminal frame. `index` is the 0-based arrival order within this turn.
* 0-based arrival order within this turn.
*/ */
onClarification?: (clarification: ClarificationPayload, index: number) => void; onClarification?: (clarification: ClarificationPayload, index: number) => void;
} }
@@ -208,8 +233,8 @@ export interface StreamIntentResult {
clarification: ClarificationPayload | null; clarification: ClarificationPayload | null;
/** /**
* All `ask_clarification` questions the agent asked this turn, in arrival * All `ask_clarification` questions the agent asked this turn, in arrival
* order. Collected from the live `type:"tool"` frames (the terminal frame * order. Collected live and reconciled against the terminal frame so a
* only carries the first). Empty unless status === "clarification". * reconnect cannot lose cards. Empty unless status === "clarification".
*/ */
clarifications: ClarificationPayload[]; clarifications: ClarificationPayload[];
/** /**
@@ -235,6 +260,7 @@ function isFinalIntentFrame(value: unknown): value is {
options?: unknown[]; options?: unknown[];
allow_custom?: boolean; allow_custom?: boolean;
allow_multiple?: boolean; allow_multiple?: boolean;
clarifications?: unknown[];
error_detail?: unknown; error_detail?: unknown;
} { } {
if (!value || typeof value !== "object") return false; if (!value || typeof value !== "object") return false;
@@ -327,6 +353,41 @@ function extractClarificationToolMessage(
}; };
} }
/**
* Normalize one clarification included in the terminal status frame. This is
* the reconnect / proxy fallback for cases where one or more live `tool`
* frames were not delivered to the browser.
*/
function parseTerminalClarification(
value: unknown,
): { payload: ClarificationPayload; toolCallId: string } | null {
if (!value || typeof value !== "object") return null;
const raw = value as Record<string, unknown>;
const clarificationType =
typeof raw.clarification_type === "string" ? raw.clarification_type : "";
const toolCallId = typeof raw.id === "string" ? raw.id : "";
return {
toolCallId,
payload: {
id: toolCallId || undefined,
question: typeof raw.question === "string" ? raw.question : "",
clarificationType,
context:
typeof raw.clarification_context === "string"
? raw.clarification_context
: typeof raw.context === "string"
? raw.context
: "",
options: Array.isArray(raw.options) ? raw.options : [],
allowCustom: raw.allow_custom !== false,
allowMultiple: resolveAllowMultiple(
typeof raw.allow_multiple === "boolean" ? raw.allow_multiple : undefined,
clarificationType,
),
},
};
}
/** /**
* Open an SSE connection to /api/intent/stream and resolve when the upstream * Open an SSE connection to /api/intent/stream and resolve when the upstream
* stream completes. Each call is one user turn; reuse the same threadId * stream completes. Each call is one user turn; reuse the same threadId
@@ -340,6 +401,7 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
thread_id: req.threadId, thread_id: req.threadId,
message: req.message, message: req.message,
model: req.model, model: req.model,
intent_agent_id: req.intentAgentId || undefined,
task_context: req.taskContext ?? undefined, task_context: req.taskContext ?? undefined,
// 仅在有文件时带上,空数组后端也安全忽略。 // 仅在有文件时带上,空数组后端也安全忽略。
files: req.files && req.files.length > 0 ? req.files : undefined, files: req.files && req.files.length > 0 ? req.files : undefined,
@@ -387,8 +449,7 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
}; };
// Clarifications collected live from `type:"tool"` frames, deduped by // Clarifications collected live from `type:"tool"` frames, deduped by
// tool_call_id. The terminal frame only carries the first question, so this // tool_call_id, then reconciled against the complete terminal list.
// is the source of truth for multi-question turns.
const seenClarificationIds = new Set<string>(); const seenClarificationIds = new Set<string>();
const collectClarification = (payload: ClarificationPayload, toolCallId: string) => { const collectClarification = (payload: ClarificationPayload, toolCallId: string) => {
const dedupeKey = toolCallId || `idx-${result.clarifications.length}`; const dedupeKey = toolCallId || `idx-${result.clarifications.length}`;
@@ -435,7 +496,7 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
result.status = parsed.status; result.status = parsed.status;
if (typeof parsed.content === "string") result.content = parsed.content; if (typeof parsed.content === "string") result.content = parsed.content;
if (parsed.summary && typeof parsed.summary === "object") { if (parsed.summary && typeof parsed.summary === "object") {
result.summary = parsed.summary as IntentSummary; result.summary = normalizeIntentReadySummary(parsed.summary);
} }
if (parsed.status === "error") { if (parsed.status === "error") {
result.errorDetail = parseIntentErrorDetail(parsed.error_detail); result.errorDetail = parseIntentErrorDetail(parsed.error_detail);
@@ -445,28 +506,26 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
detail: result.errorDetail, detail: result.errorDetail,
}); });
} }
if (parsed.status === "clarification" && result.clarifications.length === 0) { if (parsed.status === "clarification") {
// Fallback path: no live `type:"tool"` clarification frames were seen // Newer backend frames contain every card. Merge them even when a
// (older backend, or single-question turn) — synthesize one from the // subset already arrived live: an SSE reconnect can otherwise leave
// terminal frame so the UI still gets a card. When tool frames *were* // the user with only the first card in a multi-question assistance
// seen, `clarifications` already holds the full (possibly multi-question) // round. Older backends still fall back to the legacy top-level card.
// set and we keep it. const terminalCards = Array.isArray(parsed.clarifications)
const clarificationType = ? parsed.clarifications
typeof parsed.clarification_type === "string" ? parsed.clarification_type : ""; .map(parseTerminalClarification)
const fallback: ClarificationPayload = { .filter((item): item is { payload: ClarificationPayload; toolCallId: string } => item !== null)
question: typeof parsed.question === "string" ? parsed.question : "", : [];
clarificationType, if (terminalCards.length > 0) {
context: terminalCards.forEach((card, index) => {
typeof parsed.clarification_context === "string" ? parsed.clarification_context : "", collectClarification(card.payload, card.toolCallId || `terminal-${index}`);
options: Array.isArray(parsed.options) ? parsed.options : [], });
allowCustom: parsed.allow_custom !== false, } else if (result.clarifications.length === 0) {
allowMultiple: resolveAllowMultiple( const fallback = parseTerminalClarification(parsed);
typeof parsed.allow_multiple === "boolean" ? parsed.allow_multiple : undefined, if (fallback) {
clarificationType, collectClarification(fallback.payload, fallback.toolCallId || "terminal-0");
), }
}; }
result.clarification = fallback;
result.clarifications = [fallback];
} }
continue; continue;
} }
@@ -153,6 +153,10 @@ export function BusinessChainPicker({
// 席位「会商技能识别强化」开关(链条配置):按 agent_id 取,缺省 = 关。 // 席位「会商技能识别强化」开关(链条配置):按 agent_id 取,缺省 = 关。
const skillById = new Map(selectedChain.seats.map((s) => [s.agent_id, s.seat_skill_directive ?? false])); const skillById = new Map(selectedChain.seats.map((s) => [s.agent_id, s.seat_skill_directive ?? false]));
const skillOf = (id: string) => skillById.get(id) ?? false; const skillOf = (id: string) => skillById.get(id) ?? false;
const humanValidationById = new Map(
selectedChain.seats.map((s) => [s.agent_id, s.human_validation ?? false]),
);
const humanValidationOf = (id: string) => humanValidationById.get(id) ?? false;
// 席位「推理深度覆盖」(链条配置 seat_mode):按 agent_id 取,null/缺省 = 跟随链条级 seatMode。 // 席位「推理深度覆盖」(链条配置 seat_mode):按 agent_id 取,null/缺省 = 跟随链条级 seatMode。
const seatModeById = new Map(selectedChain.seats.map((s) => [s.agent_id, s.seat_mode ?? null])); const seatModeById = new Map(selectedChain.seats.map((s) => [s.agent_id, s.seat_mode ?? null]));
const seatModeOf = (id: string) => seatModeById.get(id) ?? null; const seatModeOf = (id: string) => seatModeById.get(id) ?? null;
@@ -162,12 +166,12 @@ export function BusinessChainPicker({
// dag:席位顺序按 stage 展平(与 plan 一致)。 // dag:席位顺序按 stage 展平(与 plan 一致)。
const agents = dagPlan.stages const agents = dagPlan.stages
.flatMap((st) => st.agentIds) .flatMap((st) => st.agentIds)
.map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id), seat_skill_directive: skillOf(id), seatMode: seatModeOf(id) })); .map((id) => ({ agent_id: id, name: nameOf(id), model: modelOf(id), seat_skill_directive: skillOf(id), human_validation: humanValidationOf(id), seatMode: seatModeOf(id) }));
onConfirm({ agents, mode: "dag", chain: chainMeta, plan: dagPlan, dagExecMode, gatherFirst: selectedChain.gatherFirst ?? false }); onConfirm({ agents, mode: "dag", chain: chainMeta, plan: dagPlan, dagExecMode, gatherFirst: selectedChain.gatherFirst ?? false });
return; return;
} }
const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id), model: modelOf(s.agent_id), seat_skill_directive: skillOf(s.agent_id), seatMode: seatModeOf(s.agent_id) })); const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id), model: modelOf(s.agent_id), seat_skill_directive: skillOf(s.agent_id), human_validation: humanValidationOf(s.agent_id), seatMode: seatModeOf(s.agent_id) }));
onConfirm({ agents, mode: "chain", chain: chainMeta, gatherFirst: selectedChain.gatherFirst ?? false }); onConfirm({ agents, mode: "chain", chain: chainMeta, gatherFirst: selectedChain.gatherFirst ?? false });
}; };
Loaded 100 of 329 files, more files were not shown because too many files have changed in this diff. Show more