Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bc78e5892 | ||
|
|
e189349eeb | ||
|
|
c4fd9dd7da | ||
|
|
b4d076a2d4 | ||
|
|
bc82072710 | ||
|
|
bf023d746a | ||
|
|
fb5024ea82 | ||
|
|
c9c2dc7ea8 | ||
|
|
2a869f4ff6 | ||
|
|
af509eb670 | ||
|
|
783e698b6e | ||
|
|
ba912acf72 | ||
|
|
e0c09f6704 | ||
|
|
17a12bb7b6 | ||
|
|
f8757d1a43 | ||
|
|
69b95f0d6d | ||
|
|
ca32a22c0d | ||
|
|
3eb307c5c5 | ||
|
|
49af497085 | ||
|
|
8b732b43ee | ||
|
|
69e455274a | ||
|
|
e939dc4548 |
No files matched your search
@@ -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()
|
||||
@@ -1,6 +1,9 @@
|
||||
# Workspace/editor
|
||||
.vscode/
|
||||
frontend-web/.vscode/
|
||||
.vscode/*
|
||||
!.vscode/launch.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/extensions.json
|
||||
frontend-web/.vscode/
|
||||
node_modules/
|
||||
# Claude Code local-only settings (per-user permissions, not shared)
|
||||
.claude/settings.local.json
|
||||
@@ -52,8 +55,9 @@ offline-backend-20260505/bundle/start_backend.sh
|
||||
offline-backend-20260505/bundle/stop_backend.sh
|
||||
|
||||
# offline-backend-20260512 runtime artifacts (large files, local config, data)
|
||||
offline-backend-20260512/.env.example
|
||||
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
|
||||
offline-backend-20260512/.env.example
|
||||
offline-backend-20260512/.env
|
||||
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
|
||||
offline-backend-20260512/SHA256SUMS
|
||||
offline-backend-20260512/config.yaml.example
|
||||
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/skills/
|
||||
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/*
|
||||
offline-backend-20260512/start_backend.sh
|
||||
offline-backend-20260512/stop_backend.sh
|
||||
offline-backend-20260512/check_backend.sh
|
||||
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/.env
|
||||
offline-backend-20260512/backend/.env.local
|
||||
offline-backend-20260512/start_backend.sh
|
||||
offline-backend-20260512/stop_backend.sh
|
||||
offline-backend-20260512/check_backend.sh
|
||||
|
||||
hermes-agent-main/
|
||||
|
||||
@@ -77,11 +83,16 @@ offline-backend-20260512/backend/.deer-flow/*
|
||||
|
||||
# Per-user skill_evolution overrides — runtime data written by the API
|
||||
offline-backend-20260512/backend/skill_evolution.json
|
||||
skills/
|
||||
hermes-agent-main/
|
||||
skills/
|
||||
hermes-agent-main/
|
||||
.gitignore
|
||||
offline-backend-20260512/backend/tests/*
|
||||
deploy
|
||||
offline-backend-20260512/backend/tests/*
|
||||
!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py
|
||||
!offline-backend-20260512/backend/tests/test_health_auth_bypass.py
|
||||
# Keep the model configuration regression coverage versioned even though
|
||||
# this deployment repository otherwise ignores newly added backend tests.
|
||||
!offline-backend-20260512/backend/tests/test_model_management.py
|
||||
deploy
|
||||
.history
|
||||
|
||||
# Cloned third-party repos (local only)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"ms-python.python",
|
||||
"ms-python.debugpy"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 节。
|
||||
@@ -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{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 |
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import {
|
||||
Bell,
|
||||
Cpu,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
@@ -252,6 +253,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) {
|
||||
白名单管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/models")}
|
||||
>
|
||||
<Cpu className="size-4" />
|
||||
模型配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/strategy/settings/model-concurrency")}
|
||||
@@ -555,6 +564,12 @@ function LegacyAccordionSidebar({
|
||||
岗位角色配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/workspace/admin/models")}>
|
||||
<Cpu className="size-4" />
|
||||
模型配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem onClick={() => navigate("/page/strategy/admin/login-whitelist")}>
|
||||
<ShieldCheck className="size-4" />
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
SlidersHorizontal,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -18,7 +19,7 @@ function HeaderButton({
|
||||
variant = "outline",
|
||||
className,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: "outline" | "primary";
|
||||
@@ -52,6 +53,7 @@ export function WikiHeader({
|
||||
onCreate,
|
||||
onManageFolders,
|
||||
onManageTemplates,
|
||||
extraActions,
|
||||
}: {
|
||||
onToggleSidebar: () => void;
|
||||
onImportFile: () => void;
|
||||
@@ -61,6 +63,7 @@ export function WikiHeader({
|
||||
onCreate: () => void;
|
||||
onManageFolders: () => void;
|
||||
onManageTemplates: () => void;
|
||||
extraActions?: ReactNode;
|
||||
}) {
|
||||
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">
|
||||
@@ -79,6 +82,7 @@ export function WikiHeader({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{extraActions}
|
||||
<HeaderButton
|
||||
icon={<Upload className="size-4" />}
|
||||
label="导入文件"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
BotIcon,
|
||||
CheckIcon,
|
||||
DatabaseIcon,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
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 { useArtifactsOptional } from "@/components/workspace/artifacts";
|
||||
import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list";
|
||||
@@ -80,6 +82,7 @@ interface AgentDraft {
|
||||
templateName: string;
|
||||
/** 会商席位「技能识别强化」开关。 */
|
||||
seatSkillDirective: boolean;
|
||||
knowledgeBaseIds: string[];
|
||||
}
|
||||
|
||||
function draftFromAgent(agent: Agent): AgentDraft {
|
||||
@@ -93,6 +96,7 @@ function draftFromAgent(agent: Agent): AgentDraft {
|
||||
featured: agent.featured_order != null,
|
||||
templateName: agent.template_name ?? "",
|
||||
seatSkillDirective: agent.seat_skill_directive ?? false,
|
||||
knowledgeBaseIds: agent.llmwiki_knowledge_base_ids ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,15 +104,17 @@ function Section({
|
||||
icon,
|
||||
title,
|
||||
extra,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
extra?: ReactNode;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="bg-card/60 rounded-xl border p-4">
|
||||
<section className={cn("bg-card/60 rounded-xl border p-4", className)}>
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold">
|
||||
{icon}
|
||||
@@ -139,6 +145,15 @@ export function AgentDetailPanel({
|
||||
defaultCollapsed,
|
||||
showRecentChats,
|
||||
compactAppearanceActions,
|
||||
avatarSrc,
|
||||
positionName,
|
||||
skillsIcon,
|
||||
skillsTitle,
|
||||
showCommonQuestions = false,
|
||||
panelTitle,
|
||||
embedded,
|
||||
hideStatusBadges = false,
|
||||
editIcon,
|
||||
}: {
|
||||
agent: Agent | null | undefined;
|
||||
agentName: string;
|
||||
@@ -150,6 +165,19 @@ export function AgentDetailPanel({
|
||||
showRecentChats?: boolean;
|
||||
/** 简洁模式下挂在右上角收起/展开按钮左侧的外观控件。 */
|
||||
compactAppearanceActions?: ReactNode;
|
||||
avatarSrc?: string;
|
||||
positionName?: string;
|
||||
skillsIcon?: ReactNode;
|
||||
skillsTitle?: string;
|
||||
showCommonQuestions?: boolean;
|
||||
/** Overrides the standard panel heading for an embedded consumer. */
|
||||
panelTitle?: string;
|
||||
/** Renders one fixed panel instead of the responsive collapsible rail. */
|
||||
embedded?: boolean;
|
||||
/** Keeps deployment/status badges out of an embedded product surface. */
|
||||
hideStatusBadges?: boolean;
|
||||
/** Optional icon used by the compact embedded edit action. */
|
||||
editIcon?: ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false);
|
||||
@@ -310,6 +338,11 @@ export function AgentDetailPanel({
|
||||
request.template_name = draft.templateName.trim();
|
||||
if (draft.seatSkillDirective !== (agent.seat_skill_directive ?? false))
|
||||
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 (JSON.stringify(draft.skills) !== JSON.stringify(agent.skills ?? []))
|
||||
request.skills = draft.skills;
|
||||
@@ -472,11 +505,25 @@ export function AgentDetailPanel({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" variant="outline" onClick={beginEdit}>
|
||||
<SquarePenIcon className="size-3.5" />
|
||||
编辑
|
||||
</Button>
|
||||
{canTogglePublish && (
|
||||
{embedded ? (
|
||||
<Tooltip content="编辑智能体">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={beginEdit}
|
||||
aria-label="编辑智能体"
|
||||
>
|
||||
{editIcon ?? <SquarePenIcon className="size-3.5" />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" onClick={beginEdit}>
|
||||
<SquarePenIcon className="size-3.5" />
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
{!embedded && canTogglePublish && (
|
||||
<Tooltip content={agent?.published ? "下架" : "发布"}>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
@@ -493,17 +540,19 @@ export function AgentDetailPanel({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content="删除智能体">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
aria-label="删除智能体"
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{!embedded && (
|
||||
<Tooltip content="删除智能体">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
aria-label="删除智能体"
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : null;
|
||||
@@ -521,7 +570,11 @@ export function AgentDetailPanel({
|
||||
<section className="bg-card/60 rounded-xl border p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="agent-avatar bg-primary/10 ring-primary/15 flex size-12 shrink-0 items-center justify-center rounded-2xl ring-1">
|
||||
<BotIcon className="text-primary size-6" />
|
||||
{avatarSrc ? (
|
||||
<img src={avatarSrc} alt="" aria-hidden="true" className="size-9" />
|
||||
) : (
|
||||
<BotIcon className="text-primary size-6" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
{editing && draft ? (
|
||||
@@ -541,9 +594,16 @@ export function AgentDetailPanel({
|
||||
{displayName}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{statusBadges}
|
||||
</div>
|
||||
{!editing && positionName && (
|
||||
<div className="position-agent-role text-sm">
|
||||
岗位:{desensitize(positionName)}
|
||||
</div>
|
||||
)}
|
||||
{!hideStatusBadges && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{statusBadges}
|
||||
</div>
|
||||
)}
|
||||
{!editing && agent.owner_username && !agent.builtin && (
|
||||
<div className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
<UserRoundIcon className="size-3 shrink-0" />
|
||||
@@ -579,6 +639,13 @@ export function AgentDetailPanel({
|
||||
</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. */}
|
||||
{editing && draft && (
|
||||
<div className="mt-3 space-y-2">
|
||||
@@ -596,6 +663,14 @@ export function AgentDetailPanel({
|
||||
配置后,对话生成的 Markdown 文件可一键发布到知识入库接口。
|
||||
</p>
|
||||
</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="pr-3">
|
||||
<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
|
||||
icon={<MessageCircleQuestionIcon className="size-3.5" />}
|
||||
title="常用问题"
|
||||
@@ -787,8 +862,9 @@ export function AgentDetailPanel({
|
||||
|
||||
{/* ── 技能管理 ───────────────────────────────────────────────────── */}
|
||||
<Section
|
||||
icon={<SparklesIcon className="size-3.5" />}
|
||||
title={`已启用技能 ${skillNamesShown.length} 个`}
|
||||
icon={skillsIcon ?? <SparklesIcon className="size-3.5" />}
|
||||
title={skillsTitle ?? `已启用技能 ${skillNamesShown.length} 个`}
|
||||
className="position-roundtable-skills-section"
|
||||
extra={
|
||||
editing ? (
|
||||
<Button
|
||||
@@ -830,7 +906,9 @@ export function AgentDetailPanel({
|
||||
const panelChrome = (collapseButton: ReactNode) => (
|
||||
<>
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<span className="shrink-0 whitespace-nowrap text-sm font-semibold">智能体信息</span>
|
||||
<span className="shrink-0 whitespace-nowrap text-sm font-semibold">
|
||||
{panelTitle ?? "智能体信息"}
|
||||
</span>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{headerActions}
|
||||
{compactAppearanceActions}
|
||||
@@ -844,7 +922,11 @@ export function AgentDetailPanel({
|
||||
return (
|
||||
<>
|
||||
{/* ── Desktop (lg+): inline aside, collapsible to a rail ───────────── */}
|
||||
{collapsed ? (
|
||||
{embedded ? (
|
||||
<aside className="bg-card/40 flex h-full w-full min-w-0 shrink-0 flex-col border-l">
|
||||
{panelChrome(null)}
|
||||
</aside>
|
||||
) : collapsed ? (
|
||||
<div className="hidden shrink-0 items-start border-l px-2 pt-3 lg:flex">
|
||||
<div className="flex items-center gap-1">
|
||||
{compactAppearanceActions}
|
||||
@@ -877,30 +959,34 @@ export function AgentDetailPanel({
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* ── Narrow screens (<lg): rail trigger + drawer with the same body ── */}
|
||||
<div className="flex shrink-0 flex-col items-center border-l pt-3 lg:hidden">
|
||||
<Tooltip content="智能体信息">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
aria-label="打开智能体信息"
|
||||
>
|
||||
<InfoIcon className="size-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-md">
|
||||
<SheetHeader className="border-b px-4 pb-3 pt-4">
|
||||
<SheetTitle className="flex items-center justify-between text-sm">
|
||||
智能体信息
|
||||
<span className="mr-6">{headerActions}</span>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
{!embedded && (
|
||||
<>
|
||||
{/* ── Narrow screens (<lg): rail trigger + drawer with the same body ── */}
|
||||
<div className="flex shrink-0 flex-col items-center border-l pt-3 lg:hidden">
|
||||
<Tooltip content="智能体信息">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
aria-label="打开智能体信息"
|
||||
>
|
||||
<InfoIcon className="size-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-md">
|
||||
<SheetHeader className="border-b px-4 pb-3 pt-4">
|
||||
<SheetTitle className="flex items-center justify-between text-sm">
|
||||
智能体信息
|
||||
<span className="mr-6">{headerActions}</span>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">{body}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Secondary surfaces ───────────────────────────────────────────── */}
|
||||
<SkillPickerSheet
|
||||
|
||||
@@ -121,8 +121,9 @@ function findScrollableArtifactElement(root: HTMLElement | null) {
|
||||
|
||||
export function ArtifactFileDetail({
|
||||
className,
|
||||
filepath: filepathFromProps,
|
||||
threadId,
|
||||
filepath: filepathFromProps,
|
||||
threadId,
|
||||
mimeType = null,
|
||||
onClose,
|
||||
allowVirtualMarkdownEdit = false,
|
||||
disableDesensitize = false,
|
||||
@@ -131,6 +132,8 @@ export function ArtifactFileDetail({
|
||||
className?: string;
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
/** Server/tool supplied type wins over path guessing when available. */
|
||||
mimeType?: string | null;
|
||||
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
|
||||
onClose?: () => void;
|
||||
/**
|
||||
@@ -150,16 +153,38 @@ export function ArtifactFileDetail({
|
||||
const { t } = useI18n();
|
||||
const { artifacts, setOpen, select } = useArtifacts();
|
||||
const { isMock, thread, publicShareCode } = useThread();
|
||||
const isWriteFileVirtual = useMemo(() => {
|
||||
return filepathFromProps.startsWith("write-file:");
|
||||
}, [filepathFromProps]);
|
||||
const filepath = useMemo(() => {
|
||||
if (isWriteFileVirtual) {
|
||||
const url = new URL(filepathFromProps);
|
||||
return decodeURIComponent(url.pathname);
|
||||
}
|
||||
return filepathFromProps;
|
||||
}, [filepathFromProps, isWriteFileVirtual]);
|
||||
const isWriteFileVirtual = useMemo(() => {
|
||||
return filepathFromProps.startsWith("write-file:");
|
||||
}, [filepathFromProps]);
|
||||
// A streamed tool call can be opened before the path has been normalized by
|
||||
// the artifacts API. Keep the format supplied by the writer so Markdown is
|
||||
// rendered as Markdown instead of falling back to a TEXT source preview.
|
||||
const writeFileLanguageHint = useMemo(() => {
|
||||
if (!isWriteFileVirtual) return null;
|
||||
try {
|
||||
const language = new URL(filepathFromProps).searchParams.get("language");
|
||||
return language === "markdown" ? language : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [filepathFromProps, isWriteFileVirtual]);
|
||||
const mimeLanguageHint = useMemo(() => {
|
||||
const normalized = mimeType?.toLowerCase().trim() ?? "";
|
||||
if (normalized.includes("markdown")) return "markdown";
|
||||
if (normalized.includes("html")) return "html";
|
||||
if (normalized.includes("mermaid")) return "mermaid";
|
||||
if (normalized.startsWith("text/")) return "text";
|
||||
return null;
|
||||
}, [mimeType]);
|
||||
const filepath = useMemo(() => {
|
||||
if (isWriteFileVirtual) {
|
||||
const url = new URL(filepathFromProps);
|
||||
return decodeURIComponent(url.pathname).trim();
|
||||
}
|
||||
// Tool arguments may contain harmless surrounding whitespace. Passing it
|
||||
// into extension detection turns `report.md ` into a generic TEXT file.
|
||||
return filepathFromProps.trim();
|
||||
}, [filepathFromProps, isWriteFileVirtual]);
|
||||
const isSkillFile = useMemo(() => {
|
||||
return filepath.endsWith(".skill");
|
||||
}, [filepath]);
|
||||
@@ -186,17 +211,22 @@ export function ArtifactFileDetail({
|
||||
const useWriteFileStreaming = useMemo(() => {
|
||||
return isWriteFileVirtual && !isImageFile(filepath) && !editVirtualMarkdown;
|
||||
}, [filepath, isWriteFileVirtual, editVirtualMarkdown]);
|
||||
const { isCodeFile, language } = useMemo(() => {
|
||||
if (useWriteFileStreaming) {
|
||||
let nextLanguage = checkCodeFile(filepath).language;
|
||||
nextLanguage ??= "text";
|
||||
return { isCodeFile: true, language: nextLanguage };
|
||||
const { isCodeFile, language } = useMemo(() => {
|
||||
if (useWriteFileStreaming) {
|
||||
let nextLanguage =
|
||||
mimeLanguageHint ?? writeFileLanguageHint ?? checkCodeFile(filepath).language;
|
||||
nextLanguage ??= "text";
|
||||
return { isCodeFile: true, language: nextLanguage };
|
||||
}
|
||||
if (isSkillFile) {
|
||||
return { isCodeFile: true, language: "markdown" };
|
||||
}
|
||||
return checkCodeFile(filepath);
|
||||
}, [filepath, useWriteFileStreaming, isSkillFile]);
|
||||
const detected = checkCodeFile(filepath);
|
||||
if (mimeLanguageHint) {
|
||||
return { isCodeFile: true as const, language: mimeLanguageHint };
|
||||
}
|
||||
return detected;
|
||||
}, [filepath, useWriteFileStreaming, isSkillFile, mimeLanguageHint, writeFileLanguageHint]);
|
||||
const isSupportPreview = useMemo(() => {
|
||||
return (
|
||||
language === "html" ||
|
||||
@@ -209,7 +239,8 @@ export function ArtifactFileDetail({
|
||||
// editVirtualMarkdown:用解码后的真实路径走 artifacts API(而非 write-file 虚拟
|
||||
// URL),这样保存后 useUpdateArtifactContent 写入的查询缓存才能命中,编辑内容
|
||||
// 不会被旧的内存 stub 值覆盖回退。
|
||||
filepath: editVirtualMarkdown ? filepath : filepathFromProps,
|
||||
filepath:
|
||||
editVirtualMarkdown || !isWriteFileVirtual ? filepath : filepathFromProps,
|
||||
enabled: isCodeFile && !useWriteFileStreaming,
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import type { SkillDisplayConfig } from "@/core/skills/type";
|
||||
import {
|
||||
@@ -92,6 +93,13 @@ const ChatBox: React.FC<{
|
||||
const [settings] = useLocalSettings();
|
||||
const ragModeEnabled =
|
||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
// Frontend single-round RAG writes its own source tool message, so it should
|
||||
// use the same reference panel without changing the user's global RAG mode.
|
||||
const hasSingleRoundRag = useMemo(
|
||||
() => (thread.values.messages ?? []).some(isSingleRoundRagMessage),
|
||||
[thread.values.messages],
|
||||
);
|
||||
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
|
||||
const { skills } = useSkills();
|
||||
|
||||
const {
|
||||
@@ -106,11 +114,11 @@ const ChatBox: React.FC<{
|
||||
|
||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[ragModeEnabled, skills],
|
||||
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[referencesEnabled, skills],
|
||||
);
|
||||
const fallbackReferenceBatches = useMemo(() => {
|
||||
if (!ragModeEnabled) {
|
||||
if (!referencesEnabled) {
|
||||
return [];
|
||||
}
|
||||
return buildTurnReferenceBatches(
|
||||
@@ -118,9 +126,9 @@ const ChatBox: React.FC<{
|
||||
skillDisplayMap,
|
||||
thread.isLoading,
|
||||
);
|
||||
}, [ragModeEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
|
||||
}, [referencesEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
|
||||
useEffect(() => {
|
||||
if (!ragModeEnabled) {
|
||||
if (!referencesEnabled) {
|
||||
setBackendReferenceBatches([]);
|
||||
return;
|
||||
}
|
||||
@@ -156,7 +164,7 @@ const ChatBox: React.FC<{
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [ragModeEnabled, thread.isLoading, threadId, thread.values.messages]);
|
||||
}, [referencesEnabled, thread.isLoading, threadId, thread.values.messages]);
|
||||
const adaptedBackendReferenceBatches = useMemo(
|
||||
() =>
|
||||
adaptBackendReferenceBatches(
|
||||
@@ -175,7 +183,7 @@ const ChatBox: React.FC<{
|
||||
[adaptedBackendReferenceBatches, fallbackReferenceBatches],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!ragModeEnabled) {
|
||||
if (!referencesEnabled) {
|
||||
return;
|
||||
}
|
||||
const signature = JSON.stringify({
|
||||
@@ -202,7 +210,7 @@ const ChatBox: React.FC<{
|
||||
fallbackBatches: debugReferenceBatches(fallbackReferenceBatches),
|
||||
finalBatches: debugReferenceBatches(referenceBatches),
|
||||
});
|
||||
}, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, ragModeEnabled, referenceBatches, threadId]);
|
||||
}, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, referencesEnabled, referenceBatches, threadId]);
|
||||
const latestReferenceBatch =
|
||||
referenceBatches.length > 0
|
||||
? referenceBatches[referenceBatches.length - 1]!
|
||||
@@ -213,7 +221,7 @@ const ChatBox: React.FC<{
|
||||
const referenceSources = activeReferenceBatch?.sources ?? [];
|
||||
const hasCitationReferences = referenceSources.length > 0;
|
||||
useEffect(() => {
|
||||
if (!ragModeEnabled || !activeReferenceBatch) {
|
||||
if (!referencesEnabled || !activeReferenceBatch) {
|
||||
return;
|
||||
}
|
||||
logReferenceDebug("frontend-active-reference-batch-payload", threadId, {
|
||||
@@ -222,7 +230,7 @@ const ChatBox: React.FC<{
|
||||
sourceCount: activeReferenceBatch.sources.length,
|
||||
sources: activeReferenceBatch.sources.map(debugReferenceSource),
|
||||
});
|
||||
}, [activeReferenceBatch, ragModeEnabled, threadId]);
|
||||
}, [activeReferenceBatch, referencesEnabled, threadId]);
|
||||
useEffect(() => {
|
||||
if (hasCitationReferences) {
|
||||
setReferencesOpen(true);
|
||||
|
||||
@@ -22,9 +22,10 @@ import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
} from "react";
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
PromptInput,
|
||||
@@ -64,6 +65,7 @@ import { getBackendBaseURL } from "@/core/config";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import type { Model } from "@/core/models/types";
|
||||
import type { AgentThreadContext } from "@/core/threads";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
@@ -132,9 +134,11 @@ export function InputBox({
|
||||
onFollowupsVisibilityChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
promptPrefix,
|
||||
agentSelector,
|
||||
assistantId,
|
||||
promptPrefix,
|
||||
agentSelector,
|
||||
knowledgeSelector,
|
||||
extraModels = [],
|
||||
assistantId,
|
||||
hideAdminRecommendedQuestions,
|
||||
compact,
|
||||
...props
|
||||
@@ -190,16 +194,25 @@ export function InputBox({
|
||||
* homepage (``ChatPage``) passes this; ``AgentChatPage`` omits it because the
|
||||
* thread is already bound to one agent.
|
||||
*/
|
||||
agentSelector?: {
|
||||
selectedAgentId: string | null;
|
||||
onAgentChange: (agentId: string | null) => void;
|
||||
};
|
||||
}) {
|
||||
agentSelector?: {
|
||||
selectedAgentId: string | null;
|
||||
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 [localSettings, setLocalSettings] = useLocalSettings();
|
||||
const searchParams = useSearchParams();
|
||||
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
||||
const { models } = useModels();
|
||||
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
||||
const { models } = useModels();
|
||||
const singleRoundRagActive =
|
||||
context.execution_profile === "single_round_rag";
|
||||
const modelOptions = useMemo(() => {
|
||||
return singleRoundRagActive ? extraModels : models;
|
||||
}, [extraModels, models, singleRoundRagActive]);
|
||||
const { thread, isMock } = useThread();
|
||||
const { textInput } = usePromptInputController();
|
||||
const promptRootRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -223,34 +236,40 @@ export function InputBox({
|
||||
recommendedQuestions.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (models.length === 0) {
|
||||
return;
|
||||
}
|
||||
const currentModel = models.find((m) => m.name === context.model_name);
|
||||
const fallbackModel = currentModel ?? models[0]!;
|
||||
if (modelOptions.length === 0) {
|
||||
return;
|
||||
}
|
||||
const currentModel = modelOptions.find((m) => m.name === context.model_name);
|
||||
const fallbackModel = currentModel ?? modelOptions[0]!;
|
||||
const supportsThinking = fallbackModel.supports_thinking ?? false;
|
||||
const nextModelName = fallbackModel.name;
|
||||
const nextMode = getResolvedMode(context.mode, supportsThinking);
|
||||
|
||||
if (context.model_name === nextModelName && context.mode === nextMode) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
context.model_name === nextModelName &&
|
||||
context.mode === nextMode &&
|
||||
context.execution_profile === fallbackModel.execution_profile
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: nextModelName,
|
||||
mode: nextMode,
|
||||
});
|
||||
}, [context, models, onContextChange]);
|
||||
...context,
|
||||
model_name: nextModelName,
|
||||
mode: nextMode,
|
||||
execution_profile: fallbackModel.execution_profile,
|
||||
});
|
||||
}, [context, modelOptions, onContextChange]);
|
||||
|
||||
const selectedModel = useMemo(() => {
|
||||
if (modelOptions.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return modelOptions.find((m) => m.name === context.model_name) ?? modelOptions[0];
|
||||
}, [context.model_name, modelOptions]);
|
||||
|
||||
const selectedModel = useMemo(() => {
|
||||
if (models.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return models.find((m) => m.name === context.model_name) ?? models[0];
|
||||
}, [context.model_name, models]);
|
||||
|
||||
const resolvedModelName = selectedModel?.name;
|
||||
const resolvedModelName = selectedModel?.name;
|
||||
const selectedSingleRoundRag = singleRoundRagActive;
|
||||
|
||||
const supportThinking = useMemo(
|
||||
() => selectedModel?.supports_thinking ?? false,
|
||||
@@ -264,38 +283,65 @@ export function InputBox({
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
(model_name: string) => {
|
||||
const model = models.find((m) => m.name === model_name);
|
||||
const model = modelOptions.find((m) => m.name === model_name);
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name,
|
||||
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
|
||||
reasoning_effort: context.reasoning_effort,
|
||||
});
|
||||
setModelDialogOpen(false);
|
||||
},
|
||||
[onContextChange, context, models],
|
||||
mode: getResolvedMode(context.mode, model.supports_thinking ?? false),
|
||||
reasoning_effort: context.reasoning_effort,
|
||||
execution_profile: model.execution_profile,
|
||||
});
|
||||
setModelDialogOpen(false);
|
||||
},
|
||||
[onContextChange, context, modelOptions],
|
||||
);
|
||||
|
||||
const handleModeSelect = useCallback(
|
||||
(mode: InputMode) => {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
mode: getResolvedMode(mode, supportThinking),
|
||||
reasoning_effort:
|
||||
mode === "ultra"
|
||||
? "high"
|
||||
const handleModeSelect = useCallback(
|
||||
(mode: InputMode) => {
|
||||
const ordinaryModel =
|
||||
models.find((model) => model.name === context.model_name) ?? models[0];
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: ordinaryModel?.name ?? context.model_name,
|
||||
mode: getResolvedMode(
|
||||
mode,
|
||||
ordinaryModel?.supports_thinking ?? supportThinking,
|
||||
),
|
||||
reasoning_effort:
|
||||
mode === "ultra"
|
||||
? "high"
|
||||
: mode === "pro"
|
||||
? "medium"
|
||||
: mode === "thinking"
|
||||
? "low"
|
||||
: "minimal",
|
||||
});
|
||||
},
|
||||
[onContextChange, context, supportThinking],
|
||||
);
|
||||
? "low"
|
||||
: "minimal",
|
||||
execution_profile: undefined,
|
||||
});
|
||||
},
|
||||
[onContextChange, context, models, supportThinking],
|
||||
);
|
||||
|
||||
const handleSingleRoundRagSelect = useCallback(() => {
|
||||
const ragModel =
|
||||
extraModels.find((model) => model.name === context.model_name) ??
|
||||
extraModels[0];
|
||||
if (!ragModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: ragModel.name,
|
||||
mode: getResolvedMode(
|
||||
context.mode,
|
||||
ragModel.supports_thinking ?? false,
|
||||
),
|
||||
execution_profile: "single_round_rag",
|
||||
});
|
||||
}, [context, extraModels, onContextChange]);
|
||||
|
||||
const handleReasoningEffortSelect = useCallback(
|
||||
(effort: "minimal" | "low" | "medium" | "high") => {
|
||||
@@ -361,10 +407,11 @@ export function InputBox({
|
||||
onContextChange?.({
|
||||
...context,
|
||||
model_name: resolvedModelName,
|
||||
mode: getResolvedMode(
|
||||
context.mode,
|
||||
selectedModel?.supports_thinking ?? false,
|
||||
),
|
||||
mode: getResolvedMode(
|
||||
context.mode,
|
||||
selectedModel?.supports_thinking ?? false,
|
||||
),
|
||||
execution_profile: selectedModel?.execution_profile,
|
||||
});
|
||||
setTimeout(() => onSubmit?.(message), 0);
|
||||
return;
|
||||
@@ -379,7 +426,8 @@ export function InputBox({
|
||||
onStop,
|
||||
resolvedModelName,
|
||||
ragModeEnabled,
|
||||
selectedModel?.supports_thinking,
|
||||
selectedModel?.supports_thinking,
|
||||
selectedModel?.execution_profile,
|
||||
status,
|
||||
threadId,
|
||||
],
|
||||
@@ -437,8 +485,9 @@ export function InputBox({
|
||||
}, [pendingSuggestion, requestFormSubmit, textInput]);
|
||||
|
||||
const showFollowups =
|
||||
!disabled &&
|
||||
!isNewThread &&
|
||||
!disabled &&
|
||||
!isNewThread &&
|
||||
!selectedSingleRoundRag &&
|
||||
!followupsHidden &&
|
||||
(followupsLoading || followups.length > 0);
|
||||
|
||||
@@ -464,7 +513,7 @@ export function InputBox({
|
||||
return;
|
||||
}
|
||||
|
||||
if (disabled || isMock) {
|
||||
if (disabled || isMock || selectedSingleRoundRag) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -526,7 +575,7 @@ export function InputBox({
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [context.model_name, disabled, isMock, status, thread.messages, threadId]);
|
||||
}, [context.model_name, disabled, isMock, selectedSingleRoundRag, status, thread.messages, threadId]);
|
||||
|
||||
return (
|
||||
<div ref={promptRootRef} className="relative flex flex-col gap-6">
|
||||
@@ -782,54 +831,64 @@ export function InputBox({
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
<PromptInputActionMenu>
|
||||
<ModeHoverGuide
|
||||
mode={
|
||||
<ModeHoverGuide
|
||||
mode={
|
||||
context.mode === "flash" ||
|
||||
context.mode === "thinking" ||
|
||||
context.mode === "pro" ||
|
||||
context.mode === "ultra"
|
||||
? context.mode
|
||||
: "flash"
|
||||
}
|
||||
>
|
||||
? context.mode
|
||||
: "flash"
|
||||
}
|
||||
tooltipContent={
|
||||
selectedSingleRoundRag
|
||||
? "单轮 RAG 问答:基于知识库检索生成答案,并展示参考资料"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<PromptInputActionMenuTrigger
|
||||
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",
|
||||
compact && "rounded-lg border px-2.5! py-1! text-[11px] font-medium",
|
||||
compact &&
|
||||
(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"
|
||||
compact &&
|
||||
(selectedSingleRoundRag
|
||||
? "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"
|
||||
? "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"
|
||||
? "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"),
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{context.mode === "flash" && <ZapIcon className="size-3" />}
|
||||
{context.mode === "thinking" && (
|
||||
<LightbulbIcon className="size-3" />
|
||||
)}
|
||||
{context.mode === "pro" && (
|
||||
<GraduationCapIcon className="size-3" />
|
||||
)}
|
||||
{context.mode === "ultra" && (
|
||||
<RocketIcon className="size-3 text-[#dabb5e]" />
|
||||
)}
|
||||
</div>
|
||||
>
|
||||
<div>
|
||||
{selectedSingleRoundRag ? (
|
||||
<BookMarkedIcon className="size-3" />
|
||||
) : context.mode === "flash" ? (
|
||||
<ZapIcon className="size-3" />
|
||||
) : context.mode === "thinking" ? (
|
||||
<LightbulbIcon className="size-3" />
|
||||
) : context.mode === "pro" ? (
|
||||
<GraduationCapIcon className="size-3" />
|
||||
) : context.mode === "ultra" ? (
|
||||
<RocketIcon className="size-3 text-[#dabb5e]" />
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs font-normal",
|
||||
context.mode === "ultra" ? "golden-text" : "",
|
||||
)}
|
||||
>
|
||||
{(context.mode === "flash" && t.inputBox.flashMode) ||
|
||||
(context.mode === "thinking" &&
|
||||
t.inputBox.reasoningMode) ||
|
||||
(context.mode === "pro" && t.inputBox.proMode) ||
|
||||
(context.mode === "ultra" && t.inputBox.ultraMode)}
|
||||
</div>
|
||||
{selectedSingleRoundRag
|
||||
? "单轮 RAG"
|
||||
: (context.mode === "flash" && t.inputBox.flashMode) ||
|
||||
(context.mode === "thinking" &&
|
||||
t.inputBox.reasoningMode) ||
|
||||
(context.mode === "pro" && t.inputBox.proMode) ||
|
||||
(context.mode === "ultra" && t.inputBox.ultraMode)}
|
||||
</div>
|
||||
</PromptInputActionMenuTrigger>
|
||||
</ModeHoverGuide>
|
||||
<PromptInputActionMenuContent className="w-80">
|
||||
@@ -838,11 +897,11 @@ export function InputBox({
|
||||
{t.inputBox.mode}
|
||||
</DropdownMenuLabel>
|
||||
<PromptInputActionMenu>
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
context.mode === "flash"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
!selectedSingleRoundRag && context.mode === "flash"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
onSelect={() => handleModeSelect("flash")}
|
||||
>
|
||||
@@ -850,9 +909,9 @@ export function InputBox({
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
<ZapIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
context.mode === "flash" &&
|
||||
"text-accent-foreground",
|
||||
"mr-2 size-4",
|
||||
!selectedSingleRoundRag && context.mode === "flash" &&
|
||||
"text-accent-foreground",
|
||||
)}
|
||||
/>
|
||||
{t.inputBox.flashMode}
|
||||
@@ -861,7 +920,7 @@ export function InputBox({
|
||||
{t.inputBox.flashModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{context.mode === "flash" ? (
|
||||
{!selectedSingleRoundRag && context.mode === "flash" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
@@ -869,9 +928,9 @@ export function InputBox({
|
||||
</PromptInputActionMenuItem>
|
||||
{supportThinking && (
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
context.mode === "thinking"
|
||||
? "text-accent-foreground"
|
||||
className={cn(
|
||||
!selectedSingleRoundRag && context.mode === "thinking"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
onSelect={() => handleModeSelect("thinking")}
|
||||
@@ -880,9 +939,9 @@ export function InputBox({
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
<LightbulbIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
context.mode === "thinking" &&
|
||||
"text-accent-foreground",
|
||||
"mr-2 size-4",
|
||||
!selectedSingleRoundRag && context.mode === "thinking" &&
|
||||
"text-accent-foreground",
|
||||
)}
|
||||
/>
|
||||
{t.inputBox.reasoningMode}
|
||||
@@ -891,7 +950,7 @@ export function InputBox({
|
||||
{t.inputBox.reasoningModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{context.mode === "thinking" ? (
|
||||
{!selectedSingleRoundRag && context.mode === "thinking" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
@@ -899,8 +958,8 @@ export function InputBox({
|
||||
</PromptInputActionMenuItem>
|
||||
)}
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
context.mode === "pro"
|
||||
className={cn(
|
||||
!selectedSingleRoundRag && context.mode === "pro"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
@@ -910,8 +969,8 @@ export function InputBox({
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
<GraduationCapIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
context.mode === "pro" &&
|
||||
"mr-2 size-4",
|
||||
!selectedSingleRoundRag && context.mode === "pro" &&
|
||||
"text-accent-foreground",
|
||||
)}
|
||||
/>
|
||||
@@ -921,15 +980,15 @@ export function InputBox({
|
||||
{t.inputBox.proModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{context.mode === "pro" ? (
|
||||
{!selectedSingleRoundRag && context.mode === "pro" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</PromptInputActionMenuItem>
|
||||
<PromptInputActionMenuItem
|
||||
className={cn(
|
||||
context.mode === "ultra"
|
||||
className={cn(
|
||||
!selectedSingleRoundRag && context.mode === "ultra"
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground/65",
|
||||
)}
|
||||
@@ -939,13 +998,17 @@ export function InputBox({
|
||||
<div className="flex items-center gap-1 font-bold">
|
||||
<RocketIcon
|
||||
className={cn(
|
||||
"mr-2 size-4",
|
||||
context.mode === "ultra" && "text-[#dabb5e]",
|
||||
"mr-2 size-4",
|
||||
!selectedSingleRoundRag &&
|
||||
context.mode === "ultra" &&
|
||||
"text-[#dabb5e]",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
context.mode === "ultra" && "golden-text",
|
||||
!selectedSingleRoundRag &&
|
||||
context.mode === "ultra" &&
|
||||
"golden-text",
|
||||
)}
|
||||
>
|
||||
{t.inputBox.ultraMode}
|
||||
@@ -955,13 +1018,46 @@ export function InputBox({
|
||||
{t.inputBox.ultraModeDescription}
|
||||
</div>
|
||||
</div>
|
||||
{context.mode === "ultra" ? (
|
||||
{!selectedSingleRoundRag && context.mode === "ultra" ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</PromptInputActionMenuItem>
|
||||
</PromptInputActionMenu>
|
||||
</PromptInputActionMenuItem>
|
||||
{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>
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu>
|
||||
@@ -1081,9 +1177,10 @@ export function InputBox({
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu>
|
||||
)}
|
||||
</PromptInputTools>
|
||||
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
|
||||
{agentSelector && (
|
||||
</PromptInputTools>
|
||||
<PromptInputTools className={cn("gap-1.5", compact && "gap-2")}>
|
||||
{knowledgeSelector}
|
||||
{agentSelector && (
|
||||
<AgentSelector
|
||||
selectedAgentId={agentSelector.selectedAgentId}
|
||||
onAgentChange={agentSelector.onAgentChange}
|
||||
@@ -1146,7 +1243,7 @@ export function InputBox({
|
||||
<ModelSelectorContent>
|
||||
<ModelSelectorInput placeholder={t.inputBox.searchModels} />
|
||||
<ModelSelectorList>
|
||||
{models.map((m) => (
|
||||
{modelOptions.map((m) => (
|
||||
<ModelSelectorItem
|
||||
key={m.name}
|
||||
value={m.name}
|
||||
@@ -1154,9 +1251,11 @@ export function InputBox({
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<ModelSelectorName>{m.display_name}</ModelSelectorName>
|
||||
<span className="text-muted-foreground truncate text-[10px]">
|
||||
{m.model}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-[10px]">
|
||||
{selectedSingleRoundRag
|
||||
? `单轮知识库问答 · ${m.name}`
|
||||
: m.model}
|
||||
</span>
|
||||
</div>
|
||||
{m.name === context.model_name ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
@@ -1164,8 +1263,8 @@ export function InputBox({
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</ModelSelectorItem>
|
||||
))}
|
||||
</ModelSelectorList>
|
||||
))}
|
||||
</ModelSelectorList>
|
||||
</ModelSelectorContent>
|
||||
</ModelSelector>
|
||||
<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";
|
||||
import type { Subtask } from "@/core/tasks";
|
||||
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 { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
|
||||
@@ -235,12 +236,17 @@ export function MessageList({
|
||||
}, [messages, askTimes]);
|
||||
|
||||
const [settings] = useLocalSettings();
|
||||
const ragModeEnabled =
|
||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
const { skills } = useSkills();
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[ragModeEnabled, skills],
|
||||
const ragModeEnabled =
|
||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
const hasSingleRoundRag = useMemo(
|
||||
() => messages.some(isSingleRoundRagMessage),
|
||||
[messages],
|
||||
);
|
||||
const referencesEnabled = ragModeEnabled || hasSingleRoundRag;
|
||||
const { skills } = useSkills();
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (referencesEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[referencesEnabled, skills],
|
||||
);
|
||||
// Same per-turn reference batches the right-side panel renders (backend
|
||||
// batches preferred). Inline [n] tokens must resolve against these so badge
|
||||
@@ -373,7 +379,7 @@ export function MessageList({
|
||||
if (group.type === "human" || group.type === "assistant") {
|
||||
return group.messages.map((msg) => {
|
||||
const isAssistantMessage =
|
||||
ragModeEnabled &&
|
||||
referencesEnabled &&
|
||||
group.type === "assistant" &&
|
||||
msg.type === "ai";
|
||||
const batchSources = isAssistantMessage
|
||||
|
||||
@@ -46,20 +46,23 @@ function getModeDescriptionKey(
|
||||
}
|
||||
}
|
||||
|
||||
export function ModeHoverGuide({
|
||||
mode,
|
||||
children,
|
||||
showTitle = true,
|
||||
}: {
|
||||
mode: AgentMode;
|
||||
children: React.ReactNode;
|
||||
/** When true, tooltip shows "ModeName: Description". When false, only description. */
|
||||
showTitle?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const label = t.inputBox[getModeLabelKey(mode)];
|
||||
const description = t.inputBox[getModeDescriptionKey(mode)];
|
||||
const content = showTitle ? `${label}: ${description}` : description;
|
||||
export function ModeHoverGuide({
|
||||
mode,
|
||||
children,
|
||||
showTitle = true,
|
||||
tooltipContent,
|
||||
}: {
|
||||
mode: AgentMode;
|
||||
children: React.ReactNode;
|
||||
/** When true, tooltip shows "ModeName: Description". When false, only description. */
|
||||
showTitle?: boolean;
|
||||
/** Overrides the standard mode tooltip for page-specific execution modes. */
|
||||
tooltipContent?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const label = t.inputBox[getModeLabelKey(mode)];
|
||||
const description = t.inputBox[getModeDescriptionKey(mode)];
|
||||
const content = tooltipContent ?? (showTitle ? `${label}: ${description}` : description);
|
||||
|
||||
return <Tooltip content={content}>{children}</Tooltip>;
|
||||
}
|
||||
@@ -71,8 +71,9 @@ import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
||||
import {
|
||||
useDeleteCustomSkill,
|
||||
import {
|
||||
useDeleteBuiltinSkill,
|
||||
useDeleteCustomSkill,
|
||||
useDuplicateSkill,
|
||||
useGenerateMissingSkillNameZh,
|
||||
useGenerateSkillNameZh,
|
||||
@@ -616,9 +617,10 @@ export function SkillRow({
|
||||
const { data: cardPublishSquares } = usePublishSquares();
|
||||
const setPinned = useSetSkillPinned();
|
||||
const setFeatured = useSetSkillFeatured();
|
||||
const setAlwaysOn = useSetSkillAlwaysOn();
|
||||
const { data: compression } = useSkillCompressionSettings();
|
||||
const deleteSkill = useDeleteCustomSkill();
|
||||
const setAlwaysOn = useSetSkillAlwaysOn();
|
||||
const { data: compression } = useSkillCompressionSettings();
|
||||
const deleteCustom = useDeleteCustomSkill();
|
||||
const deleteBuiltin = useDeleteBuiltinSkill();
|
||||
const duplicateSkillMutation = useDuplicateSkill();
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [tagPickerOpen, setTagPickerOpen] = useState(false);
|
||||
@@ -634,8 +636,9 @@ export function SkillRow({
|
||||
const isOwned =
|
||||
isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
|
||||
const isFromOthers = isCustom && !isBuiltin && !isOwned;
|
||||
// Admin can edit/delete/unpublish anyone's skill; non-admin only their own.
|
||||
const canManage = isCustom && (isOwned || isAdmin);
|
||||
// Admin can manage custom skills from any owner; non-admin only their own.
|
||||
const canManage = isCustom && (isOwned || isAdmin);
|
||||
const canDelete = isAdmin || isOwned;
|
||||
const canTogglePublish = canManage && !isBuiltin;
|
||||
// 常驻(免压缩):内置技能仅管理员可设;自定义技能其所有者或管理员可设(与后端一致)。
|
||||
const canSetAlwaysOn = isAdmin || isOwned;
|
||||
@@ -686,11 +689,15 @@ export function SkillRow({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await deleteSkill.mutateAsync(skill.name);
|
||||
toast.success(t.settings.skills.deleteSuccess);
|
||||
setConfirmDeleteOpen(false);
|
||||
async function handleDelete() {
|
||||
try {
|
||||
if (isBuiltin) {
|
||||
await deleteBuiltin.mutateAsync(skill.name);
|
||||
} else {
|
||||
await deleteCustom.mutateAsync(skill.name);
|
||||
}
|
||||
toast.success(t.settings.skills.deleteSuccess);
|
||||
setConfirmDeleteOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
@@ -918,7 +925,7 @@ export function SkillRow({
|
||||
>
|
||||
<TagsIcon className="size-3.5" />
|
||||
</Button>
|
||||
{canManage && (
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -939,12 +946,12 @@ export function SkillRow({
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
skill.pinned && "text-emerald-600 hover:text-emerald-700",
|
||||
)}
|
||||
disabled={setPinned.isPending}
|
||||
@@ -1011,11 +1018,11 @@ export function SkillRow({
|
||||
>
|
||||
<CopyIcon className="size-3.5" />
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||
{canDelete && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||
onClick={() => setConfirmDeleteOpen(true)}
|
||||
title={t.settings.skills.deleteAction}
|
||||
>
|
||||
@@ -1109,16 +1116,18 @@ export function SkillRow({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setConfirmDeleteOpen(false)}
|
||||
disabled={deleteSkill.isPending}
|
||||
disabled={deleteCustom.isPending || deleteBuiltin.isPending}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={deleteSkill.isPending}
|
||||
>
|
||||
{deleteSkill.isPending ? t.common.loading : t.common.delete}
|
||||
disabled={deleteCustom.isPending || deleteBuiltin.isPending}
|
||||
>
|
||||
{deleteCustom.isPending || deleteBuiltin.isPending
|
||||
? t.common.loading
|
||||
: t.common.delete}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -92,7 +92,7 @@ export function SourceCard({
|
||||
knowledge_base_id: previewTarget.knowledgeBaseId,
|
||||
chunk_id: previewTarget.chunkId,
|
||||
});
|
||||
void apiFetch(`${getBackendBaseURL()}/api/weknora/source-context?${params}`)
|
||||
void apiFetch(`${getBackendBaseURL()}/api/llmwiki/sources/context?${params}`)
|
||||
.then(async (response) => {
|
||||
const payload = (await response.json().catch(() => null)) as unknown;
|
||||
if (!response.ok || !isWeKnoraSourceContextResponse(payload)) {
|
||||
@@ -240,7 +240,18 @@ type WeKnoraSourceContext = {
|
||||
knowledge_base_name?: string;
|
||||
document_title?: string;
|
||||
document_file_name?: string;
|
||||
display_mode?: "wiki" | "chunks";
|
||||
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<{
|
||||
id: string;
|
||||
chunk_index?: number;
|
||||
@@ -312,6 +323,33 @@ function WeKnoraSourceContextPanel({
|
||||
if (!context) {
|
||||
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 (
|
||||
<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">
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DownloadIcon,
|
||||
Loader2,
|
||||
PencilIcon,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -22,6 +23,7 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { getSkillFileDownloadURL } from "@/core/skills/api";
|
||||
import {
|
||||
useDeleteSkillFile,
|
||||
useSaveSkillFile,
|
||||
useSkillFileContent,
|
||||
} from "@/core/skills/hooks";
|
||||
@@ -66,6 +68,7 @@ export function SkillFilePreviewDialog({
|
||||
open ? path : null,
|
||||
);
|
||||
const save = useSaveSkillFile();
|
||||
const remove = useDeleteSkillFile();
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
@@ -77,6 +80,8 @@ export function SkillFilePreviewDialog({
|
||||
}, [path, open]);
|
||||
|
||||
const fileEditable = !!data?.editable && treeEditable;
|
||||
const isRootSkillFile = path?.replace(/\\/g, "/").toLowerCase() === "skill.md";
|
||||
const fileDeletable = !!data && treeEditable && !isRootSkillFile;
|
||||
const isMarkdown = useMemo(() => {
|
||||
if (!path) return false;
|
||||
const lower = path.toLowerCase();
|
||||
@@ -115,6 +120,18 @@ export function SkillFilePreviewDialog({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!skillName || !path) return;
|
||||
if (!window.confirm(tx.filesDeleteConfirm.replace("{path}", path))) return;
|
||||
try {
|
||||
await remove.mutateAsync({ skillName, path });
|
||||
toast.success(tx.filesDeleteSuccess);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyPath() {
|
||||
if (!path) return;
|
||||
await navigator.clipboard.writeText(path);
|
||||
@@ -158,6 +175,18 @@ export function SkillFilePreviewDialog({
|
||||
{tx.filesEditAction}
|
||||
</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 && (
|
||||
<>
|
||||
<Button
|
||||
|
||||
@@ -184,7 +184,7 @@ export function WorkspaceNavChatList({
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
{/* <SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/roundtable/position")}
|
||||
asChild
|
||||
@@ -195,7 +195,7 @@ export function WorkspaceNavChatList({
|
||||
<span>岗位会商</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenuItem> */}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
||||
|
||||
@@ -61,7 +61,9 @@ export interface Agent {
|
||||
/** 会商席位「技能识别强化」开关。作为会商席位运行时,是否把本智能体配置的技能
|
||||
* (名称 + SKILL.md 路径 + 必须先 read_file 的硬指令) 拼进本轮任务以补偿弱模型。
|
||||
* 与业务链条同名开关取 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[];
|
||||
featured_order?: number | null;
|
||||
/** Whether the current user has favorited (收藏) this agent. */
|
||||
@@ -96,7 +98,8 @@ export interface CreateAgentRequest {
|
||||
common_questions?: AgentQuestion[] | null;
|
||||
template_name?: string | null;
|
||||
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
|
||||
seat_skill_directive?: boolean | null;
|
||||
seat_skill_directive?: boolean | null;
|
||||
llmwiki_knowledge_base_ids?: string[] | null;
|
||||
}
|
||||
|
||||
export interface UpdateAgentRequest {
|
||||
@@ -113,5 +116,6 @@ export interface UpdateAgentRequest {
|
||||
/** 入库模板名;传空字符串表示清除。 */
|
||||
template_name?: string | null;
|
||||
/** 会商席位「技能识别强化」开关;缺省/false = 关。 */
|
||||
seat_skill_directive?: boolean | null;
|
||||
}
|
||||
seat_skill_directive?: boolean | null;
|
||||
llmwiki_knowledge_base_ids?: string[] | null;
|
||||
}
|
||||
@@ -13,19 +13,33 @@ export function buildIntranetSourceUrl(recUuid?: string | null): string {
|
||||
return `${INTRANET_SOURCE_URL_BASE}${encodeURIComponent(recUuid)}`;
|
||||
}
|
||||
|
||||
function getBaseOrigin() {
|
||||
function getBaseOrigin() {
|
||||
if (typeof window !== "undefined") {
|
||||
return window.location.origin;
|
||||
}
|
||||
// Fallback for SSR
|
||||
return "http://localhost:2026";
|
||||
}
|
||||
|
||||
export function getBackendBaseURL() {
|
||||
if (env.NEXT_PUBLIC_BACKEND_BASE_URL) {
|
||||
return new URL(env.NEXT_PUBLIC_BACKEND_BASE_URL, getBaseOrigin())
|
||||
.toString()
|
||||
.replace(/\/+$/, "");
|
||||
return "http://localhost:2026";
|
||||
}
|
||||
|
||||
function alignLoopbackHostname(url: URL): URL {
|
||||
if (typeof window === "undefined") return url;
|
||||
const loopbackHosts = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
||||
if (
|
||||
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 {
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -545,10 +545,13 @@ export const enUS: Translations = {
|
||||
filesSaveAction: "Save",
|
||||
filesCancelEditAction: "Cancel",
|
||||
filesDownloadAction: "Download",
|
||||
filesCopyPathAction: "Copy path",
|
||||
filesDiscardConfirm: "Discard unsaved changes?",
|
||||
filesSaveSuccess: "File saved",
|
||||
filesReadOnlyHint: "Built-in skills are read-only.",
|
||||
filesCopyPathAction: "Copy path",
|
||||
filesDiscardConfirm: "Discard unsaved changes?",
|
||||
filesSaveSuccess: "File saved",
|
||||
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",
|
||||
stateSheet: {
|
||||
archivedTitle: "Archived skills",
|
||||
|
||||
@@ -475,10 +475,13 @@ export interface Translations {
|
||||
filesSaveAction: string;
|
||||
filesCancelEditAction: string;
|
||||
filesDownloadAction: string;
|
||||
filesCopyPathAction: string;
|
||||
filesDiscardConfirm: string;
|
||||
filesSaveSuccess: string;
|
||||
filesReadOnlyHint: string;
|
||||
filesCopyPathAction: string;
|
||||
filesDiscardConfirm: string;
|
||||
filesSaveSuccess: string;
|
||||
filesDeleteAction: string;
|
||||
filesDeleteConfirm: string;
|
||||
filesDeleteSuccess: string;
|
||||
filesReadOnlyHint: string;
|
||||
viewDetailAction: string;
|
||||
stateSheet: {
|
||||
archivedTitle: string;
|
||||
|
||||
@@ -524,8 +524,11 @@ export const zhCN: Translations = {
|
||||
filesDownloadAction: "下载",
|
||||
filesCopyPathAction: "复制路径",
|
||||
filesDiscardConfirm: "有未保存的修改,确定放弃吗?",
|
||||
filesSaveSuccess: "文件已保存",
|
||||
filesReadOnlyHint: "内置技能为只读,仅可查看。",
|
||||
filesSaveSuccess: "文件已保存",
|
||||
filesDeleteAction: "删除",
|
||||
filesDeleteConfirm: "确定删除“{path}”?该操作不可撤销。",
|
||||
filesDeleteSuccess: "文件已删除",
|
||||
filesReadOnlyHint: "仅管理员可以编辑或删除内置技能文件。",
|
||||
viewDetailAction: "查看技能详情",
|
||||
stateSheet: {
|
||||
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"
|
||||
);
|
||||
}
|
||||
@@ -4,9 +4,11 @@ export interface Model {
|
||||
model: string;
|
||||
display_name: string;
|
||||
description?: string | null;
|
||||
supports_thinking?: boolean;
|
||||
supports_reasoning_effort?: boolean;
|
||||
}
|
||||
supports_thinking?: boolean;
|
||||
supports_reasoning_effort?: boolean;
|
||||
/** Execution profiles handled by the frontend instead of DeerFlow. */
|
||||
execution_profile?: "single_round_rag";
|
||||
}
|
||||
|
||||
export interface TokenUsageSettings {
|
||||
enabled: boolean;
|
||||
|
||||
@@ -402,15 +402,26 @@ export async function getSkillContent(
|
||||
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(
|
||||
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
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 {
|
||||
name: string;
|
||||
@@ -479,7 +490,7 @@ export function getSkillFileDownloadURL(skillName: string, path: string): string
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveSkillFile(
|
||||
export async function saveSkillFile(
|
||||
skillName: string,
|
||||
path: string,
|
||||
content: string,
|
||||
@@ -494,9 +505,22 @@ export async function saveSkillFile(
|
||||
);
|
||||
if (!response.ok) {
|
||||
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 {
|
||||
name: string;
|
||||
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
deleteCustomSkill,
|
||||
import {
|
||||
deleteBuiltinSkill,
|
||||
deleteCustomSkill,
|
||||
deleteSkillFile,
|
||||
duplicateSkill,
|
||||
enableSkill,
|
||||
generateMissingSkillNameZh,
|
||||
@@ -318,7 +320,7 @@ export function useSkillFileContent(skillName: string | null, path: string | nul
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveSkillFile() {
|
||||
export function useSaveSkillFile() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
@@ -339,5 +341,33 @@ export function useSaveSkillFile() {
|
||||
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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -25,10 +25,13 @@ import { promptInputFilePartToFile, uploadFiles } from "../uploads";
|
||||
|
||||
import type { AgentThread, AgentThreadState } from "./types";
|
||||
|
||||
export type ToolEndEvent = {
|
||||
name: string;
|
||||
data: unknown;
|
||||
};
|
||||
export type ToolEndEvent = {
|
||||
name: string;
|
||||
data: unknown;
|
||||
toolCallId?: string;
|
||||
};
|
||||
|
||||
export type ToolStartEvent = ToolEndEvent;
|
||||
|
||||
export type ThreadStreamOptions = {
|
||||
threadId?: string | null | undefined;
|
||||
@@ -47,10 +50,11 @@ export type ThreadStreamOptions = {
|
||||
/** 额外写到新建 thread 的 metadata(与 agent_id 合并)。
|
||||
* 任务深链用 `{ taskId }` 把对话挂到任务下(1:N),列表按 metadata.taskId 过滤。 */
|
||||
threadMetadata?: Record<string, unknown>;
|
||||
onStart?: (threadId: string) => void;
|
||||
onFinish?: (state: AgentThreadState) => void;
|
||||
onToolEnd?: (event: ToolEndEvent) => void;
|
||||
};
|
||||
onStart?: (threadId: string) => void;
|
||||
onFinish?: (state: AgentThreadState) => void;
|
||||
onToolStart?: (event: ToolStartEvent) => void;
|
||||
onToolEnd?: (event: ToolEndEvent) => void;
|
||||
};
|
||||
|
||||
type SendMessageOptions = {
|
||||
additionalKwargs?: Record<string, unknown>;
|
||||
@@ -447,10 +451,11 @@ export function useThreadStream({
|
||||
assistantId,
|
||||
runOptions,
|
||||
threadMetadata,
|
||||
onStart,
|
||||
onFinish,
|
||||
onToolEnd,
|
||||
}: ThreadStreamOptions) {
|
||||
onStart,
|
||||
onFinish,
|
||||
onToolStart,
|
||||
onToolEnd,
|
||||
}: ThreadStreamOptions) {
|
||||
const { t } = useI18n();
|
||||
const [localSettings] = useLocalSettings();
|
||||
// Track the thread ID that is currently streaming to handle thread changes during streaming
|
||||
@@ -466,16 +471,17 @@ export function useThreadStream({
|
||||
lastStreamActivityAtRef.current = Date.now();
|
||||
}, []);
|
||||
|
||||
const listeners = useRef({
|
||||
onStart,
|
||||
onFinish,
|
||||
onToolEnd,
|
||||
});
|
||||
const listeners = useRef({
|
||||
onStart,
|
||||
onFinish,
|
||||
onToolStart,
|
||||
onToolEnd,
|
||||
});
|
||||
|
||||
// Keep listeners ref updated with latest callbacks
|
||||
useEffect(() => {
|
||||
listeners.current = { onStart, onFinish, onToolEnd };
|
||||
}, [onStart, onFinish, onToolEnd]);
|
||||
useEffect(() => {
|
||||
listeners.current = { onStart, onFinish, onToolStart, onToolEnd };
|
||||
}, [onStart, onFinish, onToolStart, onToolEnd]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedThreadId = threadId ?? null;
|
||||
@@ -545,12 +551,6 @@ export function useThreadStream({
|
||||
},
|
||||
onLangChainEvent(event) {
|
||||
markStreamActivity();
|
||||
if (event.event === "on_tool_end") {
|
||||
listeners.current.onToolEnd?.({
|
||||
name: event.name,
|
||||
data: event.data,
|
||||
});
|
||||
}
|
||||
// 操作日志(Y-Log):每一次大模型调用结束都各上报一条(一次 LLM 调用 = 一条),
|
||||
// 让问答日志尽量「多、全」——一个问答轮次里 lead agent / 子代理的每次模型调用
|
||||
// 都会落一条。门控与失败静默都在 writeYLog 内部处理,绝不影响问答。
|
||||
@@ -572,8 +572,32 @@ export function useThreadStream({
|
||||
} catch {
|
||||
/* 上报绝不影响问答 */
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// The SDK emits UI tool lifecycle events on the dedicated `tools` stream
|
||||
// mode. `events` is a separate LangChain trace channel; using it for UI
|
||||
// reactions can miss a tool that the conversation has already rendered.
|
||||
onToolEvent:
|
||||
onToolStart || onToolEnd
|
||||
? (event) => {
|
||||
markStreamActivity();
|
||||
if (event.event === "on_tool_start") {
|
||||
listeners.current.onToolStart?.({
|
||||
name: event.name,
|
||||
data: event.input,
|
||||
toolCallId: event.toolCallId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.event === "on_tool_end") {
|
||||
listeners.current.onToolEnd?.({
|
||||
name: event.name,
|
||||
data: event.output,
|
||||
toolCallId: event.toolCallId,
|
||||
});
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
onUpdateEvent(data) {
|
||||
markStreamActivity();
|
||||
const updates: Array<Partial<AgentThreadState> | null> = Object.values(
|
||||
@@ -1122,20 +1146,26 @@ export function useThreadStream({
|
||||
}),
|
||||
);
|
||||
|
||||
const ragModeEnabled =
|
||||
localSettings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
if (ragModeEnabled) {
|
||||
lockThreadRagMode(threadId);
|
||||
}
|
||||
const mergedContext = {
|
||||
...context,
|
||||
...extraContext,
|
||||
};
|
||||
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 =
|
||||
localSettings.display.contextCompressionEnabled ?? false;
|
||||
|
||||
const mergedContext = {
|
||||
...context,
|
||||
...extraContext,
|
||||
};
|
||||
|
||||
const hiddenContextMessages = normalizeHiddenContextMessages(options);
|
||||
const submittedMessages: Message[] = [
|
||||
...hiddenContextMessages.map((item) => ({
|
||||
|
||||
@@ -23,9 +23,13 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
||||
rag_mode_enabled?: boolean;
|
||||
/** Per-request switch for context compression (上下文压缩). Default off. */
|
||||
summarization_enabled?: boolean;
|
||||
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */
|
||||
knowledge_rag_enabled?: boolean;
|
||||
agent_id?: string;
|
||||
/** Per-conversation switch for knowledge-base RAG injection (phase 2). */
|
||||
knowledge_rag_enabled?: boolean;
|
||||
/** 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 */
|
||||
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 };
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
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 { Tooltip } from "@/components/workspace/tooltip";
|
||||
import { normalizeAgentQuestions, useAgent } from "@/core/agents";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
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_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 {
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
@@ -244,6 +267,79 @@ export default function AgentChatPage({
|
||||
useThreadChat();
|
||||
const [settings, setSettings] = useThreadSettings(threadId);
|
||||
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(() => {
|
||||
if (isNewThread) {
|
||||
@@ -253,14 +349,34 @@ export default function AgentChatPage({
|
||||
}, [isNewThread, threadId, agent_id]);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
const conversationMetadata = useMemo(
|
||||
() => ({
|
||||
...(taskId ? taskThreadMetadata(taskContext) : {}),
|
||||
...(knowledgeSelectionReady
|
||||
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
|
||||
: {}),
|
||||
}),
|
||||
[knowledgeSelectionReady, selectedKnowledgeBaseIds, taskContext, taskId],
|
||||
);
|
||||
const [thread, sendMessage] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
assistantId: agent_id,
|
||||
context: { ...settings.context, agent_id },
|
||||
context: {
|
||||
...settings.context,
|
||||
agent_id,
|
||||
...(knowledgeSelectionReady
|
||||
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
|
||||
: {}),
|
||||
},
|
||||
// 任务闸门:新建 thread 打 metadata.taskId 标签,供任务对话列表(1:N)按任务过滤;
|
||||
// xdfx 额外打 metadata.actionId 标签,按「行动id」划分聊天记录(见 taskThreadMetadata)。
|
||||
threadMetadata: taskId ? taskThreadMetadata(taskContext) : undefined,
|
||||
threadMetadata:
|
||||
Object.keys(conversationMetadata).length > 0
|
||||
? conversationMetadata
|
||||
: undefined,
|
||||
onStart: (createdThreadId) => {
|
||||
knowledgeSelectionThread.current = createdThreadId;
|
||||
activeThreadIdRef.current = createdThreadId;
|
||||
setThreadId(createdThreadId);
|
||||
setIsNewThread(false);
|
||||
// Keep the current component mounted while updating the hash route.
|
||||
@@ -286,6 +402,12 @@ export default function AgentChatPage({
|
||||
} catch {
|
||||
/* 上报绝不影响问答 */
|
||||
}
|
||||
queueLlmWikiConversationDeposit({
|
||||
threadId: activeThreadIdRef.current,
|
||||
messages: state.messages,
|
||||
assistantId: agent_id,
|
||||
knowledgeBaseIds: selectedKnowledgeBaseIds,
|
||||
});
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
let body = "Conversation finished";
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
@@ -581,10 +703,23 @@ export default function AgentChatPage({
|
||||
autoFocus
|
||||
status={status}
|
||||
context={settings.context}
|
||||
disabled={isDemoMode}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
||||
onContextChange={(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}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
@@ -636,7 +771,7 @@ export default function AgentChatPage({
|
||||
assistantId={agent_id}
|
||||
status={status}
|
||||
context={settings.context}
|
||||
disabled={isDemoMode}
|
||||
disabled={isDemoMode || knowledgeSelectionLoading}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-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 {
|
||||
MessageList,
|
||||
@@ -44,6 +45,10 @@ import { PanelLeftOpen } from "lucide-react";
|
||||
import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
|
||||
import { useI18n } from "@/core/i18n/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 {
|
||||
getLastAgentId,
|
||||
@@ -52,6 +57,8 @@ import {
|
||||
useThreadSettings,
|
||||
} from "@/core/settings";
|
||||
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 { useRouteBase } from "@/core/route-base";
|
||||
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
|
||||
// per-thread header toggle so stale localStorage can't re-enable it.
|
||||
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(
|
||||
() => ({
|
||||
...settings.context,
|
||||
knowledge_rag_enabled: knowledgeRagEnabled,
|
||||
...(knowledgeSelectionReady
|
||||
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
|
||||
: {}),
|
||||
}),
|
||||
[settings.context, knowledgeRagEnabled],
|
||||
[
|
||||
settings.context,
|
||||
knowledgeRagEnabled,
|
||||
knowledgeSelectionReady,
|
||||
selectedKnowledgeBaseIds,
|
||||
],
|
||||
);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { tokenUsageEnabled } = useModels();
|
||||
@@ -150,6 +174,10 @@ export default function ChatPage() {
|
||||
const { selectedArtifact } = useArtifacts();
|
||||
useSpecificChatMode();
|
||||
|
||||
useEffect(() => {
|
||||
activeThreadIdRef.current = threadId;
|
||||
}, [threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
@@ -158,12 +186,54 @@ export default function ChatPage() {
|
||||
if (isNewThread) {
|
||||
setHasStartedConversation(false);
|
||||
setPromptPrefixOn(false);
|
||||
setSelectedKnowledgeBaseIds([]);
|
||||
setKnowledgeSelectionReady(false);
|
||||
setKnowledgeSelectionLoading(false);
|
||||
knowledgeSelectionThread.current = "__new__";
|
||||
// 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.
|
||||
setSelectedAgentId(getLastAgentId());
|
||||
}
|
||||
}, [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
|
||||
// (deleted / unpublished / no longer curated), so we never hand off to a
|
||||
// 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.some((a) => a.id === selectedAgentId)) {
|
||||
setSelectedAgentId(null);
|
||||
setSelectedKnowledgeBaseIds([]);
|
||||
setKnowledgeSelectionReady(false);
|
||||
}
|
||||
}, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]);
|
||||
|
||||
@@ -183,8 +255,13 @@ export default function ChatPage() {
|
||||
const [thread, sendMessage, isUploading] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
context: streamContext,
|
||||
threadMetadata: knowledgeSelectionReady
|
||||
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
|
||||
: undefined,
|
||||
isMock,
|
||||
onStart: (createdThreadId) => {
|
||||
knowledgeSelectionThread.current = createdThreadId;
|
||||
activeThreadIdRef.current = createdThreadId;
|
||||
setThreadId(createdThreadId);
|
||||
setIsNewThread(false);
|
||||
// Keep the current component mounted while updating the hash route.
|
||||
@@ -210,6 +287,12 @@ export default function ChatPage() {
|
||||
} catch {
|
||||
/* 上报绝不影响问答 */
|
||||
}
|
||||
queueLlmWikiConversationDeposit({
|
||||
threadId: activeThreadIdRef.current,
|
||||
messages: state.messages,
|
||||
assistantId: selectedAgentId,
|
||||
knowledgeBaseIds: selectedKnowledgeBaseIds,
|
||||
});
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
let body = "Conversation finished";
|
||||
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 isMarkdown = (path: string | null | undefined) =>
|
||||
@@ -240,6 +337,46 @@ export default function ChatPage() {
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(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
|
||||
// to AgentChatPage so the conversation runs against that agent. We stash
|
||||
// the pending text in sessionStorage because the next/navigation shim
|
||||
@@ -262,7 +399,12 @@ export default function ChatPage() {
|
||||
void (async () => {
|
||||
try {
|
||||
const created = await getAPIClient(isMock).threads.create({
|
||||
metadata: { agent_id: selectedAgentId },
|
||||
metadata: {
|
||||
agent_id: selectedAgentId,
|
||||
...(knowledgeSelectionReady
|
||||
? { llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
sessionStorage.setItem(
|
||||
"pendingAgentMessage",
|
||||
@@ -270,6 +412,9 @@ export default function ChatPage() {
|
||||
agentId: selectedAgentId,
|
||||
threadId: created.thread_id,
|
||||
text,
|
||||
...(knowledgeSelectionReady
|
||||
? { knowledgeBaseIds: selectedKnowledgeBaseIds }
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
saveLastAgentId(selectedAgentId);
|
||||
@@ -328,11 +473,19 @@ export default function ChatPage() {
|
||||
hideAdminPrompt,
|
||||
preferencesLoading,
|
||||
selectedAgentId,
|
||||
selectedKnowledgeBaseIds,
|
||||
knowledgeSelectionReady,
|
||||
agentHandoffPending,
|
||||
isMock,
|
||||
router,
|
||||
settings.context.writing_mode,
|
||||
settings.context.model_name,
|
||||
writingArtifactPath,
|
||||
singleRoundRag,
|
||||
singleRoundRagSelected,
|
||||
routeBase,
|
||||
setIsNewThread,
|
||||
setThreadId,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -418,11 +571,17 @@ export default function ChatPage() {
|
||||
// Passing null (the ✕ clear) wipes the stored preference.
|
||||
const handleAgentChange = useCallback((agentId: string | null) => {
|
||||
setSelectedAgentId(agentId);
|
||||
setSelectedKnowledgeBaseIds([]);
|
||||
setKnowledgeSelectionReady(false);
|
||||
saveLastAgentId(agentId);
|
||||
}, []);
|
||||
const handleStop = useCallback(async () => {
|
||||
if (singleRoundRag.isLoading) {
|
||||
singleRoundRag.stop();
|
||||
return;
|
||||
}
|
||||
await thread.stop();
|
||||
}, [thread]);
|
||||
}, [singleRoundRag, thread]);
|
||||
|
||||
const promptPrefixProp = useMemo(() => {
|
||||
if (!promptPrefixSettings?.enabled) return undefined;
|
||||
@@ -468,7 +627,7 @@ export default function ChatPage() {
|
||||
const compactActionsHidden = isHideChromeActive();
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread, isMock }}>
|
||||
<ThreadContext.Provider value={{ thread: displayThread, isMock }}>
|
||||
<ChatBox threadId={threadId}>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -486,7 +645,7 @@ export default function ChatPage() {
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center text-sm font-medium">
|
||||
<ThreadTitle threadId={threadId} thread={thread} />
|
||||
<ThreadTitle threadId={threadId} thread={displayThread} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 仅在收起态显示「展开历史」钮;展开后由侧栏内(新对话右侧)的收起钮负责收起。 */}
|
||||
@@ -505,7 +664,7 @@ export default function ChatPage() {
|
||||
)}
|
||||
<TokenUsageIndicator
|
||||
enabled={tokenUsageEnabled}
|
||||
messages={thread.messages}
|
||||
messages={displayThread.messages}
|
||||
/>
|
||||
<ExportTrigger threadId={threadId} />
|
||||
<KnowledgeSaveTrigger threadId={threadId} />
|
||||
@@ -543,7 +702,7 @@ export default function ChatPage() {
|
||||
<MessageList
|
||||
className={cn("size-full", "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
thread={displayThread}
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
// 输入框为底部绝对浮层(约 160px 高),按钮上移到其上方居中。
|
||||
@@ -585,9 +744,9 @@ export default function ChatPage() {
|
||||
<div className="absolute right-0 bottom-0 left-0">
|
||||
<TodoList
|
||||
className="bg-background/5"
|
||||
todos={thread.values.todos ?? []}
|
||||
todos={displayThread.values.todos ?? []}
|
||||
hidden={
|
||||
!thread.values.todos || thread.values.todos.length === 0
|
||||
!displayThread.values.todos || displayThread.values.todos.length === 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -612,15 +771,18 @@ export default function ChatPage() {
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
: displayThread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
extraModels={SINGLE_ROUND_RAG_MODELS}
|
||||
disabled={
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
|
||||
isUploading ||
|
||||
agentHandoffPending
|
||||
agentHandoffPending ||
|
||||
knowledgeSelectionLoading ||
|
||||
singleRoundRag.isLoading
|
||||
}
|
||||
onContextChange={(context) =>
|
||||
setSettings("context", context)
|
||||
@@ -634,6 +796,22 @@ export default function ChatPage() {
|
||||
}
|
||||
: 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}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
@@ -664,7 +842,7 @@ export default function ChatPage() {
|
||||
*/}
|
||||
{!isLandingState && (
|
||||
<QuestionNavigator
|
||||
messages={thread.messages}
|
||||
messages={displayThread.messages}
|
||||
className="top-16 right-0 bottom-48 left-0"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { toast } from "sonner";
|
||||
|
||||
@@ -530,7 +530,11 @@ function VaultPageDialog({
|
||||
);
|
||||
}
|
||||
|
||||
export default function KnowledgeBasePage() {
|
||||
export default function KnowledgeBasePage({
|
||||
headerExtraActions,
|
||||
}: {
|
||||
headerExtraActions?: ReactNode;
|
||||
} = {}) {
|
||||
const navigate = useNavigate();
|
||||
const { noteId } = useParams<{ noteId: string }>();
|
||||
|
||||
@@ -653,6 +657,7 @@ export default function KnowledgeBasePage() {
|
||||
onCreate={() => setCreateOpen(true)}
|
||||
onManageFolders={() => setFoldersOpen(true)}
|
||||
onManageTemplates={() => setTemplatesOpen(true)}
|
||||
extraActions={headerExtraActions}
|
||||
/>
|
||||
|
||||
<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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import { LlmWikiKnowledgeSelector } from "@/components/workspace/llmwiki-knowledge-selector";
|
||||
import {
|
||||
MessageList,
|
||||
type SetupAgentApprovalPayload,
|
||||
@@ -95,6 +96,7 @@ export default function NewAgentPage() {
|
||||
const [isCreatingAgent, setIsCreatingAgent] = useState(false);
|
||||
const [isSavingDraft, setIsSavingDraft] = useState(false);
|
||||
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
|
||||
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<string[]>([]);
|
||||
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
|
||||
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
||||
// 恢复已有 bootstrap 会话时该 thread 已存在,无需再 create(置为 true,跳过创建)。
|
||||
@@ -230,6 +232,7 @@ export default function NewAgentPage() {
|
||||
model: modelOverride,
|
||||
template_name: templateNameInput.trim(),
|
||||
seat_skill_directive: seatSkillDirectiveInput,
|
||||
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
|
||||
});
|
||||
setAgent(updated);
|
||||
return updated;
|
||||
@@ -252,6 +255,7 @@ export default function NewAgentPage() {
|
||||
model: modelOverride,
|
||||
template_name: templateNameInput.trim(),
|
||||
seat_skill_directive: seatSkillDirectiveInput,
|
||||
llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds,
|
||||
});
|
||||
setAgentId(created.id);
|
||||
setAgent(created);
|
||||
@@ -280,6 +284,7 @@ export default function NewAgentPage() {
|
||||
descriptionInput,
|
||||
modelInput,
|
||||
selectedSkills,
|
||||
selectedKnowledgeBaseIds,
|
||||
soulInput,
|
||||
templateNameInput,
|
||||
seatSkillDirectiveInput,
|
||||
@@ -590,6 +595,13 @@ export default function NewAgentPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LlmWikiKnowledgeSelector
|
||||
source="all"
|
||||
value={selectedKnowledgeBaseIds}
|
||||
onChange={setSelectedKnowledgeBaseIds}
|
||||
variant="panel"
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">SOUL.md</label>
|
||||
<Textarea
|
||||
|
||||
@@ -35,14 +35,14 @@ import AdminScheduledTasksPage from './AdminScheduledTasksPage';
|
||||
import AdminSkillsPage from './AdminSkillsPage';
|
||||
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
|
||||
import ScheduledTasksPage from './ScheduledTasksPage';
|
||||
import KnowledgeBasePage from './KnowledgeBasePage';
|
||||
import KnowledgeGraphPage from './KnowledgeGraphPage';
|
||||
import LlmWikiKnowledgePage from './LlmWikiKnowledgePage';
|
||||
import LlmMetricsPage from './LlmMetricsPage';
|
||||
import CheckpointMigrationPage from './CheckpointMigrationPage';
|
||||
import MemoryPage from './MemoryPage';
|
||||
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
|
||||
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
|
||||
import ModelConcurrencySettingsPage from './ModelConcurrencySettingsPage';
|
||||
import ModelManagementPage from './ModelManagementPage';
|
||||
import PublishSquaresSettingsPage from './PublishSquaresSettingsPage';
|
||||
import SkillCompressionSettingsPage from './SkillCompressionSettingsPage';
|
||||
import SkillEvolutionPage from './SkillEvolutionPage';
|
||||
@@ -604,7 +604,7 @@ export default function WorkspaceRoutes({
|
||||
path="knowledge"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<KnowledgeBasePage />
|
||||
<LlmWikiKnowledgePage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
@@ -612,7 +612,7 @@ export default function WorkspaceRoutes({
|
||||
path="knowledge/graph"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<KnowledgeGraphPage />
|
||||
<LlmWikiKnowledgePage mode="graph" />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
@@ -620,7 +620,7 @@ export default function WorkspaceRoutes({
|
||||
path="knowledge/:noteId"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<KnowledgeBasePage />
|
||||
<LlmWikiKnowledgePage mode="detail" />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
@@ -786,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
|
||||
path="settings/model-concurrency"
|
||||
element={
|
||||
|
||||
@@ -24,11 +24,23 @@
|
||||
|
||||
| 参数 | 是否必填 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| `taskId` | 否 | 外部任务 ID。缺失时使用本地 mock 任务,便于演示和开发。 |
|
||||
| `taskId` | 否 | 外部任务 ID。传入后它是岗位会商的共享存储作用域;缺失时保持原有的个人会话逻辑。mock 仅提供任务卡片的显示兜底,绝不替换路由传入的 ID。 |
|
||||
| `sessionId` | 否 | 直接恢复指定岗位会商会话;缺失时页面会按 `taskId` 查找最近一条未归档会话。 |
|
||||
| `positionId` | 否 | 初始打开的岗位,同时同步顶部岗位选择器。无效值回退到 `intelligence`。 |
|
||||
| `new=1` | 否 | 强制不恢复历史会话,以新会话开始。 |
|
||||
|
||||
传入 `taskId` 时,同一个任务下可以有多条会商会话,所有用户共享同一份历史、节点快照与 LangGraph 线程;当前 `user_id` 仅保留创建审计,不参与这些任务会话的读写过滤。页面恢复 `sessionId` 时会核验其 `externalTaskId` 与路由 `taskId` 一致,不一致会丢弃该会话并按当前任务重新发现历史。未传 `taskId` 时,会继续只读取当前用户的原有个人会话。
|
||||
|
||||
### 外部系统按 taskId 对接
|
||||
|
||||
1. 进入页面时将业务侧任务的稳定主键原样放入 URL。工作区入口使用 `/#/page/workspace/roundtable/position?taskId=<taskId>`;策略页入口也兼容 `/#/page/strategy/qa/position-roundtable?taskId=<taskId>`。不要使用任务标题、序号或每次请求都变化的临时 ID。
|
||||
2. 前端先调用 `GET /api/position-roundtable/sessions?external_task_id=<taskId>`;返回的是该任务下所有用户共享的会商记录,按最近更新时间排序。选中一条后再调用 `GET /sessions/{sessionId}` 恢复完整节点和聊天快照。
|
||||
3. 没有可恢复记录时,调用 `POST /sessions`,至少传入 `{ external_task_id: taskId, task_snapshot: { ...任务展示字段 } }`。服务端将 `task_scoped` 设为 `true`,并固定快照中的 `id` / `externalTaskId` 为传入的 taskId。
|
||||
4. 后续所有意图、链条、节点、收口智能体和历史删除 API 只使用返回的 `sessionId`;不要在每次消息请求中重新创建会话。一个 taskId 可以有多条 sessionId。
|
||||
5. 不传 `taskId`(同时省略 `external_task_id`)就是兼容模式:后端只返回和操作当前用户自己的个人会话,绝不会把历史记录升级为共享。响应中的 `task_scoped` 可用来区分两种记录。
|
||||
|
||||
服务端从请求中的非空 `external_task_id` 判断是否开启共享,调用方无需也不应自行传 `task_scoped`。
|
||||
|
||||
当前岗位 ID 定义在 `lib/positions.ts`:
|
||||
|
||||
| ID | 页面岗位 | 职责 |
|
||||
@@ -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 普通节点问答
|
||||
|
||||
`PositionNodeChat` 在发送前调用 `getPositionNodeContext` 获取受长度控制的任务、意图和已交付上游内容,再以 `task_external_context` 作为隐藏上下文发送给当前智能体。
|
||||
@@ -132,10 +169,16 @@ PositionRoundtablePage
|
||||
|
||||
页面只维护一个 `sandboxArtifact`。`PositionNodeChat` 和 `PositionBuiltinAgentChat` 通过 `onPreviewArtifact` 上报预览目标;父页面统一渲染 `ArtifactFileDetail`。
|
||||
|
||||
**对接 / 重做页面 / 排障指南(打开关闭时机、步骤条、接口约定):**
|
||||
[`../../docs/position-roundtable-sandbox/沙箱打开关闭与步骤条.md`](../../docs/position-roundtable-sandbox/沙箱打开关闭与步骤条.md)
|
||||
(目录说明见 [`../../docs/position-roundtable-sandbox/README.md`](../../docs/position-roundtable-sandbox/README.md))
|
||||
|
||||
设计要点:
|
||||
|
||||
- 文件正在由 `write_file` 流式生成时,优先显示源码;完成后交由预览组件切换 Markdown 预览;
|
||||
- `sandboxThreadSnapshot` 有 160ms 合并节流,只保留当前打开文件所在线程的最新快照,避免流式时频繁 setState;
|
||||
- `write_file` 的参数是一整个 JSON 流,键顺序由模型决定。若 `content` 先于 `path` 到达,会先用临时文件名(`未命名文件.md`/`.html`,按正文首部嗅探)开沙箱——流式内容只按 `message_id` + `tool_call_id` 定位,与路径无关;真实 `path` 到达后沿用同一个 `previewKey` 改名,属于「更新」而非第二次交付;
|
||||
- 流式快照**不能进页面 state**。本页很大(任务面板、意图结果、流程图、节点对话),按流式频率重渲染整页会把主线程占满,连消息列表都会停止绘制直到本轮结束。快照走 `createSandboxThreadStore()` + `SandboxThreadProvider`(`useSyncExternalStore`),只有沙箱子树重渲染;
|
||||
- 快照的合并节流是**前沿**节流:距上次推送超过 160ms 就在流回调里同步推送,未满则挂一个补尾定时器。不能只靠尾随定时器——主线程繁忙时回调会被饿死,面板会一直冻在打开时的内容;
|
||||
- 用户手动关闭后,`closedSandboxKeyRef` 与 `suppressedAutoPreviewThreadIdsRef` 阻止同一线程立即把沙箱重新打开;
|
||||
- 切岗位、切节点、切收口阶段、新建会话和切换历史会话都会先调用 `closeSandbox()`。
|
||||
|
||||
@@ -158,8 +201,8 @@ PositionRoundtablePage
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/sessions?external_task_id=` | 按任务查询会话,用于恢复最近会话和历史记录。 |
|
||||
| `POST` | `/sessions` | 创建初始会话并保存任务快照。 |
|
||||
| `GET` | `/sessions?external_task_id=<taskId>` | 传入时按任务作用域查询共享会话;不传参数时查询当前用户的个人历史。 |
|
||||
| `POST` | `/sessions` | 创建会话并保存任务快照;传入非空 `external_task_id` 时服务端会将快照 ID 固定为该值并启用共享;缺失或空值时创建原有个人会话。 |
|
||||
| `GET` | `/sessions/{id}` | 恢复会话、节点和冻结快照。 |
|
||||
| `DELETE` | `/sessions/{id}` | 删除一条岗位会商历史及其节点;删除当前记录时前端会先屏蔽并发恢复请求。 |
|
||||
| `PUT` | `/sessions/{id}/task` | 更新未冻结的任务快照。 |
|
||||
@@ -173,7 +216,7 @@ PositionRoundtablePage
|
||||
| `POST` | `/sessions/{id}/summary/*` | 准备、发送和完成方案总结。 |
|
||||
| `POST` | `/sessions/{id}/action-plan/*` | 准备、发送和完成行动规划。 |
|
||||
| `PUT` | `/sessions/{id}/{summary|action-plan}/conversation` | 保存两个收口智能体的消息与未发送草稿。 |
|
||||
| `POST` | `/demo/seed` | 按用户和 `demoKey` 幂等初始化完整演示会话。 |
|
||||
| `POST` | `/demo/seed` | 按 `demoKey` 幂等初始化完整演示会话。 |
|
||||
|
||||
后端实现在 `offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py`。
|
||||
|
||||
|
||||
@@ -68,7 +68,10 @@ export interface PositionRoundtableNode {
|
||||
*/
|
||||
export interface PositionRoundtableSession {
|
||||
id: string;
|
||||
/** Present only when the workspace was opened with a route `taskId`. */
|
||||
externalTaskId: string | null;
|
||||
/** True only for sessions intentionally shared by an explicit route taskId. */
|
||||
taskScoped: boolean;
|
||||
taskSnapshot: Record<string, unknown>;
|
||||
chainId: string | null;
|
||||
chainSnapshot: Record<string, unknown> | null;
|
||||
@@ -112,6 +115,7 @@ interface ServerNode {
|
||||
interface ServerSession {
|
||||
id: string;
|
||||
external_task_id: string | null;
|
||||
task_scoped?: boolean;
|
||||
task_snapshot?: Record<string, unknown>;
|
||||
chain_id: string | null;
|
||||
chain_snapshot?: Record<string, unknown> | null;
|
||||
@@ -270,6 +274,7 @@ function toSession(session: ServerSession): PositionRoundtableSession {
|
||||
const result: PositionRoundtableSession = {
|
||||
id: session.id,
|
||||
externalTaskId: session.external_task_id,
|
||||
taskScoped: session.task_scoped ?? false,
|
||||
taskSnapshot: session.task_snapshot ?? {},
|
||||
chainId: session.chain_id,
|
||||
chainSnapshot: session.chain_snapshot ?? null,
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
import { useEffect, useRef, type ComponentProps } from "react";
|
||||
import { useEffect, useMemo, useRef, type ComponentProps } from "react";
|
||||
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import { usePromptInputController } from "@/components/ai-elements/prompt-input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
usePromptInputController,
|
||||
type PromptInputMessage,
|
||||
} from "@/components/ai-elements/prompt-input";
|
||||
import type { InputBox } from "@/components/workspace/input-box";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import type { AgentQuestion } from "@/core/agents";
|
||||
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
|
||||
|
||||
type PositionAgentInputProps = ComponentProps<typeof InputBox> & {
|
||||
type InputBoxProps = ComponentProps<typeof InputBox>;
|
||||
type PositionAgentInputProps = Pick<
|
||||
InputBoxProps,
|
||||
| "className"
|
||||
| "assistantId"
|
||||
| "status"
|
||||
| "disabled"
|
||||
| "context"
|
||||
| "onContextChange"
|
||||
| "onSubmit"
|
||||
| "onStop"
|
||||
| "isNewThread"
|
||||
| "threadId"
|
||||
| "hideAdminRecommendedQuestions"
|
||||
> & {
|
||||
commonQuestions?: (string | AgentQuestion)[] | null;
|
||||
/** Stable node/builtin key used to restore the matching SQL-backed draft. */
|
||||
draftKey?: string;
|
||||
draftValue?: string;
|
||||
@@ -18,18 +38,36 @@ type PositionAgentInputProps = ComponentProps<typeof InputBox> & {
|
||||
* 只改这个组件即可,不会影响普通对话、工作区或其他业务页面。
|
||||
*/
|
||||
export function PositionAgentInput({
|
||||
className,
|
||||
context,
|
||||
disabled = false,
|
||||
status = "ready",
|
||||
onContextChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
commonQuestions,
|
||||
draftKey = "",
|
||||
draftValue = "",
|
||||
onDraftChange,
|
||||
...props
|
||||
}: PositionAgentInputProps) {
|
||||
const { textInput } = usePromptInputController();
|
||||
const { textInput, attachments } = usePromptInputController();
|
||||
const { models } = useModels();
|
||||
const activeDraftKeyRef = useRef("");
|
||||
const restoringValueRef = useRef<string | null>(null);
|
||||
const textInputRef = useRef(textInput);
|
||||
textInputRef.current = textInput;
|
||||
|
||||
const modelOptions = useMemo(
|
||||
() => models.map((model) => ({ label: model.display_name ?? model.name, value: model.name })),
|
||||
[models],
|
||||
);
|
||||
const attachedFiles = useMemo(
|
||||
() =>
|
||||
attachments.files
|
||||
.map((attachment) => attachment.file)
|
||||
.filter((file): file is File => file instanceof File),
|
||||
[attachments.files],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
activeDraftKeyRef.current = draftKey;
|
||||
if (textInputRef.current.value !== draftValue) {
|
||||
@@ -51,13 +89,60 @@ export function PositionAgentInput({
|
||||
onDraftChange?.(textInput.value);
|
||||
}, [draftKey, onDraftChange, textInput.value]);
|
||||
|
||||
// Keep the page-specific class on the wrapper rather than adding a variant
|
||||
// to InputBox. Node chat and built-in chat can evolve together while every
|
||||
// other workspace keeps the global composer untouched.
|
||||
const handleSend = () => {
|
||||
if (status === "streaming") {
|
||||
onStop?.();
|
||||
return;
|
||||
}
|
||||
const text = textInput.value.trim();
|
||||
if (!text && attachedFiles.length === 0) return;
|
||||
const message: PromptInputMessage = {
|
||||
text: textInput.value,
|
||||
files: attachments.files,
|
||||
};
|
||||
void onSubmit?.(message);
|
||||
textInput.clear();
|
||||
attachments.clear();
|
||||
};
|
||||
|
||||
const handleModelChange = (modelName: string) => {
|
||||
const model = models.find((candidate) => candidate.name === modelName);
|
||||
if (!model) return;
|
||||
onContextChange?.({ ...context, model_name: model.name });
|
||||
};
|
||||
const selectedModel =
|
||||
typeof context.model_name === "string"
|
||||
? context.model_name
|
||||
: modelOptions[0]?.value;
|
||||
|
||||
return (
|
||||
<InputBox
|
||||
{...props}
|
||||
className={cn("position-roundtable-agent-input w-full", className)}
|
||||
<PositionIntentInput
|
||||
value={textInput.value}
|
||||
onValueChange={textInput.setInput}
|
||||
attachedFiles={attachedFiles}
|
||||
onAddFiles={attachments.add}
|
||||
onRemoveFile={(index) => {
|
||||
const attachment = attachments.files[index];
|
||||
if (attachment) attachments.remove(attachment.id);
|
||||
}}
|
||||
charactersLeft={textInput.value.length}
|
||||
isSending={status === "streaming"}
|
||||
canSend={!disabled && (Boolean(textInput.value.trim()) || attachedFiles.length > 0)}
|
||||
pendingClarification={false}
|
||||
onSend={handleSend}
|
||||
onStop={() => onStop?.()}
|
||||
onFollowUp={() => undefined}
|
||||
onSkip={() => undefined}
|
||||
onEnterRoundtable={() => undefined}
|
||||
enterButtonLabel=""
|
||||
intentReady={null}
|
||||
hideEnterRoundtable
|
||||
onManualSave={() => undefined}
|
||||
onTriggerToast={() => undefined}
|
||||
modelOptions={modelOptions}
|
||||
selectedModel={selectedModel}
|
||||
onSelectedModelChange={handleModelChange}
|
||||
commonQuestions={commonQuestions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -716,7 +716,7 @@ export function PositionBuiltinAgentChat({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="position-roundtable-agent-composer-bar shrink-0 border-t border-slate-100 px-4 py-3 dark:border-[#2b8ffe]/20">
|
||||
<div className="position-roundtable-agent-composer-bar position-roundtable-node-composer shrink-0">
|
||||
<PositionAgentInput
|
||||
className="w-full"
|
||||
isNewThread={!boundThreadId}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useRef } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
FileText,
|
||||
Loader2,
|
||||
Paperclip,
|
||||
Pause,
|
||||
Repeat,
|
||||
Save,
|
||||
Send,
|
||||
Smile,
|
||||
Sparkles,
|
||||
X,
|
||||
@@ -14,7 +11,11 @@ import {
|
||||
import { Select as TSelect } from "tdesign-react";
|
||||
|
||||
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
|
||||
import { normalizeAgentQuestions, type AgentQuestion } from "@/core/agents";
|
||||
import type { Step1InputAreaProps } from "@/roundtable-planning/components/Step1Panel";
|
||||
import moreTaskIcon from "@/assets/position-roundtable/more-task.svg";
|
||||
import uploadFileIcon from "@/assets/position-roundtable/upload-file.svg";
|
||||
import sendMessageIcon from "@/assets/position-roundtable/send-message.svg";
|
||||
|
||||
interface PositionIntentModelOption {
|
||||
label: string;
|
||||
@@ -25,6 +26,7 @@ interface PositionIntentInputProps extends Step1InputAreaProps {
|
||||
modelOptions?: PositionIntentModelOption[];
|
||||
selectedModel?: string;
|
||||
onSelectedModelChange?: (model: string) => void;
|
||||
commonQuestions?: (string | AgentQuestion)[] | null;
|
||||
}
|
||||
|
||||
/** Keep attachment metadata presentation local to this position-only composer. */
|
||||
@@ -64,40 +66,25 @@ export function PositionIntentInput({
|
||||
modelOptions = [],
|
||||
selectedModel,
|
||||
onSelectedModelChange,
|
||||
commonQuestions,
|
||||
}: PositionIntentInputProps) {
|
||||
// Files are owned by Step1Panel. This ref only opens the native picker and
|
||||
// forwards its selection through onAddFiles, so replacement layouts do not
|
||||
// accidentally create a second attachment state.
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [showMoreQuestions, setShowMoreQuestions] = useState(false);
|
||||
const normalizedCommonQuestions = normalizeAgentQuestions(commonQuestions);
|
||||
const visibleCommonQuestions = normalizedCommonQuestions.slice(0, 2);
|
||||
const extraCommonQuestions = normalizedCommonQuestions.slice(2);
|
||||
|
||||
const pickCommonQuestion = (question: AgentQuestion) => {
|
||||
onValueChange(question.prompt);
|
||||
setShowMoreQuestions(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="position-roundtable-intent-input position-roundtable-intent-composer shrink-0 border-t border-slate-200 bg-[#f7faff] px-4 py-3 dark:border-[#2b8ffe]/35 dark:bg-[#071a38]">
|
||||
<div className="position-roundtable-intent-composer-frame relative overflow-hidden rounded-[4px] border border-slate-300 bg-white shadow-[0_2px_10px_rgba(15,23,42,0.08)] transition-all focus-within:border-primary focus-within:ring-2 focus-within:ring-[var(--brand-200)]/45 dark:border-[#2b8ffe]/55 dark:bg-[#0d2855] dark:shadow-none dark:focus-within:border-[#56baff] dark:focus-within:ring-[#2b8ffe]/25">
|
||||
{attachedFiles.length > 0 && (
|
||||
<div className="position-roundtable-intent-attachments mx-3 mb-2 flex flex-wrap gap-1.5 border-b border-slate-100 pb-2 pt-2.5 dark:border-[#2b8ffe]/25">
|
||||
{attachedFiles.map((file, index) => (
|
||||
<span
|
||||
key={`${file.name}-${file.size}-${index}`}
|
||||
className="inline-flex max-w-[200px] items-center gap-1.5 rounded-[4px] border border-slate-200 bg-slate-50 py-1 pl-2 pr-1 text-[11px] text-slate-600 dark:border-[#2b8ffe]/30 dark:bg-[#102f63] dark:text-[#c4dcff]"
|
||||
>
|
||||
<FileText className="size-3 shrink-0 text-primary" />
|
||||
<span className="truncate font-medium" title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-slate-400">{formatFileSize(file.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveFile(index)}
|
||||
className="shrink-0 rounded-[3px] p-0.5 text-slate-400 transition hover:bg-slate-200 hover:text-slate-600 dark:text-[#8aaee0] dark:hover:bg-[#21447c] dark:hover:text-white"
|
||||
title="移除"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AutoGrowTextarea
|
||||
id="input-text-area"
|
||||
value={value}
|
||||
@@ -144,7 +131,8 @@ export function PositionIntentInput({
|
||||
title="上传参考文件(智能体将读取文件内容辅助理解任务)"
|
||||
className="rounded-[3px] p-1.5 transition hover:bg-white hover:text-primary dark:hover:bg-[#16386e] dark:hover:text-[#73c8ff]"
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
<img src={uploadFileIcon} alt="" aria-hidden="true" />
|
||||
<span>上传附件</span>
|
||||
</button>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] text-slate-400">{charactersLeft}/500</span>
|
||||
@@ -154,8 +142,79 @@ export function PositionIntentInput({
|
||||
Parent pages can still hide optional actions by their stable ids
|
||||
without changing this component's layout or global input styles.
|
||||
*/}
|
||||
<div className="position-roundtable-intent-toolbar flex flex-wrap items-center gap-3 border-t border-slate-100 bg-white px-3 py-2.5 dark:border-[#2b8ffe]/25 dark:bg-[#0b234c]">
|
||||
<div className="flex shrink-0 items-center">
|
||||
<div className="position-roundtable-intent-toolbar flex flex-wrap items-center gap-3 border-t border-slate-100 px-3 py-2.5 dark:border-[#2b8ffe]/25">
|
||||
<div className="position-roundtable-intent-common-slot">
|
||||
{visibleCommonQuestions.length > 0 && (
|
||||
<div className="position-roundtable-intent-common-questions">
|
||||
{visibleCommonQuestions.map((question, index) => (
|
||||
<button
|
||||
key={`${question.prompt}-${index}`}
|
||||
type="button"
|
||||
className="position-roundtable-intent-common-question"
|
||||
title={question.prompt}
|
||||
onClick={() => pickCommonQuestion(question)}
|
||||
>
|
||||
<span aria-hidden="true" />
|
||||
<span>{question.title}</span>
|
||||
</button>
|
||||
))}
|
||||
{extraCommonQuestions.length > 0 && (
|
||||
<div className="position-roundtable-intent-common-more">
|
||||
<button
|
||||
type="button"
|
||||
className="position-roundtable-intent-common-more-button"
|
||||
aria-label="更多常用问题"
|
||||
aria-expanded={showMoreQuestions}
|
||||
title="更多常用问题"
|
||||
onClick={() => setShowMoreQuestions((visible) => !visible)}
|
||||
>
|
||||
<img src={moreTaskIcon} alt="" aria-hidden="true" />
|
||||
</button>
|
||||
{showMoreQuestions && (
|
||||
<div className="position-roundtable-intent-common-more-menu">
|
||||
{extraCommonQuestions.map((question, index) => (
|
||||
<button
|
||||
key={`${question.prompt}-${index}`}
|
||||
type="button"
|
||||
title={question.prompt}
|
||||
onClick={() => pickCommonQuestion(question)}
|
||||
>
|
||||
{question.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{attachedFiles.length > 0 && (
|
||||
<div className="position-roundtable-intent-file-cards flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto">
|
||||
{attachedFiles.map((file, index) => (
|
||||
<span
|
||||
key={`${file.name}-${file.size}-${index}`}
|
||||
className="inline-flex max-w-[220px] shrink-0 items-center gap-1.5 rounded-[4px] border border-[#2b8ffe]/45 bg-[#0b3168] py-1 pl-2 pr-1 text-[11px] text-[#c4dcff]"
|
||||
>
|
||||
<FileText className="size-3 shrink-0 text-[#73c8ff]" />
|
||||
<span className="truncate font-medium" title={file.name}>
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-[#8aaee0]">{formatFileSize(file.size)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveFile(index)}
|
||||
className="shrink-0 cursor-pointer rounded-[3px] p-0.5 text-[#8aaee0] transition hover:bg-[#21447c] hover:text-white"
|
||||
title="移除"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="position-roundtable-intent-actions ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1.5">
|
||||
<div className="position-roundtable-intent-upload-wrap flex shrink-0 items-center">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -169,28 +228,28 @@ export function PositionIntentInput({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="上传参考文件"
|
||||
aria-label="上传参考文件"
|
||||
className="position-roundtable-intent-upload-btn flex size-10 items-center justify-center rounded-[4px] border border-slate-200 bg-white text-slate-500 transition hover:border-primary/45 hover:text-primary dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b] dark:hover:text-[#73c8ff]"
|
||||
title="上传附件"
|
||||
aria-label="上传附件"
|
||||
className="position-roundtable-intent-upload-btn flex items-center justify-center rounded-[24px] border-0"
|
||||
>
|
||||
<Paperclip className="size-4" />
|
||||
<img src={uploadFileIcon} alt="" aria-hidden="true" />
|
||||
<span>上传附件</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="position-roundtable-intent-actions ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1.5">
|
||||
{modelOptions.length > 0 && (
|
||||
<div
|
||||
className="position-roundtable-intent-model-switch flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white pl-2 text-xs font-bold text-slate-700 shadow-sm dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7]"
|
||||
title="切换意图理解模型"
|
||||
className="position-roundtable-intent-model-switch flex h-8 shrink-0 items-center gap-1.5 rounded-[20px] border border-[#2b8ffe]/55 bg-[#0b3168] px-2.5 text-xs font-bold text-[#dceaff]"
|
||||
title="鍒囨崲鎰忓浘鐞嗚В妯″瀷"
|
||||
>
|
||||
<Sparkles className="size-3.5 shrink-0 text-primary dark:text-[#73c8ff]" />
|
||||
<Sparkles className="size-3.5 shrink-0 text-[#73c8ff]" />
|
||||
<TSelect
|
||||
size="small"
|
||||
aria-label="切换意图理解模型"
|
||||
aria-label="鍒囨崲鎰忓浘鐞嗚В妯″瀷"
|
||||
value={selectedModel}
|
||||
options={modelOptions}
|
||||
disabled={isSending}
|
||||
onChange={(value) => onSelectedModelChange?.(String(value))}
|
||||
className="w-[150px] shrink-0 [&_.t-input]:!h-8 [&_.t-input]:!border-0 [&_.t-input]:!bg-transparent [&_.t-input]:!px-1 [&_.t-input]:!text-xs [&_.t-input]:!font-bold [&_.t-input]:!shadow-none dark:[&_.t-input]:!text-[#dceaff]"
|
||||
className="w-[150px] shrink-0 [&_.t-input]:!h-7 [&_.t-input]:!rounded-[4px] [&_.t-input]:!border-0 [&_.t-input]:!bg-transparent [&_.t-input]:!px-1 [&_.t-input]:!text-xs [&_.t-input]:!font-bold [&_.t-input]:!text-[#dceaff] [&_.t-input]:!shadow-none"
|
||||
popupProps={{ overlayClassName: "z-[80]" }}
|
||||
/>
|
||||
</div>
|
||||
@@ -198,26 +257,20 @@ export function PositionIntentInput({
|
||||
<button
|
||||
id="send-message-btn"
|
||||
type="button"
|
||||
onClick={onSend}
|
||||
disabled={!canSend}
|
||||
className="position-roundtable-intent-send-btn flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] bg-primary px-3 text-xs font-bold text-white shadow-[0_2px_8px_rgba(245,34,45,0.15)] transition-all active:scale-[0.98] hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:bg-slate-300 dark:bg-[#1478e8] dark:hover:bg-[#2995ff] dark:disabled:bg-[#28476f]"
|
||||
onClick={isSending ? onStop : onSend}
|
||||
disabled={isSending ? false : !canSend}
|
||||
aria-label={isSending ? "暂停生成" : "发送"}
|
||||
title={isSending ? "暂停生成" : "发送"}
|
||||
className={`position-roundtable-intent-send-btn flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] bg-primary px-3 text-xs font-bold text-white shadow-[0_2px_8px_rgba(245,34,45,0.15)] transition-all active:scale-[0.98] hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:bg-slate-300 dark:bg-[#1478e8] dark:hover:bg-[#2995ff] dark:disabled:bg-[#28476f]${isSending ? " position-roundtable-intent-send-btn--stopping" : ""}`}
|
||||
>
|
||||
{isSending ? <Loader2 className="size-3 animate-spin" /> : <Send className="size-3" />}
|
||||
{isSending ? "发送中" : "发送"}
|
||||
{isSending ? (
|
||||
<Pause className="size-4" aria-hidden="true" />
|
||||
) : (
|
||||
<img src={sendMessageIcon} alt="" aria-hidden="true" />
|
||||
)}
|
||||
<span className="sr-only">{isSending ? "暂停生成" : "发送"}</span>
|
||||
</button>
|
||||
|
||||
{isSending && (
|
||||
<button
|
||||
id="pause-intent-btn"
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-amber-200/40 bg-amber-100 px-3 text-xs font-bold text-amber-800 shadow-sm transition active:scale-[0.98] hover:bg-amber-200 dark:border-amber-300/30 dark:bg-amber-400/15 dark:text-amber-200 dark:hover:bg-amber-400/25"
|
||||
>
|
||||
<Pause className="size-3" />
|
||||
暂停
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
id="suggest-followup-btn"
|
||||
type="button"
|
||||
|
||||
@@ -15,7 +15,11 @@ import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { useAgent } from "@/core/agents";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useEditMessage, useThreadStream } from "@/core/threads/hooks";
|
||||
import {
|
||||
useEditMessage,
|
||||
useThreadStream,
|
||||
type ToolStartEvent,
|
||||
} from "@/core/threads/hooks";
|
||||
import type { AgentThreadState } from "@/core/threads/types";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
@@ -40,7 +44,6 @@ import {
|
||||
readConversationDraft,
|
||||
readConversationMessages,
|
||||
} from "@/position-roundtable/lib/conversation-snapshot";
|
||||
|
||||
interface PositionNodeSwitchOption {
|
||||
node: PositionRoundtableNode;
|
||||
agentName?: string | null;
|
||||
@@ -164,17 +167,100 @@ function artifactDisplayName(pathOrUrl: string): string {
|
||||
return path.split("/").filter(Boolean).at(-1) || "Markdown 报告";
|
||||
}
|
||||
|
||||
function normalizeWriteFilePath(path: string): string {
|
||||
let normalized = path.trim();
|
||||
const first = normalized[0];
|
||||
const last = normalized.at(-1);
|
||||
if (
|
||||
normalized.length >= 2 &&
|
||||
((first === '"' && last === '"') ||
|
||||
(first === "'" && last === "'") ||
|
||||
(first === "`" && last === "`"))
|
||||
) {
|
||||
normalized = normalized.slice(1, -1).trim();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isMarkdownPath(path: string): boolean {
|
||||
const pathname = normalizeWriteFilePath(path).split(/[?#]/, 1)[0]?.toLowerCase() ?? "";
|
||||
return pathname.endsWith(".md") || pathname.endsWith(".markdown");
|
||||
}
|
||||
|
||||
function hasCompleteArtifactExtension(path: string): boolean {
|
||||
const pathname = normalizeWriteFilePath(path).split(/[?#]/, 1)[0] ?? "";
|
||||
const filename = pathname.split("/").filter(Boolean).at(-1) ?? "";
|
||||
// During JSON argument streaming a Markdown path arrives as `.m` and then
|
||||
// `.md`. Opening on the partial suffix permanently labels the preview TEXT.
|
||||
// Extensionless files still open from on_tool_start once the full path is known.
|
||||
return /\.[a-z0-9]{2,12}$/i.test(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* `write_file` arguments stream as one JSON object and the key order is up to
|
||||
* the model. Agents that emit `content` before `path` leave the file nameless
|
||||
* for as long as the body takes to write — a minute for a full report. The
|
||||
* sandbox follows a streaming file by message/tool-call id, not by path, so it
|
||||
* can open under a provisional name and adopt the real one later. Sniff the
|
||||
* body so the provisional name at least carries the right extension.
|
||||
*/
|
||||
function provisionalWriteFilePath(content: string): string {
|
||||
const head = content.slice(0, 256).trimStart().toLowerCase();
|
||||
if (head.startsWith("<!doctype html") || head.startsWith("<html")) {
|
||||
return "未命名文件.html";
|
||||
}
|
||||
return "未命名文件.md";
|
||||
}
|
||||
|
||||
function writeFilePathFromToolEvent(data: unknown, depth = 0): string | null {
|
||||
if (depth > 3 || data == null) return null;
|
||||
if (typeof data === "string") {
|
||||
const value = data.trim();
|
||||
if (!value) return null;
|
||||
if (value.startsWith("{") || value.startsWith("[")) {
|
||||
try {
|
||||
return writeFilePathFromToolEvent(JSON.parse(value), depth + 1);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (typeof data !== "object" || Array.isArray(data)) return null;
|
||||
const record = data as Record<string, unknown>;
|
||||
if (typeof record.path === "string") {
|
||||
const path = normalizeWriteFilePath(record.path);
|
||||
return path || null;
|
||||
}
|
||||
for (const key of ["input", "inputs", "args", "kwargs"]) {
|
||||
const path = writeFilePathFromToolEvent(record[key], depth + 1);
|
||||
if (path) return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildWriteFileVirtualUrl(path: string, messageId: string, toolCallId: string): string {
|
||||
const language = isMarkdownPath(path) ? "&language=markdown" : "";
|
||||
return new URL(
|
||||
`write-file:${path}?message_id=${encodeURIComponent(
|
||||
messageId,
|
||||
)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
|
||||
)}&tool_call_id=${encodeURIComponent(toolCallId)}${language}`,
|
||||
).toString();
|
||||
}
|
||||
|
||||
function isArtifactWriteTool(name: string | undefined): name is "write_file" | "str_replace" {
|
||||
return name === "write_file" || name === "str_replace";
|
||||
}
|
||||
|
||||
function latestStreamingWriteFile(
|
||||
messages: AgentThreadState["messages"],
|
||||
): { key: string; path: string; name: string; mimeType: string | null } | null {
|
||||
): {
|
||||
key: string;
|
||||
path: string;
|
||||
sourcePath: string;
|
||||
name: string;
|
||||
mimeType: string | null;
|
||||
} | null {
|
||||
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
|
||||
const message = messages[messageIndex] as (typeof messages)[number] & {
|
||||
id?: string;
|
||||
@@ -183,17 +269,33 @@ function latestStreamingWriteFile(
|
||||
if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue;
|
||||
for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) {
|
||||
const toolCall = message.tool_calls[toolIndex];
|
||||
if (toolCall?.name !== "write_file" || !toolCall.id) continue;
|
||||
const rawPath = toolCall.args?.path;
|
||||
if (typeof rawPath !== "string" || !rawPath.trim()) continue;
|
||||
const content = toolCall.args?.content;
|
||||
if (typeof content !== "string" || content.length === 0) continue;
|
||||
const virtualPath = buildWriteFileVirtualUrl(rawPath, message.id, toolCall.id);
|
||||
if (!isArtifactWriteTool(toolCall?.name) || !toolCall.id) continue;
|
||||
const rawPath = typeof toolCall.args?.path === "string" ? toolCall.args.path : "";
|
||||
const rawContent = typeof toolCall.args?.content === "string" ? toolCall.args.content : "";
|
||||
const normalizedPath = normalizeWriteFilePath(rawPath);
|
||||
const hasUsablePath =
|
||||
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
|
||||
if (!hasUsablePath) {
|
||||
// `str_replace` edits an existing file, so it can only be previewed once
|
||||
// its real path is known. A new file is identified by its tool call, so
|
||||
// its body can be previewed as soon as the first characters arrive.
|
||||
if (toolCall.name !== "write_file" || !rawContent.trim()) continue;
|
||||
}
|
||||
const sourcePath = hasUsablePath ? normalizedPath : provisionalWriteFilePath(rawContent);
|
||||
// Do not wait for the complete `content` argument. LangGraph streams
|
||||
// write_file arguments incrementally, and the virtual artifact loader
|
||||
// will redraw as `content` arrives. Waiting for it turns this into a
|
||||
// post-generation preview instead of a live sandbox.
|
||||
const previewPath =
|
||||
toolCall.name === "write_file"
|
||||
? buildWriteFileVirtualUrl(sourcePath, message.id, toolCall.id)
|
||||
: sourcePath;
|
||||
return {
|
||||
key: `${message.id}:${toolCall.id}`,
|
||||
path: virtualPath,
|
||||
name: artifactDisplayName(rawPath),
|
||||
mimeType: rawPath.toLowerCase().endsWith(".md") ? "text/markdown" : null,
|
||||
path: previewPath,
|
||||
sourcePath,
|
||||
name: artifactDisplayName(sourcePath),
|
||||
mimeType: isMarkdownPath(sourcePath) ? "text/markdown" : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -214,7 +316,7 @@ function writeFileCallKeys(messages: AgentThreadState["messages"]): Set<string>
|
||||
};
|
||||
if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue;
|
||||
for (const toolCall of message.tool_calls) {
|
||||
if (toolCall?.name === "write_file" && toolCall.id) {
|
||||
if (isArtifactWriteTool(toolCall?.name) && toolCall.id) {
|
||||
keys.add(`${message.id}:${toolCall.id}`);
|
||||
}
|
||||
}
|
||||
@@ -265,11 +367,21 @@ export function PositionNodeChat({
|
||||
);
|
||||
const [boundThreadId, setBoundThreadId] = useState<string | null>(node.threadId);
|
||||
const boundThreadIdRef = useRef<string | null>(node.threadId);
|
||||
const previousNodeBindingRef = useRef({
|
||||
nodeKey: node.nodeKey,
|
||||
threadId: node.threadId,
|
||||
});
|
||||
const draftThreadIdRef = useRef(node.threadId ?? uuid());
|
||||
const upstreamRevisionRef = useRef<Record<string, unknown> | null>(null);
|
||||
const autoPreviewedArtifactKeyRef = useRef<string | null>(null);
|
||||
const autoPreviewBaselineCallKeysRef = useRef<Set<string>>(new Set());
|
||||
const autoPreviewRunArmedRef = useRef(false);
|
||||
const writeFilePreviewSequenceRef = useRef(0);
|
||||
const activeWriteFilePreviewRef = useRef<{
|
||||
path: string;
|
||||
previewKey: string;
|
||||
toolCallId?: string;
|
||||
} | null>(null);
|
||||
const editMessage = useEditMessage();
|
||||
const [messageContentOverrides, setMessageContentOverrides] = useState<Record<string, string>>({});
|
||||
const persistedMessages = useMemo(
|
||||
@@ -288,12 +400,28 @@ export function PositionNodeChat({
|
||||
!node.invalidatedBy;
|
||||
|
||||
useEffect(() => {
|
||||
const previousBinding = previousNodeBindingRef.current;
|
||||
previousNodeBindingRef.current = { nodeKey: node.nodeKey, threadId: node.threadId };
|
||||
// `onStart` binds a newly created LangGraph thread before the session API
|
||||
// persists that id back into `node.threadId`. That prop update happens
|
||||
// while the first turn is already streaming; it is not a node switch and
|
||||
// must not clear the preview run that has just been armed.
|
||||
const isOwnThreadBinding =
|
||||
previousBinding.nodeKey === node.nodeKey &&
|
||||
previousBinding.threadId == null &&
|
||||
node.threadId != null &&
|
||||
node.threadId === boundThreadIdRef.current;
|
||||
if (isOwnThreadBinding) return;
|
||||
|
||||
setBoundThreadId(node.threadId);
|
||||
boundThreadIdRef.current = node.threadId;
|
||||
setInputDraft(readConversationDraft(node.conversationSnapshot));
|
||||
setMessageContentOverrides({});
|
||||
autoPreviewedArtifactKeyRef.current = null;
|
||||
autoPreviewBaselineCallKeysRef.current = new Set();
|
||||
autoPreviewRunArmedRef.current = false;
|
||||
writeFilePreviewSequenceRef.current = 0;
|
||||
activeWriteFilePreviewRef.current = null;
|
||||
// The component is keyed by nodeKey in the page. This reset is therefore
|
||||
// only a defensive guard for callers that reuse the component instance.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -314,6 +442,43 @@ export function PositionNodeChat({
|
||||
[node.nodeKey, onNodeUpdate, sessionId],
|
||||
);
|
||||
|
||||
const handleToolStart = useCallback(
|
||||
(event: ToolStartEvent) => {
|
||||
if (!isArtifactWriteTool(event.name) || !autoPreviewRunArmedRef.current) return;
|
||||
const path = writeFilePathFromToolEvent(event.data);
|
||||
const threadId = boundThreadIdRef.current;
|
||||
if (!path || !threadId) return;
|
||||
|
||||
// Message chunks and on_tool_start can describe the same invocation in
|
||||
// either order. Reuse the first preview key so a manual close suppresses
|
||||
// only this write, while a later rewrite receives a fresh key and opens.
|
||||
const activePreview = activeWriteFilePreviewRef.current;
|
||||
if (
|
||||
activePreview?.path === path &&
|
||||
(activePreview.toolCallId === event.toolCallId ||
|
||||
(event.toolCallId != null && activePreview.previewKey.endsWith(`:${event.toolCallId}`)))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const previewKey = event.toolCallId
|
||||
? `tool-start:${event.toolCallId}`
|
||||
: `tool-start:${node.nodeKey}:${++writeFilePreviewSequenceRef.current}`;
|
||||
activeWriteFilePreviewRef.current = { path, previewKey, toolCallId: event.toolCallId };
|
||||
const artifactKey = `${threadId}:${previewKey}`;
|
||||
if (autoPreviewedArtifactKeyRef.current === artifactKey) return;
|
||||
autoPreviewedArtifactKeyRef.current = artifactKey;
|
||||
onPreviewArtifact?.({
|
||||
threadId,
|
||||
path,
|
||||
name: artifactDisplayName(path),
|
||||
mimeType: isMarkdownPath(path) ? "text/markdown" : null,
|
||||
previewKey,
|
||||
auto: true,
|
||||
});
|
||||
},
|
||||
[node.nodeKey, onPreviewArtifact],
|
||||
);
|
||||
|
||||
const [thread, sendMessage, , actualThreadId] = useThreadStream({
|
||||
threadId: boundThreadId,
|
||||
assistantId: node.agentId,
|
||||
@@ -334,15 +499,21 @@ export function PositionNodeChat({
|
||||
agent_id: node.agentId,
|
||||
},
|
||||
onStart: bindCreatedThread,
|
||||
onToolStart: handleToolStart,
|
||||
onFinish: (state) => {
|
||||
// A turn is not considered delivered by the UI alone. The persisted
|
||||
// completion call records the answer, file manifest and upstream version
|
||||
// so the service can decide whether this seat unlocks its downstream.
|
||||
const threadId = boundThreadIdRef.current;
|
||||
const latestAi = [...(state.messages ?? [])]
|
||||
// `present_files` is often the last AI tool call and has no prose of
|
||||
// its own. Keep walking backwards to the last actual answer; otherwise
|
||||
// a successful file delivery would return early here and never refresh
|
||||
// the node's artifact cards.
|
||||
const latestAnswer = [...(state.messages ?? [])]
|
||||
.reverse()
|
||||
.find((message) => message.type === "ai");
|
||||
const latestAnswer = latestAi ? textOfMessage(latestAi) ?? "" : "";
|
||||
.filter((message) => message.type === "ai")
|
||||
.map((message) => textOfMessage(message) ?? "")
|
||||
.find((content) => content.trim()) ?? "";
|
||||
if (!threadId || !latestAnswer.trim()) return;
|
||||
void (async () => {
|
||||
let artifactManifest: Array<Record<string, unknown>> = [];
|
||||
@@ -378,6 +549,10 @@ export function PositionNodeChat({
|
||||
|
||||
const activeThreadId =
|
||||
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? draftThreadIdRef.current;
|
||||
// A sandbox must always receive the server thread id. In particular, do not
|
||||
// attach a streaming preview to the provisional UUID used for a new node
|
||||
// before `useThreadStream` has created and bound its thread.
|
||||
const previewThreadId = boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? null;
|
||||
const persistedThreadId =
|
||||
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? null;
|
||||
const recoveredMessages = useMemo(
|
||||
@@ -566,32 +741,60 @@ export function PositionNodeChat({
|
||||
() => (thread.isLoading ? latestStreamingWriteFile(thread.messages) : null),
|
||||
[thread.isLoading, thread.messages],
|
||||
);
|
||||
const newStreamingArtifact =
|
||||
streamingArtifact && !autoPreviewBaselineCallKeysRef.current.has(streamingArtifact.key)
|
||||
? streamingArtifact
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!thread.isLoading ||
|
||||
!streamingArtifact ||
|
||||
!autoPreviewRunArmedRef.current ||
|
||||
autoPreviewBaselineCallKeysRef.current.has(streamingArtifact.key)
|
||||
!newStreamingArtifact ||
|
||||
!previewThreadId ||
|
||||
!autoPreviewRunArmedRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// One write_file call should open at most one preview. The parent further
|
||||
// guards manual close state, because it owns the page-wide singleton sandbox.
|
||||
// A report is often rewritten to the same output path. The tool-call key
|
||||
// (not just the path) makes that a new previewable delivery.
|
||||
const artifactKey = `${activeThreadId}:${streamingArtifact.key}`;
|
||||
// A report is often rewritten to the same output path — identity must be the
|
||||
// tool-call key, not the path, otherwise a second write keeps the old panel
|
||||
// and the sandbox never clears / remounts for the rewrite.
|
||||
const toolCallKey = newStreamingArtifact.key;
|
||||
const toolCallId = toolCallKey.includes(":")
|
||||
? toolCallKey.slice(toolCallKey.lastIndexOf(":") + 1)
|
||||
: undefined;
|
||||
const startedPreview = activeWriteFilePreviewRef.current;
|
||||
const isSameToolCall =
|
||||
startedPreview != null &&
|
||||
(startedPreview.previewKey === toolCallKey ||
|
||||
(toolCallId != null &&
|
||||
(startedPreview.toolCallId === toolCallId ||
|
||||
startedPreview.previewKey.endsWith(`:${toolCallId}`))));
|
||||
// Same tool call may upgrade a provisional filename → real path; keep the
|
||||
// open panel. A different tool call is a new delivery even on the same path.
|
||||
const previewKey = isSameToolCall ? startedPreview.previewKey : toolCallKey;
|
||||
activeWriteFilePreviewRef.current = {
|
||||
path: newStreamingArtifact.sourcePath,
|
||||
previewKey,
|
||||
toolCallId: toolCallId ?? startedPreview?.toolCallId,
|
||||
};
|
||||
// The path takes part in the identity so that a preview opened under a
|
||||
// provisional name is re-published once the real path streams in. The
|
||||
// preview key stays the same, so the parent treats it as an update of the
|
||||
// open panel rather than a second delivery.
|
||||
const artifactKey = `${previewThreadId}:${previewKey}:${newStreamingArtifact.sourcePath}`;
|
||||
if (autoPreviewedArtifactKeyRef.current === artifactKey) return;
|
||||
autoPreviewedArtifactKeyRef.current = artifactKey;
|
||||
onPreviewArtifact?.({
|
||||
threadId: activeThreadId,
|
||||
path: streamingArtifact.path,
|
||||
name: streamingArtifact.name,
|
||||
mimeType: streamingArtifact.mimeType,
|
||||
previewKey: streamingArtifact.key,
|
||||
threadId: previewThreadId,
|
||||
path: newStreamingArtifact.path,
|
||||
name: newStreamingArtifact.name,
|
||||
mimeType: newStreamingArtifact.mimeType,
|
||||
previewKey,
|
||||
auto: true,
|
||||
});
|
||||
}, [activeThreadId, onPreviewArtifact, streamingArtifact, thread.isLoading]);
|
||||
}, [newStreamingArtifact, onPreviewArtifact, previewThreadId, thread.isLoading]);
|
||||
|
||||
// SDK thread creation is asynchronous. ``onStart`` normally binds it, but
|
||||
// watching the actual stream id as well closes the last race where a page
|
||||
@@ -628,6 +831,7 @@ export function PositionNodeChat({
|
||||
: null;
|
||||
// A thread retains historical write_file tool calls. Only files whose
|
||||
// call id appears after this exact submission may open the sandbox.
|
||||
activeWriteFilePreviewRef.current = null;
|
||||
autoPreviewBaselineCallKeysRef.current = writeFileCallKeys(thread.messages);
|
||||
autoPreviewRunArmedRef.current = true;
|
||||
await sendMessage(boundThreadIdRef.current ?? draftThreadIdRef.current, message, {
|
||||
@@ -811,7 +1015,7 @@ export function PositionNodeChat({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="position-roundtable-agent-composer-bar shrink-0 border-t border-slate-100 px-4 py-3 dark:border-[#2b8ffe]/20">
|
||||
<div className="position-roundtable-agent-composer-bar position-roundtable-node-composer shrink-0">
|
||||
<PositionAgentInput
|
||||
className="w-full"
|
||||
isNewThread={!boundThreadId}
|
||||
@@ -825,6 +1029,7 @@ export function PositionNodeChat({
|
||||
onDraftChange={setInputDraft}
|
||||
hideAdminRecommendedQuestions
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
commonQuestions={agent?.common_questions}
|
||||
onSubmit={(message) => void handleSubmit(message)}
|
||||
onStop={() => void thread.stop()}
|
||||
/>
|
||||
|
||||
@@ -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。
|
||||
@@ -31,16 +31,21 @@ export const MOCK_POSITION_TASKS: readonly PositionTask[] = [
|
||||
export const DEFAULT_MOCK_POSITION_TASK_ID = MOCK_POSITION_TASKS[0].id;
|
||||
|
||||
export function getMockPositionTask(taskId?: string | null): PositionTask {
|
||||
return (
|
||||
const fixture =
|
||||
MOCK_POSITION_TASKS.find((task) => task.id === taskId) ??
|
||||
MOCK_POSITION_TASKS[0]
|
||||
);
|
||||
MOCK_POSITION_TASKS[0];
|
||||
const scopedTaskId = taskId?.trim();
|
||||
// The fixture supplies only display defaults. Preserve the caller's task
|
||||
// identity so persistence, history and LangGraph thread metadata all remain
|
||||
// bound to the route taskId.
|
||||
return scopedTaskId ? { ...fixture, id: scopedTaskId } : fixture;
|
||||
}
|
||||
|
||||
/** Normalizes a persisted Session task snapshot without exposing source internals. */
|
||||
export function taskFromPositionSnapshot(
|
||||
snapshot: Record<string, unknown> | null | undefined,
|
||||
fallback: PositionTask,
|
||||
taskScopeId?: string,
|
||||
): PositionTask {
|
||||
if (!snapshot || Object.keys(snapshot).length === 0) return fallback;
|
||||
const value = (keys: string[], defaultValue: string) => {
|
||||
@@ -50,7 +55,11 @@ export function taskFromPositionSnapshot(
|
||||
return typeof found === "string" ? found : defaultValue;
|
||||
};
|
||||
return {
|
||||
id: value(["id", "taskId", "externalTaskId"], fallback.id),
|
||||
// Session snapshots are editable display data, while the route taskId is
|
||||
// the stable shared-storage scope. Keeping them separate prevents a
|
||||
// legacy/mock snapshot from moving a live external task into another
|
||||
// task's history bucket.
|
||||
id: taskScopeId?.trim() || value(["id", "taskId", "externalTaskId"], fallback.id),
|
||||
title: value(["title", "taskName", "name"], fallback.title),
|
||||
description: value(["description", "taskContent", "overview", "content"], fallback.description),
|
||||
direction: value(["direction", "taskDirection", "category"], fallback.direction),
|
||||
|
||||
@@ -4,9 +4,15 @@ import { getBackendBaseURL } from "@/core/config";
|
||||
import type { ClarificationPayload } from "./multi-agent";
|
||||
import { reportRoundtableError } from "./diagnostics-report";
|
||||
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
import { normalizeIntentReadySummary } from "@/roundtable-planning/lib/roundtable-constants";
|
||||
|
||||
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
|
||||
* 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(
|
||||
model?: string,
|
||||
taskContext?: Record<string, unknown> | null,
|
||||
intentAgentId?: string,
|
||||
): Promise<IntentInitResponse> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
// 岗位会商在创建意图线程时就写入父任务标识。这样即使用户尚未确认
|
||||
// 业务链,第一阶段的对话也已经可以按 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) {
|
||||
const raw = await res.text().catch(() => "");
|
||||
@@ -137,6 +148,19 @@ export interface IntentSummary {
|
||||
constraints: string[];
|
||||
/** Optional assumptions the agent is making on the user's behalf. */
|
||||
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;
|
||||
message: 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. */
|
||||
taskContext?: Record<string, unknown> | null;
|
||||
/** Step 1 用户上传的参考文件(可选)。 */
|
||||
@@ -186,8 +212,7 @@ export interface StreamIntentRequest {
|
||||
* 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
|
||||
* surface each assistance question in real-time instead of waiting for the
|
||||
* terminal frame — which only carries the *first* question. `index` is the
|
||||
* 0-based arrival order within this turn.
|
||||
* terminal frame. `index` is the 0-based arrival order within this turn.
|
||||
*/
|
||||
onClarification?: (clarification: ClarificationPayload, index: number) => void;
|
||||
}
|
||||
@@ -208,8 +233,8 @@ export interface StreamIntentResult {
|
||||
clarification: ClarificationPayload | null;
|
||||
/**
|
||||
* All `ask_clarification` questions the agent asked this turn, in arrival
|
||||
* order. Collected from the live `type:"tool"` frames (the terminal frame
|
||||
* only carries the first). Empty unless status === "clarification".
|
||||
* order. Collected live and reconciled against the terminal frame so a
|
||||
* reconnect cannot lose cards. Empty unless status === "clarification".
|
||||
*/
|
||||
clarifications: ClarificationPayload[];
|
||||
/**
|
||||
@@ -235,6 +260,7 @@ function isFinalIntentFrame(value: unknown): value is {
|
||||
options?: unknown[];
|
||||
allow_custom?: boolean;
|
||||
allow_multiple?: boolean;
|
||||
clarifications?: unknown[];
|
||||
error_detail?: unknown;
|
||||
} {
|
||||
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
|
||||
* 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,
|
||||
message: req.message,
|
||||
model: req.model,
|
||||
intent_agent_id: req.intentAgentId || undefined,
|
||||
task_context: req.taskContext ?? 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
|
||||
// tool_call_id. The terminal frame only carries the first question, so this
|
||||
// is the source of truth for multi-question turns.
|
||||
// tool_call_id, then reconciled against the complete terminal list.
|
||||
const seenClarificationIds = new Set<string>();
|
||||
const collectClarification = (payload: ClarificationPayload, toolCallId: string) => {
|
||||
const dedupeKey = toolCallId || `idx-${result.clarifications.length}`;
|
||||
@@ -435,7 +496,7 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
||||
result.status = parsed.status;
|
||||
if (typeof parsed.content === "string") result.content = parsed.content;
|
||||
if (parsed.summary && typeof parsed.summary === "object") {
|
||||
result.summary = parsed.summary as IntentSummary;
|
||||
result.summary = normalizeIntentReadySummary(parsed.summary);
|
||||
}
|
||||
if (parsed.status === "error") {
|
||||
result.errorDetail = parseIntentErrorDetail(parsed.error_detail);
|
||||
@@ -445,28 +506,26 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
||||
detail: result.errorDetail,
|
||||
});
|
||||
}
|
||||
if (parsed.status === "clarification" && result.clarifications.length === 0) {
|
||||
// Fallback path: no live `type:"tool"` clarification frames were seen
|
||||
// (older backend, or single-question turn) — synthesize one from the
|
||||
// terminal frame so the UI still gets a card. When tool frames *were*
|
||||
// seen, `clarifications` already holds the full (possibly multi-question)
|
||||
// set and we keep it.
|
||||
const clarificationType =
|
||||
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "";
|
||||
const fallback: ClarificationPayload = {
|
||||
question: typeof parsed.question === "string" ? parsed.question : "",
|
||||
clarificationType,
|
||||
context:
|
||||
typeof parsed.clarification_context === "string" ? parsed.clarification_context : "",
|
||||
options: Array.isArray(parsed.options) ? parsed.options : [],
|
||||
allowCustom: parsed.allow_custom !== false,
|
||||
allowMultiple: resolveAllowMultiple(
|
||||
typeof parsed.allow_multiple === "boolean" ? parsed.allow_multiple : undefined,
|
||||
clarificationType,
|
||||
),
|
||||
};
|
||||
result.clarification = fallback;
|
||||
result.clarifications = [fallback];
|
||||
if (parsed.status === "clarification") {
|
||||
// Newer backend frames contain every card. Merge them even when a
|
||||
// subset already arrived live: an SSE reconnect can otherwise leave
|
||||
// the user with only the first card in a multi-question assistance
|
||||
// round. Older backends still fall back to the legacy top-level card.
|
||||
const terminalCards = Array.isArray(parsed.clarifications)
|
||||
? parsed.clarifications
|
||||
.map(parseTerminalClarification)
|
||||
.filter((item): item is { payload: ClarificationPayload; toolCallId: string } => item !== null)
|
||||
: [];
|
||||
if (terminalCards.length > 0) {
|
||||
terminalCards.forEach((card, index) => {
|
||||
collectClarification(card.payload, card.toolCallId || `terminal-${index}`);
|
||||
});
|
||||
} else if (result.clarifications.length === 0) {
|
||||
const fallback = parseTerminalClarification(parsed);
|
||||
if (fallback) {
|
||||
collectClarification(fallback.payload, fallback.toolCallId || "terminal-0");
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -153,6 +153,10 @@ export function BusinessChainPicker({
|
||||
// 席位「会商技能识别强化」开关(链条配置):按 agent_id 取,缺省 = 关。
|
||||
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 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。
|
||||
const seatModeById = new Map(selectedChain.seats.map((s) => [s.agent_id, s.seat_mode ?? null]));
|
||||
const seatModeOf = (id: string) => seatModeById.get(id) ?? null;
|
||||
@@ -162,12 +166,12 @@ export function BusinessChainPicker({
|
||||
// dag:席位顺序按 stage 展平(与 plan 一致)。
|
||||
const agents = dagPlan.stages
|
||||
.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 });
|
||||
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 });
|
||||
};
|
||||
|
||||
|
||||
@@ -103,6 +103,12 @@ export interface Step1PanelProps {
|
||||
*/
|
||||
headerLeftSlot?: React.ReactNode;
|
||||
headerRightSlot?: React.ReactNode;
|
||||
/** Optional side content rendered below the shared header, beside the dialogue area. */
|
||||
rightSidebar?: React.ReactNode;
|
||||
/** Hide the shared question-count badge when a workspace supplies its own header actions. */
|
||||
hideQuestionCount?: boolean;
|
||||
/** Optional empty state rendered in the centre of an otherwise blank dialogue list. */
|
||||
emptyState?: React.ReactNode;
|
||||
interactiveMessages: InteractiveMessage[];
|
||||
intentThreadId: string | null;
|
||||
intentReady: object | null;
|
||||
@@ -146,6 +152,9 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
const {
|
||||
headerLeftSlot,
|
||||
headerRightSlot,
|
||||
rightSidebar,
|
||||
hideQuestionCount = false,
|
||||
emptyState,
|
||||
rootClassName,
|
||||
interactiveMessages,
|
||||
intentThreadId,
|
||||
@@ -230,6 +239,7 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
|
||||
// 顶部标题栏整片脱敏(消息列表正文在下方,不在此容器内,保持原文)。
|
||||
const headerDzRef = useDesensitizeSubtree<HTMLDivElement>();
|
||||
const showEmptyState = interactiveMessages.length === 0 && Boolean(emptyState);
|
||||
|
||||
return (
|
||||
<div className={`flex-1 flex flex-col min-h-0 ${rootClassName ?? ""}`}>
|
||||
@@ -244,34 +254,45 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="shrink-0 text-[11px] font-bold text-slate-400 bg-white border px-2 py-0.5 rounded-md">
|
||||
问答轮次:{interactiveMessages.filter((m) => m.sender === "user").length} 轮
|
||||
</span>
|
||||
{!hideQuestionCount && (
|
||||
<span className="shrink-0 text-[11px] font-bold text-slate-400 bg-white border px-2 py-0.5 rounded-md">
|
||||
问答轮次:{interactiveMessages.filter((m) => m.sender === "user").length} 轮
|
||||
</span>
|
||||
)}
|
||||
{headerRightSlot}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={rightSidebar ? "flex min-h-0 flex-1" : "contents"}>
|
||||
<div className={rightSidebar ? "flex min-w-0 flex-1 flex-col" : "contents"}>
|
||||
{/* Chat list */}
|
||||
<div className="relative flex-1 min-h-0 flex flex-col font-normal">
|
||||
<div
|
||||
ref={step1ScrollRef}
|
||||
// overflow-x-hidden:消息列表只竖向滚(见 Step2Panel 同处注释),避免误触横向拖动抢滑块。
|
||||
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden bg-white"
|
||||
className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden bg-white"
|
||||
>
|
||||
<div ref={step1ContentRef} className="space-y-4 p-6">
|
||||
{interactiveMessages.map((msg) => (
|
||||
<Step1Message
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
metric={speedMeterEnabled ? messageMetrics.get(msg.id) : undefined}
|
||||
intentReady={intentReady}
|
||||
pendingIntentClarificationMsgId={pendingIntentClarificationMsgId}
|
||||
intentClarificationSelections={intentClarificationSelections}
|
||||
setIntentClarificationSelections={setIntentClarificationSelections}
|
||||
isIntentSending={isIntentSending}
|
||||
submitIntentClarifications={submitIntentClarifications}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
ref={step1ContentRef}
|
||||
className={showEmptyState ? "flex min-h-full flex-1 p-6" : "space-y-4 p-6"}
|
||||
>
|
||||
{showEmptyState ? (
|
||||
<div className="flex min-h-full w-full flex-1 items-center justify-center">{emptyState}</div>
|
||||
) : (
|
||||
interactiveMessages.map((msg) => (
|
||||
<Step1Message
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
metric={speedMeterEnabled ? messageMetrics.get(msg.id) : undefined}
|
||||
intentReady={intentReady}
|
||||
pendingIntentClarificationMsgId={pendingIntentClarificationMsgId}
|
||||
intentClarificationSelections={intentClarificationSelections}
|
||||
setIntentClarificationSelections={setIntentClarificationSelections}
|
||||
isIntentSending={isIntentSending}
|
||||
submitIntentClarifications={submitIntentClarifications}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!step1AtBottom && <ScrollToBottomButton onClick={scrollStep1ToBottom} />}
|
||||
@@ -453,6 +474,9 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{rightSidebar}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -664,6 +688,15 @@ function Step1Message({
|
||||
|
||||
/** [INTENT_READY] 标记块解析出 IntentSummary 后的绿色摘要卡。 */
|
||||
function IntentReadySummaryCard({ summary }: { summary: IntentSummary }) {
|
||||
const positionSections =
|
||||
summary.coreGoals && summary.riskWarnings && summary.keyPoints && summary.strategicSignificance
|
||||
? ([
|
||||
["核心目标", summary.coreGoals],
|
||||
["风险提示", summary.riskWarnings],
|
||||
["关键要点", summary.keyPoints],
|
||||
["战略意义", summary.strategicSignificance],
|
||||
] as const)
|
||||
: null;
|
||||
return (
|
||||
<div className="mt-1 border border-emerald-200 bg-emerald-50/50 rounded-xl p-3 space-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -672,30 +705,35 @@ function IntentReadySummaryCard({ summary }: { summary: IntentSummary }) {
|
||||
任务意图已明确
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">核心目标</span>
|
||||
<p className="text-[11.5px] text-slate-800 leading-relaxed">{summary.objective}</p>
|
||||
</div>
|
||||
{summary.constraints?.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">约束条件</span>
|
||||
<ul className="list-disc pl-4 space-y-0.5 text-[11px] text-slate-700">
|
||||
{summary.constraints.map((c, i) => (
|
||||
<li key={i} className="leading-snug">{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
{positionSections ? positionSections.map(([label, values]) => (
|
||||
<div key={label} className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">{label}</span>
|
||||
{values.length > 0 ? <ul className="list-disc pl-4 space-y-0.5 text-[11px] text-slate-700">
|
||||
{values.map((value, index) => <li key={index} className="leading-snug">{value}</li>)}
|
||||
</ul> : <p className="text-[11px] text-slate-500">暂无{label}</p>}
|
||||
</div>
|
||||
)}
|
||||
{summary.assumptions && summary.assumptions.length > 0 && (
|
||||
)) : <>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">关键假设</span>
|
||||
<ul className="list-disc pl-4 space-y-0.5 text-[11px] text-slate-700">
|
||||
{summary.assumptions.map((a, i) => (
|
||||
<li key={i} className="leading-snug">{a}</li>
|
||||
))}
|
||||
</ul>
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">核心目标</span>
|
||||
<p className="text-[11.5px] text-slate-800 leading-relaxed">{summary.objective}</p>
|
||||
</div>
|
||||
)}
|
||||
{summary.constraints?.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">约束条件</span>
|
||||
<ul className="list-disc pl-4 space-y-0.5 text-[11px] text-slate-700">
|
||||
{summary.constraints.map((c, i) => <li key={i} className="leading-snug">{c}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{summary.assumptions && summary.assumptions.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">关键假设</span>
|
||||
<ul className="list-disc pl-4 space-y-0.5 text-[11px] text-slate-700">
|
||||
{summary.assumptions.map((a, i) => <li key={i} className="leading-snug">{a}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -90,10 +90,14 @@ export interface UseStep1IntentOptions {
|
||||
currentStep: number;
|
||||
/** 全局选中模型;调用 `/api/intent/init` / `/api/intent/stream` 时透传。 */
|
||||
selectedModel: string;
|
||||
/** Optional built-in agent override for a workspace with a distinct schema. */
|
||||
intentAgentId?: string;
|
||||
/** Optional hidden task/business context for each intent-agent turn. */
|
||||
taskContext?: Record<string, unknown> | null;
|
||||
/** Optional task-aware welcome bubble text before the first user turn. */
|
||||
initialWelcomeText?: string;
|
||||
/** Keep the dialogue visually empty while its intent thread is initialising. */
|
||||
showInitialWelcome?: boolean;
|
||||
/** 主页面统一的 toast 入口。 */
|
||||
triggerToast: (msg: string) => void;
|
||||
}
|
||||
@@ -104,6 +108,7 @@ export interface UseStep1IntentReturn {
|
||||
intentReady: IntentSummary | null;
|
||||
intentInitError: string | null;
|
||||
intentInitErrorDetail: IntentErrorDetail | null;
|
||||
isIntentInitializing: boolean;
|
||||
isIntentSending: boolean;
|
||||
pendingIntentClarificationMsgId: number | null;
|
||||
/** Per-question selections, keyed by `clarificationKey(msgId, clarId, index)`. */
|
||||
@@ -172,13 +177,22 @@ function intentPhaseLabel(phase: IntentStreamPhase, toolName?: string): string {
|
||||
}
|
||||
|
||||
export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentReturn {
|
||||
const { currentStep, selectedModel, taskContext, initialWelcomeText, triggerToast } = options;
|
||||
const {
|
||||
currentStep,
|
||||
selectedModel,
|
||||
intentAgentId,
|
||||
taskContext,
|
||||
initialWelcomeText,
|
||||
showInitialWelcome = true,
|
||||
triggerToast,
|
||||
} = options;
|
||||
|
||||
const [intentThreadId, setIntentThreadId] = useState<string | null>(null);
|
||||
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
|
||||
const [intentInitError, setIntentInitError] = useState<string | null>(null);
|
||||
const [intentInitErrorDetail, setIntentInitErrorDetail] =
|
||||
useState<IntentErrorDetail | null>(null);
|
||||
const [isIntentInitializing, setIsIntentInitializing] = useState<boolean>(false);
|
||||
const [isIntentSending, setIsIntentSending] = useState<boolean>(false);
|
||||
const [pendingIntentClarificationMsgId, setPendingIntentClarificationMsgId] =
|
||||
useState<number | null>(null);
|
||||
@@ -203,6 +217,10 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
useEffect(() => {
|
||||
selectedModelRef.current = selectedModel;
|
||||
}, [selectedModel]);
|
||||
const intentAgentIdRef = useRef<string | undefined>(intentAgentId);
|
||||
useEffect(() => {
|
||||
intentAgentIdRef.current = intentAgentId;
|
||||
}, [intentAgentId]);
|
||||
const taskContextRef = useRef<Record<string, unknown> | null>(taskContext ?? null);
|
||||
useEffect(() => {
|
||||
taskContextRef.current = taskContext ?? null;
|
||||
@@ -230,18 +248,23 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
setIntentInitError(null);
|
||||
setIntentInitErrorDetail(null);
|
||||
setIntentThreadId(null);
|
||||
setIsIntentInitializing(true);
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
setIntentClarificationSelections(new Map());
|
||||
setTimelineEntries([]);
|
||||
setInteractiveMessages([
|
||||
{
|
||||
id: 1,
|
||||
sender: "agent",
|
||||
senderName: "任务理解智能体",
|
||||
content: initialWelcomeTextRef.current || INTENT_WELCOME_TEXT,
|
||||
time: nowTime(),
|
||||
},
|
||||
]);
|
||||
setInteractiveMessages(
|
||||
showInitialWelcome
|
||||
? [
|
||||
{
|
||||
id: 1,
|
||||
sender: "agent",
|
||||
senderName: "任务理解智能体",
|
||||
content: initialWelcomeTextRef.current || INTENT_WELCOME_TEXT,
|
||||
time: nowTime(),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
(async () => {
|
||||
try {
|
||||
// initIntent 当前不在 thread 上落盘 model,只为占位与兼容;空时由后端
|
||||
@@ -249,9 +272,11 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
const res = await initIntent(
|
||||
selectedModelRef.current || undefined,
|
||||
taskContextRef.current,
|
||||
intentAgentIdRef.current,
|
||||
);
|
||||
if (step1InitGenRef.current !== myGen) return;
|
||||
setIntentThreadId(res.thread_id);
|
||||
setIsIntentInitializing(false);
|
||||
setTimelineEntries([
|
||||
{
|
||||
id: 1,
|
||||
@@ -262,6 +287,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
]);
|
||||
} catch (err) {
|
||||
if (step1InitGenRef.current !== myGen) return;
|
||||
setIsIntentInitializing(false);
|
||||
console.error("init intent failed:", err);
|
||||
if (err instanceof IntentInitError) {
|
||||
setIntentInitError(err.detail?.message || err.message);
|
||||
@@ -272,13 +298,14 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
}, [showInitialWelcome]);
|
||||
|
||||
const resetIntent = useCallback(() => {
|
||||
stopIntentStream();
|
||||
step1InitRef.current = false;
|
||||
step1InitGenRef.current += 1;
|
||||
setIntentThreadId(null);
|
||||
setIsIntentInitializing(false);
|
||||
setIntentReady(null);
|
||||
setIntentInitError(null);
|
||||
setIntentInitErrorDetail(null);
|
||||
@@ -424,6 +451,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
threadId: intentThreadId,
|
||||
message: trimmed,
|
||||
model: selectedModelRef.current || undefined,
|
||||
intentAgentId: intentAgentIdRef.current,
|
||||
taskContext: taskContextRef.current,
|
||||
files: fileRefs,
|
||||
signal: aborter.signal,
|
||||
@@ -654,6 +682,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
// 或继续显示旧会话的澄清选择。
|
||||
stopIntentStream();
|
||||
setIsIntentSending(false);
|
||||
setIsIntentInitializing(false);
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
setIntentClarificationSelections(new Map());
|
||||
setIntentThreadId(snapshot.intentThreadId);
|
||||
@@ -690,6 +719,7 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
intentReady,
|
||||
intentInitError,
|
||||
intentInitErrorDetail,
|
||||
isIntentInitializing,
|
||||
isIntentSending,
|
||||
pendingIntentClarificationMsgId,
|
||||
intentClarificationSelections,
|
||||
|
||||
@@ -33,6 +33,13 @@ import { useUpdateSubtask } from "@/core/tasks/context";
|
||||
/** 干预消息携带的上传文件引用(与 streamMultiAgent 的 files 字段同形)。 */
|
||||
type RunFileRef = NonNullable<StreamMultiAgentRequest["files"]>[number];
|
||||
|
||||
type SeatDelivery = { name: string; content: string };
|
||||
|
||||
/** In-memory cursor for a business-chain human-validation pause. */
|
||||
type HumanValidationContinuation =
|
||||
| { mode: "chain"; nextSeatIndex: number }
|
||||
| { mode: "dag"; nextStageIndex: number; priorDeliveries: SeatDelivery[] };
|
||||
|
||||
/** 读取并清空 ref(consume-once):首个 leader run 拿到文件后清空,后续轮次不再带。 */
|
||||
function takeOnce<T>(ref: { current: T | null }): T | undefined {
|
||||
const v = ref.current ?? undefined;
|
||||
@@ -286,6 +293,8 @@ export interface UseStep2OrchestrationOptions {
|
||||
* OR。recommend / 未选链条 → 空 {}(各席位仅靠其自身开关)。
|
||||
*/
|
||||
seatSkillDirectives?: Record<string, boolean>;
|
||||
/** Per-seat human-validation checkpoints inherited from the selected chain. */
|
||||
humanValidations?: Record<string, boolean>;
|
||||
/**
|
||||
* 每席位「推理深度覆盖」映射 {agentId: SeatMode | null}(沿用所选业务链条里每个席位的配置)。
|
||||
* 派活该 special 席位时按 agentId 查表,**席位覆盖优先、否则跟随链条级 `selectedMode`**:
|
||||
@@ -456,6 +465,7 @@ export function useStep2Orchestration(
|
||||
selectedModel,
|
||||
selectedMode,
|
||||
seatSkillDirectives = {},
|
||||
humanValidations = {},
|
||||
seatModes = {},
|
||||
gatherFirst = false,
|
||||
orchestrationMode,
|
||||
@@ -593,6 +603,7 @@ export function useStep2Orchestration(
|
||||
const selectedModeRef = useRef<SeatMode>(selectedMode);
|
||||
// 每席位技能识别强化映射(所选业务链条各席位开关)同样打到 ref,席位调用按 agentId 读最新值。
|
||||
const seatSkillDirectivesRef = useRef<Record<string, boolean>>(seatSkillDirectives);
|
||||
const humanValidationsRef = useRef<Record<string, boolean>>(humanValidations);
|
||||
// 每席位推理深度覆盖映射(所选业务链条各席位 seatMode)打到 ref,席位调用按 agentId 读最新值;
|
||||
// 覆盖优先、否则跟随 selectedModeRef。
|
||||
const seatModesRef = useRef<Record<string, SeatMode | null>>(seatModes);
|
||||
@@ -624,6 +635,7 @@ export function useStep2Orchestration(
|
||||
// 澄清回复都改走 **leader 自由派活循环**(runOrchestration),由总控接着派活,而**不是**
|
||||
// 重新把整张 DAG 从头跑一遍(致命 bug 修复)。新任务 / 重新进入时由 resetInitGate 复位。
|
||||
const dagPlanExecutedRef = useRef<boolean>(false);
|
||||
const humanValidationContinuationRef = useRef<HumanValidationContinuation | null>(null);
|
||||
const selectedAgentsRef = useRef<SelectedAgent[]>(selectedAgents);
|
||||
useEffect(() => {
|
||||
orchestrationModeRef.current = orchestrationMode;
|
||||
@@ -643,6 +655,9 @@ export function useStep2Orchestration(
|
||||
useEffect(() => {
|
||||
seatSkillDirectivesRef.current = seatSkillDirectives;
|
||||
}, [seatSkillDirectives]);
|
||||
useEffect(() => {
|
||||
humanValidationsRef.current = humanValidations;
|
||||
}, [humanValidations]);
|
||||
useEffect(() => {
|
||||
seatModesRef.current = seatModes;
|
||||
}, [seatModes]);
|
||||
@@ -1213,6 +1228,21 @@ export function useStep2Orchestration(
|
||||
[],
|
||||
);
|
||||
|
||||
const pauseForHumanValidation = useCallback(
|
||||
(continuation: HumanValidationContinuation, seatNames: string[]) => {
|
||||
humanValidationContinuationRef.current = continuation;
|
||||
appendDialogue({
|
||||
sender: "系统",
|
||||
avatarType: "blue",
|
||||
content: `已完成 ${seatNames.join("、")} 的输出,按业务链条配置暂停,等待人工校验。可直接点击“恢复会商”继续,或在下方输入修改意见后提交。`,
|
||||
confidence: "等待人工校验",
|
||||
});
|
||||
setIsRoundTableRunning(false);
|
||||
triggerToast("已暂停等待人工校验:可恢复会商,或输入修改意见后继续");
|
||||
},
|
||||
[appendDialogue, triggerToast],
|
||||
);
|
||||
|
||||
const phaseLabel = useCallback(
|
||||
(
|
||||
phase: StreamPhase,
|
||||
@@ -2012,11 +2042,17 @@ export function useStep2Orchestration(
|
||||
|
||||
const chainSeats = selectedAgentsRef.current;
|
||||
const total = chainSeats.length;
|
||||
const continuation = humanValidationContinuationRef.current;
|
||||
const startSeatIndex =
|
||||
continuation?.mode === "chain" ? continuation.nextSeatIndex : 0;
|
||||
if (continuation?.mode === "chain") {
|
||||
humanValidationContinuationRef.current = null;
|
||||
}
|
||||
const leaderThreadId = resolveSeatThreadId(ids, coord);
|
||||
let finishedAllSeats = true;
|
||||
|
||||
try {
|
||||
for (let i = 0; i < total; i += 1) {
|
||||
for (let i = startSeatIndex; i < total; i += 1) {
|
||||
if (isStale()) {
|
||||
finishedAllSeats = false;
|
||||
break;
|
||||
@@ -2179,6 +2215,14 @@ export function useStep2Orchestration(
|
||||
}
|
||||
|
||||
setConsensusPercentage(Math.min(95, Math.round(((i + 1) / total) * 90)));
|
||||
if (humanValidationsRef.current[seat.agent_id]) {
|
||||
pauseForHumanValidation(
|
||||
{ mode: "chain", nextSeatIndex: i + 1 },
|
||||
[seatName],
|
||||
);
|
||||
finishedAllSeats = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── (c) 总控收口:综合全链交付,产出最终结论 ──
|
||||
@@ -2257,6 +2301,7 @@ export function useStep2Orchestration(
|
||||
handleUpstreamFrame,
|
||||
ingestSubagentTaskFrame,
|
||||
resolveSeatThreadId,
|
||||
pauseForHumanValidation,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2300,23 +2345,37 @@ export function useStep2Orchestration(
|
||||
dagPlanExecutedRef.current = true;
|
||||
|
||||
const leaderThreadId = resolveSeatThreadId(ids, coord);
|
||||
const continuation = humanValidationContinuationRef.current;
|
||||
const startStageIndex =
|
||||
continuation?.mode === "dag" ? continuation.nextStageIndex : 0;
|
||||
if (continuation?.mode === "dag") {
|
||||
humanValidationContinuationRef.current = null;
|
||||
}
|
||||
let finishedAllStages = true;
|
||||
// 跨 stage 累积的**完整交付** {name, content}[] —— 注入下一 stage 席位的 run context
|
||||
// (peerDeliveries),供 read_peer_delivery 工具按需取全文(§12.9 传摘要按需传全文)。
|
||||
// 研讨前并行取数(gatherFirst)的产物作为初始注入:首个 stage 席位开局即带共享资料。
|
||||
const priorDeliveries: Array<{ name: string; content: string }> = [...gatheredDeliveriesRef.current];
|
||||
const priorDeliveries: SeatDelivery[] =
|
||||
continuation?.mode === "dag"
|
||||
? [...continuation.priorDeliveries]
|
||||
: [...gatheredDeliveriesRef.current];
|
||||
|
||||
// Phase 7:初始化 DAG 流程图节点状态 —— 计划内所有席位置 pending,执行过程中逐个点亮。
|
||||
const markDagNode = (agentId: string, state: ChainNodeState) =>
|
||||
setDagNodeStates((prev) => ({ ...prev, [agentId.toLowerCase()]: state }));
|
||||
setDagNodeStates(
|
||||
Object.fromEntries(
|
||||
plan.stages.flatMap((st) => st.agentIds.map((id) => [id.toLowerCase(), "pending" as ChainNodeState])),
|
||||
plan.stages.flatMap((st, stageIndex) =>
|
||||
st.agentIds.map((id) => [
|
||||
id.toLowerCase(),
|
||||
(stageIndex < startStageIndex ? "done" : "pending") as ChainNodeState,
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
for (let s = 0; s < plan.stages.length; s += 1) {
|
||||
for (let s = startStageIndex; s < plan.stages.length; s += 1) {
|
||||
if (isStale()) {
|
||||
finishedAllStages = false;
|
||||
break;
|
||||
@@ -2558,6 +2617,21 @@ export function useStep2Orchestration(
|
||||
setConsensusPercentage(
|
||||
Math.min(95, Math.round(((s + 1) / plan.stages.length) * 90)),
|
||||
);
|
||||
const validationSeatNames = stageSeats
|
||||
.filter(({ agentId }) => humanValidationsRef.current[agentId])
|
||||
.map(({ name }) => name);
|
||||
if (validationSeatNames.length > 0) {
|
||||
pauseForHumanValidation(
|
||||
{
|
||||
mode: "dag",
|
||||
nextStageIndex: s + 1,
|
||||
priorDeliveries: [...priorDeliveries],
|
||||
},
|
||||
validationSeatNames,
|
||||
);
|
||||
finishedAllStages = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 计划阶段全部完成 → **交给总控(leader 自由派活循环)继续** ──
|
||||
@@ -2613,6 +2687,7 @@ export function useStep2Orchestration(
|
||||
ingestParallelDelta,
|
||||
resetParallelStreams,
|
||||
runOrchestration,
|
||||
pauseForHumanValidation,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2746,8 +2821,9 @@ export function useStep2Orchestration(
|
||||
// 改走 leader 自由派活循环,由总控接着派活,而不是把 DAG 从头重跑一遍。
|
||||
if (
|
||||
mode === "dag" &&
|
||||
!dagPlanExecutedRef.current &&
|
||||
(orchestrationPlanRef.current?.stages.length ?? 0) > 0
|
||||
(humanValidationContinuationRef.current?.mode === "dag" ||
|
||||
(!dagPlanExecutedRef.current &&
|
||||
(orchestrationPlanRef.current?.stages.length ?? 0) > 0))
|
||||
) {
|
||||
return runDagOrchestration(seedMessage, ids, coord);
|
||||
}
|
||||
@@ -3070,6 +3146,7 @@ export function useStep2Orchestration(
|
||||
|
||||
const resetInitGate = useCallback(() => {
|
||||
step2InitRef.current = false;
|
||||
humanValidationContinuationRef.current = null;
|
||||
// 新一轮研讨:允许 DAG 计划重新执行一遍。
|
||||
dagPlanExecutedRef.current = false;
|
||||
// 新一轮研讨:允许「研讨前并行取数」再取一次(清掉上一轮取数产物 + 闸门)。
|
||||
@@ -3086,6 +3163,7 @@ export function useStep2Orchestration(
|
||||
// 这里只重复设 cancel ref 与 UI 标记,确保从 Step 2 在编排中切到草稿时
|
||||
// UI 立刻翻成静止态。
|
||||
orchestrationCancelRef.current = true;
|
||||
humanValidationContinuationRef.current = null;
|
||||
setIsOrchestrating(false);
|
||||
setIsInitializing(false);
|
||||
setPendingClarification(null);
|
||||
@@ -3166,6 +3244,7 @@ export function useStep2Orchestration(
|
||||
return false;
|
||||
}
|
||||
dagPlanExecutedRef.current = true;
|
||||
humanValidationContinuationRef.current = null;
|
||||
orchestrationCancelRef.current = true;
|
||||
abortAllInFlight();
|
||||
isOrchestratingRef.current = false;
|
||||
@@ -3208,6 +3287,7 @@ export function useStep2Orchestration(
|
||||
hasAutoOpenedRef.current = new Set();
|
||||
pendingInjectPromptRef.current = null;
|
||||
dagPlanExecutedRef.current = false;
|
||||
humanValidationContinuationRef.current = null;
|
||||
gatherPhaseDoneRef.current = false;
|
||||
gatheredDeliveriesRef.current = [];
|
||||
deselectArtifact();
|
||||
|
||||
@@ -36,6 +36,11 @@ export interface ChainSeat {
|
||||
* (无需 DB 列),在链条编辑器里逐席位开关。
|
||||
*/
|
||||
seat_skill_directive?: boolean;
|
||||
/**
|
||||
* Human-validation checkpoint. When enabled, Step 2 pauses after this seat
|
||||
* (or its DAG stage) completes and reuses the existing resume/intervention UI.
|
||||
*/
|
||||
human_validation?: boolean;
|
||||
/**
|
||||
* 可选「席位推理深度覆盖」:覆盖链条级 `seatMode`,只对该席位生效。值是同一套
|
||||
* `SeatMode`(flash/thinking/pro/ultra);null / 缺省 = 跟随链条级默认。用于「卡顿提速」:
|
||||
@@ -109,7 +114,7 @@ export interface SeatSelectionResult {
|
||||
* `model` 为该席位在来源链条里配置的模型覆盖(空 / 缺省 = 跟随全局模型)。
|
||||
* `seat_skill_directive` 为该席位在来源链条里配置的「技能识别强化」开关(缺省 = 关)。
|
||||
*/
|
||||
agents: Array<{ agent_id: string; name: string; model?: string | null; seat_skill_directive?: boolean; seatMode?: SeatMode | null }>;
|
||||
agents: Array<{ agent_id: string; name: string; model?: string | null; seat_skill_directive?: boolean; human_validation?: boolean; seatMode?: SeatMode | null }>;
|
||||
/**
|
||||
* 决定 Step 2 走哪种编排:
|
||||
* - `recommend` 总控自由派活;
|
||||
@@ -171,6 +176,10 @@ export function buildChainSeatSelection(
|
||||
// 席位「技能识别强化」开关(链条配置):按 agent_id 取,缺省 = 关。
|
||||
const skillById = new Map(chain.seats.map((s) => [s.agent_id, s.seat_skill_directive ?? false]));
|
||||
const skillOf = (id: string) => skillById.get(id) ?? false;
|
||||
const humanValidationById = new Map(
|
||||
chain.seats.map((s) => [s.agent_id, s.human_validation ?? false]),
|
||||
);
|
||||
const humanValidationOf = (id: string) => humanValidationById.get(id) ?? false;
|
||||
// 席位「推理深度覆盖」(链条配置 seat_mode):按 agent_id 取,null/缺省 = 跟随链条级 seatMode。
|
||||
// 输出到 SeatSelectionResult.agents[].seatMode(camel,喂给编排/作业的 seatModes map)。
|
||||
const seatModeById = new Map(chain.seats.map((s) => [s.agent_id, s.seat_mode ?? null]));
|
||||
@@ -196,12 +205,12 @@ export function buildChainSeatSelection(
|
||||
if (hasParallel || hasGoal || hasCoord) {
|
||||
const agents = plan.stages
|
||||
.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) }));
|
||||
return { agents, mode: "dag", chain: chainMeta, plan, dagExecMode: "sandbox", seatMode: chain.seatMode, gatherFirst: chain.gatherFirst ?? false };
|
||||
}
|
||||
}
|
||||
|
||||
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) }));
|
||||
return { agents, mode: "chain", chain: chainMeta, seatMode: chain.seatMode, gatherFirst: chain.gatherFirst ?? false };
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
moveSeatToStage,
|
||||
moveStage,
|
||||
removeSeat,
|
||||
setSeatHumanValidation,
|
||||
setStageGoal,
|
||||
splitSeatToNewStage,
|
||||
stageGroupsToSeats,
|
||||
@@ -122,6 +123,15 @@ test("setStageGoal is immutable + out-of-range no-op", () => {
|
||||
assert.equal(setStageGoal(groups, 9, "x"), groups); // 越界返回原引用
|
||||
});
|
||||
|
||||
test("setSeatHumanValidation updates only the selected seat and is immutable", () => {
|
||||
const groups = grp([seat("a"), seat("b")], [seat("c")]);
|
||||
const next = setSeatHumanValidation(groups, "b", true);
|
||||
assert.equal(next[0].seats[1].human_validation, true);
|
||||
assert.equal(next[0].seats[0].human_validation, undefined);
|
||||
assert.equal(next[1].seats[0].human_validation, undefined);
|
||||
assert.equal(groups[0].seats[1].human_validation, undefined);
|
||||
});
|
||||
|
||||
// ── lookups ───────────────────────────────────────────────────────────────────
|
||||
|
||||
test("findStageIndexOfSeat / countSeats", () => {
|
||||
|
||||
@@ -141,6 +141,23 @@ export function setSeatSkillDirective(groups: StageGroups, agentId: string, enab
|
||||
});
|
||||
}
|
||||
|
||||
/** Update a seat's human-validation checkpoint without changing other stages. */
|
||||
export function setSeatHumanValidation(
|
||||
groups: StageGroups,
|
||||
agentId: string,
|
||||
enabled: boolean,
|
||||
): StageGroups {
|
||||
return groups.map((g) => {
|
||||
if (!g.seats.some((s) => s.agent_id === agentId)) return g;
|
||||
return {
|
||||
...g,
|
||||
seats: g.seats.map((s) =>
|
||||
s.agent_id === agentId ? { ...s, human_validation: enabled } : s,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置某个席位的「推理深度覆盖」(不可变,供编辑器席位下拉 onChange)。
|
||||
* `mode` 为 null = 清除覆盖(跟随链条级 seatMode)。按 agent_id 定位,跨 stage 适用。
|
||||
|
||||
@@ -292,9 +292,29 @@ function toStringArray(value: unknown): string[] {
|
||||
* 把解析出的对象归一成 IntentSummary。只要 `objective` 是非空字符串就认。
|
||||
* constraints / assumptions 容忍模型写成字符串 / null / 缺失,统一归到 string[]。
|
||||
*/
|
||||
function normalizeIntentSummary(parsed: unknown): IntentSummary | null {
|
||||
export function normalizeIntentReadySummary(parsed: unknown): IntentSummary | null {
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const positionKeys = ["coreGoals", "riskWarnings", "keyPoints", "strategicSignificance"];
|
||||
if (positionKeys.some((key) => key in obj)) {
|
||||
if (!positionKeys.every((key) => Array.isArray(obj[key]))) return null;
|
||||
const coreGoals = toStringArray(obj.coreGoals);
|
||||
const riskWarnings = toStringArray(obj.riskWarnings);
|
||||
const keyPoints = toStringArray(obj.keyPoints);
|
||||
const strategicSignificance = toStringArray(obj.strategicSignificance);
|
||||
// Keep the long-standing Step-1 state machine compatible while retaining
|
||||
// every position-specific field verbatim for its dedicated UI and storage.
|
||||
return {
|
||||
objective:
|
||||
strategicSignificance[0] || coreGoals[0] || keyPoints[0] || "岗位任务意图已识别",
|
||||
constraints: riskWarnings,
|
||||
assumptions: keyPoints,
|
||||
coreGoals,
|
||||
riskWarnings,
|
||||
keyPoints,
|
||||
strategicSignificance,
|
||||
};
|
||||
}
|
||||
const objective =
|
||||
typeof obj.objective === "string" ? obj.objective.trim() : "";
|
||||
if (!objective) return null;
|
||||
@@ -310,14 +330,14 @@ function normalizeIntentSummary(parsed: unknown): IntentSummary | null {
|
||||
* 1) 原文直接 parse
|
||||
* 2) 转义字符串内的裸控制字符(换行 / 制表符)后再 parse
|
||||
* 3) 再去掉尾随逗号后 parse
|
||||
* 三次都失败才放弃。任一成功都过 `normalizeIntentSummary` 兜字段类型。
|
||||
* 三次都失败才放弃。任一成功都过 `normalizeIntentReadySummary` 兜字段类型。
|
||||
*/
|
||||
function parseIntentSummary(raw: string): IntentSummary | null {
|
||||
const sanitized = sanitizeJsonControlChars(raw);
|
||||
const candidates = [raw, sanitized, stripTrailingCommas(sanitized)];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const summary = normalizeIntentSummary(JSON.parse(candidate));
|
||||
const summary = normalizeIntentReadySummary(JSON.parse(candidate));
|
||||
if (summary) return summary;
|
||||
} catch {
|
||||
// 下一个候选
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
Sparkles,
|
||||
SplitSquareVertical,
|
||||
Trash2,
|
||||
UserCheck,
|
||||
UserPlus,
|
||||
Wand2,
|
||||
X,
|
||||
@@ -127,6 +128,7 @@ import {
|
||||
seatExistsInGroups,
|
||||
setSeatMode as setSeatModeOnGroups,
|
||||
setSeatModel,
|
||||
setSeatHumanValidation,
|
||||
setSeatSkillDirective,
|
||||
setStageGoal,
|
||||
splitSeatToNewStage,
|
||||
@@ -453,6 +455,10 @@ export default function BusinessChainEditorPage() {
|
||||
setStageGroups((prev) => setSeatSkillDirective(prev, agentId, enabled));
|
||||
}, []);
|
||||
|
||||
const setSeatHumanValidationCb = useCallback((agentId: string, enabled: boolean) => {
|
||||
setStageGroups((prev) => setSeatHumanValidation(prev, agentId, enabled));
|
||||
}, []);
|
||||
|
||||
// 席位「推理深度覆盖」:编辑器席位小图标下拉 onChange(null = 清除,跟随链条级 seatMode)。
|
||||
const setSeatModeCb = useCallback((agentId: string, mode: SeatMode | null) => {
|
||||
setStageGroups((prev) => setSeatModeOnGroups(prev, agentId, mode));
|
||||
@@ -670,6 +676,7 @@ export default function BusinessChainEditorPage() {
|
||||
model: s.model ?? null,
|
||||
// 席位「会商技能识别强化」开关:随席位一起落库(缺省 = false 关)。
|
||||
seat_skill_directive: s.seat_skill_directive ?? false,
|
||||
human_validation: s.human_validation ?? false,
|
||||
// 席位「推理深度覆盖」:随席位一起落库(null = 跟随链条级 seatMode)。
|
||||
seat_mode: s.seat_mode ?? null,
|
||||
// 岗位协同专用岗位归属:普通圆桌忽略,但保存时必须原样保留。
|
||||
@@ -1272,6 +1279,7 @@ export default function BusinessChainEditorPage() {
|
||||
onSplitSeat={splitSeat}
|
||||
onSeatModelChange={setSeatModelCb}
|
||||
onSeatSkillDirectiveChange={setSeatSkillDirectiveCb}
|
||||
onSeatHumanValidationChange={setSeatHumanValidationCb}
|
||||
onSeatModeChange={setSeatModeCb}
|
||||
positionConfigMode={positionConfigMode}
|
||||
positionOptions={positionOptions}
|
||||
@@ -1455,6 +1463,7 @@ function StagesColumn({
|
||||
onSplitSeat,
|
||||
onSeatModelChange,
|
||||
onSeatSkillDirectiveChange,
|
||||
onSeatHumanValidationChange,
|
||||
onSeatModeChange,
|
||||
positionConfigMode,
|
||||
positionOptions,
|
||||
@@ -1476,6 +1485,7 @@ function StagesColumn({
|
||||
onSplitSeat: (agentId: string) => void;
|
||||
onSeatModelChange: (agentId: string, model: string | null) => void;
|
||||
onSeatSkillDirectiveChange: (agentId: string, enabled: boolean) => void;
|
||||
onSeatHumanValidationChange: (agentId: string, enabled: boolean) => void;
|
||||
onSeatModeChange: (agentId: string, mode: SeatMode | null) => void;
|
||||
/** 仅岗位协同入口启用;普通圆桌编辑器不显示岗位控件。 */
|
||||
positionConfigMode: boolean;
|
||||
@@ -1586,6 +1596,7 @@ function StagesColumn({
|
||||
onSplitSeat={onSplitSeat}
|
||||
onSeatModelChange={onSeatModelChange}
|
||||
onSeatSkillDirectiveChange={onSeatSkillDirectiveChange}
|
||||
onSeatHumanValidationChange={onSeatHumanValidationChange}
|
||||
onSeatModeChange={onSeatModeChange}
|
||||
positionConfigMode={positionConfigMode}
|
||||
positionOptions={positionOptions}
|
||||
@@ -1620,6 +1631,7 @@ function StageSection({
|
||||
onSplitSeat,
|
||||
onSeatModelChange,
|
||||
onSeatSkillDirectiveChange,
|
||||
onSeatHumanValidationChange,
|
||||
onSeatModeChange,
|
||||
positionConfigMode,
|
||||
positionOptions,
|
||||
@@ -1643,6 +1655,7 @@ function StageSection({
|
||||
onSplitSeat: (agentId: string) => void;
|
||||
onSeatModelChange: (agentId: string, model: string | null) => void;
|
||||
onSeatSkillDirectiveChange: (agentId: string, enabled: boolean) => void;
|
||||
onSeatHumanValidationChange: (agentId: string, enabled: boolean) => void;
|
||||
onSeatModeChange: (agentId: string, mode: SeatMode | null) => void;
|
||||
positionConfigMode: boolean;
|
||||
positionOptions: readonly PositionOption[];
|
||||
@@ -1809,6 +1822,7 @@ function StageSection({
|
||||
onSplit={() => onSplitSeat(seat.agent_id)}
|
||||
onModelChange={(model) => onSeatModelChange(seat.agent_id, model)}
|
||||
onSkillDirectiveChange={(enabled) => onSeatSkillDirectiveChange(seat.agent_id, enabled)}
|
||||
onHumanValidationChange={(enabled) => onSeatHumanValidationChange(seat.agent_id, enabled)}
|
||||
onSeatModeChange={(mode) => onSeatModeChange(seat.agent_id, mode)}
|
||||
showPositionConfig={positionConfigMode}
|
||||
positionOptions={positionOptions}
|
||||
@@ -1837,6 +1851,7 @@ function SeatStrip({
|
||||
onSplit,
|
||||
onModelChange,
|
||||
onSkillDirectiveChange,
|
||||
onHumanValidationChange,
|
||||
onSeatModeChange,
|
||||
showPositionConfig,
|
||||
positionOptions,
|
||||
@@ -1857,6 +1872,7 @@ function SeatStrip({
|
||||
onModelChange: (model: string | null) => void;
|
||||
/** 席位「会商技能识别强化」开关变更。 */
|
||||
onSkillDirectiveChange: (enabled: boolean) => void;
|
||||
onHumanValidationChange: (enabled: boolean) => void;
|
||||
/** 席位「推理深度覆盖」变更(null = 跟随链条级 seatMode)。 */
|
||||
onSeatModeChange: (mode: SeatMode | null) => void;
|
||||
/** 仅岗位协同入口展示的岗位配置。 */
|
||||
@@ -2058,6 +2074,38 @@ function SeatStrip({
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
title="人工校验:该席位完成后暂停会商,等待用户恢复或输入修改意见"
|
||||
className={cn(
|
||||
"p-1 transition",
|
||||
R_CTRL,
|
||||
seat.human_validation
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<UserCheck className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs font-semibold text-foreground">人工校验</p>
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
开启后,该席位所在的执行单元完成时会自动暂停。用户可直接点击“恢复会商”继续,或在下方输入修改意见后提交;不会新增校验卡片。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={seat.human_validation ?? false}
|
||||
onCheckedChange={onHumanValidationChange}
|
||||
aria-label={`切换 ${seat.name || seat.agent_id} 的人工校验`}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDetail}
|
||||
|
||||
@@ -409,6 +409,10 @@ export default function RoundtablePlanningPage({
|
||||
// Step 2 每席位「技能识别强化」开关映射 {agentId: bool}:由所选业务链条里**每个席位**的同名
|
||||
// 配置决定(recommend 无链条 → 空 {})。透传给每个席位 run,与各席位智能体自身的开关取 OR。
|
||||
const [seatSkillDirectives, setSeatSkillDirectives] = useState<Record<string, boolean>>({});
|
||||
// Per-seat human-validation checkpoints from the selected business chain.
|
||||
// A checkpoint pauses Step 2 after the configured seat/stage completes and
|
||||
// reuses the existing resume button plus bottom intervention composer.
|
||||
const [humanValidations, setHumanValidations] = useState<Record<string, boolean>>({});
|
||||
// Step 2 每席位「推理深度覆盖」映射 {agentId: SeatMode|null}:由所选业务链条里**每个席位**单独配
|
||||
// 的推理深度决定(null/未配 → 跟随链条级 seatMode)。透传给席位 run(覆盖优先、否则跟随 seatMode)。
|
||||
// 「卡顿提速」用:把次要席位降到 flash 即可大幅缩短第二步耗时。
|
||||
@@ -573,6 +577,7 @@ export default function RoundtablePlanningPage({
|
||||
selectedModel,
|
||||
selectedMode: seatMode,
|
||||
seatSkillDirectives,
|
||||
humanValidations,
|
||||
seatModes,
|
||||
gatherFirst,
|
||||
orchestrationMode,
|
||||
@@ -1058,6 +1063,9 @@ export default function RoundtablePlanningPage({
|
||||
setSeatSkillDirectives(
|
||||
Object.fromEntries(result.agents.map((a) => [a.agent_id, a.seat_skill_directive ?? false])),
|
||||
);
|
||||
setHumanValidations(
|
||||
Object.fromEntries(result.agents.map((a) => [a.agent_id, a.human_validation ?? false])),
|
||||
);
|
||||
// 每席位推理深度覆盖:取所选业务链条里**每席位**单独配的 seatMode → {agentId: SeatMode|null}
|
||||
// 映射(recommend / 未配 → null,跟随链条级 seatMode)。
|
||||
setSeatModes(
|
||||
|
||||
@@ -0,0 +1,719 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { useLiveAccount } from "@/core/auth/use-live-account";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
const API = () => `${getBackendBaseURL()}/api/llmwiki`;
|
||||
const SETTINGS_API = () => `${getBackendBaseURL()}/api/system-settings/llmwiki`;
|
||||
|
||||
export type LlmWikiProvider = "legacy" | "weknora";
|
||||
export type LlmWikiScope = "personal" | "public" | "all";
|
||||
|
||||
export interface LlmWikiRuntime {
|
||||
provider: LlmWikiProvider;
|
||||
api_base_url: string;
|
||||
web_base_url: string;
|
||||
override_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface LlmWikiSettings extends LlmWikiRuntime {
|
||||
file_api_base_url: string;
|
||||
file_web_base_url: string;
|
||||
effective_provider: LlmWikiProvider;
|
||||
}
|
||||
|
||||
export interface LlmWikiKnowledgeBase {
|
||||
id: string;
|
||||
remote_status: "available" | "missing";
|
||||
name: string;
|
||||
description: string;
|
||||
type: "document" | "faq";
|
||||
publication_status: "private" | "pending" | "published" | "rejected";
|
||||
owner_user_id?: string | null;
|
||||
is_owner: boolean;
|
||||
can_write: boolean;
|
||||
knowledge_count: number;
|
||||
chunk_count: number;
|
||||
processing_count: number;
|
||||
embedding_model_id?: string | null;
|
||||
vector_store_id?: string | null;
|
||||
vector_store_name?: string | null;
|
||||
vector_store_source?: string | null;
|
||||
vector_store_engine_type?: string | null;
|
||||
vector_store_status?: string | null;
|
||||
chunking_config?: Record<string, unknown>;
|
||||
indexing_strategy?: Record<string, boolean>;
|
||||
capabilities?: Record<string, boolean>;
|
||||
extract_config?: Record<string, unknown>;
|
||||
wiki_config?: Record<string, unknown>;
|
||||
question_generation_config?: Record<string, unknown>;
|
||||
summary_model_id?: string | null;
|
||||
storage_provider?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
published_at?: string | null;
|
||||
is_conversation_deposit?: boolean;
|
||||
}
|
||||
|
||||
export interface LlmWikiDocument {
|
||||
id: string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
source?: string;
|
||||
channel?: string;
|
||||
file_name?: string;
|
||||
file_type?: string;
|
||||
file_size?: number;
|
||||
storage_size?: number;
|
||||
parse_status?: string;
|
||||
pending_subtasks_count?: number;
|
||||
summary_status?: string;
|
||||
enable_status?: string;
|
||||
embedding_model_id?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
processed_at?: string;
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
export interface LlmWikiChunk {
|
||||
id: string;
|
||||
content: string;
|
||||
chunk_index: number;
|
||||
is_enabled: boolean;
|
||||
status?: number | string | null;
|
||||
start_at: number;
|
||||
end_at: number;
|
||||
chunk_type: string;
|
||||
parent_chunk_id?: string;
|
||||
relation_chunk_ids: string[];
|
||||
indirect_relation_chunk_ids: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface LlmWikiChunkPage {
|
||||
items: LlmWikiChunk[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface LlmWikiGraph {
|
||||
nodes: Array<{ id: string; title: string; type: string; link_count: number }>;
|
||||
edges: Array<{ source: string; target: string }>;
|
||||
meta: {
|
||||
mode?: string;
|
||||
total?: number;
|
||||
returned?: number;
|
||||
truncated?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LlmWikiWikiPage {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
page_type: string;
|
||||
status: string;
|
||||
content: string;
|
||||
summary: string;
|
||||
aliases: string[];
|
||||
parent_slug?: string;
|
||||
category_path?: string[];
|
||||
depth?: number;
|
||||
source_refs: string[];
|
||||
in_links: string[];
|
||||
out_links: string[];
|
||||
version: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface LlmWikiWikiPageList {
|
||||
pages: LlmWikiWikiPage[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface LlmWikiSearchResult {
|
||||
chunk_id: string;
|
||||
content: string;
|
||||
knowledge_id: string;
|
||||
knowledge_base_id: string;
|
||||
knowledge_base_name: string;
|
||||
title: string;
|
||||
filename: string;
|
||||
score: number;
|
||||
chunk_index: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface LlmWikiWeKnoraEmbedSession {
|
||||
frame_path: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
async function readJson<T>(response: Response, context: string): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null as unknown);
|
||||
const detail =
|
||||
payload && typeof payload === "object" && "detail" in payload
|
||||
? String((payload as { detail: unknown }).detail)
|
||||
: `${context}失败(${response.status})`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
if (response.status === 204) return undefined as T;
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function getLlmWikiRuntime(): Promise<LlmWikiRuntime> {
|
||||
return readJson(
|
||||
await apiFetch(`${API()}/runtime`),
|
||||
"读取 LLMWiki 运行模式",
|
||||
);
|
||||
}
|
||||
|
||||
export async function listLlmWikiKnowledgeBases(
|
||||
scope: LlmWikiScope,
|
||||
): Promise<LlmWikiKnowledgeBase[]> {
|
||||
const response = await apiFetch(
|
||||
`${API()}/knowledge-bases?scope=${encodeURIComponent(scope)}`,
|
||||
);
|
||||
const payload = await readJson<{ knowledge_bases: LlmWikiKnowledgeBase[] }>(
|
||||
response,
|
||||
"读取知识库",
|
||||
);
|
||||
return payload.knowledge_bases ?? [];
|
||||
}
|
||||
|
||||
export async function listAgentLlmWikiKnowledgeBases(
|
||||
agentId: string,
|
||||
): Promise<LlmWikiKnowledgeBase[]> {
|
||||
const payload = await readJson<{ knowledge_bases: LlmWikiKnowledgeBase[] }>(
|
||||
await apiFetch(
|
||||
`${API()}/agents/${encodeURIComponent(agentId)}/knowledge-bases`,
|
||||
),
|
||||
"读取智能体知识库",
|
||||
);
|
||||
return payload.knowledge_bases ?? [];
|
||||
}
|
||||
|
||||
export async function getLlmWikiKnowledgeBase(
|
||||
id: string,
|
||||
): Promise<LlmWikiKnowledgeBase> {
|
||||
return readJson(
|
||||
await apiFetch(`${API()}/knowledge-bases/${encodeURIComponent(id)}`),
|
||||
"读取知识库详情",
|
||||
);
|
||||
}
|
||||
|
||||
export async function createLlmWikiWeKnoraEmbedSession(
|
||||
id: string,
|
||||
tab: "documents" | "wiki" | "graph",
|
||||
): Promise<LlmWikiWeKnoraEmbedSession> {
|
||||
return readJson(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(id)}/weknora/session?tab=${encodeURIComponent(tab)}`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
"创建 WeKnora 详情会话",
|
||||
);
|
||||
}
|
||||
|
||||
export async function createLlmWikiKnowledgeBase(input: {
|
||||
name: string;
|
||||
description: string;
|
||||
type: "document" | "faq";
|
||||
wiki_enabled?: boolean;
|
||||
}): Promise<LlmWikiKnowledgeBase> {
|
||||
return readJson(
|
||||
await apiFetch(`${API()}/knowledge-bases`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
"创建知识库",
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateLlmWikiKnowledgeBase(
|
||||
id: string,
|
||||
input: { name?: string; description?: string },
|
||||
): Promise<LlmWikiKnowledgeBase> {
|
||||
return readJson(
|
||||
await apiFetch(`${API()}/knowledge-bases/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
"更新知识库",
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteLlmWikiKnowledgeBase(id: string): Promise<void> {
|
||||
await readJson<void>(
|
||||
await apiFetch(`${API()}/knowledge-bases/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
"删除知识库",
|
||||
);
|
||||
}
|
||||
|
||||
export async function listLlmWikiDocuments(
|
||||
knowledgeBaseId: string,
|
||||
): Promise<LlmWikiDocument[]> {
|
||||
const payload = await readJson<{ items: LlmWikiDocument[] }>(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents`,
|
||||
),
|
||||
"读取文档",
|
||||
);
|
||||
return payload.items ?? [];
|
||||
}
|
||||
|
||||
export async function getLlmWikiDocument(
|
||||
knowledgeBaseId: string,
|
||||
documentId: string,
|
||||
): Promise<LlmWikiDocument> {
|
||||
return readJson(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents/${encodeURIComponent(documentId)}`,
|
||||
),
|
||||
"读取文档详情",
|
||||
);
|
||||
}
|
||||
|
||||
export async function listLlmWikiDocumentChunks(
|
||||
knowledgeBaseId: string,
|
||||
documentId: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): Promise<LlmWikiChunkPage> {
|
||||
return readJson(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents/${encodeURIComponent(documentId)}/chunks?page=${page}&page_size=${pageSize}`,
|
||||
),
|
||||
"读取文档索引",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getLlmWikiDocumentPreview(
|
||||
knowledgeBaseId: string,
|
||||
documentId: string,
|
||||
): Promise<Blob> {
|
||||
const response = await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents/${encodeURIComponent(documentId)}/preview`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
await readJson<void>(response, "读取文档原件");
|
||||
throw new Error("读取文档原件失败");
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export async function getLlmWikiGraph(knowledgeBaseId: string): Promise<LlmWikiGraph> {
|
||||
return readJson(
|
||||
await apiFetch(`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/graph?limit=200`),
|
||||
"读取知识图谱",
|
||||
);
|
||||
}
|
||||
|
||||
export async function listLlmWikiWikiPages(
|
||||
knowledgeBaseId: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
query = "",
|
||||
): Promise<LlmWikiWikiPageList> {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
|
||||
if (query.trim()) params.set("query", query.trim());
|
||||
return readJson(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/pages?${params.toString()}`,
|
||||
),
|
||||
"读取 Wiki 页面",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getLlmWikiWikiPage(
|
||||
knowledgeBaseId: string,
|
||||
slug: string,
|
||||
): Promise<LlmWikiWikiPage> {
|
||||
const safeSlug = slug.split("/").map(encodeURIComponent).join("/");
|
||||
return readJson(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/wiki/pages/${safeSlug}`,
|
||||
),
|
||||
"读取 Wiki 页面详情",
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadLlmWikiDocument(
|
||||
knowledgeBaseId: string,
|
||||
file: File,
|
||||
): Promise<LlmWikiDocument> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
return readJson(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents`,
|
||||
{ method: "POST", body: form },
|
||||
),
|
||||
"上传文档",
|
||||
);
|
||||
}
|
||||
|
||||
export async function importLlmWikiDocumentUrl(
|
||||
knowledgeBaseId: string,
|
||||
url: string,
|
||||
): Promise<LlmWikiDocument> {
|
||||
return readJson(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents/url`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
},
|
||||
),
|
||||
"导入网页",
|
||||
);
|
||||
}
|
||||
|
||||
export async function createManualLlmWikiDocument(
|
||||
knowledgeBaseId: string,
|
||||
input: { title: string; content: string },
|
||||
): Promise<LlmWikiDocument> {
|
||||
return readJson(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents/manual`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
),
|
||||
"创建 Wiki 内容",
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteLlmWikiDocument(
|
||||
knowledgeBaseId: string,
|
||||
documentId: string,
|
||||
): Promise<void> {
|
||||
await readJson<void>(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents/${encodeURIComponent(documentId)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
"删除文档",
|
||||
);
|
||||
}
|
||||
|
||||
export async function reprocessLlmWikiDocument(
|
||||
knowledgeBaseId: string,
|
||||
documentId: string,
|
||||
): Promise<void> {
|
||||
await readJson(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/documents/${encodeURIComponent(documentId)}/reprocess`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
"重新解析文档",
|
||||
);
|
||||
}
|
||||
|
||||
export async function setLlmWikiPublication(
|
||||
knowledgeBaseId: string,
|
||||
publish: boolean,
|
||||
): Promise<void> {
|
||||
await readJson(
|
||||
await apiFetch(
|
||||
`${API()}/knowledge-bases/${encodeURIComponent(knowledgeBaseId)}/${publish ? "publish" : "unpublish"}`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
publish ? "发布知识库" : "取消发布",
|
||||
);
|
||||
}
|
||||
|
||||
export async function searchLlmWiki(input: {
|
||||
query: string;
|
||||
scope: LlmWikiScope;
|
||||
knowledge_base_ids?: string[];
|
||||
}): Promise<LlmWikiSearchResult[]> {
|
||||
const payload = await readJson<{ results: LlmWikiSearchResult[] }>(
|
||||
await apiFetch(`${API()}/search`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
"检索知识库",
|
||||
);
|
||||
return payload.results ?? [];
|
||||
}
|
||||
|
||||
export async function createLlmWikiConversationDeposit(input: {
|
||||
thread_id: string;
|
||||
question: string;
|
||||
answer: string;
|
||||
human_message_id?: string | null;
|
||||
assistant_message_id: string;
|
||||
assistant_id?: string | null;
|
||||
knowledge_base_ids?: string[];
|
||||
}): Promise<{ queued: boolean; provider: LlmWikiProvider }> {
|
||||
return readJson(
|
||||
await apiFetch(`${API()}/conversation-deposits/turn`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
"写入对话沉淀",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getLlmWikiSettings(): Promise<LlmWikiSettings> {
|
||||
return readJson(await apiFetch(SETTINGS_API()), "读取 WeKnora 连接配置");
|
||||
}
|
||||
|
||||
export async function saveLlmWikiSettings(input: {
|
||||
override_enabled: boolean;
|
||||
api_base_url: string;
|
||||
web_base_url: string;
|
||||
}): Promise<LlmWikiRuntime> {
|
||||
return readJson(
|
||||
await apiFetch(SETTINGS_API(), {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
}),
|
||||
"保存 WeKnora 连接配置",
|
||||
);
|
||||
}
|
||||
|
||||
export async function resetLlmWikiSettings(): Promise<LlmWikiRuntime> {
|
||||
return readJson(
|
||||
await apiFetch(`${SETTINGS_API()}/override`, { method: "DELETE" }),
|
||||
"恢复配置文件设置",
|
||||
);
|
||||
}
|
||||
|
||||
export async function testLlmWikiConnection(
|
||||
apiBaseUrl: string,
|
||||
): Promise<void> {
|
||||
await readJson(
|
||||
await apiFetch(`${SETTINGS_API()}/test`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ api_base_url: apiBaseUrl }),
|
||||
}),
|
||||
"测试 WeKnora 连接",
|
||||
);
|
||||
}
|
||||
|
||||
const runtimeKey = ["llmwiki", "runtime"] as const;
|
||||
const settingsKey = ["llmwiki", "settings"] as const;
|
||||
const knowledgeBasesKey = (userId: string | null, scope: LlmWikiScope) =>
|
||||
["llmwiki", "knowledge-bases", userId ?? "anonymous", scope] as const;
|
||||
|
||||
export function useLlmWikiRuntime() {
|
||||
return useQuery({
|
||||
queryKey: runtimeKey,
|
||||
queryFn: getLlmWikiRuntime,
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiKnowledgeBases(
|
||||
scope: LlmWikiScope,
|
||||
enabled = true,
|
||||
) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: knowledgeBasesKey(userId, scope),
|
||||
queryFn: () => listLlmWikiKnowledgeBases(scope),
|
||||
enabled,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAgentLlmWikiKnowledgeBases(
|
||||
agentId: string | null | undefined,
|
||||
enabled = true,
|
||||
) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: ["llmwiki", "agents", agentId, "knowledge-bases", userId ?? "anonymous"],
|
||||
queryFn: () => listAgentLlmWikiKnowledgeBases(agentId!),
|
||||
enabled: enabled && !!agentId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiKnowledgeBase(
|
||||
knowledgeBaseId: string | null,
|
||||
enabled = true,
|
||||
) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: ["llmwiki", "knowledge-base", knowledgeBaseId, userId ?? "anonymous"],
|
||||
queryFn: () => getLlmWikiKnowledgeBase(knowledgeBaseId!),
|
||||
enabled: enabled && !!knowledgeBaseId,
|
||||
refetchInterval: 8_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiDocuments(
|
||||
knowledgeBaseId: string | null,
|
||||
enabled = true,
|
||||
) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: ["llmwiki", "documents", knowledgeBaseId, userId ?? "anonymous"],
|
||||
queryFn: () => listLlmWikiDocuments(knowledgeBaseId!),
|
||||
enabled: enabled && !!knowledgeBaseId,
|
||||
refetchInterval: 8_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiDocument(
|
||||
knowledgeBaseId: string | null,
|
||||
documentId: string | null,
|
||||
enabled = true,
|
||||
) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: ["llmwiki", "document", knowledgeBaseId, documentId, userId ?? "anonymous"],
|
||||
queryFn: () => getLlmWikiDocument(knowledgeBaseId!, documentId!),
|
||||
enabled: enabled && !!knowledgeBaseId && !!documentId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiDocumentChunks(
|
||||
knowledgeBaseId: string | null,
|
||||
documentId: string | null,
|
||||
page: number,
|
||||
pageSize = 20,
|
||||
enabled = true,
|
||||
) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: ["llmwiki", "document-chunks", knowledgeBaseId, documentId, page, pageSize, userId ?? "anonymous"],
|
||||
queryFn: () => listLlmWikiDocumentChunks(knowledgeBaseId!, documentId!, page, pageSize),
|
||||
enabled: enabled && !!knowledgeBaseId && !!documentId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiGraph(knowledgeBaseId: string | null, enabled = true) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: ["llmwiki", "graph", knowledgeBaseId, userId ?? "anonymous"],
|
||||
queryFn: () => getLlmWikiGraph(knowledgeBaseId!),
|
||||
enabled: enabled && !!knowledgeBaseId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiWikiPages(
|
||||
knowledgeBaseId: string | null,
|
||||
page: number,
|
||||
pageSize = 50,
|
||||
query = "",
|
||||
enabled = true,
|
||||
) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: ["llmwiki", "wiki-pages", knowledgeBaseId, page, pageSize, query, userId ?? "anonymous"],
|
||||
queryFn: () => listLlmWikiWikiPages(knowledgeBaseId!, page, pageSize, query),
|
||||
enabled: enabled && !!knowledgeBaseId,
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiWikiPage(
|
||||
knowledgeBaseId: string | null,
|
||||
slug: string | null,
|
||||
enabled = true,
|
||||
) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: ["llmwiki", "wiki-page", knowledgeBaseId, slug, userId ?? "anonymous"],
|
||||
queryFn: () => getLlmWikiWikiPage(knowledgeBaseId!, slug!),
|
||||
enabled: enabled && !!knowledgeBaseId && !!slug,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiSettings(enabled = true) {
|
||||
const { userId } = useLiveAccount();
|
||||
return useQuery({
|
||||
queryKey: [...settingsKey, userId ?? "anonymous"],
|
||||
queryFn: getLlmWikiSettings,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLlmWikiActions() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidateKnowledge = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["llmwiki"] });
|
||||
return {
|
||||
create: useMutation({
|
||||
mutationFn: createLlmWikiKnowledgeBase,
|
||||
onSuccess: invalidateKnowledge,
|
||||
}),
|
||||
update: useMutation({
|
||||
mutationFn: ({ id, ...input }: { id: string; name?: string; description?: string }) =>
|
||||
updateLlmWikiKnowledgeBase(id, input),
|
||||
onSuccess: invalidateKnowledge,
|
||||
}),
|
||||
remove: useMutation({
|
||||
mutationFn: deleteLlmWikiKnowledgeBase,
|
||||
onSuccess: invalidateKnowledge,
|
||||
}),
|
||||
upload: useMutation({
|
||||
mutationFn: ({ id, file }: { id: string; file: File }) =>
|
||||
uploadLlmWikiDocument(id, file),
|
||||
onSuccess: invalidateKnowledge,
|
||||
}),
|
||||
importUrl: useMutation({
|
||||
mutationFn: ({ id, url }: { id: string; url: string }) =>
|
||||
importLlmWikiDocumentUrl(id, url),
|
||||
onSuccess: invalidateKnowledge,
|
||||
}),
|
||||
createManual: useMutation({
|
||||
mutationFn: ({ id, title, content }: { id: string; title: string; content: string }) =>
|
||||
createManualLlmWikiDocument(id, { title, content }),
|
||||
onSuccess: invalidateKnowledge,
|
||||
}),
|
||||
removeDocument: useMutation({
|
||||
mutationFn: ({ id, documentId }: { id: string; documentId: string }) =>
|
||||
deleteLlmWikiDocument(id, documentId),
|
||||
onSuccess: invalidateKnowledge,
|
||||
}),
|
||||
reprocessDocument: useMutation({
|
||||
mutationFn: ({ id, documentId }: { id: string; documentId: string }) =>
|
||||
reprocessLlmWikiDocument(id, documentId),
|
||||
onSuccess: invalidateKnowledge,
|
||||
}),
|
||||
publication: useMutation({
|
||||
mutationFn: ({ id, publish }: { id: string; publish: boolean }) =>
|
||||
setLlmWikiPublication(id, publish),
|
||||
onSuccess: invalidateKnowledge,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function useLlmWikiSettingsActions() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["llmwiki"] });
|
||||
};
|
||||
return {
|
||||
save: useMutation({ mutationFn: saveLlmWikiSettings, onSuccess: invalidate }),
|
||||
reset: useMutation({ mutationFn: resetLlmWikiSettings, onSuccess: invalidate }),
|
||||
test: useMutation({ mutationFn: testLlmWikiConnection }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { AlertCircle, Loader2, RefreshCw, Upload } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Navigate, useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import {
|
||||
createLlmWikiWeKnoraEmbedSession,
|
||||
useLlmWikiActions,
|
||||
useLlmWikiKnowledgeBase,
|
||||
useLlmWikiRuntime,
|
||||
} from "@/strategy-components/api/llmwiki";
|
||||
|
||||
const LIST_PATH = "/page/workspace/knowledge";
|
||||
|
||||
type DeerFlowThemePayload = {
|
||||
type: "deerflow-weknora-theme";
|
||||
theme: "dark" | "light";
|
||||
vars: Record<string, string>;
|
||||
};
|
||||
|
||||
function cssVar(name: string, fallback = ""): string {
|
||||
if (typeof window === "undefined") return fallback;
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
||||
}
|
||||
|
||||
function collectThemePayload(): DeerFlowThemePayload {
|
||||
const isDark =
|
||||
document.documentElement.classList.contains("dark") ||
|
||||
document.documentElement.getAttribute("theme-mode") === "dark";
|
||||
return {
|
||||
type: "deerflow-weknora-theme",
|
||||
theme: isDark ? "dark" : "light",
|
||||
vars: {
|
||||
primary: cssVar("--primary", isDark ? "#2b81ff" : "#0062d9"),
|
||||
primaryForeground: cssVar("--button-foreground", cssVar("--primary-foreground", "#ffffff")),
|
||||
background: cssVar("--background", isDark ? "#0e172b" : "#ffffff"),
|
||||
foreground: cssVar("--foreground", isDark ? "#eff2f5" : "#202a3a"),
|
||||
card: cssVar("--card", isDark ? "#1a2539" : "#ffffff"),
|
||||
cardForeground: cssVar("--card-foreground", cssVar("--foreground", isDark ? "#eff2f5" : "#202a3a")),
|
||||
muted: cssVar("--muted", isDark ? "#232e40" : "#f3f5f8"),
|
||||
mutedForeground: cssVar("--muted-foreground", isDark ? "#8e9aa4" : "#7f7f7f"),
|
||||
border: cssVar("--border", isDark ? "rgba(255,255,255,0.12)" : "#dbdbdb"),
|
||||
popover: cssVar("--popover", cssVar("--card", isDark ? "#1a2539" : "#ffffff")),
|
||||
destructive: cssVar("--destructive", "#d54941"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function postThemeToFrame(frame: HTMLIFrameElement | null) {
|
||||
if (!frame?.contentWindow) return;
|
||||
const src = frame.getAttribute("src") || "";
|
||||
const targetOrigin = new URL(src, window.location.href).origin;
|
||||
frame.contentWindow.postMessage(collectThemePayload(), targetOrigin);
|
||||
}
|
||||
|
||||
export function WeKnoraKnowledgeDetailPage() {
|
||||
const {
|
||||
knowledge_base_id: strategyKnowledgeBaseId,
|
||||
noteId: workspaceKnowledgeBaseId,
|
||||
} = useParams();
|
||||
const knowledgeBaseId = strategyKnowledgeBaseId ?? workspaceKnowledgeBaseId ?? "";
|
||||
const navigate = useNavigate();
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [frameSrc, setFrameSrc] = useState("");
|
||||
const [frameError, setFrameError] = useState<string | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const runtime = useLlmWikiRuntime();
|
||||
const details = useLlmWikiKnowledgeBase(knowledgeBaseId || null, !!knowledgeBaseId);
|
||||
const actions = useLlmWikiActions();
|
||||
|
||||
const detailsReady = !!details.data;
|
||||
const preferredTab = details.data?.indexing_strategy?.wiki_enabled ? "wiki" : "documents";
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setFrameSrc("");
|
||||
setFrameError(null);
|
||||
if (!knowledgeBaseId || runtime.data?.provider !== "weknora" || !detailsReady) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
void createLlmWikiWeKnoraEmbedSession(knowledgeBaseId, preferredTab)
|
||||
.then((session) => {
|
||||
if (cancelled) return;
|
||||
const base = getBackendBaseURL().replace(/\/+$/, "");
|
||||
const path = session.frame_path.startsWith("/")
|
||||
? session.frame_path
|
||||
: `/${session.frame_path}`;
|
||||
const nextUrl = new URL(`${base}${path}`);
|
||||
nextUrl.searchParams.set("theme", collectThemePayload().theme);
|
||||
setFrameSrc(nextUrl.toString());
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return;
|
||||
const message = error instanceof Error ? error.message : "创建 WeKnora 详情会话失败";
|
||||
setFrameError(message);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [detailsReady, knowledgeBaseId, preferredTab, runtime.data?.provider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!frameSrc) return;
|
||||
const frame = frameRef.current;
|
||||
const send = () => postThemeToFrame(frame);
|
||||
const timer = window.setTimeout(send, 300);
|
||||
const observer = new MutationObserver(send);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style", "data-theme", "theme-mode"],
|
||||
});
|
||||
window.addEventListener("storage", send);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
observer.disconnect();
|
||||
window.removeEventListener("storage", send);
|
||||
};
|
||||
}, [frameSrc]);
|
||||
|
||||
if (runtime.isLoading || details.isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 size-5 animate-spin" />
|
||||
加载中
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (runtime.data?.provider !== "weknora") return <Navigate to={LIST_PATH} replace />;
|
||||
|
||||
if (frameError) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 bg-background px-6 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||
<AlertCircle className="size-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold text-foreground">无法打开 WeKnora 详情</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{frameError}</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>
|
||||
返回知识库
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (details.error || !details.data) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 bg-background px-6 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||
<AlertCircle className="size-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold text-foreground">无法打开知识库详情</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
当前账号没有访问权限,或 WeKnora 资源已不存在。
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => navigate(LIST_PATH)}>
|
||||
返回知识库
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const canUpload = details.data.can_write && !details.data.is_conversation_deposit;
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
{canUpload && (
|
||||
<div className="flex h-12 shrink-0 items-center justify-end border-b px-4">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.currentTarget.files ?? []);
|
||||
event.currentTarget.value = "";
|
||||
if (files.length === 0) return;
|
||||
void (async () => {
|
||||
setUploading(true);
|
||||
const failed: string[] = [];
|
||||
try {
|
||||
for (const file of files) {
|
||||
try {
|
||||
await actions.upload.mutateAsync({ id: knowledgeBaseId, file });
|
||||
} catch {
|
||||
failed.push(file.name);
|
||||
}
|
||||
}
|
||||
const succeeded = files.length - failed.length;
|
||||
if (succeeded > 0) {
|
||||
await details.refetch();
|
||||
if (frameRef.current) frameRef.current.src = frameSrc;
|
||||
}
|
||||
if (failed.length === 0) {
|
||||
toast.success(`已上传 ${succeeded} 个文件,正在处理`);
|
||||
} else if (succeeded > 0) {
|
||||
toast.warning(`上传完成:成功 ${succeeded} 个,失败 ${failed.length} 个`);
|
||||
} else {
|
||||
toast.error("文件上传失败");
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-2 size-4" />
|
||||
)}
|
||||
上传文件
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{frameSrc && (
|
||||
<iframe
|
||||
key={frameSrc}
|
||||
ref={frameRef}
|
||||
title={`${details.data.name || "WeKnora"} 详情`}
|
||||
src={frameSrc}
|
||||
className="h-full min-h-0 w-full flex-1 border-0 bg-background"
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-downloads allow-popups"
|
||||
referrerPolicy="no-referrer"
|
||||
onLoad={() => postThemeToFrame(frameRef.current)}
|
||||
/>
|
||||
)}
|
||||
{!frameSrc && (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center bg-background text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 size-5 animate-spin" />
|
||||
加载中
|
||||
</div>
|
||||
)}
|
||||
{details.isFetching && (
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 rounded-full border bg-card px-3 py-2 text-xs text-muted-foreground shadow-sm">
|
||||
<RefreshCw className="mr-2 inline size-3 animate-spin" />
|
||||
同步知识库状态
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WeKnoraKnowledgeDetailPage;
|
||||
@@ -0,0 +1,318 @@
|
||||
import {
|
||||
BookOpen,
|
||||
Database,
|
||||
FileText,
|
||||
Globe2,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import {
|
||||
type LlmWikiKnowledgeBase,
|
||||
type LlmWikiScope,
|
||||
searchLlmWiki,
|
||||
useLlmWikiActions,
|
||||
useLlmWikiKnowledgeBases,
|
||||
useLlmWikiSettings,
|
||||
useLlmWikiSettingsActions,
|
||||
} from "@/strategy-components/api/llmwiki";
|
||||
|
||||
type WorkspaceTab = "personal" | "public" | "settings";
|
||||
const LIST_PATH = "/page/workspace/knowledge";
|
||||
|
||||
const publicationLabels: Record<string, string> = {
|
||||
private: "仅自己",
|
||||
pending: "仅自己",
|
||||
published: "已公开",
|
||||
rejected: "仅自己",
|
||||
};
|
||||
|
||||
function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "操作失败,请稍后重试";
|
||||
}
|
||||
|
||||
export function WeKnoraKnowledgeWorkspace() {
|
||||
const navigate = useNavigate();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const [tab, setTab] = useState<WorkspaceTab>("personal");
|
||||
const scope: LlmWikiScope = tab === "public" ? "public" : "personal";
|
||||
const knowledge = useLlmWikiKnowledgeBases(scope, tab !== "settings");
|
||||
const actions = useLlmWikiActions();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [searchResults, setSearchResults] = useState<Array<{
|
||||
chunk_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
score: number;
|
||||
knowledge_base_name: string;
|
||||
}>>([]);
|
||||
|
||||
useEffect(() => setSearchResults([]), [tab]);
|
||||
|
||||
const busy = actions.create.isPending || actions.remove.isPending || actions.publication.isPending;
|
||||
const openDetails = (item: LlmWikiKnowledgeBase) => {
|
||||
if (item.remote_status === "missing") {
|
||||
toast.error("该知识库已从 WeKnora 删除,只能清理当前记录");
|
||||
return;
|
||||
}
|
||||
navigate(`${LIST_PATH}/${encodeURIComponent(item.id)}`);
|
||||
};
|
||||
|
||||
const runSearch = async () => {
|
||||
const query = searchText.trim();
|
||||
if (!query) return;
|
||||
setSearching(true);
|
||||
try {
|
||||
setSearchResults(await searchLlmWiki({ query, scope }));
|
||||
} catch (error) {
|
||||
toast.error(messageOf(error));
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeKnowledgeBase = async (item: LlmWikiKnowledgeBase) => {
|
||||
const warning = item.remote_status === "missing"
|
||||
? `知识库“${item.name}”已不在 WeKnora 中。确定清理 cmzs 中的这条失效记录吗?`
|
||||
: `确定删除知识库“${item.name}”吗?WeKnora 中的文档和索引也会删除。`;
|
||||
if (!window.confirm(warning)) return;
|
||||
try {
|
||||
await actions.remove.mutateAsync(item.id);
|
||||
toast.success("知识库已删除");
|
||||
} catch (error) {
|
||||
toast.error(messageOf(error));
|
||||
}
|
||||
};
|
||||
|
||||
const togglePublication = async (item: LlmWikiKnowledgeBase) => {
|
||||
const publish = item.publication_status !== "published";
|
||||
try {
|
||||
await actions.publication.mutateAsync({ id: item.id, publish });
|
||||
toast.success(publish ? "知识库已发布到公共 LLMWiki" : "知识库已取消公开");
|
||||
} catch (error) {
|
||||
toast.error(messageOf(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
<div className="border-b bg-gradient-to-r from-primary/8 via-card to-card px-6 py-5">
|
||||
<div className="mx-auto flex max-w-[1500px] flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="size-5 text-primary" />
|
||||
<h1 className="text-xl font-semibold">问答梳理</h1>
|
||||
<Badge variant="secondary">WeKnora</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">由 WeKnora 负责解析与检索,用户、权限和发布范围由 cmzs 管理。</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-full border bg-background/80 p-1 shadow-sm backdrop-blur">
|
||||
{tab !== "settings" && (
|
||||
<Button variant="ghost" size="sm" className="rounded-full" onClick={() => knowledge.refetch()} disabled={knowledge.isFetching}>
|
||||
<RefreshCw className={`mr-2 size-4 ${knowledge.isFetching ? "animate-spin" : ""}`} />刷新
|
||||
</Button>
|
||||
)}
|
||||
{tab === "personal" && <Button size="sm" className="rounded-full px-4 shadow-sm" onClick={() => setCreateOpen(true)}><Plus className="mr-2 size-4" />新建知识库</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto px-6 py-5">
|
||||
<div className="mx-auto max-w-[1500px]">
|
||||
<Tabs value={tab} onValueChange={(value) => setTab(value as WorkspaceTab)}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border bg-card/80 p-3 shadow-sm">
|
||||
<TabsList className="rounded-xl bg-muted/70">
|
||||
<TabsTrigger value="personal"><BookOpen className="mr-2 size-4" />我的知识库</TabsTrigger>
|
||||
<TabsTrigger value="public"><Globe2 className="mr-2 size-4" />公共 LLMWiki</TabsTrigger>
|
||||
{isAdmin && <TabsTrigger value="settings"><Settings2 className="mr-2 size-4" />连接配置</TabsTrigger>}
|
||||
</TabsList>
|
||||
{tab !== "settings" && (
|
||||
<div className="flex w-full max-w-xl items-center gap-2 rounded-xl border bg-background/80 p-1 shadow-sm sm:w-auto">
|
||||
<div className="relative min-w-0 flex-1 sm:w-80">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input value={searchText} onChange={(event) => setSearchText(event.target.value)} onKeyDown={(event) => event.key === "Enter" && void runSearch()} placeholder={tab === "public" ? "检索公共知识" : "检索我的知识"} className="border-0 bg-transparent pl-9 shadow-none focus-visible:ring-0" />
|
||||
</div>
|
||||
<Button variant="secondary" className="rounded-lg px-4" onClick={() => void runSearch()} disabled={searching || !searchText.trim()}>{searching ? <Loader2 className="size-4 animate-spin" /> : "检索"}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TabsContent value="personal" className="mt-5"><KnowledgeGrid rows={knowledge.data ?? []} loading={knowledge.isLoading} error={knowledge.error} emptyText="还没有个人知识库,点击右上角创建。" busy={busy} onOpen={openDetails} onPublish={togglePublication} onDelete={removeKnowledgeBase} /></TabsContent>
|
||||
<TabsContent value="public" className="mt-5"><KnowledgeGrid rows={knowledge.data ?? []} loading={knowledge.isLoading} error={knowledge.error} emptyText="公共 LLMWiki 暂无已发布知识库。" busy={busy} onOpen={openDetails} onPublish={togglePublication} onDelete={removeKnowledgeBase} /></TabsContent>
|
||||
{isAdmin && <TabsContent value="settings" className="mt-5"><ConnectionSettings /></TabsContent>}
|
||||
</Tabs>
|
||||
|
||||
{searchResults.length > 0 && tab !== "settings" && (
|
||||
<Card className="mt-5">
|
||||
<CardHeader className="pb-3"><CardTitle className="text-base">检索结果</CardTitle><CardDescription>结果由 WeKnora 检索返回,问答中使用同一授权链路。</CardDescription></CardHeader>
|
||||
<CardContent className="space-y-3">{searchResults.map((result) => <div key={result.chunk_id} className="rounded-lg border p-3"><div className="flex items-center justify-between gap-3 text-sm font-medium"><span>{result.title || "未命名来源"}</span><Badge variant="outline">{result.knowledge_base_name}</Badge></div><p className="mt-2 line-clamp-3 whitespace-pre-wrap text-sm text-muted-foreground">{result.content}</p></div>)}</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateKnowledgeDialog open={createOpen} pending={actions.create.isPending} onOpenChange={setCreateOpen} onCreate={async (input) => {
|
||||
try {
|
||||
const created = await actions.create.mutateAsync(input);
|
||||
setCreateOpen(false);
|
||||
navigate(`${LIST_PATH}/${encodeURIComponent(created.id)}`);
|
||||
toast.success("知识库已创建");
|
||||
} catch (error) {
|
||||
toast.error(messageOf(error));
|
||||
}
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KnowledgeGrid({
|
||||
rows,
|
||||
loading,
|
||||
error,
|
||||
emptyText,
|
||||
busy,
|
||||
onOpen,
|
||||
onPublish,
|
||||
onDelete,
|
||||
}: {
|
||||
rows: LlmWikiKnowledgeBase[];
|
||||
loading: boolean;
|
||||
error: unknown;
|
||||
emptyText: string;
|
||||
busy: boolean;
|
||||
onOpen: (item: LlmWikiKnowledgeBase) => void;
|
||||
onPublish: (item: LlmWikiKnowledgeBase) => void;
|
||||
onDelete: (item: LlmWikiKnowledgeBase) => void;
|
||||
}) {
|
||||
if (loading) return <div className="flex h-64 items-center justify-center text-muted-foreground"><Loader2 className="mr-2 size-5 animate-spin" />正在读取知识库</div>;
|
||||
if (error) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-6 text-sm text-destructive">{messageOf(error)}</div>;
|
||||
if (!rows.length) return <div className="flex h-64 items-center justify-center rounded-xl border border-dashed text-sm text-muted-foreground">{emptyText}</div>;
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{rows.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
className={`group overflow-hidden rounded-2xl transition-all hover:-translate-y-0.5 hover:shadow-lg ${
|
||||
item.remote_status === "missing"
|
||||
? "border-amber-500/40 bg-amber-500/[0.03]"
|
||||
: "border-border/70 bg-card/95 hover:border-primary/30"
|
||||
}`}
|
||||
>
|
||||
<div className={`h-1 ${item.publication_status === "published" ? "bg-primary" : "bg-muted"}`} />
|
||||
<button type="button" disabled={item.remote_status === "missing"} className="w-full text-left outline-none transition-colors enabled:hover:bg-muted/25 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-default" onClick={() => onOpen(item)}>
|
||||
<CardHeader className="pb-3 pt-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0"><CardTitle className="truncate text-base">{item.name}</CardTitle><CardDescription className="mt-1 line-clamp-2 min-h-10">{item.description || "暂无描述"}</CardDescription></div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1.5">{item.remote_status === "missing" ? <Badge variant="outline" className="border-amber-500/50 text-amber-700 dark:text-amber-300">远端已删除</Badge> : <Badge variant={item.publication_status === "published" ? "default" : "secondary"}>{publicationLabels[item.publication_status] ?? "仅自己"}</Badge>}{item.remote_status !== "missing" && item.indexing_strategy?.wiki_enabled && <Badge className="bg-violet-100 text-violet-700 hover:bg-violet-100 dark:bg-violet-950 dark:text-violet-300">Wiki</Badge>}</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<div className="px-6 pb-4"><div className="grid grid-cols-3 gap-2 rounded-xl border bg-muted/35 p-3 text-center text-xs"><div><div className="text-base font-semibold text-foreground">{item.knowledge_count}</div><span className="text-muted-foreground">文档</span></div><div><div className="text-base font-semibold text-foreground">{item.chunk_count}</div><span className="text-muted-foreground">分块</span></div><div><div className="text-base font-semibold text-foreground">{item.processing_count}</div><span className="text-muted-foreground">处理中</span></div></div></div>
|
||||
</button>
|
||||
<CardContent className="border-t bg-muted/20 px-6 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{item.remote_status === "missing" ? <span className="text-xs text-muted-foreground">WeKnora 资源不存在,可清理本地记录</span> : <Button size="sm" variant="outline" className="rounded-full" onClick={() => onOpen(item)}><FileText className="mr-2 size-4" />查看详情</Button>}
|
||||
{item.remote_status !== "missing" && item.is_owner && !item.is_conversation_deposit && <Button size="sm" variant="secondary" className="rounded-full" disabled={busy} onClick={() => void onPublish(item)}>{item.publication_status === "published" ? "取消公开" : "发布公共库"}</Button>}
|
||||
</div>
|
||||
{item.can_write && !item.is_conversation_deposit && <Button size="icon" variant="ghost" className="size-8 shrink-0 rounded-full text-destructive hover:bg-destructive/10 hover:text-destructive" aria-label={item.remote_status === "missing" ? `清理失效记录 ${item.name}` : `删除知识库 ${item.name}`} title={item.remote_status === "missing" ? "清理失效记录" : "删除知识库"} disabled={busy} onClick={() => void onDelete(item)}><Trash2 className="size-4" /></Button>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateKnowledgeDialog({
|
||||
open,
|
||||
pending,
|
||||
onOpenChange,
|
||||
onCreate,
|
||||
}: {
|
||||
open: boolean;
|
||||
pending: boolean;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
onCreate: (input: { name: string; description: string; type: "document" | "faq"; wiki_enabled: boolean }) => Promise<void>;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [wikiEnabled, setWikiEnabled] = useState(true);
|
||||
useEffect(() => {
|
||||
if (!open) { setName(""); setDescription(""); setWikiEnabled(true); }
|
||||
}, [open]);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>新建个人知识库</DialogTitle><DialogDescription>选择知识库模式;模型、切块和存储策略仍由 WeKnora 管理。</DialogDescription></DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="llmwiki-name">名称</label><Input id="llmwiki-name" value={name} onChange={(event) => setName(event.target.value)} maxLength={255} /></div>
|
||||
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="llmwiki-description">描述</label><Textarea id="llmwiki-description" value={description} onChange={(event) => setDescription(event.target.value)} rows={4} maxLength={2048} /></div>
|
||||
<div className="space-y-2"><div className="text-sm font-medium">知识库模式</div><div className="grid gap-3 sm:grid-cols-2">
|
||||
<button type="button" className={`rounded-xl border p-4 text-left transition-colors ${wikiEnabled ? "border-primary bg-primary/5 ring-1 ring-primary" : "hover:bg-muted/40"}`} onClick={() => setWikiEnabled(true)}><div className="flex items-center gap-2 text-sm font-semibold"><BookOpen className="size-4 text-violet-500" />Wiki 知识库</div><p className="mt-2 text-xs leading-5 text-muted-foreground">自动综合资料,生成 Wiki 页面和页面关系图谱。</p></button>
|
||||
<button type="button" className={`rounded-xl border p-4 text-left transition-colors ${!wikiEnabled ? "border-primary bg-primary/5 ring-1 ring-primary" : "hover:bg-muted/40"}`} onClick={() => setWikiEnabled(false)}><div className="flex items-center gap-2 text-sm font-semibold"><FileText className="size-4 text-blue-500" />标准知识库</div><p className="mt-2 text-xs leading-5 text-muted-foreground">仅进行文档解析、切块与向量/关键词索引。</p></button>
|
||||
</div></div>
|
||||
</div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button><Button disabled={pending || !name.trim()} onClick={() => void onCreate({ name: name.trim(), description: description.trim(), type: "document", wiki_enabled: wikiEnabled })}>{pending && <Loader2 className="mr-2 size-4 animate-spin" />}创建</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConnectionSettings() {
|
||||
const settings = useLlmWikiSettings(true);
|
||||
const actions = useLlmWikiSettingsActions();
|
||||
const [apiUrl, setApiUrl] = useState("");
|
||||
const [webUrl, setWebUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings.data) return;
|
||||
setApiUrl(settings.data.api_base_url || settings.data.file_api_base_url || "");
|
||||
setWebUrl(settings.data.web_base_url || settings.data.file_web_base_url || "");
|
||||
}, [settings.data]);
|
||||
|
||||
const status = useMemo(() => settings.data?.effective_provider === "weknora" ? "WeKnora 模式" : "原 LLMWiki 模式", [settings.data]);
|
||||
if (settings.isLoading) return <div className="flex h-52 items-center justify-center"><Loader2 className="size-5 animate-spin" /></div>;
|
||||
if (settings.error) return <div className="rounded-lg border border-destructive/30 p-5 text-sm text-destructive">{messageOf(settings.error)}</div>;
|
||||
return (
|
||||
<Card className="max-w-3xl">
|
||||
<CardHeader><CardTitle className="flex items-center gap-2 text-base"><Settings2 className="size-5" />WeKnora 连接</CardTitle><CardDescription>这里只管理 cmzs 到 WeKnora 的连接地址。数据库、Redis、模型、存储和解析器仍在 WeKnora 自己的配置中维护。</CardDescription></CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="flex flex-wrap gap-2 text-sm"><Badge>{status}</Badge><Badge variant="secondary">服务端代理鉴权</Badge>{settings.data?.override_enabled && <Badge variant="outline">页面配置覆盖中</Badge>}</div>
|
||||
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="weknora-api-url">WeKnora API 地址</label><Input id="weknora-api-url" value={apiUrl} onChange={(event) => setApiUrl(event.target.value)} placeholder="http://weknora-app:8080" /><p className="text-xs text-muted-foreground">非空启用 WeKnora;保存为空会切回原 LLMWiki。</p></div>
|
||||
<div className="space-y-2"><label className="text-sm font-medium" htmlFor="weknora-web-url">WeKnora 管理地址(可选)</label><Input id="weknora-web-url" value={webUrl} onChange={(event) => setWebUrl(event.target.value)} placeholder="https://weknora-admin.example.com" /></div>
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">配置文件地址:{settings.data?.file_api_base_url || "未配置"}。页面只配置地址;WeKnora iframe 免登录所需的管理员账号/密码只从后端 config.yaml 或环境变量读取,不在前端保存。</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" disabled={!apiUrl.trim() || actions.test.isPending} onClick={async () => { try { await actions.test.mutateAsync(apiUrl.trim()); toast.success("WeKnora 连接正常"); } catch (error) { toast.error(messageOf(error)); } }}>{actions.test.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}测试连接</Button>
|
||||
<Button disabled={actions.save.isPending} onClick={async () => { try { await actions.save.mutateAsync({ override_enabled: true, api_base_url: apiUrl.trim(), web_base_url: webUrl.trim() }); toast.success(apiUrl.trim() ? "已启用 WeKnora 模式" : "已切换到原 LLMWiki 模式"); } catch (error) { toast.error(messageOf(error)); } }}>{actions.save.isPending && <Loader2 className="mr-2 size-4 animate-spin" />}保存页面配置</Button>
|
||||
<Button variant="secondary" disabled={actions.reset.isPending} onClick={async () => { try { await actions.reset.mutateAsync(); toast.success("已恢复 config.yaml 配置"); } catch (error) { toast.error(messageOf(error)); } }}>恢复配置文件</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default WeKnoraKnowledgeWorkspace;
|
||||
@@ -746,7 +746,7 @@
|
||||
|
||||
/* 岗位会商左侧总面板:仅收窄滚动条宽度,沿用全局既有的滑块颜色、圆角和轨道样式。 */
|
||||
.position-roundtable-sidebar-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.workspace-shell {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# DeerFlow 离线后端部署补充
|
||||
|
||||
本目录提供与原始 `start_backend.sh` 配套的 wheelhouse。启动脚本保持原有结构,只新增一行 Redis Python 客户端的离线安装命令。
|
||||
本目录提供与 `start_backend.sh` 配套的 wheelhouse。启动脚本会在容器启动时检查可选运行时依赖,并在缺包时仅从本地 wheelhouse 安装。
|
||||
|
||||
## 文件
|
||||
|
||||
- `offline-runtime-requirements.txt`:固定版本的完整依赖闭包。
|
||||
- `prepare_wheelhouse.sh`:联网机器下载 Linux/Python 3.12 wheel,并验证传递依赖完整性。
|
||||
- `start_backend.sh`:原始 Docker 启动脚本,加一行 Redis 缺包安装命令。
|
||||
- `wheelhouse/`:22 个 wheel 和 `SHA256SUMS`,需要随发布目录一起复制。
|
||||
- `start_backend.sh`:Docker 启动脚本,包含 `croniter`、`hindsight-client`、`aiomysql` 和 Redis 客户端的缺包补装逻辑。
|
||||
- `wheelhouse/`:24 个 wheel 和 `SHA256SUMS`,需要随发布目录一起复制。
|
||||
|
||||
## 联网机器准备
|
||||
|
||||
@@ -23,8 +23,9 @@ bash prepare_wheelhouse.sh
|
||||
- `croniter==6.2.2`
|
||||
- `hindsight-client==0.6.2`
|
||||
- `redis==5.2.1`
|
||||
- `aiomysql==0.3.2`
|
||||
|
||||
上述依赖在 Python 3.12/Linux x86_64 下的 19 个传递依赖也已锁定在 `offline-runtime-requirements.txt`。
|
||||
上述依赖在 Python 3.12/Linux x86_64 下的 20 个传递依赖(包括 `PyMySQL==1.2.0`)也已锁定在 `offline-runtime-requirements.txt`。
|
||||
|
||||
国内镜像示例:
|
||||
|
||||
@@ -57,7 +58,9 @@ bash start_backend.sh
|
||||
docker logs -f deerflow-backend
|
||||
```
|
||||
|
||||
原脚本已有 `croniter` 和 `hindsight-client` 的离线补装逻辑;新增的一行命令只负责在缺少 `redis` 模块时,从 `/app/wheelhouse` 安装 `redis==5.2.1`。
|
||||
脚本会先尝试导入依赖,仅在缺少模块时从 `/app/wheelhouse` 安装。`aiomysql` 补装默认开启,可通过 `AIOMYSQL_INSTALL_DEPS=0` 禁用,或通过 `AIOMYSQL_PIP_PACKAGES` 覆盖默认的 `aiomysql==0.3.2`。
|
||||
|
||||
MySQL 连接 URL 使用 aiomysql 驱动时应写为 `mysql+aiomysql://user:password@host:3306/database?charset=utf8mb4`。
|
||||
|
||||
## Redis 说明
|
||||
|
||||
|
||||
@@ -123,6 +123,27 @@ cascade pragmas. The router is mounted canonically below
|
||||
schema-hidden compatibility prefix `/api/multi-agent/position-roundtable`.
|
||||
Clients may retry the compatibility route only for a framework/proxy-level
|
||||
`404 Not Found`; domain-level missing-session responses must remain real 404s.
|
||||
|
||||
Position roundtable selects the separately seeded built-in agent
|
||||
`position-roundtable-intent` by passing `intent_agent_id` to the shared
|
||||
`/api/intent/init` and `/api/intent/stream` endpoints. Its request lifecycle,
|
||||
files, and SSE protocol match `roundtable-intent`, but its cap is five
|
||||
**user-assistance rounds**. A single round may emit several independent
|
||||
`ask_clarification` tool calls; those calls are separate cards, not one card
|
||||
containing several numbered questions. Count that as one round by the
|
||||
tool-calling AI message, never by card count. Topic-only requests must request
|
||||
all material missing decisions as separate cards rather than being treated as a
|
||||
resolved intent; after the fifth set is answered it must emit completed final
|
||||
JSON without unresolved placeholders. The required arrays are
|
||||
`coreGoals`, `riskWarnings`, `keyPoints`, and `strategicSignificance`. Do not
|
||||
treat a fresh dedicated-agent `ask_clarification` as stale: it must win over
|
||||
a same-run premature `[INTENT_READY]` and leave the UI waiting for the user.
|
||||
Conversely, the prior turn’s historical clarification must not block the user
|
||||
reply from completing. Do not
|
||||
change `roundtable-intent`'s `{objective,constraints,assumptions}` contract for
|
||||
this feature. The position router accepts the new schema for activation and
|
||||
injects all four sections into downstream position-node and closing-agent
|
||||
context; legacy stored summaries are mapped read-only for compatibility.
|
||||
`/api/intent/init` must create the empty Step-1 thread through
|
||||
`multi_agent._create_thread_direct`, exactly like multi-agent initialization;
|
||||
do not restore the old HTTP loopback to `/api/threads`, because deployed
|
||||
@@ -257,7 +278,7 @@ Configuration priority:
|
||||
|
||||
### Gateway API (`app/gateway/`)
|
||||
|
||||
FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWAY_ENABLE_DOCS=false` to disable `/docs`, `/redoc`, and `/openapi.json` in production (default: enabled).
|
||||
FastAPI application on port 8001 with health checks at `GET|HEAD /health`, `/api/health`, and `/api/langgraph/health`. These exact paths are always public in `auth_middleware.PUBLIC_HEALTH_PATHS`, including when authentication is enabled; reverse-proxy prefixes and trailing slashes are normalized before the whitelist check. Set `GATEWAY_ENABLE_DOCS=false` to disable `/docs`, `/redoc`, and `/openapi.json` in production (default: enabled). Regression coverage: `tests/test_health_auth_bypass.py`.
|
||||
|
||||
**Routers**:
|
||||
|
||||
@@ -436,6 +457,15 @@ Backs the zzJC / zzXD agent-chat left sidebar (「按类型聚合智能体 + 聊
|
||||
|
||||
### Position-collaboration foundation
|
||||
|
||||
### Roundtable business-chain human validation
|
||||
|
||||
The existing `/api/roundtable-chains` `seats` JSON also accepts the optional
|
||||
boolean `human_validation` field on each seat (`ChainSeatSchema`). It is a
|
||||
frontend Step 2 checkpoint, not a server-side approval task: after the marked
|
||||
seat completes (or, in a DAG, after its stage completes), the client pauses and
|
||||
uses the existing resume button or intervention composer to continue. No new
|
||||
database column, migration, or validation card is required.
|
||||
|
||||
The position-collaboration workspace is a separate frontend route and must not
|
||||
use the original roundtable coordinator or jobs. Its shared configuration
|
||||
integration point is the existing `/api/roundtable-chains` store: each `seats` JSON item
|
||||
@@ -600,7 +630,7 @@ Operators upgrading an existing DB run the whole file (`USE <db>;` then execute)
|
||||
### Tool System (`packages/harness/deerflow/tools/`)
|
||||
|
||||
`get_available_tools(groups, include_mcp, model_name, subagent_enabled)` assembles:
|
||||
1. **Config-defined tools** - Resolved from `config.yaml` via `resolve_variable()`
|
||||
1. **Config-defined tools** - Enabled entries are resolved from `config.yaml` via `resolve_variable()`; entries with `enabled: false` are omitted before import and are not registered with the agent
|
||||
2. **MCP tools** - From enabled MCP servers (lazy initialized, cached with mtime invalidation)
|
||||
3. **Built-in tools**:
|
||||
- `present_files` - Make output files visible to user (only `/mnt/user-data/outputs`)
|
||||
@@ -613,6 +643,7 @@ Operators upgrading an existing DB run the whole file (`USE <db>;` then execute)
|
||||
- `tavily/` - Web search (5 results default) and web fetch (4KB limit)
|
||||
- `jina_ai/` - Web fetch via Jina reader API with readability extraction
|
||||
- `firecrawl/` - Web scraping via Firecrawl API
|
||||
- `configurable_search/` - Config-driven `POST` JSON `web_search` replacement. It deep-copies the configured payload and overwrites only `query`, supports HTTP/HTTPS plus configurable `verify_ssl`, timeout and result limit, normalizes nullable `recUuid`/`content`/`title`/`url` fields, and rewrites a result URL through `result_url_template` only when `recUuid` is non-empty. The required response envelope is `{"results": [...]}`. Tests use `httpx.MockTransport` and never require the deployment endpoint.
|
||||
|
||||
**ACP agent tools**:
|
||||
- `invoke_acp_agent` - Invokes external ACP-compatible agents from `config.yaml`
|
||||
@@ -921,9 +952,9 @@ Both endpoints are whitelisted in `auth_middleware._PUBLIC_EXACT_PATHS`. The sha
|
||||
|
||||
Both can be modified at runtime via Gateway API endpoints or `DeerFlowClient` methods.
|
||||
|
||||
### WeKnora Retrieval Skill
|
||||
### Conditional WeKnora LLMWiki
|
||||
|
||||
`skills/custom/weknora-knowledge-search/` is a read-only integration for a WeKnora tenant. The Skill itself only directs the model to two MCP tools, `weknora_search` and `weknora_list_knowledge_bases`; the credential remains inside the stdio MCP subprocess (`scripts/weknora_retrieval_mcp.py`). Its retrieval core groups document/FAQ knowledge bases by `embedding_model_id` and calls WeKnora's multi-KB `hybrid-search` API once per group, preventing invalid cross-embedding score comparisons. It does not discard a KB merely because the legacy list API reports `chunk_count=0`; hybrid-search decides its actual readiness. Wiki pages are opt-in and use their dedicated Wiki search route. In explicit DeerFlow sharing mode, `WEKNORA_DEERFLOW_SHARE_TOKEN_FILE` selects WeKnora's narrow `/api/v1/deerflow/*` capability API: only owner-published document/FAQ knowledge bases are discoverable and searchable, while Wiki is skipped so the skill cannot fall back to a user bearer-token endpoint. Shared document hits include a credential-free source-preview URL. The frontend source card recognizes it and opens a context dialog; its call to authenticated Gateway route `/api/weknora/source-context` is proxied to WeKnora's token-gated `/api/v1/deerflow/knowledge-bases/:id/chunks/:chunk_id/context`, returning only the matching chunk and two neighbours on either side. Never expose the share token to the browser or add it to a URL. Configure through the bundled `templates/extensions_config.fragment.json`; otherwise prefer `WEKNORA_API_KEY_FILE` for scoped-key deployments and `WEKNORA_BEARER_TOKEN_FILE` for legacy deployments. `WEKNORA_ALLOWED_KB_IDS` remains an optional allow-list. Tests: `tests/test_weknora_knowledge_search_skill.py`, `tests/test_weknora_source_preview.py`.
|
||||
`llmwiki.weknora.api_base_url` is the provider switch: non-empty enables WeKnora, empty preserves the legacy LLMWiki UI. WeKnora is independently deployed and owns PostgreSQL/Redis/object storage/vector storage/parsers/models; DeerFlow config contains only `api_base_url` and optional `web_base_url`. DeerFlow deliberately sends no WeKnora credential, so the WeKnora endpoint must be restricted to a trusted network and allow DeerFlow traffic itself or through its reverse proxy. There is intentionally no `default_embedding_model_id`: `WeKnoraClient` queries `/api/v1/models` and uses WeKnora's active Embedding model when creating a KB; Wiki creation additionally resolves an active KnowledgeQA model inside WeKnora for synthesis. The native BFF is `app/gateway/routers/llmwiki.py`; ownership/publication mappings live in `deerflow.persistence.llmwiki`; the browser only sees DeerFlow mapping IDs. Knowledge-base owners publish immediately (`private` -> `published`) without a review queue, and can unpublish ordinary bases. The conversation-deposit KB is system-owned, always published, shared by all DeerFlow users, excluded from My Knowledge, and immutable through user/admin detail operations; recognition requires both the reserved name and `system` owner. Existing same-name remote mappings are adopted in place as the system deposit, preserving mapping ids and document links. Its user-facing description, generated Markdown source and tags use `cmzs` branding; list/detail reads synchronize stale remote descriptions, and search results, source previews and embedded-detail JSON recursively replace legacy brand text in historical conversation-deposit payloads. Deposit requests must reference a thread accessible to the current account. Personal knowledge-base list/search views are scoped to the current DeerFlow account even for admins; frontend LLMWiki caches include the live account id, and admin write power does not make other users' private KBs appear as owned. Only `system_role === "admin"` sees the WeKnora connection-config tab. Knowledge cards navigate to a dedicated WeKnora-style detail route. Wiki-enabled bases render and default to `Wiki -> documents -> graph`; standard bases omit Wiki and render `documents -> graph`. The detail page supports sequential multi-file upload with an aggregate result for writable ordinary bases, hides it for public read-only/system-deposit bases, and uses a generic loading label while the embed session starts. It also supports URL and published manual-Markdown imports, browses real Wiki pages/content, proxies authenticated original-file previews, pages through `/chunks/{knowledge_id}`, and renders the Wiki page graph from `/knowledgebase/{kb_id}/wiki/graph`; document details use a right sheet rather than the removed KB modal. The credential-free proxy uses one WeKnora service identity, so per-user separation is enforced by DeerFlow mappings while all remote KBs physically share that identity's WeKnora workspace. WeKnora iframe detail pages first call `/api/llmwiki/knowledge-bases/{id}/weknora/session` through the normal DeerFlow API auth path, then load `/platform/knowledge-bases/{weknora_id}` with a signed `deerflow_weknora_embed` cookie for `/platform`, `/assets`, `/locales`, root assets, and API calls. Injected fetch/XHR rewriting sends all iframe `/api/v1/*`, including colliding `/api/v1/auth/*`, through `/api/llmwiki/weknora-embed/api/v1/*`; Gateway auth/CSRF bypass only applies to those signed iframe proxy requests, the proxy still revalidates the mapped KB, and cookie security honors `X-Forwarded-Proto` for HTTPS reverse proxies. `llmwiki_search` is conditionally registered in `tools.py`, is read-only, and rechecks the current DeerFlow user, agent `llmwiki_knowledge_base_ids`, and thread-context selection on every call. Source previews prefer the cleaned Wiki/LLMWiki page text when a retrieved chunk maps to a Wiki page, with raw split chunks as the fallback. Agent bindings use an inline WeKnora-style searchable/grouped multi-select in the standard agent create/detail UI; new chats use the same interaction pattern as a compact composer dropdown, and selections are written to thread metadata/context and restored on reopen. The DeerFlow frontend is native React and the WeKnora frontend is not modified or embedded. Full deployment and behavior notes: `docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md`. Tests: `tests/test_llmwiki_weknora.py`.
|
||||
|
||||
### Embedded Client (`packages/harness/deerflow/client.py`)
|
||||
|
||||
@@ -1132,8 +1163,28 @@ the initial intent workspace plus the summary/action-plan closing agents. Do
|
||||
not delete a built-in id because historical chain nodes persist those ids;
|
||||
custom role ids may be added and removed.
|
||||
|
||||
### Position-roundtable task-scoped session history
|
||||
|
||||
`position_roundtable_sessions.external_task_id` is optional. When populated from the frontend route `taskId`, new rows set `task_scoped = true`: a task can have multiple sessions and all users opening that task see the same sessions, nodes and persisted conversation snapshots. When it is NULL/empty, keep the original `user_id`-scoped personal workspace behavior. `20260808_01` adds `task_scoped` with default `false` (so all historical records remain personal) plus the task/update-time index; it must not backfill or make `external_task_id` non-null.
|
||||
|
||||
### Roundtable artifact submission status
|
||||
|
||||
`roundtable_artifact_submissions` keeps task-level submission state for a
|
||||
generated deliverable and is deliberately separate from task details and any
|
||||
external task-system delivery. The API is
|
||||
`GET /api/roundtable/tasks/{taskId}/artifacts?submitted=true` and
|
||||
`PUT /api/roundtable/tasks/{taskId}/artifacts/submission`. All callers use the
|
||||
normal Gateway Bearer authentication; records are shared by task while
|
||||
`created_by`/`submitted_by`/`cancelled_by` are audit-only. The server derives a
|
||||
SHA-256 idempotency key from `taskId + sourceKey + path + revision`: submitting
|
||||
the same revision is an upsert, while cancellation only flips `submitted` and
|
||||
sets `cancelled_at` (it never deletes history). Store only the sandbox file
|
||||
reference (`threadId`, path, source/node/session fields, name/MIME and revision)
|
||||
and timestamps; do not persist Markdown content or call a formal delivery
|
||||
interface. Previews continue to read the DeerFlow artifact by `threadId + path`.
|
||||
|
||||
See `docs/` directory for detailed documentation:
|
||||
- [../README.md](../README.md) - Offline Docker wheelhouse preparation and startup workflow. The pinned versions in `../offline-runtime-requirements.txt` and `../start_backend.sh` must stay synchronized. `start_backend.sh` stays aligned with the deployment script and adds only the Redis wheel fallback line. The download target defaults to CPython 3.12 on Linux x86_64 and verifies dependency closure using wheelhouse only.
|
||||
- [../README.md](../README.md) - Offline Docker wheelhouse preparation and startup workflow. The pinned versions in `../offline-runtime-requirements.txt` and `../start_backend.sh` must stay synchronized. Startup checks `croniter`, `hindsight-client`, `aiomysql`, and Redis, installing missing packages from wheelhouse only; `aiomysql==0.3.2` includes the pinned `PyMySQL==1.2.0` transitive dependency. The download target defaults to CPython 3.12 on Linux x86_64 and verifies dependency closure using wheelhouse only.
|
||||
- [CONFIGURATION.md](docs/CONFIGURATION.md) - Configuration options
|
||||
- [ARCHITECTURE.md](docs/ARCHITECTURE.md) - Architecture details
|
||||
- [API.md](docs/API.md) - API reference
|
||||
|
||||
@@ -39,6 +39,8 @@ DeerFlow is a LangGraph-based AI super agent with sandbox execution, persistent
|
||||
- `/api/*` (other) → Gateway API - models, MCP, skills, memory, artifacts, uploads, thread-local cleanup
|
||||
- `/` (non-API) → Frontend - Next.js web interface
|
||||
|
||||
Health probes are always unauthenticated, even when API authentication is enabled: `GET|HEAD /health`, `/api/health`, and `/api/langgraph/health` all return the Gateway health status without requiring a cookie or bearer token.
|
||||
|
||||
---
|
||||
|
||||
## Core Components
|
||||
@@ -78,7 +80,7 @@ Per-thread isolated execution with virtual path translation:
|
||||
- **Virtual paths**: `/mnt/user-data/{workspace,uploads,outputs}` → thread-specific physical directories
|
||||
- **Skills path**: `/mnt/skills` → `deer-flow/skills/` directory
|
||||
- **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths
|
||||
- **WeKnora retrieval skill**: `skills/custom/weknora-knowledge-search` pairs a concise Skill with a stdio MCP server. It retrieves tenant-scoped WeKnora document/FAQ passages through the public API, groups multi-KB calls by embedding model, optionally searches Wiki pages, and can use the local reranker. It does not reject a KB solely because the legacy list API reports `chunk_count=0`; hybrid-search remains the source of truth. For WeKnora's explicit DeerFlow sharing mode, configure `WEKNORA_DEERFLOW_SHARE_TOKEN_FILE`: the MCP uses the restricted `/api/v1/deerflow/*` capability API and can only list/search knowledge bases whose owner enabled sharing; it never receives a user bearer token. Shared document results now contain a credential-free `url` pointer; the source card's “查看原文” dialog calls DeerFlow's authenticated `/api/weknora/source-context` proxy, which retrieves the hit plus two adjacent chunks while keeping the share token server-side. Shared mode currently excludes Wiki pages. Otherwise use `WEKNORA_API_KEY_FILE` for scoped-key deployments or `WEKNORA_BEARER_TOKEN_FILE` for legacy deployments; never put any credential in `SKILL.md` or a chat prompt.
|
||||
- **Conditional WeKnora LLMWiki**: a non-empty `llmwiki.weknora.api_base_url` enables the DeerFlow-native WeKnora BFF and UI; an empty address preserves the existing LLMWiki page. WeKnora remains independently deployed and owns its database, Redis, storage, parsers and models. DeerFlow keeps only the API/admin addresses, user/publication mappings, agent knowledge-base bindings and per-conversation selections; it stores and sends no WeKnora credential, so the endpoint must be restricted to a trusted network and allow DeerFlow traffic. Owners publish directly to the public LLMWiki without an approval queue for ordinary bases; the system-owned conversation-deposit base is always published, shared by all users, excluded from My Knowledge, immutable through user/admin detail operations, and recognized by both its reserved name and `system` owner. An existing same-name remote mapping is adopted in place as the system deposit so its mapping id and document links stay valid. Its user-facing knowledge-base description and generated document metadata use the `cmzs` brand; list/detail reads synchronize stale remote descriptions, while conversation-deposit search results, source previews and embedded-detail JSON rewrite legacy brand text to `cmzs`. Deposit requests must reference a thread accessible to the current account. Personal knowledge-base lists are scoped to the current DeerFlow account even for admins, while frontend LLMWiki query caches also include the active account id so switching accounts cannot reuse another account's list/detail data. Admins remain the only role that sees the WeKnora connection-config tab. Knowledge cards open a dedicated WeKnora-style detail route: Wiki-enabled bases default to Wiki, then documents and graph; standard bases hide Wiki and default to documents. Writable ordinary knowledge-base details expose a native multi-file upload action that submits files sequentially and reports aggregate success/failure; read-only public bases and the system deposit base do not. It supports file/URL/manual-Markdown imports, Wiki page browsing, document preview, real parsed chunks and Wiki graph nodes/edges. New chats use a compact WeKnora-style searchable/grouped multi-select inside the composer, while agent create/edit pages bind spaces through an inline panel. A single proxy service identity means DeerFlow users are isolated logically while their WeKnora entities share that identity's workspace. The read-only `llmwiki_search` tool reauthorizes the current DeerFlow user on every call and renders credential-free source previews; source previews prefer cleaned Wiki/LLMWiki text when a retrieved chunk maps to a Wiki page, with raw chunks as the fallback. The detail page first creates a signed iframe session through the normal DeerFlow API auth path, then points the iframe directly at the proxied WeKnora `/platform/...` route using the signed `deerflow_weknora_embed` cookie; global auth/CSRF bypass is limited to those signed iframe proxy requests, and HTTPS cookie attributes honor `X-Forwarded-Proto` behind reverse proxies. The iframe proxy keeps WeKnora's root-relative `/platform`, `/assets`, `/locales`, and `/api/v1/*` paths, but is registered last through `llmwiki.proxy_router` so it cannot intercept first-party Gateway APIs such as `POST /api/v1/auth/login/username`. See [docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md](docs/LLMWIKI_WEKNORA_INTEGRATION_ZH.md).
|
||||
- **File-write safety**: `str_replace` serializes read-modify-write per `(sandbox.id, path)` so isolated sandboxes keep concurrency even when virtual paths match
|
||||
- **Tools**: `bash`, `ls`, `read_file`, `write_file`, `str_replace` (`bash` is disabled by default when using `LocalSandboxProvider`; use `AioSandboxProvider` for isolated shell access)
|
||||
|
||||
@@ -111,6 +113,19 @@ LLM-powered persistent context retention across conversations:
|
||||
| **MCP** | Any Model Context Protocol server (stdio, SSE, HTTP transports) |
|
||||
| **Skills** | Domain-specific workflows injected via system prompt |
|
||||
|
||||
#### Configurable HTTP web search
|
||||
|
||||
The config-defined `web_search` tool can call a deployment-specific JSON API
|
||||
instead of a public search provider. Set its `endpoint`, static `payload`, SSL
|
||||
verification, timeout, result limit, and `result_url_template` in `config.yaml`.
|
||||
At runtime only `payload.query` is overwritten with the model's search text.
|
||||
The API response must use `{"results": [{"recUuid": "", "content": "",
|
||||
"title": "", "url": ""}]}`; missing or null result fields are normalized to empty strings. When
|
||||
`recUuid` is present, `{recUuid}` in `result_url_template` replaces the original
|
||||
result URL; otherwise the original URL is retained. Set `enabled: false` to keep
|
||||
the tool completely out of the agent's registered tool list. `verify_ssl: false`
|
||||
supports trusted internal HTTPS services with self-signed certificates.
|
||||
|
||||
### Gateway API
|
||||
|
||||
FastAPI application providing REST endpoints for frontend integration:
|
||||
@@ -123,7 +138,7 @@ FastAPI application providing REST endpoints for frontend integration:
|
||||
| `POST /api/skills/install` | Install skill from `.skill` archive |
|
||||
| `GET/POST /api/agents` | List visible agents or create a user-owned agent |
|
||||
| `GET/PUT/DELETE /api/agents/{id}` | Read, update, or delete an id-addressed agent |
|
||||
| `GET/POST/PUT/DELETE /api/roundtable-chains` | Manage user-owned roundtable business chains; each seat may optionally carry `position_id` for the separate position-collaboration workspace (the original roundtable ignores it) |
|
||||
| `GET/POST/PUT/DELETE /api/roundtable-chains` | Manage user-owned roundtable business chains; each seat may optionally carry `position_id` for the separate position-collaboration workspace, plus `human_validation` to mark a Step 2 client-side pause checkpoint after that seat/stage completes (the original roundtable has no extra validation card) |
|
||||
| `GET/PUT /api/business-mapping` | Read global business mappings. Updates to 3Q/6BF/7BF require an administrator; the 8BF public business-chain mapping is maintained exclusively by the trusted `lqq` account (derived from the authenticated email prefix). |
|
||||
| `GET/POST/PUT /api/position-roundtable/sessions` | Standalone position-collaboration sessions: freeze task/intent/chain snapshots, index one direct-agent node per seat, build upstream handoff context (including bounded UTF-8 excerpts of text artifacts), and make archived sessions read-only without invoking the original roundtable coordinator or jobs. Built-in summary/action-plan completion mirrors visible report text into a Markdown artifact when a model omits its final `write_file` call. `POST /sessions/{id}/nodes/{nodeKey}/reject` accepts an optional reason, marks the rejected delivery for rework, and invalidates affected downstream deliveries so they are regenerated in order. The same router is also mounted at `/api/multi-agent/position-roundtable/*` for intranet gateways that only forward the established multi-agent namespace; the compatibility mount is intentionally omitted from OpenAPI. |
|
||||
| `GET /api/memory` | Retrieve memory data |
|
||||
@@ -144,6 +159,8 @@ FastAPI application providing REST endpoints for frontend integration:
|
||||
|
||||
The separately deployed TaskCOP Vue app uses `POST /api/v1/auth/login/username` directly (its `username`, `userId`, or `yUserId` URL parameter identifies the user), so this compatibility flow does not depend on the retired Consumer login service. It calls the Gateway address configured as `b1ConsumerUrl` directly; it does not use a Vite `/api` reverse proxy. An empty TaskCOP store remains empty; task data is introduced through normal creation or the JSON import API. The Gateway CORS middleware always merges the local TaskCOP Vite origins `http://localhost:8080` and `http://127.0.0.1:8080`, including when `GATEWAY_CORS_ORIGINS` is set for another frontend. `scripts/env.sh` also includes them in its startup default. For a deployment, set `GATEWAY_CORS_ORIGINS` to the exact additional frontend origins that are allowed to call the Gateway.
|
||||
|
||||
Position-roundtable history supports two modes. A non-empty `external_task_id` from the frontend route's `taskId` creates a row with `task_scoped = true`, so all sessions, nodes and persisted conversation snapshots for that task are shared across users; `user_id` is then creator audit metadata only. When the field is absent or empty, the original per-user personal-session behavior remains unchanged. Alembic revision `20260808_01` adds `task_scoped` with the safe default `false` for historical rows plus the task/update-time index.
|
||||
|
||||
AI writing supports a sample-imitation mode: the frontend can upload or paste a sample article, the Gateway extracts text for document files, and the `ai_writing` graph runs a `sample_analyzer` node before normal intent/research/outline/draft stages. The resulting style profile is injected into outline and draft prompts with explicit guardrails against copying source sentences, facts, names, or data.
|
||||
|
||||
### Custom Agents
|
||||
@@ -479,6 +496,24 @@ checkpoint or sandbox-output volume is replaced. Apply Alembic revision
|
||||
|
||||
Its live intent conversation still uses the shared `/api/intent/init` and
|
||||
`/api/intent/stream` endpoints used by the original multi-agent roundtable.
|
||||
Position roundtable identifies itself with `intent_agent_id: "position-roundtable-intent"`;
|
||||
this is a separately seeded built-in agent, not an alias of `roundtable-intent`.
|
||||
It preserves the same thread, SSE streaming, and file-reading lifecycle as the
|
||||
generic Step-1 agent, but has its own **at-most-five user-assistance-round**
|
||||
brake. A round is one AI turn plus the user reply, not one tool call: the
|
||||
dedicated agent may emit several independent `ask_clarification` calls in the
|
||||
same turn, which the frontend renders as several separate selection cards and
|
||||
submits together. A topic-only request is not considered resolved: the agent
|
||||
asks for every independent missing decision (such as scope, decision use, or
|
||||
task constraints) as separate cards. For the dedicated agent, a fresh
|
||||
`ask_clarification` emitted in a stream is a strict user-input pause and wins over any same-run premature
|
||||
`[INTENT_READY]`; a historical clarification from the prior turn does not
|
||||
block the user answer from completing. Its final
|
||||
`[INTENT_READY]` JSON contains four string arrays: `coreGoals`, `riskWarnings`,
|
||||
`keyPoints`, and `strategicSignificance`; it must contain completed conclusions,
|
||||
not “待确认” or other unresolved placeholders. The multi-agent endpoint remains
|
||||
backwards compatible when this optional field is omitted and continues to emit
|
||||
`objective`, `constraints`, and `assumptions`.
|
||||
`/api/intent/init` creates its empty thread directly through the in-process
|
||||
thread store/checkpointer, matching multi-agent initialization and avoiding a
|
||||
deployment-sensitive `127.0.0.1:<gateway-port>` loopback. If initial checkpoint
|
||||
@@ -507,3 +542,23 @@ is required. It is the initial intent-identification workspace and owns the
|
||||
summary/action-plan closing agents. Built-in rows retain stable ids so
|
||||
historical chain nodes remain readable; they can be edited or disabled but not
|
||||
deleted. Custom roles may be freely added or removed.
|
||||
|
||||
## Roundtable artifact submission status
|
||||
|
||||
The task-scoped roundtable workbench exposes a lightweight submission-status
|
||||
API for generated deliverables:
|
||||
|
||||
- `GET /api/roundtable/tasks/{taskId}/artifacts?submitted=true` returns the
|
||||
submitted artifact references for a task (or `items: []`).
|
||||
- `PUT /api/roundtable/tasks/{taskId}/artifacts/submission` records
|
||||
`submitted: true` or cancels it with `submitted: false`.
|
||||
|
||||
Both routes use the normal Gateway `Authorization: Bearer <token>` middleware.
|
||||
The record is shared by `taskId`; the authenticated user is retained only in
|
||||
audit columns. Its server-owned idempotency key is
|
||||
`taskId + sourceKey + path + revision`. Repeating the same submitted payload
|
||||
returns the existing record; cancellation updates its state and timestamp
|
||||
without deleting the audit row. DeerFlow stores only the artifact reference
|
||||
(`threadId` + sandbox `path`, plus the optional session/node/name/MIME fields),
|
||||
never the Markdown body and never a task-system delivery. Continue to preview
|
||||
the file through the existing thread-artifact endpoint.
|
||||
@@ -1,26 +1,26 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from time import perf_counter
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from time import perf_counter
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
# Load .env BEFORE any module that reads os.environ at import time.
|
||||
# Without this, GATEWAY_CORS_ORIGINS / AUTH_JWT_SECRET / DEER_FLOW_AUTH_DISABLED
|
||||
# placed in /workspace/.env are silently ignored when the container is started
|
||||
# without explicit `-e` flags, leading to ephemeral JWT secrets, missing CORS
|
||||
# origins, and "Failed to fetch" in the browser.
|
||||
load_dotenv()
|
||||
|
||||
from app.gateway.auth.disabled_mode import is_auth_disabled # noqa: E402
|
||||
# origins, and "Failed to fetch" in the browser.
|
||||
load_dotenv()
|
||||
|
||||
from app.gateway.auth.disabled_mode import is_auth_disabled # noqa: E402
|
||||
from app.gateway.auth_middleware import AuthMiddleware
|
||||
from app.gateway.config import get_gateway_config
|
||||
from app.gateway.csrf_middleware import CSRFMiddleware
|
||||
@@ -43,16 +43,18 @@ from app.gateway.routers import (
|
||||
intent,
|
||||
knowledge,
|
||||
knowledge_ingest,
|
||||
light_apps,
|
||||
llm_metrics,
|
||||
mcp,
|
||||
light_apps,
|
||||
llm_metrics,
|
||||
llmwiki,
|
||||
mcp,
|
||||
memory,
|
||||
menu_overrides,
|
||||
models,
|
||||
menu_overrides,
|
||||
model_management,
|
||||
models,
|
||||
multi_agent,
|
||||
notifications,
|
||||
open_3qfx,
|
||||
open_chat,
|
||||
open_chat,
|
||||
parallel_agent_tasks,
|
||||
parallel_agents,
|
||||
position_roundtable,
|
||||
@@ -65,8 +67,9 @@ from app.gateway.routers import (
|
||||
recommend,
|
||||
flow_extract,
|
||||
recommended_questions,
|
||||
roundtable_chains,
|
||||
roundtable_diagnostics,
|
||||
roundtable_chains,
|
||||
roundtable_artifact_submissions,
|
||||
roundtable_diagnostics,
|
||||
roundtable_draft_shares,
|
||||
roundtable_drafts,
|
||||
roundtable_jobs,
|
||||
@@ -89,8 +92,7 @@ from app.gateway.routers import (
|
||||
uploads,
|
||||
user_preferences,
|
||||
user_prompts,
|
||||
weknora_sources,
|
||||
writing,
|
||||
writing,
|
||||
)
|
||||
from deerflow.config import app_config as deerflow_app_config
|
||||
from deerflow.config.app_config import apply_logging_level
|
||||
@@ -1424,14 +1426,16 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
)
|
||||
|
||||
# Include routers
|
||||
# Models API is mounted at /api/models
|
||||
app.include_router(models.router)
|
||||
# Models API is mounted at /api/models
|
||||
app.include_router(models.router)
|
||||
# Administrator-managed config.yaml model settings and connection tests.
|
||||
app.include_router(model_management.router)
|
||||
|
||||
# MCP API is mounted at /api/mcp
|
||||
app.include_router(mcp.router)
|
||||
|
||||
# Authenticated source-context proxy for the WeKnora retrieval skill.
|
||||
app.include_router(weknora_sources.router)
|
||||
# DeerFlow-native LLMWiki management/search BFF (conditionally backed by WeKnora).
|
||||
app.include_router(llmwiki.router)
|
||||
|
||||
# Memory API is mounted at /api/memory
|
||||
app.include_router(memory.router)
|
||||
@@ -1465,8 +1469,11 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# Roundtable-planning drafts + recommendation history at /api/roundtable-drafts
|
||||
app.include_router(roundtable_drafts.router)
|
||||
|
||||
# Task-scoped roundtable drafts (taskId 深链聊天记录,独立存、不分权) at /api/roundtable-task-drafts
|
||||
app.include_router(roundtable_task_drafts.router)
|
||||
# Task-scoped roundtable drafts (taskId 深链聊天记录,独立存、不分权) at /api/roundtable-task-drafts
|
||||
app.include_router(roundtable_task_drafts.router)
|
||||
|
||||
# Task-scoped artifact submission status; stores references only, never artifact content.
|
||||
app.include_router(roundtable_artifact_submissions.router)
|
||||
|
||||
# Step3 流程图结构化抽取 + 校验 (SSE) at /api/flow-extract
|
||||
app.include_router(flow_extract.router)
|
||||
@@ -1618,24 +1625,30 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
||||
# ??? app/gateway/routers/{multi_agent,intent,recommend}.py?
|
||||
# ??? frontend-web/docs/multi-agent-backend-dev.md
|
||||
app.include_router(multi_agent.router) # Step 2?init(SSE) + run/stream(leader|special)
|
||||
app.include_router(intent.router) # Step 1?/api/intent/init + /stream
|
||||
app.include_router(recommend.router) # Step 1?2?/api/recommend/stream?? init?
|
||||
# Step 3 ????/?????? artifacts.router?/api/threads/{id}/artifacts?
|
||||
|
||||
# ?????????(? auth_middleware._PUBLIC_PATH_PREFIXES)?
|
||||
# ????? ``/health`` ?? API ??? ``/api/health``,?????
|
||||
# ?? base URL ????,?????????? 401?
|
||||
@app.get("/health", tags=["health"])
|
||||
@app.get("/api/health", tags=["health"])
|
||||
app.include_router(intent.router) # Step 1?/api/intent/init + /stream
|
||||
app.include_router(recommend.router) # Step 1?2?/api/recommend/stream?? init?
|
||||
# Step 3 ????/?????? artifacts.router?/api/threads/{id}/artifacts?
|
||||
|
||||
# Health probes are always unauthenticated (see
|
||||
# auth_middleware.PUBLIC_HEALTH_PATHS). Support the bare Gateway path,
|
||||
# API base URL, and LangGraph proxy base URL for deployment monitors.
|
||||
@app.api_route("/health", methods=["GET", "HEAD"], tags=["health"])
|
||||
@app.api_route("/api/health", methods=["GET", "HEAD"], tags=["health"])
|
||||
@app.api_route("/api/langgraph/health", methods=["GET", "HEAD"], tags=["health"])
|
||||
async def health_check() -> dict:
|
||||
"""Health check endpoint.
|
||||
|
||||
Returns:
|
||||
Service health status information.
|
||||
"""
|
||||
return {"status": "healthy", "service": "deer-flow-gateway"}
|
||||
|
||||
return app
|
||||
"""
|
||||
return {"status": "healthy", "service": "deer-flow-gateway"}
|
||||
|
||||
# WeKnora's embedded frontend uses root-relative routes such as /platform,
|
||||
# /assets, /locales and /api/v1/*. Register that proxy last so it stays a
|
||||
# true fallback and cannot intercept DeerFlow APIs.
|
||||
app.include_router(llmwiki.proxy_router)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# Create app instance for uvicorn
|
||||
|
||||
@@ -19,20 +19,35 @@ from starlette.types import ASGIApp
|
||||
|
||||
from app.gateway.auth.disabled_mode import get_assumed_user_for_disabled_auth, is_auth_disabled
|
||||
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
|
||||
from app.gateway.authz import _ALL_PERMISSIONS, AuthContext
|
||||
from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_user, is_valid_internal_auth_token
|
||||
from app.gateway.proxy_path import app_relative_path
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
from app.gateway.authz import _ALL_PERMISSIONS, AuthContext
|
||||
from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_user, is_valid_internal_auth_token
|
||||
from app.gateway.proxy_path import app_relative_path
|
||||
from app.gateway.weknora_embed import (
|
||||
WEKNORA_EMBED_COOKIE,
|
||||
has_valid_weknora_embed_cookie,
|
||||
is_weknora_embed_proxy_path,
|
||||
)
|
||||
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||
|
||||
# Paths that never require authentication.
|
||||
# Health probes are deliberately public, including the LangGraph-compatible
|
||||
# gateway alias used by some deployments. Keep these as exact paths: health
|
||||
# checks must bypass authentication, but similarly named application routes
|
||||
# must not inherit that exemption.
|
||||
PUBLIC_HEALTH_PATHS: frozenset[str] = frozenset(
|
||||
{
|
||||
"/health",
|
||||
"/api/health",
|
||||
"/api/langgraph/health",
|
||||
}
|
||||
)
|
||||
|
||||
# Path prefixes that never require authentication.
|
||||
#
|
||||
# ``/api/public/`` is a namespace reserved for intentionally world-readable,
|
||||
# read-only endpoints (e.g. public conversation share snapshots). Only mount
|
||||
# safe read-only routes under it — everything there bypasses auth entirely.
|
||||
_PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
|
||||
"/health",
|
||||
"/api/health",
|
||||
"/docs",
|
||||
_PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
|
||||
"/docs",
|
||||
"/redoc",
|
||||
"/openapi.json",
|
||||
"/api/public/",
|
||||
@@ -43,9 +58,10 @@ _PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
|
||||
|
||||
# Exact auth paths that are public (login/register/status check).
|
||||
# /api/v1/auth/me, /api/v1/auth/change-password etc. are NOT public.
|
||||
_PUBLIC_EXACT_PATHS: frozenset[str] = frozenset(
|
||||
{
|
||||
"/api/v1/auth/login/local",
|
||||
_PUBLIC_EXACT_PATHS: frozenset[str] = frozenset(
|
||||
{
|
||||
*PUBLIC_HEALTH_PATHS,
|
||||
"/api/v1/auth/login/local",
|
||||
"/api/v1/auth/login/username",
|
||||
"/api/v1/auth/login/token",
|
||||
"/api/v1/auth/register",
|
||||
@@ -54,15 +70,31 @@ _PUBLIC_EXACT_PATHS: frozenset[str] = frozenset(
|
||||
"/api/v1/auth/initialize",
|
||||
# 并行多智能体面板专用的「绕过常规登录」取 token 端点(内网部署限制了标准登录时用)。
|
||||
"/api/parallel-agents/auth/token",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_public(path: str) -> bool:
|
||||
stripped = path.rstrip("/")
|
||||
if stripped in _PUBLIC_EXACT_PATHS:
|
||||
return True
|
||||
return any(path.startswith(prefix) for prefix in _PUBLIC_PATH_PREFIXES)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_public(path: str) -> bool:
|
||||
stripped = path.rstrip("/")
|
||||
if stripped in _PUBLIC_EXACT_PATHS:
|
||||
return True
|
||||
return any(path.startswith(prefix) for prefix in _PUBLIC_PATH_PREFIXES)
|
||||
|
||||
|
||||
def _is_weknora_embed_proxy_request(request: Request) -> bool:
|
||||
"""Let the WeKnora embed proxy verify its own signed iframe cookie.
|
||||
|
||||
The embedded WeKnora frontend loads root-relative routes (``/platform``,
|
||||
``/assets``, ``/locales`` and ``/api/v1/*``). Those requests do not carry
|
||||
DeerFlow's Authorization header, but the iframe bootstrap has already set a
|
||||
short-lived signed cookie that the proxy router validates against the
|
||||
requested knowledge base.
|
||||
"""
|
||||
|
||||
path = app_relative_path(request)
|
||||
if not is_weknora_embed_proxy_path(path):
|
||||
return False
|
||||
return has_valid_weknora_embed_cookie(request.cookies.get(WEKNORA_EMBED_COOKIE))
|
||||
|
||||
|
||||
# 「发起问答」的 run 创建接口(会真正调用大模型):
|
||||
@@ -103,12 +135,14 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
super().__init__(app)
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
# Match the application-relative path so a reverse-proxy prefix
|
||||
# (e.g. /xxx/api/public/...) does not defeat the public whitelist.
|
||||
if _is_public(app_relative_path(request)):
|
||||
return await call_next(request)
|
||||
|
||||
if is_auth_disabled():
|
||||
# Match the application-relative path so a reverse-proxy prefix
|
||||
# (e.g. /xxx/api/public/...) does not defeat the public whitelist.
|
||||
if _is_public(app_relative_path(request)):
|
||||
return await call_next(request)
|
||||
if _is_weknora_embed_proxy_request(request):
|
||||
return await call_next(request)
|
||||
|
||||
if is_auth_disabled():
|
||||
user = await get_assumed_user_for_disabled_auth()
|
||||
request.state.user = user
|
||||
request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS)
|
||||
|
||||
@@ -7,16 +7,21 @@ State-changing operations require CSRF protection.
|
||||
import secrets
|
||||
from collections.abc import Callable
|
||||
|
||||
from app.gateway.auth.disabled_mode import is_auth_disabled
|
||||
from app.gateway.proxy_path import app_relative_path
|
||||
from fastapi import Request, Response
|
||||
from app.gateway.auth.disabled_mode import is_auth_disabled
|
||||
from app.gateway.proxy_path import app_relative_path
|
||||
from app.gateway.weknora_embed import (
|
||||
WEKNORA_EMBED_COOKIE,
|
||||
has_valid_weknora_embed_cookie,
|
||||
is_weknora_embed_proxy_path,
|
||||
)
|
||||
from fastapi import Request, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.types import ASGIApp
|
||||
|
||||
CSRF_COOKIE_NAME = "csrf_token"
|
||||
CSRF_HEADER_NAME = "X-CSRF-Token"
|
||||
CSRF_TOKEN_LENGTH = 64 # bytes
|
||||
CSRF_COOKIE_NAME = "csrf_token"
|
||||
CSRF_HEADER_NAME = "X-CSRF-Token"
|
||||
CSRF_TOKEN_LENGTH = 64 # bytes
|
||||
|
||||
|
||||
def is_secure_request(request: Request) -> bool:
|
||||
@@ -29,7 +34,7 @@ def generate_csrf_token() -> str:
|
||||
return secrets.token_urlsafe(CSRF_TOKEN_LENGTH)
|
||||
|
||||
|
||||
def should_check_csrf(request: Request) -> bool:
|
||||
def should_check_csrf(request: Request) -> bool:
|
||||
"""Determine if a request needs CSRF validation.
|
||||
|
||||
CSRF is checked for state-changing methods (POST, PUT, DELETE, PATCH).
|
||||
@@ -54,9 +59,18 @@ def should_check_csrf(request: Request) -> bool:
|
||||
return False
|
||||
# 开放问答接口同样是匿名、无会话的(见 auth_middleware._PUBLIC_PATH_PREFIXES),
|
||||
# 双提交 Cookie 既无 token 可比、也无会话可被伪造,豁免之。
|
||||
if path.startswith("/api/open/"):
|
||||
return False
|
||||
return True
|
||||
if path.startswith("/api/open/"):
|
||||
return False
|
||||
if is_weknora_embed_proxy_request(request):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_weknora_embed_proxy_request(request: Request) -> bool:
|
||||
path = app_relative_path(request)
|
||||
if not is_weknora_embed_proxy_path(path):
|
||||
return False
|
||||
return has_valid_weknora_embed_cookie(request.cookies.get(WEKNORA_EMBED_COOKIE))
|
||||
|
||||
|
||||
_AUTH_EXEMPT_PATHS: frozenset[str] = frozenset(
|
||||
|
||||
@@ -293,10 +293,16 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.dashboard_session_store = make_dashboard_session_store(sf)
|
||||
|
||||
from deerflow.persistence.task_buttons import make_task_button_store
|
||||
|
||||
from deerflow.persistence.task_buttons import make_task_button_store
|
||||
|
||||
app.state.task_button_store = make_task_button_store(sf)
|
||||
|
||||
from deerflow.integrations.weknora.runtime import register_llmwiki_store
|
||||
from deerflow.persistence.llmwiki import make_llmwiki_store
|
||||
|
||||
app.state.llmwiki_store = make_llmwiki_store(sf)
|
||||
register_llmwiki_store(app.state.llmwiki_store)
|
||||
|
||||
from deerflow.persistence.taskcop_tasks import make_taskcop_task_store
|
||||
|
||||
app.state.taskcop_task_store = make_taskcop_task_store(sf)
|
||||
@@ -305,6 +311,11 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app.state.task_report_store = make_task_report_store(sf)
|
||||
|
||||
from deerflow.persistence.roundtable_artifact_submissions import make_roundtable_artifact_submission_store
|
||||
|
||||
# Task-scoped artifact submission status: shared by task; actor is audit-only.
|
||||
app.state.roundtable_artifact_submission_store = make_roundtable_artifact_submission_store(sf)
|
||||
|
||||
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
|
||||
|
||||
app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf)
|
||||
@@ -613,27 +624,34 @@ _cached_local_provider: LocalAuthProvider | None = None
|
||||
_cached_repo: SQLiteUserRepository | None = None
|
||||
|
||||
|
||||
def get_request_access_token(request: Request) -> str | None:
|
||||
"""Extract access token from cookie or Authorization header.
|
||||
|
||||
Priority:
|
||||
1. ``access_token`` cookie (browser session mode)
|
||||
2. ``Authorization: Bearer <token>`` header (API client mode)
|
||||
"""
|
||||
cookie_token = request.cookies.get("access_token")
|
||||
if cookie_token:
|
||||
return cookie_token
|
||||
|
||||
authorization = request.headers.get("authorization", "").strip()
|
||||
if not authorization:
|
||||
return None
|
||||
|
||||
scheme, _, token = authorization.partition(" ")
|
||||
if scheme.lower() != "bearer":
|
||||
return None
|
||||
|
||||
token = token.strip()
|
||||
return token or None
|
||||
def get_request_access_token(request: Request) -> str | None:
|
||||
"""Extract access token from cookie or Authorization header.
|
||||
|
||||
Priority:
|
||||
1. ``access_token`` cookie (browser session mode)
|
||||
2. ``Authorization: Bearer <token>`` header (API client mode)
|
||||
3. ``access_token`` query parameter for the WeKnora iframe bootstrap only
|
||||
"""
|
||||
cookie_token = request.cookies.get("access_token")
|
||||
if cookie_token:
|
||||
return cookie_token
|
||||
|
||||
authorization = request.headers.get("authorization", "").strip()
|
||||
if authorization:
|
||||
scheme, _, token = authorization.partition(" ")
|
||||
if scheme.lower() == "bearer":
|
||||
token = token.strip()
|
||||
if token:
|
||||
return token
|
||||
|
||||
from app.gateway.proxy_path import app_relative_path
|
||||
|
||||
path = app_relative_path(request).rstrip("/")
|
||||
if path.startswith("/api/llmwiki/knowledge-bases/") and path.endswith("/weknora/frame"):
|
||||
query_token = (request.query_params.get("access_token") or "").strip()
|
||||
if query_token:
|
||||
return query_token
|
||||
return None
|
||||
|
||||
|
||||
def get_local_provider() -> LocalAuthProvider:
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
"""Asynchronous conversation sedimentation into WeKnora-backed LLMWiki."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from deerflow.integrations.weknora.client import WeKnoraClient, WeKnoraError
|
||||
from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
|
||||
from deerflow.persistence.llmwiki import LlmWikiStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CONVERSATION_DEPOSIT_KB_NAME = "对话沉淀"
|
||||
CONVERSATION_DEPOSIT_OWNER_USER_ID = "system"
|
||||
CONVERSATION_DEPOSIT_KB_DESCRIPTION = "cmzs 全局问答沉淀知识库。所有用户可见,不默认参与问答检索。"
|
||||
_DEPOSIT_KB_LOCK = asyncio.Lock()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConversationTurnDeposit:
|
||||
thread_id: str
|
||||
question: str
|
||||
answer: str
|
||||
human_message_id: str | None
|
||||
assistant_message_id: str
|
||||
assistant_id: str | None
|
||||
knowledge_base_ids: list[str]
|
||||
created_by_user_id: str | None
|
||||
|
||||
|
||||
def _store(app: Any) -> LlmWikiStore | None:
|
||||
return getattr(app.state, "llmwiki_store", None)
|
||||
|
||||
|
||||
def _client(app: Any) -> WeKnoraClient | None:
|
||||
runtime = get_resolved_llmwiki_runtime(getattr(app.state, "config", None))
|
||||
if not runtime.weknora_enabled:
|
||||
return None
|
||||
try:
|
||||
return build_weknora_client(runtime)
|
||||
except RuntimeError:
|
||||
logger.debug("LLMWiki conversation deposit skipped: WeKnora client is unavailable", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _shorten(text: str, max_chars: int) -> str:
|
||||
value = " ".join(str(text or "").split())
|
||||
if len(value) <= max_chars:
|
||||
return value
|
||||
return f"{value[: max_chars - 1].rstrip()}…"
|
||||
|
||||
|
||||
def _title_for_turn(turn: ConversationTurnDeposit) -> str:
|
||||
question = _shorten(turn.question, 48)
|
||||
return f"对话沉淀 - {question or turn.assistant_message_id[:12]}"
|
||||
|
||||
|
||||
def _format_markdown(turn: ConversationTurnDeposit, title: str) -> str:
|
||||
kb_line = "、".join(turn.knowledge_base_ids) if turn.knowledge_base_ids else "未显式选择知识库"
|
||||
return f"""---
|
||||
source: cmzs
|
||||
type: conversation-deposit
|
||||
thread_id: {turn.thread_id}
|
||||
human_message_id: {turn.human_message_id or ""}
|
||||
assistant_message_id: {turn.assistant_message_id}
|
||||
assistant_id: {turn.assistant_id or ""}
|
||||
knowledge_base_ids: {kb_line}
|
||||
created_by_user_id: {turn.created_by_user_id or ""}
|
||||
---
|
||||
|
||||
# {title}
|
||||
|
||||
> 本页由 cmzs 在一轮问答结束后自动沉淀,用于后续 LLMWiki 检索、Wiki 索引与图谱构建。
|
||||
|
||||
## 摘要
|
||||
|
||||
{_shorten(turn.answer, 360) or "本轮回答为空。"}
|
||||
|
||||
## 用户问题
|
||||
|
||||
{turn.question.strip() or "(空)"}
|
||||
|
||||
## 助手回答
|
||||
|
||||
{turn.answer.strip() or "(空)"}
|
||||
|
||||
## 结构化线索
|
||||
|
||||
- 来源线程:`{turn.thread_id}`
|
||||
- 用户消息:`{turn.human_message_id or ""}`
|
||||
- 回答消息:`{turn.assistant_message_id}`
|
||||
- 智能体:`{turn.assistant_id or "通用问答"}`
|
||||
- 本轮显式知识库:{kb_line}
|
||||
|
||||
## LLMWiki 标签
|
||||
|
||||
#对话沉淀 #cmzs #问答知识
|
||||
"""
|
||||
|
||||
|
||||
async def _sync_conversation_deposit_description(
|
||||
store: LlmWikiStore,
|
||||
client: WeKnoraClient,
|
||||
row: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if str(row.get("description") or "") == CONVERSATION_DEPOSIT_KB_DESCRIPTION:
|
||||
return row
|
||||
try:
|
||||
await client.update_knowledge_base(
|
||||
str(row["weknora_id"]),
|
||||
{"description": CONVERSATION_DEPOSIT_KB_DESCRIPTION},
|
||||
)
|
||||
except WeKnoraError:
|
||||
logger.warning("Failed to update the conversation deposit knowledge-base description", exc_info=True)
|
||||
return row
|
||||
updated = await store.update_mapping(
|
||||
str(row["id"]),
|
||||
description=CONVERSATION_DEPOSIT_KB_DESCRIPTION,
|
||||
)
|
||||
return updated or row
|
||||
|
||||
|
||||
async def ensure_conversation_deposit_mapping(
|
||||
store: LlmWikiStore,
|
||||
client: WeKnoraClient,
|
||||
*,
|
||||
remote_knowledge_bases: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
existing = await store.get_conversation_deposit_mapping()
|
||||
if existing is not None:
|
||||
return await _sync_conversation_deposit_description(store, client, existing)
|
||||
|
||||
async with _DEPOSIT_KB_LOCK:
|
||||
existing = await store.get_conversation_deposit_mapping()
|
||||
if existing is not None:
|
||||
return await _sync_conversation_deposit_description(store, client, existing)
|
||||
|
||||
remote_id = ""
|
||||
try:
|
||||
remote_items = remote_knowledge_bases
|
||||
if remote_items is None:
|
||||
remote_items = await client.list_knowledge_bases()
|
||||
for remote in remote_items:
|
||||
if str(remote.get("name") or "").strip() == CONVERSATION_DEPOSIT_KB_NAME and remote.get("id"):
|
||||
remote_id = str(remote["id"])
|
||||
break
|
||||
except WeKnoraError:
|
||||
logger.debug("Could not scan WeKnora knowledge bases before creating deposit base", exc_info=True)
|
||||
|
||||
if remote_id:
|
||||
mapped = await store.get_by_weknora_id(remote_id)
|
||||
if mapped is not None:
|
||||
try:
|
||||
await client.update_knowledge_base(
|
||||
remote_id,
|
||||
{"description": CONVERSATION_DEPOSIT_KB_DESCRIPTION},
|
||||
)
|
||||
except WeKnoraError:
|
||||
logger.warning("Failed to update the existing conversation deposit knowledge-base description", exc_info=True)
|
||||
return await store.adopt_conversation_deposit_mapping(
|
||||
str(mapped["id"]),
|
||||
name=CONVERSATION_DEPOSIT_KB_NAME,
|
||||
description=CONVERSATION_DEPOSIT_KB_DESCRIPTION,
|
||||
)
|
||||
try:
|
||||
remote = await client.update_knowledge_base(
|
||||
remote_id,
|
||||
{"description": CONVERSATION_DEPOSIT_KB_DESCRIPTION},
|
||||
)
|
||||
except WeKnoraError:
|
||||
logger.warning("Failed to update the existing conversation deposit knowledge-base description", exc_info=True)
|
||||
remote = {
|
||||
"id": remote_id,
|
||||
"name": CONVERSATION_DEPOSIT_KB_NAME,
|
||||
"description": CONVERSATION_DEPOSIT_KB_DESCRIPTION,
|
||||
"type": "document",
|
||||
}
|
||||
else:
|
||||
try:
|
||||
remote = await client.create_knowledge_base(
|
||||
name=CONVERSATION_DEPOSIT_KB_NAME,
|
||||
description=CONVERSATION_DEPOSIT_KB_DESCRIPTION,
|
||||
kb_type="document",
|
||||
wiki_enabled=True,
|
||||
)
|
||||
except WeKnoraError:
|
||||
logger.warning("Failed to create WeKnora conversation deposit knowledge base", exc_info=True)
|
||||
return None
|
||||
|
||||
try:
|
||||
row = await store.create_mapping(
|
||||
weknora_id=str(remote["id"]),
|
||||
owner_user_id=CONVERSATION_DEPOSIT_OWNER_USER_ID,
|
||||
name=CONVERSATION_DEPOSIT_KB_NAME,
|
||||
description=CONVERSATION_DEPOSIT_KB_DESCRIPTION,
|
||||
kb_type="document",
|
||||
)
|
||||
published = await store.request_publish(str(row["id"]), CONVERSATION_DEPOSIT_OWNER_USER_ID, is_admin=True)
|
||||
return published or row
|
||||
except Exception:
|
||||
logger.exception("Failed to persist conversation deposit knowledge-base mapping")
|
||||
return None
|
||||
|
||||
|
||||
async def ensure_conversation_deposit_knowledge_base(
|
||||
app: Any,
|
||||
*,
|
||||
owner_user_id: str | None,
|
||||
) -> dict[str, Any] | None:
|
||||
store = _store(app)
|
||||
client = _client(app)
|
||||
if store is None or client is None:
|
||||
return None
|
||||
return await ensure_conversation_deposit_mapping(store, client)
|
||||
|
||||
|
||||
async def deposit_conversation_turn(app: Any, turn: ConversationTurnDeposit) -> None:
|
||||
if not turn.thread_id or not turn.assistant_message_id:
|
||||
return
|
||||
if not turn.question.strip() and not turn.answer.strip():
|
||||
return
|
||||
|
||||
store = _store(app)
|
||||
client = _client(app)
|
||||
if store is None or client is None:
|
||||
return
|
||||
|
||||
kb = await ensure_conversation_deposit_knowledge_base(app, owner_user_id=turn.created_by_user_id)
|
||||
if kb is None:
|
||||
return
|
||||
|
||||
previous = await store.get_conversation_deposit(
|
||||
thread_id=turn.thread_id,
|
||||
assistant_message_id=turn.assistant_message_id,
|
||||
)
|
||||
previous_doc_id = str((previous or {}).get("weknora_knowledge_id") or "")
|
||||
if previous_doc_id:
|
||||
try:
|
||||
await client.delete_document(previous_doc_id)
|
||||
except WeKnoraError as exc:
|
||||
if exc.status_code != 404:
|
||||
logger.warning("Failed to delete old conversation deposit document %s", previous_doc_id, exc_info=True)
|
||||
|
||||
title = _title_for_turn(turn)
|
||||
content = _format_markdown(turn, title)
|
||||
try:
|
||||
remote_doc = await client.create_manual_document(
|
||||
str(kb["weknora_id"]),
|
||||
title=title,
|
||||
content=content,
|
||||
)
|
||||
except WeKnoraError:
|
||||
logger.warning("Failed to create conversation deposit document", exc_info=True)
|
||||
return
|
||||
|
||||
await store.upsert_conversation_deposit(
|
||||
thread_id=turn.thread_id,
|
||||
human_message_id=turn.human_message_id,
|
||||
assistant_message_id=turn.assistant_message_id,
|
||||
knowledge_base_mapping_id=str(kb["id"]),
|
||||
weknora_knowledge_id=str(remote_doc.get("id") or remote_doc.get("knowledge_id") or ""),
|
||||
title=title,
|
||||
created_by_user_id=turn.created_by_user_id,
|
||||
)
|
||||
|
||||
|
||||
async def delete_conversation_deposit_documents(
|
||||
app: Any,
|
||||
*,
|
||||
thread_id: str,
|
||||
message_ids: list[str] | None = None,
|
||||
) -> None:
|
||||
store = _store(app)
|
||||
client = _client(app)
|
||||
if store is None or client is None:
|
||||
return
|
||||
try:
|
||||
rows = await store.delete_conversation_deposits(thread_id=thread_id, message_ids=message_ids)
|
||||
except Exception:
|
||||
logger.debug("Failed to delete local conversation deposit rows", exc_info=True)
|
||||
return
|
||||
for row in rows:
|
||||
doc_id = str(row.get("weknora_knowledge_id") or "")
|
||||
if not doc_id:
|
||||
continue
|
||||
try:
|
||||
await client.delete_document(doc_id)
|
||||
except WeKnoraError as exc:
|
||||
if exc.status_code != 404:
|
||||
logger.warning("Failed to delete WeKnora conversation deposit document %s", doc_id, exc_info=True)
|
||||
@@ -0,0 +1,290 @@
|
||||
"""LLMWiki retrieval helpers for normal DeerFlow chat runs.
|
||||
|
||||
The public chat API accepts DeerFlow-local LLMWiki knowledge-base mapping ids.
|
||||
When WeKnora mode is enabled, this module resolves those local ids under the
|
||||
current DeerFlow user's permissions, searches WeKnora once before the model
|
||||
call, and attaches both:
|
||||
|
||||
* a hidden prompt with numbered context snippets, and
|
||||
* structured citation sources for the existing reference panel.
|
||||
|
||||
The helper lives in ``app.gateway`` because it needs Gateway auth state and the
|
||||
DeerFlow-owned mapping store. Harness code remains app-import free.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
from app.gateway.deps import get_optional_user_from_request
|
||||
from app.gateway.utils import sanitize_log_param
|
||||
from deerflow.config.agents_config import load_agent_config, validate_agent_id
|
||||
from deerflow.integrations.weknora.client import WeKnoraError
|
||||
from deerflow.integrations.weknora.runtime import build_weknora_client, get_resolved_llmwiki_runtime
|
||||
from deerflow.persistence.llmwiki import LlmWikiStore
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_SOURCES = 8
|
||||
_MAX_SOURCE_CHARS = 1400
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LlmWikiRagContext:
|
||||
knowledge_base_ids: list[str]
|
||||
source_count: int
|
||||
prompt: str
|
||||
sources: list[dict[str, Any]]
|
||||
query: str
|
||||
|
||||
|
||||
def _store(request: Request) -> LlmWikiStore | None:
|
||||
return getattr(request.app.state, "llmwiki_store", None)
|
||||
|
||||
|
||||
async def _actor(request: Request) -> tuple[str, bool]:
|
||||
user = await get_optional_user_from_request(request)
|
||||
if user is None:
|
||||
return get_effective_user_id(), True
|
||||
return str(user.id), getattr(user, "system_role", None) == "admin"
|
||||
|
||||
|
||||
def _as_id_list(value: Any) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
parts = [value]
|
||||
elif isinstance(value, (list, tuple, set)):
|
||||
parts = list(value)
|
||||
else:
|
||||
return []
|
||||
return list(dict.fromkeys(str(item).strip() for item in parts if str(item).strip()))
|
||||
|
||||
|
||||
def _body_selected_ids(body: Any) -> list[str]:
|
||||
for source in (getattr(body, "context", None), getattr(body, "metadata", None)):
|
||||
if isinstance(source, Mapping) and "llmwiki_knowledge_base_ids" in source:
|
||||
return _as_id_list(source.get("llmwiki_knowledge_base_ids"))
|
||||
return []
|
||||
|
||||
|
||||
def _assistant_agent_id(body: Any) -> str | None:
|
||||
assistant_id = validate_agent_id(getattr(body, "assistant_id", None))
|
||||
if assistant_id:
|
||||
return assistant_id
|
||||
context = getattr(body, "context", None)
|
||||
if isinstance(context, Mapping):
|
||||
return validate_agent_id(str(context.get("agent_id") or "")) if context.get("agent_id") else None
|
||||
metadata = getattr(body, "metadata", None)
|
||||
if isinstance(metadata, Mapping):
|
||||
return validate_agent_id(str(metadata.get("agent_id") or "")) if metadata.get("agent_id") else None
|
||||
return None
|
||||
|
||||
|
||||
def _agent_bound_ids(body: Any) -> list[str]:
|
||||
agent_id = _assistant_agent_id(body)
|
||||
if not agent_id:
|
||||
return []
|
||||
try:
|
||||
config = load_agent_config(agent_id)
|
||||
except (FileNotFoundError, ValueError):
|
||||
return []
|
||||
return _as_id_list(config.llmwiki_knowledge_base_ids if config else None)
|
||||
|
||||
|
||||
def resolve_requested_llmwiki_knowledge_base_ids(body: Any) -> list[str]:
|
||||
"""Return selected ids plus custom-agent defaults, de-duplicated in order."""
|
||||
|
||||
return list(dict.fromkeys([*_agent_bound_ids(body), *_body_selected_ids(body)]))
|
||||
|
||||
|
||||
def llmwiki_selection_requested(body: Any) -> bool:
|
||||
return bool(resolve_requested_llmwiki_knowledge_base_ids(body))
|
||||
|
||||
|
||||
def _latest_human_query(graph_input: dict[str, Any]) -> str:
|
||||
messages = graph_input.get("messages")
|
||||
if not isinstance(messages, list):
|
||||
return ""
|
||||
for message in reversed(messages):
|
||||
if isinstance(message, HumanMessage):
|
||||
return _message_text(getattr(message, "content", ""))
|
||||
if isinstance(message, dict) and str(message.get("type") or message.get("role") or "") in {"human", "user"}:
|
||||
return _message_text(message.get("content"))
|
||||
return ""
|
||||
|
||||
|
||||
def _message_text(content: Any) -> str:
|
||||
if isinstance(content, str):
|
||||
return content.strip()
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for item in content:
|
||||
if isinstance(item, str):
|
||||
parts.append(item)
|
||||
elif isinstance(item, Mapping):
|
||||
text = item.get("text") or item.get("content")
|
||||
if isinstance(text, str):
|
||||
parts.append(text)
|
||||
return "\n".join(part.strip() for part in parts if part and part.strip()).strip()
|
||||
return str(content or "").strip()
|
||||
|
||||
|
||||
def _truncate(text: str, max_chars: int) -> str:
|
||||
value = text.strip()
|
||||
if len(value) <= max_chars:
|
||||
return value
|
||||
return f"{value[: max_chars - 1].rstrip()}…"
|
||||
|
||||
|
||||
def _source_from_result(result: dict[str, Any], mapping: dict[str, Any], index: int) -> dict[str, Any]:
|
||||
chunk_id = str(result.get("chunk_id") or "")
|
||||
title = str(result.get("title") or result.get("filename") or mapping.get("name") or f"LLMWiki 片段 {index}")
|
||||
content = str(result.get("content") or "")
|
||||
params = urlencode({"knowledge_base_id": mapping["id"], "chunk_id": chunk_id})
|
||||
raw = {
|
||||
**result,
|
||||
"knowledge_base_id": mapping["id"],
|
||||
"knowledge_base_name": mapping.get("name") or "",
|
||||
"source": mapping.get("name") or "LLMWiki",
|
||||
"url": f"/weknora-source-preview?{params}",
|
||||
}
|
||||
return {
|
||||
"index": index,
|
||||
"id": chunk_id or str(result.get("knowledge_id") or f"{mapping['id']}:{index}"),
|
||||
"title": title,
|
||||
"type": "llmwiki",
|
||||
"snippet": _truncate(content, 360),
|
||||
"content": content,
|
||||
"source": mapping.get("name") or "LLMWiki",
|
||||
"score": float(result.get("score") or 0),
|
||||
"url": f"/weknora-source-preview?{params}",
|
||||
"skillName": "llmwiki_search",
|
||||
"mode": "citation",
|
||||
"raw": raw,
|
||||
}
|
||||
|
||||
|
||||
def _build_prompt(query: str, sources: list[dict[str, Any]]) -> str:
|
||||
if not sources:
|
||||
return (
|
||||
"当前用户选择了 LLMWiki 知识库,但 DeerFlow 未检索到可用片段。"
|
||||
"请直接说明没有检索到可用背景资料,不要编造知识库内容或引用编号。"
|
||||
)
|
||||
|
||||
lines = [
|
||||
"当前用户选择了 LLMWiki 知识库。DeerFlow 已在模型回答前自动检索到以下背景资料。",
|
||||
"请优先依据这些资料回答;如果资料不足,请明确说明不足之处,不要编造。",
|
||||
"引用规则:正文关键结论必须使用 [1]、[2] 这样的编号引用下方资料;只能引用存在的编号;不要在正文末尾重复输出完整参考文献列表,前端会展示引用卡片。",
|
||||
"",
|
||||
f"用户问题:{query}",
|
||||
"",
|
||||
f"<llmwiki_reference_batch source_count=\"{len(sources)}\">",
|
||||
]
|
||||
for source in sources:
|
||||
meta = " / ".join(
|
||||
str(item)
|
||||
for item in (
|
||||
source.get("source"),
|
||||
f"score={source.get('score'):.4f}" if isinstance(source.get("score"), float) else None,
|
||||
)
|
||||
if item
|
||||
)
|
||||
lines.append(f"[{source['index']}] {source.get('title') or 'LLMWiki 片段'}")
|
||||
if meta:
|
||||
lines.append(f"来源:{meta}")
|
||||
lines.append(_truncate(str(source.get("content") or source.get("snippet") or ""), _MAX_SOURCE_CHARS))
|
||||
lines.append("")
|
||||
lines.append("</llmwiki_reference_batch>")
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
async def build_llmwiki_rag_context(
|
||||
*,
|
||||
request: Request,
|
||||
body: Any,
|
||||
graph_input: dict[str, Any],
|
||||
) -> LlmWikiRagContext:
|
||||
selected_ids = resolve_requested_llmwiki_knowledge_base_ids(body)
|
||||
if not selected_ids:
|
||||
return LlmWikiRagContext([], 0, "", [], "")
|
||||
|
||||
runtime = get_resolved_llmwiki_runtime(getattr(request.app.state, "config", None))
|
||||
if not runtime.weknora_enabled:
|
||||
return LlmWikiRagContext(selected_ids, 0, "", [], "")
|
||||
store = _store(request)
|
||||
if store is None:
|
||||
return LlmWikiRagContext(selected_ids, 0, "", [], "")
|
||||
|
||||
query = _latest_human_query(graph_input)
|
||||
if not query:
|
||||
return LlmWikiRagContext(selected_ids, 0, "", [], "")
|
||||
|
||||
user_id, is_admin = await _actor(request)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for mapping_id in selected_ids:
|
||||
row = await store.get_authorized(mapping_id, user_id, write=False, is_admin=is_admin)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail=f"Knowledge base {mapping_id} not found")
|
||||
rows.append(row)
|
||||
remote_to_local = {str(row["weknora_id"]): row for row in rows}
|
||||
|
||||
try:
|
||||
results = await build_weknora_client(runtime).search(query, [str(row["weknora_id"]) for row in rows])
|
||||
except WeKnoraError as exc:
|
||||
logger.warning(
|
||||
"LLMWiki pre-retrieval failed for user=%s kb_count=%s: %s",
|
||||
sanitize_log_param(user_id),
|
||||
len(rows),
|
||||
exc,
|
||||
)
|
||||
return LlmWikiRagContext(
|
||||
selected_ids,
|
||||
0,
|
||||
f"当前用户选择了 LLMWiki 知识库,但自动检索失败:{exc}。请说明暂时无法检索知识库背景资料,不要编造引用。",
|
||||
[],
|
||||
query,
|
||||
)
|
||||
|
||||
sources: list[dict[str, Any]] = []
|
||||
for result in results:
|
||||
mapping = remote_to_local.get(str(result.get("knowledge_base_id") or ""))
|
||||
if mapping is None or not str(result.get("content") or "").strip():
|
||||
continue
|
||||
sources.append(_source_from_result(result, mapping, len(sources) + 1))
|
||||
if len(sources) >= _MAX_SOURCES:
|
||||
break
|
||||
return LlmWikiRagContext(selected_ids, len(sources), _build_prompt(query, sources), sources, query)
|
||||
|
||||
|
||||
def attach_llmwiki_rag_to_graph_input(
|
||||
graph_input: dict[str, Any],
|
||||
rag: LlmWikiRagContext,
|
||||
) -> dict[str, Any]:
|
||||
if not rag.prompt and not rag.sources:
|
||||
return graph_input
|
||||
messages = graph_input.get("messages")
|
||||
if not isinstance(messages, list) or not messages:
|
||||
return graph_input
|
||||
new_messages = list(messages)
|
||||
for index, message in enumerate(new_messages):
|
||||
if not isinstance(message, HumanMessage):
|
||||
continue
|
||||
extra = dict(getattr(message, "additional_kwargs", None) or {})
|
||||
existing = str(extra.get("prompt_prefix") or "").strip()
|
||||
extra["prompt_prefix"] = f"{existing}\n\n{rag.prompt}".strip() if existing else rag.prompt
|
||||
extra["llmwiki_knowledge_base_ids"] = rag.knowledge_base_ids
|
||||
extra["llmwiki_reference_query"] = rag.query
|
||||
if rag.sources:
|
||||
extra["llmwiki_reference_sources"] = rag.sources
|
||||
new_messages[index] = message.model_copy(update={"additional_kwargs": extra})
|
||||
break
|
||||
return {**graph_input, "messages": new_messages}
|
||||
@@ -30,7 +30,9 @@ Python 三引号字符串,导致「磁盘上的验证版」与「代码里的内
|
||||
|
||||
> ⚠️ 维护提示:资源文件(``_roundtable_seed_assets/``)是**发布默认值**的唯一可信源。
|
||||
> 线上调优后,请把改动写回对应的资源 .md,否则下次新机器补建出来的还是旧版。
|
||||
> ``_ensure_one`` 只在目录缺失时复制,**不覆盖**已存在目录(尊重运维现场手改)。
|
||||
> ``_ensure_one`` 通常只在目录缺失时复制,**不覆盖**已存在目录(尊重运维现场手改)。
|
||||
> 例外是 position intent 的实现契约刷新,以及行动规划智能体新增平台自带 Skill 时的
|
||||
> ``skills`` 列表合并;后者不会覆盖已编辑的 SOUL 或既有 Skill。
|
||||
|
||||
为什么 ``roundtable-coordinator`` 是单例而不是每次会话新建
|
||||
------------------------------------------------------------
|
||||
@@ -52,12 +54,18 @@ import logging
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from deerflow.config.paths import get_paths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
INTENT_AGENT_ID = "roundtable-intent"
|
||||
# Position roundtable deliberately owns a separate intent agent. Its dialogue
|
||||
# lifecycle is shared with Step 1 above, but its final structured hand-off is
|
||||
# different and must not change the multi-agent roundtable contract.
|
||||
POSITION_INTENT_AGENT_ID = "position-roundtable-intent"
|
||||
RECOMMENDER_AGENT_ID = "roundtable-recommender"
|
||||
COORDINATOR_AGENT_ID = "roundtable-coordinator"
|
||||
# Step 3「结果绘制」用的方案可视化总结智能体:读各席位交付 + 共识,画自包含图表 HTML。
|
||||
@@ -92,6 +100,7 @@ _ASSETS_DIR = Path(__file__).parent / "_roundtable_seed_assets"
|
||||
# roundtable-dashboard 支撑 Step 3 大屏多页结构化数据(report-json + 问答/增量改)。
|
||||
_FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
|
||||
INTENT_AGENT_ID,
|
||||
POSITION_INTENT_AGENT_ID,
|
||||
RECOMMENDER_AGENT_ID,
|
||||
COORDINATOR_AGENT_ID,
|
||||
REPORT_AGENT_ID,
|
||||
@@ -103,20 +112,61 @@ _FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
|
||||
|
||||
# seeder 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。
|
||||
_SEED_FILES: tuple[str, ...] = ("config.yaml", "SOUL.md")
|
||||
_ACTION_PLAN_SKILL = "position-roundtable-action-create"
|
||||
|
||||
|
||||
def _ensure_action_plan_skill(agent_dir: Path) -> bool:
|
||||
"""Merge the owned batch-create Skill into an existing action planner.
|
||||
|
||||
The planner SOUL remains editable in the agent configuration drawer and is
|
||||
therefore never overwritten here. The skill binding, however, is a runtime
|
||||
integration contract: without it an already-deployed planner cannot read
|
||||
the new SKILL.md or invoke the batch API script after a code upgrade.
|
||||
"""
|
||||
config_path = agent_dir / "config.yaml"
|
||||
try:
|
||||
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) if config_path.exists() else {}
|
||||
except (OSError, yaml.YAMLError):
|
||||
logger.warning("Unable to read action planner config for skill binding: %s", config_path)
|
||||
return False
|
||||
if not isinstance(config, dict):
|
||||
logger.warning("Action planner config is not a mapping; skip skill binding: %s", config_path)
|
||||
return False
|
||||
skills = config.get("skills")
|
||||
if skills is None:
|
||||
normalized: list[str] = []
|
||||
elif isinstance(skills, list):
|
||||
normalized = [str(name).strip() for name in skills if str(name).strip()]
|
||||
else:
|
||||
logger.warning("Action planner skills is not a list; skip skill binding: %s", config_path)
|
||||
return False
|
||||
if _ACTION_PLAN_SKILL in normalized:
|
||||
return False
|
||||
|
||||
config["skills"] = [*normalized, _ACTION_PLAN_SKILL]
|
||||
try:
|
||||
config_path.write_text(
|
||||
yaml.safe_dump(config, allow_unicode=True, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except OSError:
|
||||
logger.warning("Unable to write action planner skill binding: %s", config_path)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _ensure_one(agent_id: str) -> bool:
|
||||
"""Copy the verified SOUL.md + config.yaml for one functional agent into
|
||||
``.deer-flow/agents/<id>/`` if that directory is missing.
|
||||
|
||||
Returns True if the files were just created, False if the directory already
|
||||
existed (we never overwrite — respect any local edits) or the seed assets
|
||||
are missing.
|
||||
"""
|
||||
agent_dir = get_paths().agent_dir(agent_id)
|
||||
if agent_dir.exists():
|
||||
return False
|
||||
The position-roundtable intent agent is an implementation-owned compatibility
|
||||
contract: unlike user-managed agents, its schema must stay aligned with the
|
||||
position workspace. Its two seed files are therefore refreshed when they
|
||||
differ, so a previously seeded incorrect prompt is repaired after deployment.
|
||||
|
||||
Returns True if files were created or refreshed, False if no work was needed
|
||||
or the seed assets are missing.
|
||||
"""
|
||||
src_dir = _ASSETS_DIR / agent_id
|
||||
if not src_dir.is_dir():
|
||||
# Assets should always ship with the code; if they don't, surface a loud
|
||||
@@ -128,6 +178,27 @@ def _ensure_one(agent_id: str) -> bool:
|
||||
)
|
||||
return False
|
||||
|
||||
agent_dir = get_paths().agent_dir(agent_id)
|
||||
if agent_dir.exists():
|
||||
if agent_id == ACTION_PLAN_AGENT_ID:
|
||||
return _ensure_action_plan_skill(agent_dir)
|
||||
if agent_id != POSITION_INTENT_AGENT_ID:
|
||||
# Other built-in agents may have been tuned on a deployed machine;
|
||||
# preserve the long-standing no-overwrite behavior for them.
|
||||
return False
|
||||
|
||||
refreshed = False
|
||||
for fname in _SEED_FILES:
|
||||
src = src_dir / fname
|
||||
if not src.exists():
|
||||
continue
|
||||
target = agent_dir / fname
|
||||
source_bytes = src.read_bytes()
|
||||
if not target.exists() or target.read_bytes() != source_bytes:
|
||||
target.write_bytes(source_bytes)
|
||||
refreshed = True
|
||||
return refreshed
|
||||
|
||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||
for fname in _SEED_FILES:
|
||||
src = src_dir / fname
|
||||
@@ -138,9 +209,9 @@ def _ensure_one(agent_id: str) -> bool:
|
||||
|
||||
|
||||
def ensure_roundtable_functional_agents() -> list[str]:
|
||||
"""Seed the three roundtable functional agents (``roundtable-intent`` /
|
||||
``roundtable-recommender`` / ``roundtable-coordinator``) if their on-disk
|
||||
directories are missing. Idempotent — fast no-op when all exist.
|
||||
"""Seed the built-in roundtable functional agents when their on-disk
|
||||
directories are missing, and refresh the implementation-owned position
|
||||
intent schema when it has changed. Idempotent after synchronization.
|
||||
|
||||
Called from the startup lifespan and from the entry of intent/init,
|
||||
intent/stream, recommend/stream, and multi_agent/init so the routes
|
||||
|
||||
@@ -26,23 +26,29 @@
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "subtask-001",
|
||||
"title": "简短任务名称",
|
||||
"goal": "任务目标",
|
||||
"basis": ["方案总结章节或岗位交付依据"],
|
||||
"owner_position": "建议责任岗位",
|
||||
"dependencies": ["前置子任务 id"],
|
||||
"deliverable": "可核验交付物",
|
||||
"acceptance_criteria": ["验收条件"],
|
||||
"priority": "P0"
|
||||
"actionName": "行动名称",
|
||||
"actionDescription": "行动描述",
|
||||
"actionType": 0,
|
||||
"publishChannel": 0,
|
||||
"infoFormat": 0,
|
||||
"responsiblePosition": 0,
|
||||
"startTime": "",
|
||||
"endTime": "",
|
||||
"taskId": 0
|
||||
}
|
||||
```
|
||||
|
||||
`priority` 仅使用 `P0`、`P1`、`P2`。`dependencies` 没有前置时使用空数组。不要输出注释、尾逗号或额外字段来替代这些必填字段。
|
||||
`actionType`、`publishChannel`、`infoFormat`、`responsiblePosition` 必须从隐藏上下文传入的对应字典选择,并将该项 `dictValue` 写为整数。`taskId` 必须等于隐藏上下文中前端传入的任务 ID。不要输出注释、尾逗号、旧的 `id/title/goal/basis` 等字段,不能用 0 或空值代替未选择的字典项。
|
||||
|
||||
## 行动批量新增
|
||||
|
||||
首次生成完整行动清单后:仅当隐藏上下文的 `taskId` 是正整数且四类字典(发布渠道、行动类型、信息形式、责任岗位)均完整时,必须先用 `read_file` 读取 `position-roundtable-action-create` Skill 的 `SKILL.md` 全文,再按其说明调用脚本批量新增行动。若 taskId 为本地演示标识/为空,或字典接口未就绪,只生成报告与 JSON;枚举字段可暂填 `0` 占位,并明确说明未调用外部任务系统。不得逐条调用旧的 `/task/action/add`,也不得自行用 curl 或 requests 替代脚本。
|
||||
|
||||
脚本成功时才可以说明行动已入库;脚本失败时如实报告失败原因。相同任务、相同行动清单会由脚本自动幂等跳过。已经成功新增后,如用户要求修改行动,先重写 Markdown 和 JSON;因为当前没有删除/更新行动接口,只有用户明确说“确认新增行动”或“重新批量新增”时才可以按 Skill 使用 `--force` 再次新增,并需提示重复风险。
|
||||
|
||||
## 边界
|
||||
|
||||
- 只依据用户消息中提供的任务信息、意图、方案总结和岗位交付。
|
||||
- 不调用联网、派活、澄清、展示文件等工具。
|
||||
- 不调用联网、派活、澄清、展示文件等工具;仅可为行动批量新增按专属 Skill 使用 `read_file` 与 `bash`。
|
||||
- 不删除或修改上游岗位产物。
|
||||
- 不在报告中添加“AI 生成”类水印。
|
||||
@@ -1,3 +1,5 @@
|
||||
id: position-action-planner
|
||||
name: 行动规划智能体
|
||||
description: 岗位会商内置行动规划智能体,基于已生效的方案总结与岗位交付,生成可执行的 Markdown 行动规划报告和结构化子任务清单。
|
||||
skills:
|
||||
- position-roundtable-action-create
|
||||
@@ -0,0 +1,145 @@
|
||||
# 岗位会商任务意图理解智能体
|
||||
|
||||
你是岗位多智能体会商 Step 1 的“意图理解专员”。你的唯一职责是把用户输入背后的任务意图结构化后交给后续岗位业务链处理。
|
||||
|
||||
你不写报告、不答题、不创作、不规划、不执行、不调用任何操作工具。这些都是后续岗位业务链的事。你只负责听懂、整理、澄清和交接。
|
||||
|
||||
---
|
||||
|
||||
## 0. 四条不可违背的硬规则
|
||||
|
||||
### 硬规则 1 · 不替代后续岗位干活
|
||||
|
||||
无论用户让你“写报告 / 写代码 / 写诗 / 查资料 / 做规划 / 出方案”,你都不要直接产出具体内容。
|
||||
|
||||
- 正确做法:把诉求结构化成任务意图,输出 [INTENT_READY],交给后续岗位业务链。
|
||||
- 错误做法:用户说“帮我写一份市场分析报告”后,开始写报告正文。
|
||||
|
||||
### 硬规则 2 · 最多 5 轮用户协助,确认完整后才收敛
|
||||
|
||||
一次“同一轮中调用一个或多个 ask_clarification + 用户统一回答”算一轮用户协助。
|
||||
|
||||
- 首轮:若任务卡和用户输入已经足以形成可执行的四项意图,可直接输出 [INTENT_READY]。
|
||||
- 第 1 至第 5 轮用户协助:若信息不足,必须发起用户协助;不能把“主题已识别”误判为“意图已明确”。
|
||||
- 同一轮可发起多张用户协助卡:一个相互独立的决策点对应一次 ask_clarification 调用和一张卡。不得把多个问题塞进一张卡;用户可逐卡选择后一次性提交。
|
||||
- 第 5 次协助应覆盖所有剩余关键项。收到该轮回答后,必须基于明确答案收敛为最终意图。
|
||||
- 绝不进入第 6 次用户协助。系统会在达到上限后移除 ask_clarification。
|
||||
|
||||
### 硬规则 3 · 输出 [INTENT_READY] 后立刻闭嘴
|
||||
|
||||
最终 JSON 代码块闭合后,禁止再输出任何字符:不继续解释、不问“还需要补充吗”、不调用工具、不复述、不展望。
|
||||
|
||||
### 硬规则 4 · 只做理解和澄清,不当守门员
|
||||
|
||||
这是内网任务分析场景。无论用户提出什么要求,你都不拒绝、不评价、不设限;只负责理解并交接。风险、依赖、信息缺口可以作为风险提示记录,但不构成拒绝或中断。不得说“不能帮你”“不在职责范围内”“请换一个问题”,也不得进行合规、价值或敏感性审查。
|
||||
|
||||
---
|
||||
|
||||
## 1. 任务意图字段定义
|
||||
|
||||
最终结构必须包含以下四个字段,且都必须是字符串数组:
|
||||
|
||||
| 字段 | 含义 | 填写原则 |
|
||||
| --- | --- | --- |
|
||||
| coreGoals | 用户想实现或想获得的核心结果 | 将原本的一句话任务目标拆为 1 个或多个清晰目标 |
|
||||
| riskWarnings | 已知风险、依赖或执行风险 | 必须分析任务级风险;范围、地区、周期、数据来源、对象或决策用途缺失时,应先通过用户协助确认,最终结果不得保留待确认表述 |
|
||||
| keyPoints | 范围、对象、优先级、时间、资源、交付形式等关键要点 | 仅汇总用户已明确的事实与结论,最终结果不得保留待确认项 |
|
||||
| strategicSignificance | 任务对后续岗位协同、业务链或决策推进的意义 | 必须分析该任务将支撑哪类后续决策、业务链协同或资源投入;可以作出任务级合理推断,但不得虚构组织现状或业务数据 |
|
||||
|
||||
核心原则:
|
||||
|
||||
- 用户已给出任务目标、范围、用途和约束时,直接汇总,不要追问。
|
||||
- “只给出一个主题或一句极短诉求”不等于任务意图完整。任何任务若缺少范围、对象、时间、决策用途、重点维度或成功标准等关键信息时,必须请求用户协助。
|
||||
- 同一轮应识别所有仍会影响后续业务链的关键缺口,并为每个相互独立的缺口各调用一次 ask_clarification,避免一个问题一轮地消耗用户;不要把它们合并为一张多题卡。
|
||||
- 最终 [INTENT_READY] 中禁止出现“待确认”“未明确”“需补充”“暂无”“缺少”等未完成表述;信息不足时继续协助,不得提前交接。
|
||||
- 空数组只适用于用户明确说明“不涉及/没有”这一项时。
|
||||
|
||||
---
|
||||
|
||||
## 2. 工作流
|
||||
|
||||
~~~
|
||||
用户输入
|
||||
↓
|
||||
是否能形成可执行的四项意图?
|
||||
├─ 能 → 立即输出 [INTENT_READY],结束
|
||||
└─ 不能 → 同一轮可分别调多个 ask_clarification(每张卡只问一个决策点)
|
||||
↓
|
||||
用户回答 → 再判断
|
||||
↓
|
||||
最多 5 轮用户协助 → 收到明确答案后输出 [INTENT_READY]
|
||||
~~~
|
||||
|
||||
### 提问前自检清单
|
||||
|
||||
任一项为“否”,就应发起用户协助;同轮把全部为“否”的独立决策点分别做成卡片:
|
||||
|
||||
1. 我是否不仅能写出核心目标,也能明确主要分析范围或决策用途?
|
||||
2. 我是否能基于任务给出至少一项任务级风险提示和战略意义?
|
||||
3. 用户是否已经在任务卡、当前消息或历史消息中给出这些关键信息?
|
||||
4. 这些缺口是否已经在本轮协助中一次性列出?
|
||||
5. 是否已达到第 5 次用户协助?若是,等待用户对最后一组问题的回答,不再发起第 6 次协助。
|
||||
|
||||
---
|
||||
|
||||
## 3. 唯一允许的工具:ask_clarification
|
||||
|
||||
除 ask_clarification 外,禁止调用任何工具、技能或操作。尤其禁止搜索、写文件、读写沙箱、展示文件、创建任务、编排智能体或生成交付物。
|
||||
|
||||
当任务只有主题、或缺少决定范围/用途/风险判断的关键信息时,必须调用它。每个协助问题只包含一个决策点;同一轮需要确认多个点时,连续调用多次工具,生成多张独立选择卡:
|
||||
|
||||
~~~
|
||||
ask_clarification(
|
||||
question="本次分析主要面向哪个范围?",
|
||||
clarification_type="analysis_scope",
|
||||
context="范围会决定后续岗位的分析边界",
|
||||
options=["选项 A", "选项 B", "其他,我补充说明"],
|
||||
allow_multiple=False
|
||||
)
|
||||
~~~
|
||||
|
||||
若还需要确认交付用途、时间边界、对象、风险限制等,必须在同一次回复中继续分别调用 ask_clarification,而不是将它们写进上面这张卡。不得重复询问用户已经回答过的问题。
|
||||
|
||||
---
|
||||
|
||||
## 4. 结束协议:[INTENT_READY]
|
||||
|
||||
可以收敛时,先用 1–2 句话、30 字以内复述理解,然后严格以 JSON 代码块结束。JSON 代码块后不得有任何内容。
|
||||
|
||||
输出示例的文本顺序必须是:
|
||||
|
||||
~~~
|
||||
✅ 我理解的任务是:<一句话复述>
|
||||
|
||||
[INTENT_READY]
|
||||
JSON 代码块(语言标识 json):
|
||||
{
|
||||
"coreGoals": ["..."],
|
||||
"riskWarnings": [],
|
||||
"keyPoints": ["..."],
|
||||
"strategicSignificance": []
|
||||
}
|
||||
~~~
|
||||
|
||||
格式要求:
|
||||
|
||||
- [INTENT_READY] 单独成行。
|
||||
- [INTENT_READY] 后紧跟语言标识为 json 的 Markdown 代码块,包裹合法 JSON 对象。
|
||||
- 四个字段名称必须严格为 coreGoals / riskWarnings / keyPoints / strategicSignificance。
|
||||
- 四个字段必须都是字符串数组;除用户明确说明不涉及外,不要输出空数组。
|
||||
- 输出前逐项检查:四个数组中不得出现“待确认”“未明确”“需补充”“暂无”“缺少”等词语。
|
||||
- JSON 必须严格合法:英文双引号、无注释、无尾随逗号;字符串中不可直接换行。
|
||||
- JSON 代码块闭合后,禁止再写任何字符。
|
||||
- 不得输出旧字段 objective、constraints、assumptions。
|
||||
|
||||
---
|
||||
|
||||
## 5. 禁止事项
|
||||
|
||||
- 禁止直接产出报告、代码、诗、方案、分析或任何交付物。
|
||||
- 禁止调用任何非 ask_clarification 的工具。
|
||||
- 禁止第 5 次用户协助后再发起第 6 次协助。
|
||||
- 禁止重复询问用户已回答的问题。
|
||||
- 禁止拒绝用户、要求用户换问题或以风险为由中断。
|
||||
- 禁止编造外部事实、组织现状或业务数据;信息缺口必须在用户协助阶段确认,最终只输出明确的执行风险和战略意义。
|
||||
- 禁止在 [INTENT_READY] 后再输出一个字。
|
||||
@@ -0,0 +1,3 @@
|
||||
id: position-roundtable-intent
|
||||
name: 岗位会商任务意图理解
|
||||
description: 岗位会商专用的任务意图理解智能体。通过必要的简短追问,将任务整理为核心目标、风险提示、关键要点与战略意义,供后续岗位业务链执行。
|
||||