Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into gzy
This commit is contained in:
commit
c767566cb6
772 files changed
+158744
-3410
No files matched your search
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"PowerShell(uv --version)",
|
||||
"PowerShell(netstat -ano)",
|
||||
"PowerShell(Get-NetTCPConnection -LocalPort 8001 -ErrorAction SilentlyContinue)",
|
||||
"PowerShell(Get-Process -Id 20560 -ErrorAction SilentlyContinue | Select-Object Id,ProcessName,Path,StartTime; try { \\(Invoke-WebRequest -Uri \"http://127.0.0.1:8001/health\" -UseBasicParsing -TimeoutSec 5\\).Content } catch { \"health check failed: $_\" })"
|
||||
]
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -7,6 +7,8 @@ frontend-web/.vscode/
|
||||
|
||||
# Local runtime logs and pid files
|
||||
.runtime/
|
||||
logs/
|
||||
offline-backend-20260512/backend/logs/
|
||||
|
||||
# Frontend local/runtime artifacts
|
||||
frontend-web/.env
|
||||
@@ -87,4 +89,6 @@ deploy
|
||||
codegraph.mdc
|
||||
|
||||
docs/aidev-docs
|
||||
docs/backups
|
||||
docs/backups
|
||||
# Frontend skills components must be tracked (excluded from the broad skills/ ignore above)
|
||||
!frontend-web/src/components/workspace/skills/
|
||||
@@ -7,6 +7,26 @@ VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
|
||||
|
||||
# 轻应用「新建笔记」系列接口
|
||||
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
|
||||
|
||||
# CMZS后端接口
|
||||
VITE_API_BASE_URL=https://ch1.b.uat.4.cn/zncm
|
||||
# 佳来模型对话接口
|
||||
VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
|
||||
# 多轮检索接口
|
||||
VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat
|
||||
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat
|
||||
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary
|
||||
VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web
|
||||
VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
|
||||
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
|
||||
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
||||
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
|
||||
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
|
||||
# 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
|
||||
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
||||
@@ -3,5 +3,21 @@
|
||||
|
||||
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
|
||||
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
|
||||
# 佳来模型对话接口
|
||||
VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
|
||||
# 多轮检索接口
|
||||
VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat
|
||||
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat
|
||||
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary
|
||||
VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web
|
||||
VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
|
||||
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
|
||||
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
||||
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
|
||||
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
|
||||
|
||||
# VITE_NOTE_API_BASE_URL=http://127.0.0.1:8000
|
||||
@@ -24,6 +24,11 @@
|
||||
# --- 生产打包(见 .env.production)---
|
||||
# 部署前修改 .env.production 中的地址,再执行 pnpm build
|
||||
|
||||
# VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
|
||||
# VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
|
||||
# VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
|
||||
# VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
|
||||
# 可选
|
||||
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
||||
# VITE_STATIC_WEBSITE_ONLY=
|
||||
|
||||
@@ -7,6 +7,22 @@ VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
|
||||
|
||||
# 轻应用「新建笔记」系列接口
|
||||
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
|
||||
# 佳来模型对话接口
|
||||
VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
|
||||
# 多轮检索接口
|
||||
VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat
|
||||
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat
|
||||
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary
|
||||
VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web
|
||||
VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
|
||||
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
|
||||
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
||||
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
|
||||
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
|
||||
|
||||
# 可选:Canvas 独立 LangGraph 地址
|
||||
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
||||
@@ -12,8 +12,8 @@
|
||||
|
||||
- 三个圆桌 Router(`intent / recommend / multi_agent`)**通过 httpx ASGITransport 进程内直调本机 ASGI app**,复用既有 `/api/threads`、`/api/agents`、`/api/threads/{id}/runs/stream` 路由——**不走 TCP / 不经过网络栈**。所有 client 都由 `multi_agent._make_loopback_client(request)` 工厂统一构造。
|
||||
- 历史包袱:早期实现走 `httpx.AsyncClient(trust_env=False)` + `127.0.0.1:<port>`,遇到开发机 `HTTP_PROXY` / `HTTPS_PROXY` 时仍可能把回环走代理(症状 `headers_in=42.160s`)。**改用 ASGITransport 后此问题不再存在**,相关 `trust_env=False` workaround 全部移除。
|
||||
- **席位 agent 持久注册**(磁盘 `.deer-flow/agents/` + `_sync_legacy_agents`),**协调智能体每次新建**(命名 `roundtable-coordinator-{timestamp36}`,DB 累积,需后续 GC)。
|
||||
- **`.deer-flow/` 整个目录被 git ignore**(仓库根 `.gitignore` 第 64 / 74 行),`git clone` 后内置 agent 目录在部署机上**不会存在**——任何环境都必须把 `roundtable-intent` + `roundtable-recommender` 两个**功能型 agent** 持久化进发布物,否则 `/api/intent/stream` 报 500。**互联网 demo 还需要 6 个示例研讨席位**(`roundtable-intelligence` 等),**内网部署用客户自有 agent 替代**这些席位,无需打包。详见 §11.7。
|
||||
- **席位 agent 持久注册**(磁盘 `.deer-flow/agents/` + `_sync_legacy_agents`),**协调智能体也是持久单例**(id 固定为 `roundtable-coordinator`,与 intent/recommender 同样走 `_roundtable_seed.ensure_roundtable_functional_agents()` 缺失自动落地)。2026-05 之前曾按 `roundtable-coordinator-{timestamp36}` 每会话新建并把动态席位列表写进 SOUL,导致 `.deer-flow/agents/` 累积大量壳目录;改造后 SOUL 是静态通用版本,「本次可调度席位列表」由 `multi_agent.init` 在 coordinator thread 创建后以 human 消息追加到对话历史,leader run 每轮都能看到。
|
||||
- **`.deer-flow/` 整个目录被 git ignore**(仓库根 `.gitignore` 第 64 / 74 行),`git clone` 后内置 agent 目录在部署机上**不会存在**。但功能型 agent 现在通过 `_roundtable_seed.ensure_roundtable_functional_agents()` **自动落地**(首次任何 intent/recommend/multi-agent 路由调用都会触发),因此**新部署不再需要手动准备** `roundtable-intent` / `roundtable-recommender` / `roundtable-coordinator` 三个目录——它们都是 seeder 自动写盘的。**互联网 demo 仍需要 6 个示例研讨席位**(`roundtable-intelligence` 等),**内网部署用客户自有 agent 替代**这些席位,无需打包。详见 §11.7。
|
||||
- **`init` 是 SSE 而非一次性 JSON**:用 `asyncio.as_completed` 按真实完成顺序推送 `seat_ready`,前端因此能逐个点亮席位。
|
||||
- **`init_done.thread_ids` 包含协调智能体自身**(`agent_threads[main_agent_name] = coord_thread`),前端 Step 3 列文件时需自行过滤。
|
||||
- **澄清协议统一**:Step 1 与 Step 2 leader 都借 `ask_clarification` + 终态 `clarification` 帧驱动同一套选项卡片组件;`allow_multiple` 默认 false(单选、点击即发送)。
|
||||
@@ -282,33 +282,50 @@ offline-backend-20260512/backend/
|
||||
请求体:
|
||||
{
|
||||
"agent_names": ["roundtable-intelligence", ...], // 用户选的席位 id
|
||||
"main_agent_name": "roundtable-coordinator-xxx", // 当次总控 id,前端生成
|
||||
"model": "deepseek-chat" // 可选,写入 coordinator agent
|
||||
"main_agent_name": "<可选,已废弃>", // 历史字段;2026-05 改造后后端忽略,统一用 COORDINATOR_AGENT_ID
|
||||
"model": "deepseek-chat" // 可选;不传由 lead_agent 回退到 config.yaml models[0]
|
||||
}
|
||||
```
|
||||
|
||||
**协调智能体改造说明(2026-05)**:以前 `main_agent_name` 由前端用时间戳生成
|
||||
(`roundtable-coordinator-${Date.now().toString(36)}`),后端 `_create_coordinator_direct`
|
||||
把动态拼接的席位列表 SOUL 写盘并入 DB,导致 `.deer-flow/agents/` 越用越多。改造后:
|
||||
|
||||
- **id 固定**:`COORDINATOR_AGENT_ID = "roundtable-coordinator"`(前后端常量必须保持一致);
|
||||
- **缺失自动落地**:路由入口调 `ensure_roundtable_functional_agents()`,与 intent/recommender 共用同一套 seeder;
|
||||
- **席位列表运行时注入**:init 创建好 coordinator thread 之后,立刻通过 `_append_thread_message` 把「【圆桌系统初始化】本次可调度席位:…」作为 human 消息追加到 thread 历史最前面,leader run 每轮都能看到;
|
||||
- **`main_agent_name` 入参保留为向后兼容**:旧 client 仍可传,后端只是日志里看到不会用它创建新 agent。
|
||||
|
||||
**早期校验**(同步抛 HTTP 4xx,**不**进 SSE):
|
||||
|
||||
- `agent_names` 必须非空
|
||||
- 每个 `agent_names` 元素 + `main_agent_name` 都需通过 `_AGENT_ID_PATTERN = ^[A-Za-z0-9_-]+$`(用 `_validate_agent_id` 提前 400;上游若收到不合规 id 会抛 422,提前拦更友好)
|
||||
- 每个 `agent_names` 元素需通过 `_AGENT_ID_PATTERN = ^[A-Za-z0-9_-]+$`(用 `_validate_agent_id` 提前 400;上游若收到不合规 id 会抛 422,提前拦更友好)
|
||||
- 所有 id 都 `.lower()` 后比较 / 存储
|
||||
- `main_agent_name` 入参不再做校验(被忽略,统一用 `COORDINATOR_AGENT_ID` 常量)
|
||||
|
||||
**SSE 内部流程**:
|
||||
**SSE 内部流程**(2026-05 单例改造后):
|
||||
|
||||
```
|
||||
ensure_roundtable_functional_agents()
|
||||
// 路由入口:保证 .deer-flow/agents/roundtable-coordinator/ 存在
|
||||
// 缺失时由 _roundtable_seed.COORDINATOR_AGENT_SOUL 落地静态 SOUL
|
||||
|
||||
asyncio.as_completed(各席位 _prepare_sub_agent)
|
||||
每完成一个 → data: {"event":"seat_ready","agent_name","thread_id","display_name","description"}
|
||||
(按真实完成顺序,先完成先发,前端因此能逐个点亮)
|
||||
|
||||
异常时:取消所有未完成的 task,避免泄露到 AsyncClient close 之后
|
||||
|
||||
协调智能体:
|
||||
POST /api/threads → coord_thread
|
||||
POST /api/agents
|
||||
skills=["agent_orchestration"]
|
||||
soul=动态拼接(本次 init 的席位列表 + 派活规则)
|
||||
id=main_agent_name(前端生成的 roundtable-coordinator-<ts36>)
|
||||
协调智能体(单例):
|
||||
main_agent_name = COORDINATOR_AGENT_ID # 固定常量,不再每次新建 agent
|
||||
coord_thread = _create_thread_direct(...) # 仍然每次新 thread,会话独立
|
||||
agent_threads[main_agent_name] = coord_thread // ⚠️ thread_ids 含 coordinator 自身
|
||||
|
||||
# 通过 HTTP loopback append 一条 human 消息,把本次席位清单写入对话历史:
|
||||
# "【圆桌系统初始化】本次研讨可调度的席位清单如下..."
|
||||
# leader run 每轮都能从对话历史里读到,等效于旧版动态 SOUL 的作用。
|
||||
_append_thread_message(seed_client, ..., coord_thread, seat_introduction)
|
||||
|
||||
→ data: {"event":"coordinator_ready","main_agent_name","thread_id"}
|
||||
|
||||
→ data: {"event":"init_done","main_agent_name","thread_ids":{...}}
|
||||
@@ -316,28 +333,26 @@ asyncio.as_completed(各席位 _prepare_sub_agent)
|
||||
失败 → data: {"event":"error","detail":"..."}
|
||||
```
|
||||
|
||||
**协调智能体创建幂等**:若 `_create_agent` 返回 (False, "exists ...")(同名 agent 已存在)则继续往下走,不报错。注意这里的「已存在」会复用历史 agent 的 SOUL(与本次动态拼接的不同),通常只在前端用同一个时间戳跑过两遍时触发。
|
||||
**协调智能体单例 SOUL**(磁盘上唯一一份,见 `_roundtable_seed.COORDINATOR_AGENT_SOUL`):
|
||||
|
||||
**协调智能体 SOUL**(动态拼接,与席位列表一起写入):
|
||||
通用协调规则,**不含**席位列表。模型派活时从对话历史最前面的「【圆桌系统初始化】…」消息读取本次可用 agent_name。仍然强调英文 id(避免把中文显示名拼进 `agent_name`),靠后端 `_strip_display_suffix` 兜底(详见 §6.4)。
|
||||
|
||||
**席位清单注入消息形状**(init 阶段写入 coordinator thread):
|
||||
|
||||
```
|
||||
#核心定位:
|
||||
用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总,
|
||||
当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果
|
||||
|
||||
#调度方法:
|
||||
通过使用 agent_orchestration 技能调用其他智能体干活,使用技能时传入
|
||||
agent_name 和需要做的活。
|
||||
⚠️ agent_name 必须严格使用下方列出的英文 id(例如 roundtable-intelligence),
|
||||
不要把括号里的中文显示名拼进 agent_name 里。
|
||||
|
||||
#可调度智能体:
|
||||
- agent_name: roundtable-intelligence(情报收集):<description>
|
||||
- agent_name: roundtable-environment(环境评估):<description>
|
||||
【圆桌系统初始化】本次研讨可调度的席位清单如下,请严格按 agent_name 通过 agent_orchestration 技能派活,不要派给不在清单内的 agent:
|
||||
- agent_name: roundtable-intelligence(情报收集):<description>
|
||||
- agent_name: roundtable-environment(环境评估):<description>
|
||||
...
|
||||
```
|
||||
|
||||
中文显示名通过 `GET /api/agents/{id}` 取 `name`、描述取 `description`。**强调英文 id** 是因为部分模型会把整段 `roundtable-x(中文)` 当 `agent_name` 塞回来,靠后端 `_strip_display_suffix` 兜底(详见 §6.4)。
|
||||
中文显示名通过 `_get_agents_batch_direct` 一次 DB 查询取 `name`、描述取 `description`。
|
||||
|
||||
**单例 agent 的几个语义保障**:
|
||||
|
||||
1. **agent 落地幂等**:`_ensure_one` 检查目录是否存在,存在就 no-op,不覆盖运维手改的 SOUL;
|
||||
2. **席位列表 append 失败不阻断**:`_append_thread_message` 包在 try/except 里,失败只记 warning,leader run 仍能跑(只是看不到席位清单,可能派活到错的 id,由 `_validate_agent_id` 在 run/stream 里报错);
|
||||
3. **`_create_coordinator_direct` 已删除**:旧函数已移除(2026-05 改造),避免后续维护者误调;如果你正在 review 旧 PR 看到这个函数,那是 pre-rebase 版本。
|
||||
|
||||
### 6.2 `/run/stream` — `agent_type = "leader"`(`_leader_run`)
|
||||
|
||||
@@ -609,13 +624,20 @@ httpx.AsyncClient(
|
||||
|
||||
**已废止的 `trust_env=False` workaround**:旧版本走 `127.0.0.1:<port>` 真实 HTTP,需要 `trust_env=False` 阻止 httpx 读 `HTTP_PROXY` 把回环也走代理(症状日志 `headers_in=42.160s`)。改 ASGITransport 后这个问题在物理上已不存在——根本没走网络栈。
|
||||
|
||||
### 9.2 协调智能体每次新建
|
||||
### 9.2 协调智能体单例(2026-05 改造)
|
||||
|
||||
`main_agent_name` 形如 `roundtable-coordinator-{timestamp36}`,会话隔离;长期会累积 agent 目录(实际仓库已有数十个 `roundtable-coordinator-mp*`),需后续 GC 策略:
|
||||
历史实现 `main_agent_name = roundtable-coordinator-{timestamp36}`,会话隔离,但长期会累积 agent 目录(仓库里曾积累几十个 `roundtable-coordinator-mp*` 壳目录)。2026-05 改造选用上方决策 §9.2 选项 C 的本质:**把 coordinator 实例化为「内置 + 静态 SOUL + 运行时注入席位列表」**,固定 id `roundtable-coordinator`,与 `roundtable-intent` / `roundtable-recommender` 同样走 `ensure_roundtable_functional_agents()` 缺失自动落地。
|
||||
|
||||
- 选项 A:定时任务清理 N 天前无活跃 thread 的 coordinator agent
|
||||
- 选项 B:每次创建前 LRU 清理(注意:跨服务实例难协调)
|
||||
- 选项 C:把 coordinator 实例化为「内置 + 动态 SOUL」复用同一个 id
|
||||
实现要点:
|
||||
|
||||
- **磁盘 SOUL 是通用版本**:不含本次席位列表,只规定协调规则、工具用法、终止条件;
|
||||
- **席位列表运行时注入**:init 创建 coordinator thread 后立刻 `_append_thread_message` 写入一条 human 消息("【圆桌系统初始化】…"),位置在对话历史最前面,leader run 每轮自然读到;
|
||||
- **`_create_coordinator_direct` 已删除**:不再有"每次创建"路径。
|
||||
|
||||
**遗留 `roundtable-coordinator-*` 目录的处理**:仓库可能仍有几十个历史时间戳壳目录。改造后**不会**再生成新的,但旧目录目前不自动清理(避免破坏可能仍存在的旧草稿引用)。运维可通过:
|
||||
|
||||
- 在 admin agents 管理页逐个 DELETE;
|
||||
- 或写一次性脚本扫描 `.deer-flow/agents/roundtable-coordinator-*` 并删除目录 + DB 记录(注意不要删除 `roundtable-coordinator` 本体)。
|
||||
|
||||
### 9.3 澄清协议统一
|
||||
|
||||
@@ -705,15 +727,22 @@ offline-backend-20260512/backend/.deer-flow/*
|
||||
|
||||
| Agent | 角色 | 是否必须打包发布 |
|
||||
|-------|------|------------------|
|
||||
| `roundtable-intent` | **功能型**:Step 1 任务理解,所有部署都用它 | ✅ **必须**(任何环境) |
|
||||
| `roundtable-recommender` | **功能型**:Step 1→2 之间从候选池里挑推荐,所有部署都用它 | ✅ **必须**(任何环境) |
|
||||
| `roundtable-intent` | **功能型**:Step 1 任务理解,所有部署都用它 | ✅ seeder 自动落地(无需手工打包) |
|
||||
| `roundtable-recommender` | **功能型**:Step 1→2 之间从候选池里挑推荐,所有部署都用它 | ✅ seeder 自动落地(无需手工打包) |
|
||||
| `roundtable-coordinator` | **功能型单例**:Step 2 总控协调,2026-05 起为单例(id 固定) | ✅ seeder 自动落地(无需手工打包) |
|
||||
| `roundtable-intelligence` | 示例研讨席位(情报收集) | ⚠️ **互联网 demo 用**,内网不需要 |
|
||||
| `roundtable-environment` | 示例研讨席位(环境评估) | ⚠️ 同上 |
|
||||
| `roundtable-solution-design` | 示例研讨席位(方案设计) | ⚠️ 同上 |
|
||||
| `roundtable-risk-review` | 示例研讨席位(风险审查) | ⚠️ 同上 |
|
||||
| `roundtable-execution-plan` | 示例研讨席位(执行规划) | ⚠️ 同上 |
|
||||
| `roundtable-summary` | 示例研讨席位(总结陈述) | ⚠️ 同上 |
|
||||
| `roundtable-coordinator-*` | 协调智能体壳,每次会话动态新建 | ❌ **不打包**(运行时生成) |
|
||||
| `roundtable-coordinator-*`(带后缀) | 2026-05 之前每次会话动态新建的临时壳 | ❌ **遗留**(仓库可能仍有几十个旧目录,新版本不再生成;运维按需清理) |
|
||||
|
||||
**关键变化(2026-05)**:三个功能型 agent(intent / recommender / coordinator)现在都由
|
||||
``_roundtable_seed.ensure_roundtable_functional_agents()`` **自动落地**——任何 intent /
|
||||
recommend / multi-agent 路由的入口都会触发,首次请求时缺什么补什么。所以**新部署的
|
||||
机器 `git clone` 后既不需要手工拷目录、也不需要走下文的 `.gitignore` 白名单方案**,
|
||||
SOUL 改动跟着 Python 代码走 git review 即可。
|
||||
|
||||
**关键区分**:
|
||||
- `roundtable-intent` / `roundtable-recommender` 是**圆桌功能本身的一部分**——没有它们,整个 Step 1 + 推荐流程跑不起来,跟客户业务无关
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
- **不使用 LangGraph SDK**:所有 API 都是自研的 `apiFetch` + `ReadableStream` SSE 解析(见 `api/intent.ts`、`api/recommend.ts`、`api/multi-agent.ts`)。
|
||||
- **不写死模型名**:与 `/page/canvas/ai-writing` 同样的策略 —— 全局选中模型由 `selectedModel` state 维护,首次 `useModels()` 完成后初始化为 `availableModels[0].name`,顶栏「模型」下拉可切换。所有 API 调用统一传 `model: selectedModel || undefined`;后端缺省由 `lead_agent._resolve_model_name()` 回退到 `config.yaml` 的 `models[0]`。**内网部署只需在 `config.yaml` 配自家模型即可,前后端均无需改动**。
|
||||
- **席位区间硬约束**:推荐弹窗要求 **2 ≤ 选中数 ≤ 8**(`MIN_SELECTED` / `MAX_SELECTED`)。
|
||||
- **协调智能体单例(2026-05)**:`COORDINATOR_AGENT_ID = "roundtable-coordinator"` 是固定常量,不再每会话生成新 id。后端 seeder 自动落地,「本次席位列表」改由后端通过 thread 历史 human 消息注入,前端无感。`newCoordinatorName()` 仅作为兼容性壳保留。
|
||||
- **总控编排循环上限**:`MAX_CYCLES = 8`,超出后强制停止并 toast。
|
||||
- **席位派活策略**:**顺序**(不是并行)。旧实现并行触发多路 SSE 会让浏览器卡顿,现已改为顺序。
|
||||
- **调试开关**:`window.__MULTI_AGENT_INIT_TIMING__` 与 `window.__MULTI_AGENT_DEBUG__`(**不是** `__MULTI_AGENT_STREAM_DEBUG__`,文档历史版本有误)。
|
||||
@@ -341,7 +342,12 @@ React 18 StrictMode 开发环境下,effect 会 mount → cleanup → mount 两
|
||||
| `agentConfigs` | `Map<seatId, {model, weight, prompt, temp}>` Persona 覆盖;下一轮该席位的派活会用此 model |
|
||||
| `isUpperPanelCollapsed` | 是否折叠圆桌可视化 + 调控台,给对话区让出垂直空间 |
|
||||
|
||||
**协调智能体命名**:`roundtable-coordinator-${Date.now().toString(36)}`,每次 Step 2 会话新建。后端把这类壳 agent 留在 DB 中,因此 `filterRecommendCandidates` 默认过滤掉。
|
||||
**协调智能体命名(2026-05 改造)**:现在是**固定单例 id** `roundtable-coordinator`(见 `lib/roundtable-constants.ts::COORDINATOR_AGENT_ID`)。`newCoordinatorName()` 函数为兼容性保留,内部直接返回这个常量字符串。后端 `ensure_roundtable_functional_agents()` 缺失自动落地,不再每次新建。`filterRecommendCandidates` 在 `EXCLUDED_AGENT_IDS` 集合里同时排除:
|
||||
|
||||
- `roundtable-coordinator`(新版单例 id,永远过滤);
|
||||
- `roundtable-coordinator-*` 前缀(历史残留,管理员清理之前先在 UI 隐藏)。
|
||||
|
||||
> 历史草稿兼容性:用户加载 2026-05 前保存的草稿时,存储的 `coordinatorName` 仍是 `roundtable-coordinator-<ts36>` 形式。只要那个旧 agent 目录还在磁盘上(即运维没手动删过),加载后继续在 Step 2 干活就能正常工作;如果旧 agent 被清理掉了,需要让用户「重新起草」从头跑一遍 Step 1 → Step 2。
|
||||
|
||||
### 5.4 编排循环 `runOrchestration(seedMessage, ids, coord)`
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
# 圆桌 Step 2 沙箱预览 · 开发进度
|
||||
|
||||
> 最后更新:2026-05-27
|
||||
> 关联文档:`multi-agent-frontend-dev.md`(总览)、`multi-agent-backend-dev.md`(后端)、`multi-agent-api.md`(接口)
|
||||
> 对照参考:主聊天 `/page/workspace/chats/:threadId`(`ChatBox` + `ArtifactsProvider` + `message-group.tsx`)
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在 Step 2「多智能体圆桌研讨」中,当子智能体调用 `write_file` / `str_replace` / `present_files` 时,**行为与主聊天一致**:
|
||||
|
||||
| 能力 | 主聊天 | Step 2 目标 |
|
||||
|------|--------|-------------|
|
||||
| `write_file` 流式期间自动打开右侧沙箱 | ✅ | ✅ 虚拟 URL |
|
||||
| 步骤卡里点击文件路径打开沙箱 | ✅ | ✅ |
|
||||
| 消息下方文件卡片(`present_files`)点击打开 | ✅ | ✅ `PresentFilesRow` |
|
||||
| 沙箱打开时收起 workspace 左侧栏 | ✅(`ArtifactsProvider.select`) | ✅ Step 2 左栏联动 |
|
||||
| 60/40 对话 \| 产物预览分栏 | ✅ `ChatBox` | ✅ `Step2SandboxLayout` |
|
||||
| 流式 MD 从 tool_call.args.content 预览 | ✅ 虚拟 URL + stub messages | ✅ `seatStubMessages`(双通道:`onPhaseChange` + `onUpstreamFrame`) |
|
||||
| 落盘后 HTTP 拉取真实文件 | ✅ artifacts API | ✅ `present_files` 卡片走 HTTP |
|
||||
| 草稿 reload 后历史文件可预览 | ✅ | ✅ `seatStubMessages` 一并入草稿 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 已完成
|
||||
|
||||
### 2.1 Step 2 布局调整
|
||||
|
||||
| 项 | 状态 | 说明 |
|
||||
|----|------|------|
|
||||
| 推理模型下拉 | ✅ | 从右侧栏迁至 Step 2 顶栏「多智能体圆桌会商中心」右侧 |
|
||||
| 意图理解 | ✅ | 移至左侧栏顶部,支持展开/收起;收起时仅显示「目标」 |
|
||||
| 任务概览 | ✅ | 放在「人工参与」下方 |
|
||||
| 右侧栏 Step 2/3 隐藏 | ✅ | `RightSidebar` 仅在 `currentStep === 1` 渲染 |
|
||||
| 左侧栏整体收起 | ✅ | 宽度 56px;展开按钮在顶部;研讨中角色 ring + ping 动画 |
|
||||
| 沙箱打开 → 左栏收起 | ✅ | `Step2SandboxSidebarSync` 监听 `useArtifacts().open` |
|
||||
|
||||
**主要文件**:`RoundtablePlanningPage.tsx`(`step2SidebarExpanded`、`LeftSidebar`、`Step2SandboxSidebarSync`)
|
||||
|
||||
### 2.2 沙箱基础设施(Phase 1)
|
||||
|
||||
| 项 | 状态 | 说明 |
|
||||
|----|------|------|
|
||||
| 路由包 `ChatRuntime` | ✅ | `WorkspaceRoutes.tsx` → `roundtable/planning` 外包 `ArtifactsProvider` |
|
||||
| 60/40 分栏壳 | ✅ | `Step2SandboxLayout.tsx`,对齐 `chat-box.tsx` |
|
||||
| `ArtifactFileDetail` / `ArtifactFileList` | ✅ | 嵌在右栏 ResizablePanel |
|
||||
| `ThreadContext` stub | ✅ | 最小 `BaseStream` stub,供 `useArtifactContent` 工作 |
|
||||
|
||||
### 2.3 沙箱打开逻辑(Phase 2)
|
||||
|
||||
| 项 | 状态 | 说明 |
|
||||
|----|------|------|
|
||||
| `write_file` 自动打开 | ✅ | `handleArtifactToolPhase` 在 `tool_calling` + 完整 `args.path` 时触发 |
|
||||
| 步骤卡点击打开 | ✅ | `MessageStepsCard` → `openArtifactFromStep` |
|
||||
| 虚拟 URL 机制 | ✅ | `write-file:<path>?message_id=...&tool_call_id=...`(对齐主聊天) |
|
||||
| Stub messages 注入 | ✅ | `seatStubMessages` + `upsertSeatToolCall` → `loadArtifactContentFromToolCall` |
|
||||
| 重置清理 | ✅ | `resetStep2State` 清空 artifacts / stub / deselect |
|
||||
| `present_files` 步骤点击 | ✅ | 走真实路径 `openArtifactPreview`(HTTP API) |
|
||||
|
||||
### 2.4 上游帧兜底注入(Phase 3)
|
||||
|
||||
| 项 | 状态 | 说明 |
|
||||
|----|------|------|
|
||||
| `onPhaseChange` 主通道 | ✅ | `tool_calling` 阶段每帧把完整 `args` 喂进 stub;首次出现 `path` 即触发自动打开 |
|
||||
| `onUpstreamFrame` 兜底通道 | ✅ | `handleUpstreamFrame` 扫描 messages-tuple 帧的 `tool_calls[]`,对 `write_file`/`str_replace` 持续 upsert `args.content`;覆盖 phase 已切走但 tool_calls 又回包的边界 |
|
||||
| 双通道幂等 | ✅ | `upsertSeatToolCall` 按 `(toolName, path)` 去重,两个通道并发写入安全 |
|
||||
|
||||
### 2.5 `present_files` 文件卡(Phase 4)
|
||||
|
||||
| 项 | 状态 | 说明 |
|
||||
|----|------|------|
|
||||
| 气泡下方文件卡行 | ✅ | `Step2Message` → `collectPresentedFiles` → `PresentFilesRow` |
|
||||
| 卡片样式 | ✅ | 文件名 + 扩展名徽章(FILE_EXT_LABELS),最小宽度 30/最大 50(hover 边框亮色) |
|
||||
| 点击 → HTTP 预览 | ✅ | `openArtifactPreview(agentThreadId, path)`,与步骤卡 `present_files` 走同一通道 |
|
||||
| 席位 thread 缺失保护 | ✅ | 没有 `agentThreadId` 时 disabled + tooltip |
|
||||
| 同气泡多次 present_files | ✅ | `filepaths` 顺序去重,全部聚合到一行卡片 |
|
||||
|
||||
### 2.6 草稿持久化(Phase 5)
|
||||
|
||||
| 项 | 状态 | 说明 |
|
||||
|----|------|------|
|
||||
| `seatStubMessages` 进 `Step2Snapshot` | ✅ | `useStep2Orchestration.ts` Step2Snapshot 新增字段 |
|
||||
| `seatStubMessages` 进 `DraftStep2Snapshot` | ✅ | `drafts.ts` 加 optional 字段,老草稿 reload 时回退到 `{}` |
|
||||
| `hydrateFromDraft` 还原 stub | ✅ | 同步写 `seatStubMessagesRef` + state,加载草稿后点击历史 write_file 步骤可直接预览 |
|
||||
| `getStep2Snapshot` 透出 stub | ✅ | `RoundtablePlanningPage.tsx` 给 `useDraftPersistence` 的 getter 加上该字段 |
|
||||
|
||||
**主要文件**:
|
||||
|
||||
```
|
||||
frontend-web/src/pages/WorkspaceRoutes.tsx
|
||||
frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts
|
||||
frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx
|
||||
frontend-web/src/roundtable-planning/components/Step2Panel.tsx
|
||||
frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx
|
||||
frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
|
||||
frontend-web/src/roundtable-planning/utils/drafts.ts
|
||||
```
|
||||
|
||||
### 2.7 与主聊天对齐的核心数据流
|
||||
|
||||
```
|
||||
special seat SSE (streamMultiAgent)
|
||||
├── onPhaseChange("tool_calling", "write_file", args)
|
||||
│ ↓
|
||||
│ upsertSeatToolCall(seatThreadId, "write_file", args)
|
||||
│ → seatStubMessages[seatThreadId] = [{ type:"ai", tool_calls:[{ id, name, args }] }]
|
||||
│ ↓
|
||||
│ buildWriteFileVirtualUrl(path, messageId, toolCallId)
|
||||
│ → "write-file:/mnt/user-data/outputs/foo.md?message_id=...&tool_call_id=..."
|
||||
│ ↓
|
||||
│ openSeatVirtualArtifact(seatThreadId, virtualUrl, auto=true)
|
||||
│ → selectArtifact(virtualUrl) + setArtifactsOpen(true)
|
||||
│
|
||||
└── onUpstreamFrame(frame) ← 兜底通道
|
||||
↓
|
||||
handleUpstreamFrame —— 持续 upsert tool_calls 里完整的 args(不打开沙箱)
|
||||
|
||||
Step2SandboxLayout
|
||||
ThreadContext.thread.messages = seatStubMessages[activeThreadId]
|
||||
ArtifactFileDetail(filepath=virtualUrl, threadId=seatThreadId)
|
||||
↓
|
||||
useArtifactContent → loadArtifactContentFromToolCall
|
||||
→ 从 thread.messages 读 tool_call.args.content(不发 HTTP)
|
||||
```
|
||||
|
||||
**为何不用真实 path 直接预览**:流式期间文件可能尚未落盘,直接调 `/api/threads/:id/artifacts/...` 会返回 `404 {"detail":"Not Found"}`。主聊天同样用虚拟 URL 绕过此问题。
|
||||
|
||||
---
|
||||
|
||||
## 3. 未完成 / 待验证
|
||||
|
||||
### 3.1 功能缺口(按优先级)
|
||||
|
||||
| 优先级 | 项 | 状态 | 说明 |
|
||||
|--------|----|------|------|
|
||||
| P0 | 端到端回归 | ⚠️ 待验证 | 虚拟 URL + 双通道 + 文件卡 + 草稿持久化全链路尚未在浏览器里跑一遍 |
|
||||
| P2 | `str_replace` 流式编辑预览 | ❌ | 主聊天 `applyPendingTextEdit` 依赖完整 message 链;stub 未模拟 tool result / 多轮 str_replace 累加 |
|
||||
| P2 | 沙箱 `artifacts[]` 列表与虚拟 URL 混排 | ❌ | 虚拟 URL 不写入 `setArtifacts([])`;多文件切换、文件名下拉体验未完全对齐主聊天 |
|
||||
| P3 | Step 3 与沙箱衔接 | ❌ | Step 3 仍用 `HighFidelityReport` + `listSessionArtifacts`;与 Step 2 沙箱选中态无联动 |
|
||||
| P3 | 文档同步 | ❌ | `multi-agent-frontend-dev.md` 仍描述旧布局(右侧栏模型选择器等),需更新 § 布局 + 沙箱章节 |
|
||||
|
||||
### 3.2 已知风险 / 边界
|
||||
|
||||
1. **打开时机**:仅在 SSE 进入 `tool_calling` 且 `tool_calls[0].args` 为完整对象时触发自动打开(`multi-agent.ts` 刻意忽略 `tool_call_chunks` 的拼接 args)。若模型只发 chunks、迟迟不发完整 tool_calls,自动打开会延迟到 args 完整那一帧——与主聊天一致。
|
||||
2. **多 write_file 同气泡**:`upsertSeatToolCall` 按 `(toolName, path)` 幂等;同 path 多次 write 会更新 args,不会新建多条 stub tool_call。
|
||||
3. **leader 席位**:当前仅 special 流绑定 `agentThreadId` 并接 `onUpstreamFrame`;总控若直接 write_file,需单独接 thread id 与帧回调。
|
||||
4. **`present_files` 文件大小**:流式期间 backend 尚未给出真实大小;卡片只显示扩展名徽章,不显示 size。落盘后用户点开走 HTTP API 自然能看到完整内容。
|
||||
5. **调试日志**:控制台 `[roundtable-sandbox]` 前缀;生产环境可考虑加 `window.__ROUNDTABLE_SANDBOX_DEBUG__` 门控。
|
||||
|
||||
---
|
||||
|
||||
## 4. 分阶段计划(原始规划 vs 现状)
|
||||
|
||||
| 阶段 | 内容 | 状态 |
|
||||
|------|------|------|
|
||||
| Phase 1 | `ChatRuntime` + 分栏壳 + 侧栏联动 | ✅ 完成 |
|
||||
| Phase 2 | `write_file` 自动/点击打开 + 虚拟 URL 预览 | ✅ 完成 |
|
||||
| Phase 3 | `onUpstreamFrame` 注入完整 LangGraph messages | ✅ 完成(双通道兜底) |
|
||||
| Phase 4 | `present_files` 卡片 + Step 3 文件区衔接 | ⚠️ 卡片完成;Step 3 衔接遗留 |
|
||||
| Phase 5 | 草稿 stub 持久化 + 文档更新 | ✅ stub 完成;本文档同步更新 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 测试清单
|
||||
|
||||
在 `/page/workspace/roundtable/planning` Step 2 下逐项勾选:
|
||||
|
||||
- [ ] 子智能体 `write_file` 进入 `tool_calling` 后,右侧沙箱 **自动** 打开(约 100ms 延迟,对齐主聊天)
|
||||
- [ ] 沙箱标题为文件名(如 `sample-paper.md`),**不是** `{"detail":"Not Found"}`
|
||||
- [ ] 流式期间 MD 内容随 `args.content` 更新(双通道:phase 切换帧 + 兜底帧)
|
||||
- [ ] 点击 ChainOfThought 步骤「写入文件」/ 路径 chip → 手动打开同一文件
|
||||
- [ ] `present_files` 后气泡下方出现文件卡(带扩展名徽章),点击 → HTTP 预览
|
||||
- [ ] 沙箱打开时左侧「意图理解」栏 **自动收起**
|
||||
- [ ] 关闭沙箱(X)后对话区恢复全宽
|
||||
- [ ] 「新建任务」后 artifacts / 沙箱 / `seatStubMessages` 全部清空
|
||||
- [ ] 「保存草稿」→「加载草稿」后,点击历史 `write_file` 步骤可立即预览(stub 已还原)
|
||||
- [ ] 与主聊天 `/page/workspace/chats/:threadId` 对比:同一 write_file 行为一致
|
||||
|
||||
**调试**:DevTools Console 过滤 `[roundtable-sandbox]` 或 `[stream:special:...]`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 关键代码索引
|
||||
|
||||
| 用途 | 路径 |
|
||||
|------|------|
|
||||
| 主聊天 write_file 虚拟 URL + 自动打开 | `src/components/workspace/messages/message-group.tsx` |
|
||||
| 主聊天 `RichFileCard`(present_files) | `src/components/workspace/messages/message-list-item.tsx` |
|
||||
| 主聊天 60/40 分栏 | `src/components/workspace/chats/chat-box.tsx` |
|
||||
| 虚拟 URL 内容加载 | `src/core/artifacts/loader.ts` → `loadArtifactContentFromToolCall` |
|
||||
| Artifacts 上下文 | `src/components/workspace/artifacts/context.tsx` |
|
||||
| Step 2 编排 + 沙箱状态 + 草稿快照 | `src/roundtable-planning/hooks/useStep2Orchestration.ts` |
|
||||
| Step 2 沙箱布局 | `src/roundtable-planning/components/Step2SandboxLayout.tsx` |
|
||||
| Step 2 文件卡 + 步骤渲染 | `src/roundtable-planning/components/Step2Panel.tsx` |
|
||||
| SSE phase 推断 + `onUpstreamFrame` | `src/roundtable-planning/api/multi-agent.ts` → `setPhase` |
|
||||
| 主页面侧栏 + 联动 | `src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
|
||||
| 草稿持久化 | `src/roundtable-planning/utils/drafts.ts`、`hooks/useDraftPersistence.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 7. 下一步建议(给接续开发者)
|
||||
|
||||
1. **先跑通 P0 测试清单**,确认 Phase 3/4/5 改造后无回归;重点观察双通道并发写入 stub 时 args 是否被旧帧覆盖(理论上 LangGraph 完整 tool_calls 是单调累积的,应该没问题)。
|
||||
2. **Phase 4 收尾**:把 Step 3 的 `listSessionArtifacts` / `HighFidelityReport` 接进沙箱选中态——Step 3 进入时复用 Step 2 的 `ArtifactsProvider`,让用户在 Step 3 也能预览 Step 2 留下的产物。
|
||||
3. **Phase 2 边界**:`str_replace` 多轮编辑的 stub 模拟(追加 tool result 消息让 `applyPendingTextEdit` 能 replay edit 序列),优先级低。
|
||||
4. **更新** `multi-agent-frontend-dev.md`:Step 2 布局、沙箱架构、`seatStubMessages` 数据流,避免与本文档重复——可在总览文档加一节「见 `roundtable-step2-sandbox-progress.md`」。
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,6 +3,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
import { QueryClientProvider } from "@/components/query-client-provider";
|
||||
import { AuthSyncWatcher } from "@/core/auth/sync";
|
||||
import { I18nProvider } from "@/core/i18n/context";
|
||||
import { detectLocale } from "@/core/i18n/locale";
|
||||
import { ChatProvider } from "@/contexts/ChatContext";
|
||||
@@ -31,13 +32,18 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
disableTransitionOnChange
|
||||
forcedTheme={forcedTheme}
|
||||
>
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<ThemeProvider>
|
||||
<ChatProvider>
|
||||
<QueryClientProvider>{children}</QueryClientProvider>
|
||||
</ChatProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
{/* QueryClientProvider must wrap ThemeProvider because the custom
|
||||
ThemeProvider reads the admin-configured default via useQuery. */}
|
||||
<QueryClientProvider>
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<ThemeProvider>
|
||||
<ChatProvider>
|
||||
<AuthSyncWatcher />
|
||||
{children}
|
||||
</ChatProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16" height="16" viewBox="0 0 16 16"><defs><style>.a{fill:#fff;opacity:0;}.b{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><g transform="translate(-128.5 -117)"><rect class="a" width="16" height="16" transform="translate(128.5 117)"/><path class="b" d="M4,0,8,5H0Z" transform="translate(140.5 128) rotate(180)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 589 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="84.499" height="70" viewBox="0 0 84.499 70"><defs><style>.a{fill:url(#a);}</style><linearGradient id="a" x1="0.043" x2="0.978" y2="1.016" gradientUnits="objectBoundingBox"><stop offset="0" stop-color="#0062d9" stop-opacity="0.6"/><stop offset="1" stop-color="#0062d9" stop-opacity="0.302"/></linearGradient></defs><path class="a" d="M8855.277-1940.39l-.054-70h78v31.183l6.5,5.257-6.5,5.259v28.3Z" transform="translate(-8855.224 2010.389)"/></svg>
|
||||
|
After Width: | Height: | Size: 536 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="77" height="45" viewBox="0 0 77 45"><defs><style>.a{fill:url(#a);}</style><pattern id="a" preserveAspectRatio="none" width="100%" height="100%" viewBox="0 0 340 45"><image width="340" height="45" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAVQAAAAtCAYAAADxyJMIAAAABHNCSVQICAgIfAhkiAAAIABJREFUeF7tnd2O5MZ1x5vdPTO7WkmGhMiQDQS5NODbvEBeIgbygcRwjOQur+D1K+QiQC4CBMld8hJ+CgO+DpAIcixblvZjZnqG+f/PqcM6Vawii+zuda+0XLSaXawvFos//s85xVHX9/3mXFvXdeeq+htSb/9mB4iX+vlmfZu/PKLs1BX7/ET1fndznsn8w5X1Pkc5jvfPV5ZfPcu784zD6v683QWXMLJbknnpsHy7gXpmWP5sIYRaYHgqsC2dKG97/haQL4Xy2SH8Drqt024JI98BtXVUi/nOBM0pWLaAkX09JRx/sBDe+Vj99sjyR10jV/ijI5Xir44s78+jBcLMPwXis0H3HWz9pXoH1FPdgFLPCaF5DCjXAvLrALM/rQzKuWH36QnH7xTX9bMzw2IO2muhPAfgNwbeM4/fKa7xiet4B9RVAzq4ndb7GL3nquSrnFKXrcBcqhbngHlO4P3+Qp3oH54xcDAH7Dng5nO3FcBTwC3Blv5d29bPeNfbby5o3wF1EVBH/vtl0+tn0ti4TA2ex4JzDpD5uc8B81TQe3FhSnTRHEDmZycCwhys54Cb97sG4GNBW1O0R7sRTjSOS6/fGfO/A2pxcJsDnykcFZjlLYdmGZZj2JZUZisof4iufJHBaykUW+D38QJ1+fIth6ld3fcWwOCLTOUSyPm4zkHa4MvrVwNxK4BL4F0L3bO4DxaM7RnhuKbqd0BdEzuo+TdLSnNOZY6B2W3mgFlSki2grMFxCoinAOBtBbg3ATS14zajP3FT+/WJgPzE3bS/XnPruDJ2HsdUUwL0/6DC78tDsfyEb4Vw3q858K4B7tiN0CNI5rd4Dj+fHajC+S4zBmdbOFOGbxlQm5VnHO4SPH85HNar/Pno6kwrzTZgah05KKlO8rQSKEuQXArHGugIuBawEVot+fzw3U5E+W/w9Js67utZkrd0c7H8kq31XJmvBvClYC5BuATfHLylOWSq92P0L87v8gh44I6V7XjcctiWVG0K2caxvzzIfguA2nht8qkzDVLNXVef3eYHSYVUnbaNZ0GuOBWY5dmSw/M18lLF+C0Hp4GxdMPWoPnBCZRgK/xmwPXRswUuhSUQDHl/+2JF4KkE7KUQLvX1q4q5O3Xt8mM5aKl0nxTcDrWxyl0KU4o2wlVvtCnXQTtYV9y0lwHXbyhQF16PHJ7+Ca3qs361vMk+pTw9NKPCTOtNYanHcqU5pTJPBccZELYC7vFEQF3ByDdSZBsAaudpv+canwX4UjAvgbB1Lodui7K1sh64Ebbjm25ayVptWu67btRyBbs4+PWHg+s3AKiL4ZnOd+/3HJvuqao01VkDZ6o0O5jmaXn75cGpwNSMY1imM6MEzDklWYNaTfUh/3fQFcLh5EC8v5BF+3PEaz1+tdAt0Fjvl1NAranpOQiXoDvnYvDQ9cBNXQjpDViGLc885jPQ/koGpH4De0Wb+mM3y1/RfTOQfQuBugCgpah7C0BT1elvA70qn2brUBWcZbX5x6H4i2C2KjTHV/e22yb3WwRleSaUQFmA5HeOUYmNAOyfnsA90Aibty7bl7jY141R6yMAXYRwCb5LwVuDrsH2Wf8o1+S/8RkHyfRGmXch9JuP3JWtuQ2mAEswzwe7XCPnAexbBNRGkFq25xi7sXO9KwSQNhP+TsJzPPI+KGRqk+A0aD5DJ14EyHrV6RXmJw6qMXAT24og1LQMlgZKb2bOKsoaIClJC1t/dxpQ9nfjB0h3rcqkdEyeTuH4VJ5TwbWlran++vLH9KkKXkC5uM0AuDQ3RuDNoVsCbm1FxE2AKTsXAYslYWEaP4PflqDV+Rtv4AjYmOb9tPkqA1WyMhWGPe8isES6Cp6HH828bM7YdGkvHKiNEOWp5mrUYDq1bGnKhDcVOl6O1GENoQ1uBx+n7kdwlsx0TUvN81Z4Sr4mpTmjKE+pJGsgbJp1LlN/OG/AaWl/avm7/YrAVaGyU8GXVXevZpRvg+IdAZaK9Ius4zVV+1Wn6pRbVLLpTWugVZeBHist9zLIlgJg0UUw5R7Qfoz9rwumwPFwvVCgNoLUQ3QcSJI5N4xmhOd4gE2FlnyeCk8LEOV+Tk3PfZs1c11VZ644c2DmVzX+rgDzGFC2gHE19J4UFO5XGIEPFszxS83K87DNzud1o2mfnVMLrI8BcRW8a4DrFe3NJgJVz0lv3NRf2zvY2pn3ULS2vnYtZLW9qF7jqNYCXM0ugfVgvSCgNtw5BtDclC8Fk8wPGpcraQPe/xkBqsfSYBFTOihP65jBU9O5jddjhjwO5GqqT0EzB+bwO4By8dWdguQiOJaA2HCZzpWlb/TptrbfNQClta6j8xHQV21AngPwWviOwDs9Pn2ibhW0erNERRuVUVx/G/Ikil8By/JfqCdI6nkW0mxwP3THPstuizTQlV6O8XItPX4GwF42UOtm/Hj++nWfPgofAcpF8ikczXS3pUlT8PzAKVWrxfyc0b85D84IBZ0tGtCpq1J3rAbKWUieAYwnhdvNhUf/b08XzT8LxGeUcQ3AS8BbgG1ZnWIyB9BGmBpsU9fBWM3mboMcsgrYuNVWFKQBLl9C2zQFO145MOZKkjKvbS4XqK0wTU35eMbRB5oO0liFjpcrmQk/XpLU4U0dbonZnvk3/ah3m1RVdc48T/OlvewyeEbV2uJzPAFAVwHz0sE4c7uc/PAKEJ8EuA2uh2MhC8CmQL1KzP/EZwfAat7oLoAbYPSQ6gdXQe1FhXTpluiR4Zqpet1sch+swTVforUaqvH2L82XywNqbtaXzXk9F2/Opwvn47nWVaiOzG1gVWk9p19yFFRogOdYUdbBmV8BVaYa+U6g6pRmWbEeCcpmSP4BwDirsk9Ou7YK58zrtloW5KKZ/XVb/qPhu0LZOlWbBzrUhNNAWTxWAe2gYvX+rLsKzB9bh6yW9+o1Va5xMCNcx0GaHLAr3QGXA9Tn4bxjdD6dVbWgEpXo+N12vmGk5cevYarvkzB77ca1Dk/rh1a4AJwllRnAYcCM4FRYjtO19aqtMQvJM8BxNfyusyCV+Qzvj1ue1T9Muwy63elM9mRS3rX5PHM8ngXSM2p4NXwr0MU5jEz+AmxjACIFbb9J/bOSb/DJpko2AteWb/FvIUwBtuR7bfW5rlKu8fa8HKD+WT7t8JsQrQeVIkjjMiYFafSFxgi8+kB1K8NzDC7n74Spnh8f4Adw+mOqQKNpHiEZFWbN3E+A6mA5BuqJQNkExxyEhUuVJ81BrqGKtzrLIog3gPlkEJ4A70Lo4g/fDA+UCM4UtHrT6Jpirwptv098swrZJF8Bsj7oFVcU3AzFbAVBBLGpV3ML8IgCNrZF9Tq1YsD7WyfU65LFdef9f0oZUPn9v+FeSk16TfSRefpDoxKNNyDNeKrQX0O5GEjp+/yYMO0677MeFsN75akL3eHHTCE6Y6ZzOVAEYqq6cuU5AmqAZ0x/H3VlAaylhKnC8pmrqUEdrobjIVOOT9HuHpM4T6+dWK48qTRn1OjG1OhcvrzNNSr2VVYJz23F1gRfvwLgRbmRo6FbcDdMQLYESK5UMOUKn+jQz971LcLUvbxBuA0vNnyZuAwkv3tJoRcs2HrZG2mD/le757lMKw6QD2ylbgHNk75QoGnfw+cX2RinS7H0YAGslwPUf3AnYCAtReipRhWiCh9To+YLZZpF5DWApBsGs7A4ntCSrRIsGvk6R8ozNdVjvzL/qLRxv/Gvl6aQVcU5p1yzq5wo4dGxTYOyXA1L35oHJKGZb0vhFsr3rt4uwMqnFVratObLy1q5Up3FtDkAe9iuBK1vtwm6E0p3NWzrirZ3sC2qT7uM8q1qNsI0fVECMI3H3HlHBVtzEUTrlIEu3fw6WINr+ncJbEkWX4uVW1PKmVugtkqgAaqXA9QfuWFUv2g6jw2kClA9GANKMa9F4fk08kC1MgGgUoea8VLfhxzV7BXJzN/JP4FWU5oJCAumuh5PoVmC51i5qutgbPLPwPJoUJYgOQHGOdClF3PBr+s39CbV3ZJbob3/k6A2KJ9I6cpEngN9BborgMu/TTsAMoyI3pyp6V6HbR2yg6vg91qx1uHfDIuAjfVz5YAB1q8ioHqV+89dY32xIILV2iBgvb81XSWg9UwEsJbMovOa/P84AVGeRFSi0Yy3eU1wKkBT8JThKdec//nA+T4BzyHdgTOmlRWnh11s+2ZCiWaBp2qQ6rovqVmdXUsV35yJfT0G9ipAtsDv0BiAemIX9w1/v25sb98QkFoB6SKA72ZcCQsV8CR024FbB6WCdgTbgqItAtmrWe8q+OrawVUvUw/I6je3q+w4FSsDXKZcrQwBG5WrpipcU5eNh2tJtRYCWP3zxvlDCC2JYLVXqzm7H4cScZlTHlyK78Kb8jSfiFei5nd06pM1B3hKUwGesg94WleL5nbm2/RKU8uH7le+hzxoMwWkgqXbvKen78arC8Csmf/joa0B81hQ1gC5EIr9Q6PKfFgIW6t3h5uG+/y2jWC8Cmk+H4+XoLnDzZS3z7SGrbN2j4XxAgAvAu8pgPsyDER8kKiKVNO9DMZxOouYsk3KzKpap2a9mgZk07YNsApX3Uyt5urVg5X54uuwXrmqWyBLine truncated
|
||||
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,185 @@
|
||||
.sidebar-contents {
|
||||
width: 98px;
|
||||
min-height: 986px;
|
||||
background: rgba(3, 38, 80, 0.8);
|
||||
border: 1px solid;
|
||||
border-color: rgba(43, 143, 254, 0.6);
|
||||
border-radius: 4px;
|
||||
backdrop-filter: blur(2px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item-nav {
|
||||
width: 100%;
|
||||
height: 70px;
|
||||
background: #ffffff1a;
|
||||
border-color: #fefafa4d;
|
||||
margin-top: 13px;
|
||||
font-family: Noto Sans S Chinese;
|
||||
font-weight: 550;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
background: #000c1c26;
|
||||
border: 1px solid;
|
||||
border-color: #55a6fe33;
|
||||
box-shadow: 0 0 20px #00b9ff26 inset;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item-nav:hover {
|
||||
background: rgba(85, 166, 254, 0.12);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.item-nav__label {
|
||||
display: block;
|
||||
width: 3em;
|
||||
margin: 0 auto;
|
||||
padding-left: 6px;
|
||||
box-sizing: border-box;
|
||||
letter-spacing: 6px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.item-nav-content {
|
||||
width: 155px;
|
||||
min-height: 970px;
|
||||
background: rgba(3, 38, 80, 0.8);
|
||||
border: 1px solid;
|
||||
border-color: rgba(43, 143, 254, 0.6);
|
||||
border-radius: 4px;
|
||||
backdrop-filter: blur(2px);
|
||||
position: absolute;
|
||||
left: 97px;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
padding: 14px 13px 18px 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.item-nav-active {
|
||||
color: #ffffff;
|
||||
background: linear-gradient(134.25deg,
|
||||
rgba(0, 98, 217, 0.59) 2.28%,
|
||||
rgba(0, 98, 217, 0.3) 99.59%) !important;
|
||||
background-size: cover;
|
||||
border: 1px solid;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item-nav-active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 8px solid transparent;
|
||||
border-bottom: 8px solid transparent;
|
||||
border-left: 10px solid rgba(0, 98, 217, 0.45);
|
||||
pointer-events: none;
|
||||
}
|
||||
.item-nav-active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 24px;
|
||||
width: 77px;
|
||||
height: 45px;
|
||||
background: url('../assets/icon/nav-line-logo.svg') no-repeat;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.sidebar-flyout-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-flyout-list--level-1,
|
||||
.sidebar-flyout-list--level-2,
|
||||
.sidebar-flyout-list--level-3 {
|
||||
margin-left: 19px;
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
.sidebar-flyout-node {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-flyout-node--has-children.sidebar-flyout-node--expanded>.sidebar-flyout-list::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 1px;
|
||||
height: 100%;
|
||||
background: rgba(43, 143, 254, 0.22);
|
||||
}
|
||||
|
||||
.sidebar-flyout-item {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 3px 0;
|
||||
font-family: Noto Sans S Chinese;
|
||||
font-size: 18px;
|
||||
line-height: 28px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease, text-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-flyout-item:hover,
|
||||
.sidebar-flyout-item--active {
|
||||
color: #48ddff;
|
||||
text-shadow: 0 0 8px rgba(72, 221, 255, 0.25);
|
||||
}
|
||||
|
||||
.sidebar-flyout-item--leaf {
|
||||
justify-content: center;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.sidebar-flyout-list--level-1>.sidebar-flyout-node>.sidebar-flyout-item--leaf,
|
||||
.sidebar-flyout-list--level-2>.sidebar-flyout-node>.sidebar-flyout-item--leaf,
|
||||
.sidebar-flyout-list--level-3>.sidebar-flyout-node>.sidebar-flyout-item--leaf {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.sidebar-flyout-arrow {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
flex: 0 0 9px;
|
||||
background: url('../assets/icon/down-logo.svg') center / contain no-repeat;
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-flyout-node--expanded>.sidebar-flyout-item>.sidebar-flyout-arrow {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.sidebar-flyout-label {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ElementType } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import {
|
||||
Bell,
|
||||
Bot,
|
||||
Brain,
|
||||
CalendarClock,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
CircleHelp,
|
||||
Database,
|
||||
FileBox,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Gauge,
|
||||
Grid3X3,
|
||||
HelpCircle,
|
||||
History,
|
||||
Layout,
|
||||
Library,
|
||||
List,
|
||||
ListChecks,
|
||||
ListTodo,
|
||||
MessageSquareCode,
|
||||
MessageSquarePlus,
|
||||
Network,
|
||||
NotebookPen,
|
||||
Plus,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Target,
|
||||
User,
|
||||
Users,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { NotificationDialog } from "@/components/workspace/notification-dialog";
|
||||
import { SettingsDialog } from "@/components/workspace/settings";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useUnreadCount } from "@/core/notifications";
|
||||
import './page-sdebar.css'
|
||||
|
||||
type MenuItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ElementType;
|
||||
path?: string;
|
||||
adminOnly?: boolean;
|
||||
children?: MenuItem[];
|
||||
};
|
||||
|
||||
type PageSidebarDarkProps = {
|
||||
collapsed: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
};
|
||||
|
||||
type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about";
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
id: "qa-center",
|
||||
label: "问答中心",
|
||||
icon: HelpCircle,
|
||||
children: [
|
||||
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
|
||||
{ id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
|
||||
{ id: "qa-zzjc", label: "zzjc支持", icon: Bot },
|
||||
{ id: "qa-zzxd", label: "zzXD应用", icon: Layout },
|
||||
{ id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "light-app-center",
|
||||
label: "轻应用",
|
||||
icon: Grid3X3,
|
||||
children: [
|
||||
{
|
||||
id: "light-app-note-management",
|
||||
label: "笔记管理",
|
||||
icon: NotebookPen,
|
||||
children: [
|
||||
{
|
||||
id: "light-app-new-note",
|
||||
label: "新建笔记",
|
||||
icon: Plus,
|
||||
path: "/page/strategy/light-app/new-note",
|
||||
},
|
||||
{
|
||||
id: "light-app-note-list",
|
||||
label: "笔记列表",
|
||||
icon: List,
|
||||
path: "/page/strategy/light-app/notes",
|
||||
},
|
||||
{
|
||||
id: "light-app-note-qa",
|
||||
label: "笔记问答",
|
||||
icon: MessageSquareCode,
|
||||
path: "/page/strategy/light-app/note-qa",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ id: "light-app-square", label: "应用广场", icon: Layout },
|
||||
{ id: "light-app-records", label: "运行记录", icon: History },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "task-management",
|
||||
label: "任务管理",
|
||||
icon: Target,
|
||||
children: [
|
||||
{
|
||||
id: "xk-center-task",
|
||||
label: "XK中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "xk-task-jc", label: "任务JC", icon: ListTodo, path: "/page/strategy/task-management/xk-task-jc" },
|
||||
{ id: "xk-task-dispatch", label: "任务调度", icon: ListTodo, path: "/page/strategy/task-management/xk-task-dispatch" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "coh-center-task",
|
||||
label: "COH中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{
|
||||
id: "coh-task-3qfx",
|
||||
label: "3QFX",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/plan-task-sentiment",
|
||||
},
|
||||
{
|
||||
id: "coh-task-fx",
|
||||
label: "任务FX",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/plan-task/plan-task-fx",
|
||||
},
|
||||
{
|
||||
id: "coh-task-xdfx",
|
||||
label: "XDFX",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/plan-task/plan-task-action",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pg-center-task",
|
||||
label: "PG中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "pg-task-pg", label: "任务PG", icon: ListTodo },
|
||||
{ id: "pg-task-xdpg", label: "XDPG", icon: ListTodo },
|
||||
{ id: "pg-task-info-pg", label: "信息PG", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ty-center-task",
|
||||
label: "TY中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "ty-task-xdty", label: "XDTY任务", icon: ListTodo },
|
||||
{ id: "ty-task-plan", label: "方案TY任务", icon: ListTodo },
|
||||
{ id: "ty-task-xxty", label: "XXTY任务", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "comprehensive-management",
|
||||
label: "综合管理",
|
||||
icon: Settings,
|
||||
children: [
|
||||
{ id: "mgmt-agents", label: "智能体管理", icon: Bot, path: "/page/strategy/agents" },
|
||||
{ id: "mgmt-skills", label: "技能管理", icon: Sparkles, path: "/page/strategy/skills" },
|
||||
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
|
||||
{
|
||||
id: "mgmt-tasks",
|
||||
label: "任务管理",
|
||||
icon: ListChecks,
|
||||
path: "/page/strategy/scheduled/tasks",
|
||||
},
|
||||
{
|
||||
id: "mgmt-recommended-questions",
|
||||
label: "推荐问题配置",
|
||||
icon: MessageSquarePlus,
|
||||
path: "/page/strategy/settings/recommended-questions",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-prompt-prefix",
|
||||
label: "问答前缀配置",
|
||||
icon: MessageSquareCode,
|
||||
path: "/page/strategy/settings/prompt-prefix",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-llm-metrics",
|
||||
label: "大模型调用统计",
|
||||
icon: Gauge,
|
||||
path: "/page/strategy/settings/llm-metrics",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-users",
|
||||
label: "用户管理",
|
||||
icon: Users,
|
||||
path: "/page/strategy/admin/users",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-admin-scheduled-tasks",
|
||||
label: "定时任务管理",
|
||||
icon: CalendarClock,
|
||||
path: "/page/strategy/admin/scheduled-tasks",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-user-activity",
|
||||
label: "用户活动审计",
|
||||
icon: History,
|
||||
path: "/page/strategy/admin/user-activity",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "resource-management",
|
||||
label: "资源管理",
|
||||
icon: Database,
|
||||
children: [
|
||||
{
|
||||
id: "resource-network-xwt",
|
||||
label: "网络xwt资源管理",
|
||||
icon: Network,
|
||||
path: "/page/strategy/resource-management/resource-network-actor",
|
||||
},
|
||||
{
|
||||
id: "resource-knowledge-base",
|
||||
label: "知识库资源管理",
|
||||
icon: Library,
|
||||
path: "/page/strategy/resource-management/resource-knowledge-base",
|
||||
},
|
||||
{
|
||||
id: "resource-evaluation-index",
|
||||
label: "作战资源管理",
|
||||
icon: FileBox,
|
||||
path: "/page/strategy/resource-management/resource-evaluation-index",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const defaultExpandedIds = new Set([
|
||||
"qa-center",
|
||||
"light-app-center",
|
||||
"light-app-note-management",
|
||||
"task-management",
|
||||
"xk-center-task",
|
||||
]);
|
||||
|
||||
function filterMenuItemsByRole(items: MenuItem[], isAdmin: boolean): MenuItem[] {
|
||||
return items
|
||||
.filter((item) => !item.adminOnly || isAdmin)
|
||||
.map((item) =>
|
||||
item.children
|
||||
? { ...item, children: filterMenuItemsByRole(item.children, isAdmin) }
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
export function PageSidebarDark({
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
}: PageSidebarDarkProps) {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const visibleMenuItems = useMemo(
|
||||
() => filterMenuItemsByRole(menuItems, !!isAdmin),
|
||||
[isAdmin],
|
||||
);
|
||||
const { t } = useI18n();
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(defaultExpandedIds);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsDefaultSection, setSettingsDefaultSection] =
|
||||
useState<SettingsSection>("appearance");
|
||||
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const openSettings = (section: SettingsSection) => {
|
||||
setSettingsDefaultSection(section);
|
||||
setSettingsOpen(true);
|
||||
};
|
||||
|
||||
const hasActiveChild = (item: MenuItem): boolean => {
|
||||
if (item.path && pathname === item.path) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return item.children?.some(hasActiveChild) ?? false;
|
||||
};
|
||||
|
||||
const initialActiveTopItem = visibleMenuItems.find(hasActiveChild) ?? visibleMenuItems[0];
|
||||
const [activeTopId, setActiveTopId] = useState(initialActiveTopItem?.id ?? "qa-center");
|
||||
|
||||
useEffect(() => {
|
||||
const matchedTopItem = visibleMenuItems.find(hasActiveChild);
|
||||
if (matchedTopItem) {
|
||||
setActiveTopId(matchedTopItem.id);
|
||||
}
|
||||
}, [pathname, visibleMenuItems]);
|
||||
|
||||
const activeTopItem = visibleMenuItems.find((item) => item.id === activeTopId) ?? initialActiveTopItem;
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleTopItemClick = (item: MenuItem) => {
|
||||
setActiveTopId(item.id);
|
||||
|
||||
if (!item.children?.length && item.path) {
|
||||
navigate(item.path);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMenuItemClick = (item: MenuItem) => {
|
||||
if (item.children?.length) {
|
||||
toggleExpanded(item.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.path) {
|
||||
navigate(item.path);
|
||||
}
|
||||
};
|
||||
|
||||
const renderFlyoutMenu = (items: MenuItem[], level = 0) => (
|
||||
<div className={`sidebar-flyout-list sidebar-flyout-list--level-${level}`}>
|
||||
{items.map((item) => {
|
||||
const hasChildren = !!item.children?.length;
|
||||
const isExpanded = expandedIds.has(item.id);
|
||||
const isSelected = item.path ? pathname === item.path : hasActiveChild(item);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`sidebar-flyout-node sidebar-flyout-node--level-${level} ${hasChildren ? "sidebar-flyout-node--has-children" : ""} ${isExpanded ? "sidebar-flyout-node--expanded" : ""}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`sidebar-flyout-item ${isSelected ? "sidebar-flyout-item--active" : ""} ${hasChildren ? "sidebar-flyout-item--parent" : "sidebar-flyout-item--leaf"}`}
|
||||
onClick={() => handleMenuItemClick(item)}
|
||||
>
|
||||
{hasChildren ? <span className="sidebar-flyout-arrow" aria-hidden="true" /> : null}
|
||||
<span className="sidebar-flyout-label">{item.label}</span>
|
||||
</button>
|
||||
{hasChildren && isExpanded ? renderFlyoutMenu(item.children ?? [], level + 1) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<SettingsDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
defaultSection={settingsDefaultSection}
|
||||
/>
|
||||
<NotificationDialog
|
||||
open={notificationOpen}
|
||||
onOpenChange={setNotificationOpen}
|
||||
/>
|
||||
{/* TODO: 在这里编写深色主题的侧边栏 HTML 结构 */}
|
||||
<div className="sidebar-contnet">
|
||||
<div className="sidebar-contents">
|
||||
<div className="item-nav-content">
|
||||
{activeTopItem?.children?.length ? renderFlyoutMenu(activeTopItem.children) : null}
|
||||
</div>
|
||||
{visibleMenuItems.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.id}
|
||||
className={`item-nav ${item.id === activeTopId ? "item-nav-active" : ""}`}
|
||||
onClick={() => handleTopItemClick(item)}
|
||||
>
|
||||
<span className="item-nav__label">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
ListTodo,
|
||||
MessageSquareCode,
|
||||
MessageSquarePlus,
|
||||
ShieldCheck,
|
||||
Network,
|
||||
NotebookPen,
|
||||
Plus,
|
||||
@@ -72,6 +73,7 @@ const menuItems: MenuItem[] = [
|
||||
icon: HelpCircle,
|
||||
children: [
|
||||
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" },
|
||||
{ id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" },
|
||||
// 「圆桌规划」入口暂时隐藏 —— 功能仍在 /page/roundtable/planning,只是不在侧边栏出现。
|
||||
// 需要重新开放时取消注释即可。
|
||||
// {
|
||||
@@ -129,8 +131,18 @@ const menuItems: MenuItem[] = [
|
||||
label: "XK中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "xk-task-jc", label: "任务JC", icon: ListTodo },
|
||||
{ id: "xk-task-dispatch", label: "任务调度", icon: ListTodo },
|
||||
{
|
||||
id: "xk-task-jc",
|
||||
label: "任务JC",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/xk-task-jc",
|
||||
},
|
||||
{
|
||||
id: "xk-task-dispatch",
|
||||
label: "任务调度",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/xk-task-dispatch",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -138,9 +150,24 @@ const menuItems: MenuItem[] = [
|
||||
label: "COH中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "coh-task-3qfx", label: "3QFX", icon: ListTodo },
|
||||
{ id: "coh-task-fx", label: "任务FX", icon: ListTodo },
|
||||
{ id: "coh-task-xdfx", label: "XDFX", icon: ListTodo },
|
||||
{
|
||||
id: "coh-task-3qfx",
|
||||
label: "3QFX",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/plan-task-sentiment",
|
||||
},
|
||||
{
|
||||
id: "coh-task-fx",
|
||||
label: "任务FX",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/plan-task/plan-task-fx",
|
||||
},
|
||||
{
|
||||
id: "coh-task-xdfx",
|
||||
label: "XDFX",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/plan-task/plan-task-action",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -148,9 +175,24 @@ const menuItems: MenuItem[] = [
|
||||
label: "PG中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "pg-task-pg", label: "任务PG", icon: ListTodo },
|
||||
{ id: "pg-task-xdpg", label: "XDPG", icon: ListTodo },
|
||||
{ id: "pg-task-info-pg", label: "信息PG", icon: ListTodo },
|
||||
{
|
||||
id: "pg-task-pg",
|
||||
label: "任务PG",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/effect-task-pg",
|
||||
},
|
||||
{
|
||||
id: "pg-task-xdpg",
|
||||
label: "XDPG",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/effect-action-pg",
|
||||
},
|
||||
{
|
||||
id: "pg-task-info-pg",
|
||||
label: "信息PG",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/effect-info-pg",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -158,9 +200,24 @@ const menuItems: MenuItem[] = [
|
||||
label: "TY中任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "ty-task-xdty", label: "XDTY任务", icon: ListTodo },
|
||||
{ id: "ty-task-plan", label: "方案TY任务", icon: ListTodo },
|
||||
{ id: "ty-task-xxty", label: "XXTY任务", icon: ListTodo },
|
||||
{
|
||||
id: "ty-task-xdty",
|
||||
label: "XDTY任务",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/mnty-task-mechanism",
|
||||
},
|
||||
{
|
||||
id: "ty-task-plan",
|
||||
label: "方案TY任务",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/mnty-task-plan",
|
||||
},
|
||||
{
|
||||
id: "ty-task-xxty",
|
||||
label: "XXTY任务",
|
||||
icon: ListTodo,
|
||||
path: "/page/strategy/task-management/mnty-task-zsty",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -175,7 +232,7 @@ const menuItems: MenuItem[] = [
|
||||
{ id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
|
||||
{
|
||||
id: "mgmt-tasks",
|
||||
label: "任务管理",
|
||||
label: "定时任务",
|
||||
icon: ListChecks,
|
||||
path: "/page/strategy/scheduled/tasks",
|
||||
},
|
||||
@@ -193,6 +250,13 @@ const menuItems: MenuItem[] = [
|
||||
path: "/page/strategy/settings/prompt-prefix",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-private-square",
|
||||
label: "私密广场配置",
|
||||
icon: ShieldCheck,
|
||||
path: "/page/strategy/settings/private-square",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "mgmt-llm-metrics",
|
||||
label: "大模型调用统计",
|
||||
@@ -228,9 +292,24 @@ const menuItems: MenuItem[] = [
|
||||
label: "资源管理",
|
||||
icon: Database,
|
||||
children: [
|
||||
{ id: "resource-network-xwt", label: "网络xwt资源管理", icon: Network },
|
||||
{ id: "resource-knowledge-base", label: "知识库资源管理", icon: Library },
|
||||
{ id: "resource-zz", label: "ZZ资源管理", icon: FileBox },
|
||||
{
|
||||
id: "resource-network-xwt",
|
||||
label: "网络xwt资源管理",
|
||||
icon: Network,
|
||||
path: "/page/strategy/resource-management/resource-network-actor",
|
||||
},
|
||||
{
|
||||
id: "resource-knowledge-base",
|
||||
label: "知识库资源管理",
|
||||
icon: Library,
|
||||
path: "/page/strategy/resource-management/resource-knowledge-base",
|
||||
},
|
||||
{
|
||||
id: "resource-evaluation-index",
|
||||
label: "作战资源管理",
|
||||
icon: FileBox,
|
||||
path: "/page/strategy/resource-management/resource-evaluation-index",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-[4px] border py-6 shadow-sm dark:border-[#0049A5] dark:bg-[#02244C]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -42,7 +42,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
className={cn("text-muted-foreground text-sm dark:text-[#F2F3F5]", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -65,7 +65,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
className={cn("px-6 ", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group border-input/50 dark:bg-background/80 relative flex w-full items-center rounded-md border bg-white/80 shadow-xs transition-[color,box-shadow] outline-none",
|
||||
"group/input-group relative flex w-full items-center rounded-md border border-input/50 bg-white/80 shadow-xs transition-[color,box-shadow] outline-none dark:border-[#1B477E] dark:bg-[#022248]",
|
||||
"h-9 min-w-0 has-[>textarea]:h-auto",
|
||||
|
||||
// Variants based on alignment.
|
||||
|
||||
@@ -8,9 +8,12 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base outline-none transition-[color,box-shadow] md:text-sm",
|
||||
"border-input placeholder:text-muted-foreground",
|
||||
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF] dark:placeholder:text-[#566B7F] rounded-[4px] text-[16px]",
|
||||
"file:text-foreground selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] dark:focus-visible:border-[#3b82f6] dark:focus-visible:ring-[#3b82f6]/30",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -30,7 +30,8 @@ const tabsListVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
default:
|
||||
"bg-muted dark:group-data-[orientation=horizontal]/tabs:h-8! dark:items-stretch dark:gap-0 dark:overflow-hidden dark:rounded-[2px] dark:border dark:border-[#55A6FE] dark:bg-[#022248] dark:p-0",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
@@ -40,60 +41,65 @@ const tabsListVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>
|
||||
>(({ className, variant = "default", ...props }, ref) => {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>
|
||||
>(({ className, variant = "default", ...props }, ref) => {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
|
||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"dark:text-muted-foreground dark:hover:text-foreground",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background data-[state=active]:text-foreground",
|
||||
"dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-input dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-input/30 dark:group-data-[variant=line]/tabs-list:data-[state=active]:text-foreground",
|
||||
"dark:group-data-[variant=default]/tabs-list:relative dark:group-data-[variant=default]/tabs-list:!h-full dark:group-data-[variant=default]/tabs-list:self-stretch dark:group-data-[variant=default]/tabs-list:flex-none dark:group-data-[variant=default]/tabs-list:rounded-none dark:group-data-[variant=default]/tabs-list:border-0 dark:group-data-[variant=default]/tabs-list:bg-transparent dark:group-data-[variant=default]/tabs-list:px-4 dark:group-data-[variant=default]/tabs-list:!py-0 dark:group-data-[variant=default]/tabs-list:!leading-none dark:group-data-[variant=default]/tabs-list:shadow-none dark:group-data-[variant=default]/tabs-list:focus-visible:ring-0 dark:group-data-[variant=default]/tabs-list:[&_svg]:text-[#CCD2DA]",
|
||||
"dark:group-data-[variant=default]/tabs-list:not(:first-child):border-l dark:group-data-[variant=default]/tabs-list:not(:first-child):border-[#1d3961] dark:group-data-[variant=default]/tabs-list:data-[state=active]:border-l-0",
|
||||
"dark:group-data-[variant=default]/tabs-list:data-[state=active]:rounded-none dark:group-data-[variant=default]/tabs-list:data-[state=active]:border-0 dark:group-data-[variant=default]/tabs-list:data-[state=active]:bg-[#0062D9] dark:group-data-[variant=default]/tabs-list:data-[state=active]:text-[#FFFFFF] dark:group-data-[variant=default]/tabs-list:data-[state=active]:shadow-none dark:group-data-[variant=default]/tabs-list:data-[state=active]:[&_svg]:text-[#FFFFFF] dark:group-data-[variant=default]/tabs-list:hover:text-[#CCD2DA]",
|
||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
|
||||
@@ -7,7 +7,9 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground/60 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"border-input placeholder:text-muted-foreground/60 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF] ",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
InfoIcon,
|
||||
LockIcon,
|
||||
MessageSquareIcon,
|
||||
ShieldIcon,
|
||||
SparklesIcon,
|
||||
SquarePenIcon,
|
||||
StarIcon,
|
||||
TagsIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
@@ -40,8 +42,11 @@ import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-di
|
||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
import {
|
||||
useAgent,
|
||||
useDeleteAgent,
|
||||
useSetAgentFeatured,
|
||||
useSetAgentPrivate,
|
||||
useToggleAgentFavorite,
|
||||
useUpdateAgent,
|
||||
} from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
@@ -61,6 +66,8 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
const deleteAgent = useDeleteAgent();
|
||||
const updateAgent = useUpdateAgent();
|
||||
const setFeatured = useSetAgentFeatured();
|
||||
const setPrivate = useSetAgentPrivate();
|
||||
const toggleFavorite = useToggleAgentFavorite();
|
||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
@@ -69,11 +76,19 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
const [editDescription, setEditDescription] = useState(agent.description ?? "");
|
||||
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
|
||||
const [editPublished, setEditPublished] = useState(agent.published);
|
||||
const [editIsPrivate, setEditIsPrivate] = useState(agent.is_private);
|
||||
const [selectedSkills, setSelectedSkills] = useState<string[]>(
|
||||
agent.skills ?? [],
|
||||
);
|
||||
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
||||
|
||||
// The /api/agents list response intentionally omits `soul` (perf optimization
|
||||
// — SOUL.md is only read on demand). Fetch the detail row when the edit
|
||||
// dialog opens so the SOUL textarea can be hydrated.
|
||||
const { agent: agentDetail, isLoading: isAgentDetailLoading } = useAgent(
|
||||
editOpen ? agent.id : null,
|
||||
);
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
const isAdmin = storedAuth?.system_role === "admin";
|
||||
@@ -81,11 +96,16 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
const isOwned =
|
||||
!isBuiltin && agent.user_id !== null && agent.user_id === currentUserId;
|
||||
const isFromOthers = !isBuiltin && !isOwned;
|
||||
// System admins are allowed to edit and delete built-in agents.
|
||||
// System admins are allowed to edit and delete any agent (built-in or owned
|
||||
// by another user) so the admin console can manage the full catalog.
|
||||
// Publish-toggle stays owner-only — built-ins are inherently visible to
|
||||
// everyone so toggling published is moot.
|
||||
const canEdit = isOwned || (isBuiltin && isAdmin);
|
||||
const canDelete = isOwned || (isBuiltin && isAdmin);
|
||||
const canEdit = isOwned || isAdmin;
|
||||
const canDelete = isOwned || isAdmin;
|
||||
// Favoriting collects a square agent into "我的"; pointless for one you
|
||||
// already own, so only offer it on built-in / others' agents.
|
||||
const canFavorite = !isOwned;
|
||||
const isFavorite = !!agent.is_favorite;
|
||||
|
||||
useEffect(() => {
|
||||
if (!editOpen) {
|
||||
@@ -93,18 +113,32 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
}
|
||||
setEditName(agent.name);
|
||||
setEditDescription(agent.description ?? "");
|
||||
setEditSoul(agent.soul ?? "");
|
||||
setEditPublished(agent.published);
|
||||
setEditIsPrivate(agent.is_private);
|
||||
setSelectedSkills(agent.skills ?? []);
|
||||
}, [
|
||||
agent.description,
|
||||
agent.is_private,
|
||||
agent.name,
|
||||
agent.published,
|
||||
agent.skills,
|
||||
agent.soul,
|
||||
editOpen,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editOpen) {
|
||||
return;
|
||||
}
|
||||
// SOUL.md only ships on the detail endpoint; fall back to whatever the list
|
||||
// happened to carry so older deployments (or cached objects that still
|
||||
// include `soul`) keep working.
|
||||
if (agentDetail) {
|
||||
setEditSoul(agentDetail.soul ?? "");
|
||||
} else {
|
||||
setEditSoul(agent.soul ?? "");
|
||||
}
|
||||
}, [agent.soul, agentDetail, editOpen]);
|
||||
|
||||
function handleChat() {
|
||||
router.push(`/page/workspace/agents/${agent.id}/chats/new`);
|
||||
}
|
||||
@@ -125,6 +159,10 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
toast.error(t.agents.nameStepInvalidError ?? "名称不能为空");
|
||||
return;
|
||||
}
|
||||
if (editIsPrivate && editPublished) {
|
||||
toast.warning("私密广场与公开发布互斥,请只勾选其一");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
@@ -132,7 +170,10 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
name: trimmedName,
|
||||
description: editDescription,
|
||||
soul: editSoul,
|
||||
published: editPublished,
|
||||
// Private agents cannot also be published — force the value the
|
||||
// backend will see, regardless of any stale state in editPublished.
|
||||
published: editIsPrivate ? false : editPublished,
|
||||
is_private: editIsPrivate,
|
||||
skills: selectedSkills,
|
||||
},
|
||||
});
|
||||
@@ -144,6 +185,13 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
}
|
||||
|
||||
async function handleTogglePublished() {
|
||||
// Private and public squares are mutually exclusive — a private agent
|
||||
// can't simultaneously be published to everyone. Block the toggle
|
||||
// instead of silently moving the agent between squares.
|
||||
if (!agent.published && agent.is_private) {
|
||||
toast.warning("该智能体已加入私密广场,不可发布");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
@@ -155,6 +203,37 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTogglePrivate() {
|
||||
const turningOn = !agent.is_private;
|
||||
try {
|
||||
if (isOwned) {
|
||||
// Owners can re-categorize their own agents through the normal update
|
||||
// endpoint — keeps publish/private edits in one transaction.
|
||||
await updateAgent.mutateAsync({
|
||||
id: agent.id,
|
||||
request: { is_private: turningOn },
|
||||
});
|
||||
} else {
|
||||
// Admins can move any agent (built-in or other users') via the
|
||||
// dedicated admin endpoint.
|
||||
await setPrivate.mutateAsync({ id: agent.id, isPrivate: turningOn });
|
||||
}
|
||||
toast.success(turningOn ? "已加入私密广场" : "已移出私密广场");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleFavorite() {
|
||||
const next = !isFavorite;
|
||||
try {
|
||||
await toggleFavorite.mutateAsync({ id: agent.id, favorite: next });
|
||||
toast.success(next ? "已收藏到「我的」" : "已取消收藏");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleFeatured() {
|
||||
// One-click on/off — ordering is handled by the dedicated 管理 Agent 列表
|
||||
// dialog. Featured agents get appended to the end on add (Date.now() is a
|
||||
@@ -188,15 +267,18 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="group flex flex-col transition-shadow hover:shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Card className="group flex min-w-0 flex-col transition-shadow hover:shadow-md">
|
||||
<CardHeader className="min-w-0 pb-3">
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<BotIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="truncate text-base" title={desensitize(agent.name)}>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<CardTitle
|
||||
className="block w-full min-w-0 overflow-hidden text-base text-ellipsis whitespace-nowrap"
|
||||
title={desensitize(agent.name)}
|
||||
>
|
||||
{desensitize(agent.name)}
|
||||
</CardTitle>
|
||||
{agent.model && (
|
||||
@@ -229,7 +311,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
来自他人
|
||||
</Badge>
|
||||
)}
|
||||
{isOwned && agent.published && (
|
||||
{isOwned && agent.published && !agent.is_private && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||
@@ -239,6 +321,16 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
已发布
|
||||
</Badge>
|
||||
)}
|
||||
{agent.is_private && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
|
||||
title="该智能体位于私密广场,需要密码才能查看"
|
||||
>
|
||||
<ShieldIcon className="size-3" />
|
||||
私密
|
||||
</Badge>
|
||||
)}
|
||||
{agent.featured_order != null && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
@@ -294,13 +386,26 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
<CardFooter className="mt-auto flex items-center justify-between gap-2 pt-3">
|
||||
<Button size="sm" className="flex-1" onClick={handleChat}>
|
||||
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t.agents.chat}
|
||||
</Button>
|
||||
{(canEdit || canDelete || isOwned || isAdmin) && (
|
||||
<div className="flex gap-1">
|
||||
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-3">
|
||||
{(canEdit || canDelete || isOwned || isAdmin || canFavorite) && (
|
||||
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||
{canFavorite && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"mr-auto h-8 w-8 shrink-0",
|
||||
isFavorite && "text-amber-500 hover:text-amber-600",
|
||||
)}
|
||||
onClick={() => void handleToggleFavorite()}
|
||||
disabled={toggleFavorite.isPending}
|
||||
title={isFavorite ? "取消收藏" : "收藏到「我的」"}
|
||||
>
|
||||
<StarIcon
|
||||
className={cn("h-3.5 w-3.5", isFavorite && "fill-current")}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -348,6 +453,24 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{(isOwned || isAdmin) && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
agent.is_private &&
|
||||
"text-purple-600 hover:text-purple-700",
|
||||
)}
|
||||
onClick={() => void handleTogglePrivate()}
|
||||
disabled={updateAgent.isPending || setPrivate.isPending}
|
||||
title={
|
||||
agent.is_private ? "移出私密广场" : "放入私密广场"
|
||||
}
|
||||
>
|
||||
<ShieldIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -372,6 +495,10 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Button size="sm" className="w-full" onClick={handleChat}>
|
||||
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t.agents.chat}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
@@ -459,7 +586,11 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
<Textarea
|
||||
value={editSoul ?? ""}
|
||||
onChange={(e) => setEditSoul(e.target.value)}
|
||||
placeholder="输入 SOUL.md 内容"
|
||||
placeholder={
|
||||
isAgentDetailLoading && !agentDetail
|
||||
? "正在加载 SOUL.md..."
|
||||
: "输入 SOUL.md 内容"
|
||||
}
|
||||
className="min-h-40 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -467,12 +598,41 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
<div>
|
||||
<p className="text-sm font-medium">公开发布</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
打开后,该智能体对所有用户可见。
|
||||
{editIsPrivate
|
||||
? "已加入私密广场,不可发布;请先关闭私密广场。"
|
||||
: "打开后,该智能体对所有用户可见。"}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={editPublished}
|
||||
onCheckedChange={setEditPublished}
|
||||
checked={editPublished && !editIsPrivate}
|
||||
disabled={editIsPrivate}
|
||||
onCheckedChange={(v) => {
|
||||
if (editIsPrivate) {
|
||||
toast.warning("该智能体已加入私密广场,不可发布");
|
||||
return;
|
||||
}
|
||||
setEditPublished(v);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">加入私密广场</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
打开后,该智能体仅在私密广场显示,需输入密码才能查看;与「公开发布」互斥。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={editIsPrivate}
|
||||
onCheckedChange={(v) => {
|
||||
setEditIsPrivate(v);
|
||||
if (v) {
|
||||
// Mutual-exclusion: switching the agent into the private
|
||||
// square automatically clears the public-publish flag so
|
||||
// the user can't end up with a half-and-half state.
|
||||
setEditPublished(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,44 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon, PlusIcon } from "lucide-react";
|
||||
import { BotIcon, LockIcon, PlusIcon, ShieldIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||
import { useAgents } from "@/core/agents";
|
||||
import { useAgents, usePrivateAgents } from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { usePrivateSquareStatus } from "@/core/system-settings/hooks";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
import { AgentCard } from "./agent-card";
|
||||
|
||||
// Within a single tab the verified password is kept in sessionStorage so a
|
||||
// refresh / route navigation does not re-prompt. Cleared on tab close, so the
|
||||
// password never persists beyond the browsing session.
|
||||
const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
|
||||
|
||||
export function AgentGallery() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const [activeTab, setActiveTab] = useState<"all" | "mine">("mine");
|
||||
const [activeTab, setActiveTab] = useState<"all" | "mine" | "private">(
|
||||
"mine",
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
// Name + tag filtering is resolved server-side via the /api/agents query.
|
||||
const { agents, isLoading } = useAgents({ search, tagIds });
|
||||
// Tag filtering stays server-side; name search must run on the *desensitized*
|
||||
// form because that's what the user sees on the card. If we sent the search
|
||||
// string to the backend it would compare against the raw stored name and
|
||||
// miss every desensitized hit (e.g. typing "TS分析" wouldn't match the
|
||||
// stored "态势分析").
|
||||
const { agents, isLoading } = useAgents({ tagIds });
|
||||
|
||||
const needle = search.trim().toLowerCase();
|
||||
const matchesSearch = (a: Agent) =>
|
||||
!needle || desensitize(a.name).toLowerCase().includes(needle);
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
|
||||
const { data: privateStatus } = usePrivateSquareStatus();
|
||||
const privateFeatureAvailable = !!privateStatus?.enabled;
|
||||
const privateFeatureUsable =
|
||||
privateFeatureAvailable && !!privateStatus?.password_set;
|
||||
|
||||
const [privatePassword, setPrivatePassword] = useState<string | null>(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
return window.sessionStorage.getItem(PRIVATE_SQUARE_PASSWORD_KEY);
|
||||
});
|
||||
const [passwordInput, setPasswordInput] = useState("");
|
||||
|
||||
const {
|
||||
agents: privateAgents,
|
||||
isLoading: isPrivateLoading,
|
||||
error: privateError,
|
||||
} = usePrivateAgents(privatePassword);
|
||||
|
||||
// If the server reports the stored password no longer works (likely the
|
||||
// admin rotated it), drop it from session storage and re-prompt.
|
||||
useEffect(() => {
|
||||
if (privatePassword && privateError) {
|
||||
window.sessionStorage.removeItem(PRIVATE_SQUARE_PASSWORD_KEY);
|
||||
setPrivatePassword(null);
|
||||
toast.error("私密广场密码已失效,请重新输入");
|
||||
}
|
||||
}, [privateError, privatePassword]);
|
||||
|
||||
// The "广场" tab must never surface private-square agents, even to their
|
||||
// own owner — moving an agent into the private square is supposed to hide
|
||||
// it from the public square entirely. (The backend listing endpoint
|
||||
// already filters them out for non-owners; this extra clause covers the
|
||||
// owner's own private agents, which still come back in the unified list
|
||||
// because owners always see their own.)
|
||||
const allAgents = useMemo(
|
||||
() => agents.filter((a) => a.builtin || a.published),
|
||||
[agents],
|
||||
() =>
|
||||
agents.filter(
|
||||
(a) => (a.builtin || a.published) && !a.is_private && matchesSearch(a),
|
||||
),
|
||||
// matchesSearch closes over `needle`; depending on `search` is sufficient
|
||||
// and avoids re-running on every render via the function reference.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[agents, search],
|
||||
);
|
||||
|
||||
// "我的" shows the user's own agents plus any agent they've favorited (收藏)
|
||||
// from the square — favorited public agents have a different user_id but a
|
||||
// truthy is_favorite flag.
|
||||
const myAgents = useMemo(
|
||||
() => agents.filter((a) => a.user_id !== null && a.user_id === currentUserId),
|
||||
[agents, currentUserId],
|
||||
() =>
|
||||
agents.filter(
|
||||
(a) =>
|
||||
((a.user_id !== null && a.user_id === currentUserId) ||
|
||||
a.is_favorite) &&
|
||||
matchesSearch(a),
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[agents, currentUserId, search],
|
||||
);
|
||||
|
||||
const filteredPrivateAgents = useMemo(() => {
|
||||
const wanted = new Set(tagIds);
|
||||
return privateAgents.filter((a) => {
|
||||
if (!matchesSearch(a)) return false;
|
||||
if (wanted.size > 0) {
|
||||
if (!(a.tags ?? []).some((tg) => wanted.has(tg.id))) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [privateAgents, search, tagIds]);
|
||||
|
||||
const handleNewAgent = () => {
|
||||
router.push("/page/workspace/agents/new");
|
||||
};
|
||||
|
||||
const handleSubmitPassword = () => {
|
||||
const trimmed = passwordInput.trim();
|
||||
if (!trimmed) {
|
||||
toast.error("请输入密码");
|
||||
return;
|
||||
}
|
||||
window.sessionStorage.setItem(PRIVATE_SQUARE_PASSWORD_KEY, trimmed);
|
||||
setPrivatePassword(trimmed);
|
||||
setPasswordInput("");
|
||||
};
|
||||
|
||||
const handleLockPrivate = () => {
|
||||
window.sessionStorage.removeItem(PRIVATE_SQUARE_PASSWORD_KEY);
|
||||
setPrivatePassword(null);
|
||||
};
|
||||
|
||||
const renderGrid = (list: typeof agents) => (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{list.map((agent) => (
|
||||
@@ -49,15 +146,21 @@ export function AgentGallery() {
|
||||
|
||||
const renderEmpty = (description: string, showNewButton = false) => (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<BotIcon className="text-muted-foreground h-7 w-7" />
|
||||
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full dark:bg-[#022248]">
|
||||
<BotIcon className="text-muted-foreground h-7 w-7 dark:text-[#AACEFF]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{t.agents.emptyTitle}</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">{description}</p>
|
||||
<p className="font-medium dark:text-[#AACEFF]">{t.agents.emptyTitle}</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm dark:text-[#55A6FE]">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
{showNewButton && (
|
||||
<Button variant="outline" className="mt-2" onClick={handleNewAgent}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-2 dark:border-[#2580FF] dark:bg-[#2580FF] dark:text-white dark:hover:border-[#2580FF] dark:hover:bg-[#2580FF]/90 dark:hover:text-white"
|
||||
onClick={handleNewAgent}
|
||||
>
|
||||
<PlusIcon className="mr-1.5 h-4 w-4" />
|
||||
{t.agents.newAgent}
|
||||
</Button>
|
||||
@@ -65,6 +168,78 @@ export function AgentGallery() {
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderPrivateTab = () => {
|
||||
if (!privateFeatureAvailable) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<ShieldIcon className="text-muted-foreground h-7 w-7" />
|
||||
</div>
|
||||
<p className="font-medium">私密广场尚未开启</p>
|
||||
<p className="text-muted-foreground max-w-sm text-sm">
|
||||
请管理员前往「综合管理 → 私密广场配置」开启该功能并设置密码。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!privateFeatureUsable) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<ShieldIcon className="text-muted-foreground h-7 w-7" />
|
||||
</div>
|
||||
<p className="font-medium">私密广场未配置密码</p>
|
||||
<p className="text-muted-foreground max-w-sm text-sm">
|
||||
请管理员在私密广场配置页设置一个访问密码。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!privatePassword) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-sm flex-col gap-4 pt-12 text-center">
|
||||
<div className="bg-muted mx-auto flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<LockIcon className="text-muted-foreground h-7 w-7" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">私密广场</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
请输入访问密码,验证通过后才能查看其中的智能体。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="访问密码"
|
||||
value={passwordInput}
|
||||
onChange={(e) => setPasswordInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSubmitPassword();
|
||||
}}
|
||||
/>
|
||||
<Button onClick={handleSubmitPassword}>进入私密广场</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPrivateLoading) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
||||
{t.common.loading}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredPrivateAgents.length === 0) {
|
||||
return renderEmpty("私密广场暂时还没有智能体。");
|
||||
}
|
||||
|
||||
return renderGrid(filteredPrivateAgents);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
{/* Page header */}
|
||||
@@ -84,14 +259,35 @@ export function AgentGallery() {
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "all" | "mine")}
|
||||
onValueChange={(v) =>
|
||||
setActiveTab(v as "all" | "mine" | "private")
|
||||
}
|
||||
className="flex flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<div className="mx-6 mt-4 flex flex-col gap-3">
|
||||
<TabsList className="w-auto self-start">
|
||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||
<TabsTrigger value="all">广场</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<TabsList className="w-auto self-start">
|
||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||
<TabsTrigger value="all">广场</TabsTrigger>
|
||||
{privateFeatureAvailable && (
|
||||
<TabsTrigger value="private">
|
||||
<LockIcon className="mr-1 h-3.5 w-3.5" />
|
||||
私密广场
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
{activeTab === "private" && privatePassword && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLockPrivate}
|
||||
title="锁定私密广场,下次需要重新输入密码"
|
||||
>
|
||||
<LockIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
锁定
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<TagSearchBar
|
||||
targetType="agent"
|
||||
onSearchChange={setSearch}
|
||||
@@ -123,6 +319,15 @@ export function AgentGallery() {
|
||||
renderGrid(myAgents)
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{privateFeatureAvailable && (
|
||||
<TabsContent
|
||||
value="private"
|
||||
className="flex-1 overflow-y-auto p-6 mt-0"
|
||||
>
|
||||
{renderPrivateTab()}
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -61,7 +61,12 @@ export function AgentManageDialog({
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
const existing = new Set(agents.map((a) => a.id));
|
||||
return allAgents.filter((a) => a.published && !existing.has(a.id));
|
||||
// Private-square agents are hidden from the homepage selector even if
|
||||
// they're published — the backend ``list_featured`` filter would drop
|
||||
// them again on the way out, so offering them here is just confusing.
|
||||
return allAgents.filter(
|
||||
(a) => a.published && !a.is_private && !existing.has(a.id),
|
||||
);
|
||||
}, [agents, allAgents]);
|
||||
|
||||
const busy = setFeaturedMutation.isPending || reorderMutation.isPending;
|
||||
|
||||
@@ -32,7 +32,10 @@ interface ArtifactsProviderProps {
|
||||
}
|
||||
|
||||
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
const [artifacts, setArtifacts] = useState<string[]>([]);
|
||||
const [artifacts, setArtifactsState] = useState<string[]>([]);
|
||||
const setArtifacts = useCallback((next: string[]) => {
|
||||
setArtifactsState(Array.isArray(next) ? next : []);
|
||||
}, []);
|
||||
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
|
||||
const [autoSelect, setAutoSelect] = useState(true);
|
||||
const [open, setOpen] = useState(
|
||||
|
||||
@@ -1,183 +1,769 @@
|
||||
import { FilesIcon, XIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||
|
||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
ArtifactFileDetail,
|
||||
ArtifactFileList,
|
||||
useArtifacts,
|
||||
} from "../artifacts";
|
||||
import { useThread } from "../messages/context";
|
||||
|
||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
||||
|
||||
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
children,
|
||||
threadId,
|
||||
}) => {
|
||||
const { thread } = useThread();
|
||||
const pathname = usePathname();
|
||||
const threadIdRef = useRef(threadId);
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
|
||||
const {
|
||||
artifacts,
|
||||
open: artifactsOpen,
|
||||
setOpen: setArtifactsOpen,
|
||||
setArtifacts,
|
||||
select: selectArtifact,
|
||||
deselect,
|
||||
selectedArtifact,
|
||||
} = useArtifacts();
|
||||
|
||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||
useEffect(() => {
|
||||
if (threadIdRef.current !== threadId) {
|
||||
threadIdRef.current = threadId;
|
||||
deselect();
|
||||
}
|
||||
|
||||
// Update artifacts from the current thread
|
||||
setArtifacts(thread.values.artifacts);
|
||||
|
||||
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
|
||||
// if (
|
||||
// selectedArtifact &&
|
||||
// !thread.values.artifacts?.includes(selectedArtifact)
|
||||
// ) {
|
||||
// deselect();
|
||||
// }
|
||||
|
||||
if (
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
||||
autoSelectFirstArtifact
|
||||
) {
|
||||
if (thread?.values?.artifacts?.length > 0) {
|
||||
setAutoSelectFirstArtifact(false);
|
||||
selectArtifact(thread.values.artifacts[0]!);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
threadId,
|
||||
autoSelectFirstArtifact,
|
||||
deselect,
|
||||
selectArtifact,
|
||||
selectedArtifact,
|
||||
setArtifacts,
|
||||
thread.values.artifacts,
|
||||
]);
|
||||
|
||||
const artifactPanelOpen = useMemo(() => {
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
|
||||
return artifactsOpen && artifacts?.length > 0;
|
||||
}
|
||||
return artifactsOpen;
|
||||
}, [artifactsOpen, artifacts]);
|
||||
|
||||
const resizableIdBase = useMemo(() => {
|
||||
const raw = pathname
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return raw.length > 0 ? raw : `thread-${threadId}`;
|
||||
}, [pathname, threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (layoutRef.current) {
|
||||
if (artifactPanelOpen) {
|
||||
layoutRef.current.setLayout(OPEN_MODE);
|
||||
} else {
|
||||
layoutRef.current.setLayout(CLOSE_MODE);
|
||||
}
|
||||
}
|
||||
}, [artifactPanelOpen]);
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
id={`${resizableIdBase}-panels`}
|
||||
orientation="horizontal"
|
||||
defaultLayout={{ chat: 100, artifacts: 0 }}
|
||||
groupRef={layoutRef}
|
||||
>
|
||||
<ResizablePanel className="relative" defaultSize={100} id="chat">
|
||||
{children}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
id={`${resizableIdBase}-separator`}
|
||||
className={cn(
|
||||
"opacity-33 hover:opacity-100",
|
||||
!artifactPanelOpen && "pointer-events-none opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ResizablePanel
|
||||
className={cn(
|
||||
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
|
||||
!artifactsOpen && "opacity-0",
|
||||
)}
|
||||
id="artifacts"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
|
||||
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
|
||||
)}
|
||||
>
|
||||
{selectedArtifact ? (
|
||||
<ArtifactFileDetail
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex size-full justify-center">
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setArtifactsOpen(false);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{thread.values.artifacts?.length === 0 ? (
|
||||
<ConversationEmptyState
|
||||
icon={<FilesIcon />}
|
||||
title="No artifact selected"
|
||||
description="Select an artifact to view its details"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
|
||||
<header className="shrink-0">
|
||||
<h2 className="text-lg font-medium">Artifacts</h2>
|
||||
</header>
|
||||
<main className="min-h-0 grow">
|
||||
<ArtifactFileList
|
||||
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||
files={thread.values.artifacts ?? []}
|
||||
threadId={threadId}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export { ChatBox };
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { FilesIcon, XIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||
|
||||
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
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 { useSkills } from "@/core/skills/hooks";
|
||||
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
collectRuntimeDisplaySources,
|
||||
} from "@/core/skill-display/runtime";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
ArtifactFileDetail,
|
||||
ArtifactFileList,
|
||||
useArtifacts,
|
||||
} from "../artifacts";
|
||||
import { useThread } from "../messages/context";
|
||||
import { SkillDisplaySources } from "../skill-display-sources";
|
||||
|
||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
||||
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
|
||||
|
||||
type BackendReferenceSource = Parameters<typeof SkillDisplaySources>[0]["sources"][number];
|
||||
|
||||
type BackendReferenceBatch = {
|
||||
id: string;
|
||||
assistant_message_id?: string;
|
||||
sources: BackendReferenceSource[];
|
||||
};
|
||||
|
||||
function logReferenceDebug(
|
||||
event: string,
|
||||
threadId: string,
|
||||
payload: Record<string, unknown>,
|
||||
) {
|
||||
console.info(`[RAG引用调试] ${event}`, payload);
|
||||
void apiFetch(`${getBackendBaseURL()}/api/threads/reference-debug-log`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
event,
|
||||
thread_id: threadId,
|
||||
payload,
|
||||
}),
|
||||
}).catch(() => {
|
||||
// Debug logging must never affect the chat flow.
|
||||
});
|
||||
}
|
||||
|
||||
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
children,
|
||||
threadId,
|
||||
}) => {
|
||||
const { thread } = useThread();
|
||||
const pathname = usePathname();
|
||||
const threadIdRef = useRef(threadId);
|
||||
const artifactsCountRef = useRef(0);
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
const latestReferenceBatchIdRef = useRef<string | null>(null);
|
||||
const manualReferenceBatchRef = useRef(false);
|
||||
const referenceDebugSignatureRef = useRef<string>("");
|
||||
const [sideTab, setSideTab] = useState("document");
|
||||
const [referencesOpen, setReferencesOpen] = useState(true);
|
||||
const [activeReferenceBatchId, setActiveReferenceBatchId] = useState<string | null>(null);
|
||||
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
||||
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
||||
const [settings] = useLocalSettings();
|
||||
const ragModeEnabled =
|
||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
const { skills } = useSkills();
|
||||
|
||||
const {
|
||||
artifacts,
|
||||
open: artifactsOpen,
|
||||
setOpen: setArtifactsOpen,
|
||||
setArtifacts,
|
||||
select: selectArtifact,
|
||||
deselect,
|
||||
selectedArtifact,
|
||||
} = useArtifacts();
|
||||
|
||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[ragModeEnabled, skills],
|
||||
);
|
||||
const fallbackReferenceBatches = useMemo(() => {
|
||||
if (!ragModeEnabled) {
|
||||
return [];
|
||||
}
|
||||
return buildTurnReferenceBatches(
|
||||
thread.values.messages ?? [],
|
||||
skillDisplayMap,
|
||||
thread.isLoading,
|
||||
);
|
||||
}, [ragModeEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
|
||||
useEffect(() => {
|
||||
if (!ragModeEnabled) {
|
||||
setBackendReferenceBatches([]);
|
||||
return;
|
||||
}
|
||||
if (thread.isLoading) {
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
logReferenceDebug("开始拉取后端参考文献", threadId, {
|
||||
messageCount: thread.values.messages?.length ?? 0,
|
||||
});
|
||||
apiFetch(
|
||||
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/reference-batches`,
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load reference batches: ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as { batches?: BackendReferenceBatch[] };
|
||||
})
|
||||
.then((data) => {
|
||||
const batches = data.batches ?? [];
|
||||
logReferenceDebug("后端参考文献返回", threadId, {
|
||||
batchCount: batches.length,
|
||||
sourceCounts: batches.map((batch) => batch.sources?.length ?? 0),
|
||||
batchIds: batches.map((batch) => batch.id),
|
||||
});
|
||||
setBackendReferenceBatches(batches);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) {
|
||||
console.warn("Failed to load backend reference batches", error);
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [ragModeEnabled, thread.isLoading, threadId, thread.values.messages]);
|
||||
const adaptedBackendReferenceBatches = useMemo(
|
||||
() =>
|
||||
adaptBackendReferenceBatches(
|
||||
backendReferenceBatches,
|
||||
skillDisplayMap,
|
||||
),
|
||||
[backendReferenceBatches, skillDisplayMap],
|
||||
);
|
||||
const referenceBatches = useMemo(
|
||||
() => {
|
||||
if (adaptedBackendReferenceBatches.length > 0) {
|
||||
return adaptedBackendReferenceBatches;
|
||||
}
|
||||
return fallbackReferenceBatches;
|
||||
},
|
||||
[adaptedBackendReferenceBatches, fallbackReferenceBatches],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!ragModeEnabled) {
|
||||
return;
|
||||
}
|
||||
const signature = JSON.stringify({
|
||||
backend: backendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||
adaptedBackend: adaptedBackendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||
fallback: fallbackReferenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||
final: referenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||
});
|
||||
if (referenceDebugSignatureRef.current === signature) {
|
||||
return;
|
||||
}
|
||||
referenceDebugSignatureRef.current = signature;
|
||||
logReferenceDebug("前端参考文献来源选择", threadId, {
|
||||
using: backendReferenceBatches.length > 0 ? "后端接口" : "前端兜底",
|
||||
backendBatchCount: backendReferenceBatches.length,
|
||||
backendSourceCounts: backendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||
adaptedBackendSourceCounts: adaptedBackendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||
fallbackBatchCount: fallbackReferenceBatches.length,
|
||||
fallbackSourceCounts: fallbackReferenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||
finalBatchCount: referenceBatches.length,
|
||||
finalSourceCounts: referenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||
backendBatches: debugReferenceBatches(backendReferenceBatches),
|
||||
adaptedBackendBatches: debugReferenceBatches(adaptedBackendReferenceBatches),
|
||||
fallbackBatches: debugReferenceBatches(fallbackReferenceBatches),
|
||||
finalBatches: debugReferenceBatches(referenceBatches),
|
||||
});
|
||||
}, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, ragModeEnabled, referenceBatches, threadId]);
|
||||
const latestReferenceBatch =
|
||||
referenceBatches.length > 0
|
||||
? referenceBatches[referenceBatches.length - 1]!
|
||||
: null;
|
||||
const activeReferenceBatch =
|
||||
referenceBatches.find((batch) => batch.id === activeReferenceBatchId) ??
|
||||
latestReferenceBatch;
|
||||
const referenceSources = activeReferenceBatch?.sources ?? [];
|
||||
const hasCitationReferences = referenceSources.length > 0;
|
||||
useEffect(() => {
|
||||
if (!ragModeEnabled || !activeReferenceBatch) {
|
||||
return;
|
||||
}
|
||||
logReferenceDebug("frontend-active-reference-batch-payload", threadId, {
|
||||
activeBatchId: activeReferenceBatch.id,
|
||||
assistantMessageId: activeReferenceBatch.assistant_message_id,
|
||||
sourceCount: activeReferenceBatch.sources.length,
|
||||
sources: activeReferenceBatch.sources.map(debugReferenceSource),
|
||||
});
|
||||
}, [activeReferenceBatch, ragModeEnabled, threadId]);
|
||||
useEffect(() => {
|
||||
if (hasCitationReferences) {
|
||||
setReferencesOpen(true);
|
||||
}
|
||||
}, [hasCitationReferences]);
|
||||
useEffect(() => {
|
||||
if (!latestReferenceBatch) {
|
||||
setActiveReferenceBatchId(null);
|
||||
latestReferenceBatchIdRef.current = null;
|
||||
manualReferenceBatchRef.current = false;
|
||||
return;
|
||||
}
|
||||
const previousLatestId = latestReferenceBatchIdRef.current;
|
||||
latestReferenceBatchIdRef.current = latestReferenceBatch.id;
|
||||
setActiveReferenceBatchId((current) =>
|
||||
current &&
|
||||
referenceBatches.some((batch) => batch.id === current) &&
|
||||
manualReferenceBatchRef.current &&
|
||||
current !== previousLatestId
|
||||
? current
|
||||
: latestReferenceBatch.id,
|
||||
);
|
||||
}, [latestReferenceBatch?.id, referenceBatches]);
|
||||
useEffect(() => {
|
||||
const handleShowReference = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ batchId?: string; referenceId?: string }>).detail;
|
||||
if (detail?.batchId) {
|
||||
manualReferenceBatchRef.current = true;
|
||||
setActiveReferenceBatchId(detail.batchId);
|
||||
}
|
||||
setPinnedReferenceId(null);
|
||||
window.setTimeout(() => {
|
||||
setPinnedReferenceId(detail?.referenceId ?? null);
|
||||
}, 0);
|
||||
setReferencesOpen(true);
|
||||
setSideTab("references");
|
||||
};
|
||||
window.addEventListener(SHOW_REFERENCE_EVENT, handleShowReference);
|
||||
return () => {
|
||||
window.removeEventListener(SHOW_REFERENCE_EVENT, handleShowReference);
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (threadIdRef.current !== threadId) {
|
||||
threadIdRef.current = threadId;
|
||||
artifactsCountRef.current = 0;
|
||||
deselect();
|
||||
setReferencesOpen(false);
|
||||
setActiveReferenceBatchId(null);
|
||||
setPinnedReferenceId(null);
|
||||
setBackendReferenceBatches([]);
|
||||
latestReferenceBatchIdRef.current = null;
|
||||
manualReferenceBatchRef.current = false;
|
||||
referenceDebugSignatureRef.current = "";
|
||||
}
|
||||
|
||||
// Update artifacts from the current thread
|
||||
const nextArtifacts = thread.values.artifacts ?? [];
|
||||
const hadArtifacts = artifactsCountRef.current > 0;
|
||||
const hasNewArtifact = nextArtifacts.length > artifactsCountRef.current;
|
||||
artifactsCountRef.current = nextArtifacts.length;
|
||||
setArtifacts(nextArtifacts);
|
||||
if ((hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) && !selectedArtifact) {
|
||||
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
}
|
||||
|
||||
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
|
||||
// if (
|
||||
// selectedArtifact &&
|
||||
// !thread.values.artifacts?.includes(selectedArtifact)
|
||||
// ) {
|
||||
// deselect();
|
||||
// }
|
||||
|
||||
if (
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
||||
autoSelectFirstArtifact
|
||||
) {
|
||||
if (thread?.values?.artifacts?.length > 0) {
|
||||
setAutoSelectFirstArtifact(false);
|
||||
selectArtifact(thread.values.artifacts[0]!);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
threadId,
|
||||
autoSelectFirstArtifact,
|
||||
deselect,
|
||||
selectArtifact,
|
||||
selectedArtifact,
|
||||
setArtifacts,
|
||||
thread.values.artifacts,
|
||||
]);
|
||||
|
||||
const artifactPanelOpen = useMemo(() => {
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
|
||||
return artifactsOpen && artifacts?.length > 0;
|
||||
}
|
||||
return artifactsOpen;
|
||||
}, [artifactsOpen, artifacts]);
|
||||
const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen);
|
||||
const showTabs = !!selectedArtifact && hasCitationReferences;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedArtifact) {
|
||||
setSideTab("document");
|
||||
} else if (hasCitationReferences) {
|
||||
setSideTab("references");
|
||||
}
|
||||
}, [hasCitationReferences, selectedArtifact]);
|
||||
|
||||
const resizableIdBase = useMemo(() => {
|
||||
const raw = pathname
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return raw.length > 0 ? raw : `thread-${threadId}`;
|
||||
}, [pathname, threadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (layoutRef.current) {
|
||||
if (sidePanelOpen) {
|
||||
layoutRef.current.setLayout(OPEN_MODE);
|
||||
} else {
|
||||
layoutRef.current.setLayout(CLOSE_MODE);
|
||||
}
|
||||
}
|
||||
}, [sidePanelOpen]);
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
id={`${resizableIdBase}-panels`}
|
||||
orientation="horizontal"
|
||||
defaultLayout={{ chat: 100, artifacts: 0 }}
|
||||
groupRef={layoutRef}
|
||||
>
|
||||
<ResizablePanel className="relative" defaultSize={100} id="chat">
|
||||
{children}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
id={`${resizableIdBase}-separator`}
|
||||
className={cn(
|
||||
"opacity-33 hover:opacity-100",
|
||||
!sidePanelOpen && "pointer-events-none opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ResizablePanel
|
||||
className={cn(
|
||||
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
|
||||
!sidePanelOpen && "opacity-0",
|
||||
)}
|
||||
id="artifacts"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
|
||||
sidePanelOpen ? "translate-x-0" : "translate-x-full",
|
||||
)}
|
||||
>
|
||||
{showTabs ? (
|
||||
<Tabs value={sideTab} onValueChange={setSideTab} className="flex size-full flex-col overflow-hidden">
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-2">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="document">文档</TabsTrigger>
|
||||
<TabsTrigger value="references">参考文献</TabsTrigger>
|
||||
</TabsList>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setReferencesOpen(false);
|
||||
setArtifactsOpen(false);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
|
||||
<ArtifactFileDetail
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
|
||||
<ReferencePanel
|
||||
highlightedReferenceId={pinnedReferenceId}
|
||||
sources={referenceSources}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : selectedArtifact ? (
|
||||
<ArtifactFileDetail
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
/>
|
||||
) : hasCitationReferences ? (
|
||||
<div className="relative size-full overflow-hidden">
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setReferencesOpen(false);
|
||||
setArtifactsOpen(false);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<ReferencePanel
|
||||
highlightedReferenceId={pinnedReferenceId}
|
||||
sources={referenceSources}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex size-full justify-center">
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setArtifactsOpen(false);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{thread.values.artifacts?.length === 0 ? (
|
||||
<ConversationEmptyState
|
||||
icon={<FilesIcon />}
|
||||
title="No artifact selected"
|
||||
description="Select an artifact to view its details"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
|
||||
<header className="shrink-0">
|
||||
<h2 className="text-lg font-medium">Artifacts</h2>
|
||||
</header>
|
||||
<main className="min-h-0 grow">
|
||||
<ArtifactFileList
|
||||
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||
files={thread.values.artifacts ?? []}
|
||||
threadId={threadId}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
};
|
||||
|
||||
function ReferencePanel({
|
||||
highlightedReferenceId,
|
||||
sources,
|
||||
}: {
|
||||
highlightedReferenceId?: string | null;
|
||||
sources: Parameters<typeof SkillDisplaySources>[0]["sources"];
|
||||
}) {
|
||||
const orderedSources = useMemo(() => {
|
||||
if (!highlightedReferenceId) {
|
||||
return sources;
|
||||
}
|
||||
const index = Number(highlightedReferenceId);
|
||||
if (!Number.isFinite(index)) {
|
||||
return sources;
|
||||
}
|
||||
const selected = sources.find((source) => source.index === index);
|
||||
if (!selected) {
|
||||
return sources;
|
||||
}
|
||||
return [selected, ...sources.filter((source) => source.index !== index)];
|
||||
}, [highlightedReferenceId, sources]);
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col overflow-hidden">
|
||||
<header className="shrink-0 px-1 pb-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h2 className="text-foreground text-base font-semibold tracking-tight">
|
||||
参考文献
|
||||
</h2>
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{sources.length} 条
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs leading-relaxed">
|
||||
点击编号或卡片可查看引用详情。
|
||||
</p>
|
||||
</header>
|
||||
<main className="min-h-0 flex-1 overflow-y-auto px-1 pt-1 pb-6">
|
||||
<SkillDisplaySources
|
||||
highlightedReferenceId={highlightedReferenceId}
|
||||
sources={orderedSources}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function debugReferenceBatches(batches: BackendReferenceBatch[]) {
|
||||
return batches.map((batch) => ({
|
||||
id: batch.id,
|
||||
assistantMessageId: batch.assistant_message_id,
|
||||
sourceCount: batch.sources?.length ?? 0,
|
||||
sources: (batch.sources ?? []).map(debugReferenceSource),
|
||||
}));
|
||||
}
|
||||
|
||||
function debugReferenceSource(source: BackendReferenceSource) {
|
||||
const raw =
|
||||
source.raw && typeof source.raw === "object" && !Array.isArray(source.raw)
|
||||
? (source.raw as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
index: source.index,
|
||||
id: source.id,
|
||||
skillName: source.skillName,
|
||||
mode: source.mode,
|
||||
title: source.title,
|
||||
source: source.source,
|
||||
author: source.author,
|
||||
time: source.time,
|
||||
url: source.url,
|
||||
snippet: source.snippet,
|
||||
content: source.content,
|
||||
rawIdCandidates: {
|
||||
recUuid: raw.recUuid,
|
||||
rec_uuid: raw.rec_uuid,
|
||||
uuid: raw.uuid,
|
||||
docId: raw.docId,
|
||||
doc_id: raw.doc_id,
|
||||
id: raw.id,
|
||||
url: raw.url,
|
||||
},
|
||||
raw: source.raw,
|
||||
};
|
||||
}
|
||||
|
||||
function adaptBackendReferenceBatches(
|
||||
backendBatches: BackendReferenceBatch[],
|
||||
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
|
||||
): BackendReferenceBatch[] {
|
||||
if (backendBatches.length === 0) {
|
||||
return backendBatches;
|
||||
}
|
||||
return backendBatches.map((backendBatch) => {
|
||||
return {
|
||||
...backendBatch,
|
||||
sources: backendBatch.sources.map((backendSource) => {
|
||||
const configuredSource = adaptBackendSourceWithSkillDisplay(
|
||||
backendSource,
|
||||
skillDisplayMap,
|
||||
);
|
||||
if (!configuredSource) {
|
||||
return backendSource;
|
||||
}
|
||||
return {
|
||||
...backendSource,
|
||||
...configuredSource,
|
||||
index: backendSource.index,
|
||||
mode:
|
||||
backendSource.mode !== "citation"
|
||||
? backendSource.mode
|
||||
: configuredSource.mode || backendSource.mode,
|
||||
raw: backendSource.raw ?? configuredSource.raw,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function adaptBackendSourceWithSkillDisplay(
|
||||
source: BackendReferenceSource,
|
||||
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
|
||||
): BackendReferenceSource | null {
|
||||
const raw = source.raw;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return null;
|
||||
}
|
||||
const rawItem = raw as Record<string, unknown>;
|
||||
const match = findBackendSourceDisplay(source, skillDisplayMap);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const normalized = normalizeDisplaySources(
|
||||
buildPayloadAtPath(match.display.resultPath || "results", [rawItem]),
|
||||
match.display,
|
||||
)[0];
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...source,
|
||||
...normalized,
|
||||
skillName: match.skillName,
|
||||
mode: match.display.mode,
|
||||
tableColumns: match.display.tableColumns,
|
||||
index: source.index,
|
||||
raw: source.raw,
|
||||
};
|
||||
}
|
||||
|
||||
function findBackendSourceDisplay(
|
||||
source: BackendReferenceSource,
|
||||
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
|
||||
) {
|
||||
const direct = skillDisplayMap.get(source.skillName);
|
||||
if (direct) {
|
||||
return { skillName: source.skillName, display: direct };
|
||||
}
|
||||
const normalizedSource = normalizeSkillName(source.skillName);
|
||||
for (const [skillName, display] of skillDisplayMap) {
|
||||
const normalizedSkill = normalizeSkillName(skillName);
|
||||
if (
|
||||
normalizedSource === normalizedSkill ||
|
||||
normalizedSource.endsWith(normalizedSkill) ||
|
||||
normalizedSource.includes(normalizedSkill)
|
||||
) {
|
||||
return { skillName, display };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeSkillName(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
function buildPayloadAtPath(path: string, items: Record<string, unknown>[]) {
|
||||
const parts = path.split(".").map((part) => part.trim()).filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
return items;
|
||||
}
|
||||
const root: Record<string, unknown> = {};
|
||||
let cursor = root;
|
||||
for (let index = 0; index < parts.length; index++) {
|
||||
const part = parts[index]!;
|
||||
if (index === parts.length - 1) {
|
||||
cursor[part] = items;
|
||||
} else {
|
||||
const next: Record<string, unknown> = {};
|
||||
cursor[part] = next;
|
||||
cursor = next;
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function findPreviousHumanMessageId(
|
||||
messages: Message[],
|
||||
beforeMessageId?: string,
|
||||
): string | undefined {
|
||||
const beforeIndex =
|
||||
beforeMessageId === undefined
|
||||
? messages.length
|
||||
: messages.findIndex((message) => message.id === beforeMessageId);
|
||||
const end = beforeIndex < 0 ? messages.length : beforeIndex;
|
||||
for (let index = end - 1; index >= 0; index--) {
|
||||
const message = messages[index];
|
||||
if (message?.type === "human") {
|
||||
return message.id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildTurnReferenceBatches(
|
||||
messages: Message[],
|
||||
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
|
||||
isLoading: boolean,
|
||||
): BackendReferenceBatch[] {
|
||||
const batches: BackendReferenceBatch[] = [];
|
||||
for (let index = 0; index < messages.length; index++) {
|
||||
const human = messages[index];
|
||||
if (human?.type !== "human" || !human.id) {
|
||||
continue;
|
||||
}
|
||||
const nextHumanIndex = findNextHumanIndex(messages, index + 1);
|
||||
if (nextHumanIndex === -1 && isLoading) {
|
||||
continue;
|
||||
}
|
||||
const endIndex = nextHumanIndex === -1 ? messages.length : nextHumanIndex;
|
||||
const assistant = findLastAssistantAnswer(messages, index + 1, endIndex);
|
||||
if (!assistant?.id) {
|
||||
continue;
|
||||
}
|
||||
const sources = collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||||
afterMessageId: human.id,
|
||||
beforeMessageId:
|
||||
nextHumanIndex === -1 ? undefined : messages[nextHumanIndex]?.id,
|
||||
includeBeforeMessage: false,
|
||||
}).filter((source) => source.mode !== "none");
|
||||
if (sources.length > 0) {
|
||||
batches.push({
|
||||
id: assistant.id,
|
||||
assistant_message_id: assistant.id,
|
||||
sources,
|
||||
});
|
||||
}
|
||||
}
|
||||
return batches;
|
||||
}
|
||||
|
||||
function findNextHumanIndex(messages: Message[], start: number): number {
|
||||
for (let index = start; index < messages.length; index++) {
|
||||
if (messages[index]?.type === "human") {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findLastAssistantAnswer(
|
||||
messages: Message[],
|
||||
start: number,
|
||||
end: number,
|
||||
): Message | null {
|
||||
for (let index = end - 1; index >= start; index--) {
|
||||
const message = messages[index];
|
||||
if (
|
||||
message?.type === "ai" &&
|
||||
message.id &&
|
||||
hasAssistantAnswerContent(message)
|
||||
) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasAssistantAnswerContent(message: Message): boolean {
|
||||
const content = message.content;
|
||||
if (typeof content === "string") {
|
||||
return content.trim().length > 0;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return false;
|
||||
}
|
||||
return content.some((part) => {
|
||||
if (!part || typeof part !== "object") {
|
||||
return false;
|
||||
}
|
||||
const block = part as Record<string, unknown>;
|
||||
return block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export { ChatBox };
|
||||
@@ -2,12 +2,19 @@
|
||||
|
||||
import { useParams, usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
import { uuid } from "@/core/utils/uuid";
|
||||
|
||||
export function useThreadChat() {
|
||||
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
|
||||
const pathname = usePathname();
|
||||
// ``location.key`` is regenerated on every react-router navigation, even when
|
||||
// the resolved pathname is identical. We depend on it so that clicking
|
||||
// "新对话" while already at /chats/new still triggers a fresh thread reset
|
||||
// (otherwise navigate-to-same-path is silently a no-op and the in-flight
|
||||
// conversation would persist on screen).
|
||||
const locationKey = useLocation().key;
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const [threadId, setThreadId] = useState(() => {
|
||||
@@ -37,7 +44,7 @@ export function useThreadChat() {
|
||||
}
|
||||
setIsNewThread(false);
|
||||
setThreadId(threadIdFromPath);
|
||||
}, [pathname, threadIdFromPath]);
|
||||
}, [pathname, threadIdFromPath, locationKey]);
|
||||
const isMock = searchParams.get("mock") === "true";
|
||||
return { threadId, setThreadId, isNewThread, setIsNewThread, isMock };
|
||||
}
|
||||
@@ -1,82 +1,77 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function CitationLink({
|
||||
href,
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<"a">) {
|
||||
const domain = extractDomain(href ?? "");
|
||||
|
||||
// Priority: children > domain
|
||||
const childrenText =
|
||||
typeof children === "string"
|
||||
? children.replace(/^citation:\s*/i, "")
|
||||
: null;
|
||||
const isGenericText = childrenText === "Source" || childrenText === "来源";
|
||||
const displayText = (!isGenericText && childrenText) ?? domain;
|
||||
|
||||
return (
|
||||
<HoverCard closeDelay={0} openDelay={0}>
|
||||
<HoverCardTrigger asChild>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
{...props}
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="hover:bg-secondary/80 mx-0.5 cursor-pointer gap-1 rounded-full px-2 py-0.5 text-xs font-normal"
|
||||
>
|
||||
{displayText}
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</Badge>
|
||||
</a>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className={cn("relative w-80 p-0", props.className)}>
|
||||
<div className="p-3">
|
||||
<div className="space-y-1">
|
||||
{displayText && (
|
||||
<h4 className="truncate text-sm leading-tight font-medium">
|
||||
{displayText}
|
||||
</h4>
|
||||
)}
|
||||
{href && (
|
||||
<p className="text-muted-foreground truncate text-xs break-all">
|
||||
{href}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary mt-2 inline-flex items-center gap-1 text-xs hover:underline"
|
||||
>
|
||||
Visit source
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
function extractDomain(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./i, "");
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
|
||||
|
||||
export function CitationLink({
|
||||
href,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<"a">) {
|
||||
const childrenText = normalizeCitationText(children);
|
||||
const displayText = childrenText ?? "";
|
||||
const referenceId = href?.startsWith("#reference-")
|
||||
? href.slice("#reference-".length)
|
||||
: null;
|
||||
|
||||
if (referenceId) {
|
||||
const badgeLabel = displayText.replace(/^\[|\]$/g, "") || referenceId;
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
title={`查看参考文献 ${badgeLabel}`}
|
||||
className={cn(
|
||||
"border-primary/30 bg-primary/10 text-primary hover:border-primary/60 hover:bg-primary/20 focus-visible:ring-primary/30 mx-0.5 inline-flex h-[18px] min-w-[18px] -translate-y-[1px] items-center justify-center rounded-full border px-1 text-[10.5px] font-semibold tabular-nums no-underline transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
className,
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
const batchId =
|
||||
event.currentTarget.closest<HTMLElement>("[data-reference-batch-id]")
|
||||
?.dataset.referenceBatchId;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SHOW_REFERENCE_EVENT, {
|
||||
detail: { batchId, referenceId },
|
||||
}),
|
||||
);
|
||||
const scroll = () => {
|
||||
const target = document.getElementById(`reference-${referenceId}`);
|
||||
target?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
};
|
||||
window.setTimeout(scroll, 240);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{badgeLabel}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={href?.startsWith("http") ? "_blank" : undefined}
|
||||
rel={href?.startsWith("http") ? "noopener noreferrer" : undefined}
|
||||
className={cn(
|
||||
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{displayText || children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCitationText(children: ComponentProps<"a">["children"]) {
|
||||
if (typeof children !== "string") {
|
||||
return null;
|
||||
}
|
||||
let text = children.trim();
|
||||
while (/^citation:\s*/i.test(text)) {
|
||||
text = text.replace(/^citation:\s*/i, "");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
PencilIcon,
|
||||
Settings2Icon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
useCreateCustomPrompt,
|
||||
useCustomPrompts,
|
||||
useDeleteCustomPrompt,
|
||||
useUpdateCustomPrompt,
|
||||
} from "@/core/custom-prompts";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Bar above the chat input showing the user's saved prompts as centered,
|
||||
* clickable chips. When there are too many to fit, they become a horizontal
|
||||
* carousel (arrows appear). A gear pinned to the far right opens the manage
|
||||
* dialog. Prompts are bound to the account (server-backed), and this coexists
|
||||
* with the admin-configured global prompt prefix.
|
||||
*/
|
||||
export function CustomPromptBar({
|
||||
onInsert,
|
||||
className,
|
||||
}: {
|
||||
onInsert: (content: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const prompts = useCustomPrompts();
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canLeft, setCanLeft] = useState(false);
|
||||
const [canRight, setCanRight] = useState(false);
|
||||
|
||||
const updateArrows = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
setCanLeft(el.scrollLeft > 4);
|
||||
setCanRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
updateArrows();
|
||||
window.addEventListener("resize", updateArrows);
|
||||
return () => window.removeEventListener("resize", updateArrows);
|
||||
}, [updateArrows, prompts]);
|
||||
|
||||
const scrollByDir = (dir: 1 | -1) => {
|
||||
scrollRef.current?.scrollBy({ left: dir * 220, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const overflowing = canLeft || canRight;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("flex items-center gap-1", className)}>
|
||||
{prompts.length > 0 && (
|
||||
<>
|
||||
{overflowing && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground h-7 w-7 shrink-0 rounded-full"
|
||||
disabled={!canLeft}
|
||||
onClick={() => scrollByDir(-1)}
|
||||
aria-label="上一组"
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={updateArrows}
|
||||
className="flex-1 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
<div className="mx-auto flex w-max items-center gap-1.5 px-0.5 py-0.5">
|
||||
{prompts.map((prompt) => (
|
||||
<button
|
||||
key={prompt.id}
|
||||
type="button"
|
||||
onClick={() => onInsert(prompt.content)}
|
||||
title={prompt.content}
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 text-foreground/80 hover:bg-muted hover:text-foreground hover:border-border max-w-[200px] shrink-0 truncate rounded-full border px-3 py-1 text-xs font-medium shadow-sm transition-colors",
|
||||
"dark:border-slate-600/70 dark:bg-slate-700/40 dark:text-slate-200 dark:hover:bg-slate-700",
|
||||
)}
|
||||
>
|
||||
{prompt.title || prompt.content || "未命名提示词"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{overflowing && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground h-7 w-7 shrink-0 rounded-full"
|
||||
disabled={!canRight}
|
||||
onClick={() => scrollByDir(1)}
|
||||
aria-label="下一组"
|
||||
>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{prompts.length === 0 && <div className="flex-1" />}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-muted/70 h-7 w-7 shrink-0 rounded-full"
|
||||
title="管理常用提示词"
|
||||
aria-label="管理常用提示词"
|
||||
onClick={() => setManageOpen(true)}
|
||||
>
|
||||
<Settings2Icon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ManagePromptsDialog open={manageOpen} onOpenChange={setManageOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ManagePromptsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const prompts = useCustomPrompts();
|
||||
const createPrompt = useCreateCustomPrompt();
|
||||
const updatePrompt = useUpdateCustomPrompt();
|
||||
const deletePrompt = useDeleteCustomPrompt();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
|
||||
const saving = createPrompt.isPending || updatePrompt.isPending;
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingId(null);
|
||||
setTitle("");
|
||||
setContent("");
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmedTitle = title.trim();
|
||||
if (!content.trim()) {
|
||||
toast.error("提示词内容不能为空");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (editingId) {
|
||||
await updatePrompt.mutateAsync({
|
||||
id: editingId,
|
||||
input: { title: trimmedTitle, content },
|
||||
});
|
||||
toast.success("提示词已更新");
|
||||
} else {
|
||||
await createPrompt.mutateAsync({ title: trimmedTitle, content });
|
||||
toast.success("提示词已添加");
|
||||
}
|
||||
resetForm();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (id: string) => {
|
||||
const target = prompts.find((p) => p.id === id);
|
||||
if (!target) return;
|
||||
setEditingId(id);
|
||||
setTitle(target.title);
|
||||
setContent(target.content);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deletePrompt.mutateAsync(id);
|
||||
if (editingId === id) resetForm();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) resetForm();
|
||||
onOpenChange(next);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>管理常用提示词</DialogTitle>
|
||||
<DialogDescription>
|
||||
保存常用的提示词,保存后会以小标签形式显示在输入框上方,点击即可填入。提示词与你的账号绑定,可跨设备使用。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="名称(可选,例如:周报总结)"
|
||||
/>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="提示词内容"
|
||||
className="min-h-28"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
{editingId && (
|
||||
<Button variant="outline" size="sm" onClick={resetForm} disabled={saving}>
|
||||
取消编辑
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={handleSubmit} disabled={saving}>
|
||||
{editingId ? "保存修改" : "添加提示词"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{prompts.length === 0 ? (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
暂无常用提示词
|
||||
</p>
|
||||
) : (
|
||||
prompts.map((prompt) => (
|
||||
<div
|
||||
key={prompt.id}
|
||||
className="flex items-start justify-between gap-2 rounded-md border p-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{prompt.title || "未命名提示词"}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs whitespace-pre-wrap">
|
||||
{prompt.content}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleEdit(prompt.id)}
|
||||
title="编辑"
|
||||
>
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive h-8 w-8"
|
||||
onClick={() => void handleDelete(prompt.id)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
BrainCircuitIcon,
|
||||
BookMarkedIcon,
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
@@ -86,8 +87,14 @@ import {
|
||||
} from "../ui/dropdown-menu";
|
||||
|
||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||
import {
|
||||
isThreadRagModeLocked,
|
||||
lockThreadRagMode,
|
||||
useLocalSettings,
|
||||
} from "@/core/settings";
|
||||
|
||||
|
||||
import { CustomPromptBar } from "./custom-prompt-menu";
|
||||
import { useThread } from "./messages/context";
|
||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||
import { Tooltip } from "./tooltip";
|
||||
@@ -177,6 +184,7 @@ export function InputBox({
|
||||
};
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [localSettings, setLocalSettings] = useLocalSettings();
|
||||
const searchParams = useSearchParams();
|
||||
const [modelDialogOpen, setModelDialogOpen] = useState(false);
|
||||
const { models } = useModels();
|
||||
@@ -297,6 +305,15 @@ export function InputBox({
|
||||
});
|
||||
}, [context, memoryInjectionEnabled, onContextChange]);
|
||||
|
||||
const ragModeLocked = isThreadRagModeLocked(threadId);
|
||||
const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked;
|
||||
const handleRagModeToggle = useCallback(() => {
|
||||
if (ragModeLocked) {
|
||||
return;
|
||||
}
|
||||
setLocalSettings("display", { ragModeEnabled: !ragModeEnabled });
|
||||
}, [ragModeEnabled, ragModeLocked, setLocalSettings]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
if (status === "streaming") {
|
||||
@@ -309,6 +326,9 @@ export function InputBox({
|
||||
setFollowups([]);
|
||||
setFollowupsHidden(false);
|
||||
setFollowupsLoading(false);
|
||||
if (ragModeEnabled) {
|
||||
lockThreadRagMode(threadId);
|
||||
}
|
||||
|
||||
// Guard against submitting before the initial model auto-selection
|
||||
// effect has flushed thread settings to storage/state.
|
||||
@@ -333,8 +353,10 @@ export function InputBox({
|
||||
onSubmit,
|
||||
onStop,
|
||||
resolvedModelName,
|
||||
ragModeEnabled,
|
||||
selectedModel?.supports_thinking,
|
||||
status,
|
||||
threadId,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -538,6 +560,21 @@ export function InputBox({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{isNewThread && (
|
||||
<CustomPromptBar
|
||||
className="px-1"
|
||||
onInsert={(content) => {
|
||||
const current = textInput.value?.trim() ?? "";
|
||||
textInput.setInput(current ? `${current}\n${content}` : content);
|
||||
setTimeout(() => {
|
||||
document
|
||||
.querySelector<HTMLTextAreaElement>("textarea[name='message']")
|
||||
?.focus();
|
||||
}, 0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className={cn(isNewThread && "gemini-gradient-border gemini-shadow")}>
|
||||
<PromptInput
|
||||
className={cn(
|
||||
@@ -622,6 +659,33 @@ export function InputBox({
|
||||
<span className="text-xs font-normal">记忆</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={
|
||||
ragModeEnabled
|
||||
? "参考文献模式已开启:检索类结果会按编号引用展示"
|
||||
: "参考文献模式已关闭:点击开启"
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
type="button"
|
||||
aria-pressed={ragModeEnabled}
|
||||
disabled={ragModeLocked || disabled}
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
ragModeLocked && "cursor-not-allowed opacity-80",
|
||||
ragModeEnabled
|
||||
? "border border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-200"
|
||||
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
)}
|
||||
onClick={handleRagModeToggle}
|
||||
>
|
||||
{ragModeEnabled && (
|
||||
<CheckIcon className="size-3 text-emerald-700 dark:text-emerald-200" />
|
||||
)}
|
||||
<BookMarkedIcon className="size-3" />
|
||||
<span className="text-xs font-normal">参考文献</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
<PromptInputActionMenu>
|
||||
<ModeHoverGuide
|
||||
mode={
|
||||
@@ -997,6 +1061,7 @@ export function InputBox({
|
||||
)}
|
||||
</PromptInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent>
|
||||
|
||||
@@ -36,10 +36,13 @@ export function MarkdownContent({
|
||||
}: MarkdownContentProps) {
|
||||
const components = useMemo(() => {
|
||||
return {
|
||||
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
if (typeof props.children === "string") {
|
||||
const match = /^citation:(.+)$/.exec(props.children);
|
||||
if (match) {
|
||||
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
if (props.href?.startsWith("#reference-")) {
|
||||
return <CitationLink {...props} />;
|
||||
}
|
||||
if (typeof props.children === "string") {
|
||||
const match = /^citation:(.+)$/.exec(props.children);
|
||||
if (match) {
|
||||
const [, text] = match;
|
||||
return <CitationLink {...props}>{text}</CitationLink>;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,17 @@ import {
|
||||
findToolCallResult,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
findDisplayConfigForTool,
|
||||
isDisplaySamplePayload,
|
||||
normalizeDefaultDisplaySources,
|
||||
shouldUseDefaultCitationMode,
|
||||
type RuntimeDisplaySource,
|
||||
} from "@/core/skill-display/runtime";
|
||||
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
|
||||
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -54,6 +65,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useArtifactsOptional } from "../artifacts";
|
||||
import { FlipDisplay } from "../flip-display";
|
||||
import { Tooltip } from "../tooltip";
|
||||
import { SourceCard } from "../skill-display-sources";
|
||||
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
|
||||
@@ -74,6 +86,12 @@ export function MessageGroup({
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||
);
|
||||
const steps = useMemo(() => convertToSteps(messages), [messages]);
|
||||
const [settings] = useLocalSettings();
|
||||
const { skills } = useSkills();
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[settings.display.ragModeEnabled, skills],
|
||||
);
|
||||
const lastToolCallStep = useMemo(() => {
|
||||
const filteredSteps = steps.filter((step) => step.type === "toolCall");
|
||||
return filteredSteps[filteredSteps.length - 1];
|
||||
@@ -142,7 +160,13 @@ export function MessageGroup({
|
||||
}
|
||||
></ChainOfThoughtStep>
|
||||
) : (
|
||||
<ToolCall key={step.id} {...step} isLoading={isLoading} />
|
||||
<ToolCall
|
||||
key={step.id}
|
||||
{...step}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
ragModeEnabled={settings.display.ragModeEnabled}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{lastToolCallStep && (
|
||||
@@ -152,6 +176,8 @@ export function MessageGroup({
|
||||
{...lastToolCallStep}
|
||||
isLast={true}
|
||||
isLoading={isLoading}
|
||||
skillDisplayMap={skillDisplayMap}
|
||||
ragModeEnabled={settings.display.ragModeEnabled}
|
||||
/>
|
||||
</FlipDisplay>
|
||||
)}
|
||||
@@ -209,6 +235,8 @@ function ToolCall({
|
||||
result,
|
||||
isLast = false,
|
||||
isLoading = false,
|
||||
skillDisplayMap = new Map(),
|
||||
ragModeEnabled = false,
|
||||
}: {
|
||||
id?: string;
|
||||
messageId?: string;
|
||||
@@ -217,11 +245,56 @@ function ToolCall({
|
||||
result?: string | Record<string, unknown>;
|
||||
isLast?: boolean;
|
||||
isLoading?: boolean;
|
||||
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
|
||||
ragModeEnabled?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const artifacts = useArtifactsOptional();
|
||||
|
||||
const displayMatch =
|
||||
result !== undefined && isDisplaySamplePayload(result)
|
||||
? null
|
||||
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
|
||||
null);
|
||||
const displayItems =
|
||||
displayMatch && result !== undefined
|
||||
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: displayMatch.skillName,
|
||||
mode: displayMatch.display.mode,
|
||||
}))
|
||||
: [];
|
||||
if (displayItems.length > 0) {
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
label={
|
||||
displayMatch?.display.mode === "citation" || !displayMatch
|
||||
? "检索参考来源"
|
||||
: `获取 ${displayItems.length} 条结果`
|
||||
}
|
||||
icon={SearchIcon}
|
||||
>
|
||||
<DisplayResultsList results={displayItems} />
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
|
||||
const searchItems = normalizeSearchResults(result);
|
||||
if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) {
|
||||
const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: name,
|
||||
mode: "citation" as const,
|
||||
}));
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label="检索参考来源" icon={SearchIcon}>
|
||||
<DisplayResultsList results={defaultItems} />
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
}
|
||||
if (name === "web_search" || searchItems.length > 0) {
|
||||
const query = resolveSearchQuery(args, result);
|
||||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||
@@ -544,6 +617,28 @@ function WebSearchResultsList({ results }: { results: SearchResultItem[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="w-full">
|
||||
<CollapsibleTrigger className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs">
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-3.5 transition-transform duration-200",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
<span>{results.length} 条真实返回结果</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2 flex flex-col gap-2">
|
||||
{results.map((item) => (
|
||||
<SourceCard key={`${item.skillName}-${item.index}`} source={item} />
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultCard({ item }: { item: SearchResultItem }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const content = item.snippet ?? item.content;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { FileIcon, Loader2Icon } from "lucide-react";
|
||||
import { ClockIcon, FileIcon, Loader2Icon } from "lucide-react";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { Task, TaskTrigger } from "@/components/ai-elements/task";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
||||
import { useChatAskTimes } from "@/core/chat-timestamps";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
@@ -36,15 +37,20 @@ import {
|
||||
type FileInMessage,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import {
|
||||
applyInlineCitations,
|
||||
type RuntimeDisplaySource,
|
||||
} from "@/core/skill-display/runtime";
|
||||
import { humanMessagePlugins } from "@/core/streamdown";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { CitationLink } from "../citations/citation-link";
|
||||
import { CopyButton } from "../copy-button";
|
||||
|
||||
import { SkillDisplayBlock } from "../skill-display-block";
|
||||
import { ThreadContext } from "./context";
|
||||
import { DownloadWordButton } from "./download-word-button";
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { MarkdownContent, type MarkdownContentProps } from "./markdown-content";
|
||||
import { MessageTokenUsage } from "./message-token-usage";
|
||||
|
||||
/**
|
||||
@@ -76,6 +82,7 @@ export function MessageListItem({
|
||||
isLoading,
|
||||
threadId,
|
||||
tokenUsageEnabled = false,
|
||||
displaySources = [],
|
||||
extraRehypePlugins,
|
||||
}: {
|
||||
className?: string;
|
||||
@@ -83,6 +90,7 @@ export function MessageListItem({
|
||||
isLoading?: boolean;
|
||||
threadId: string;
|
||||
tokenUsageEnabled?: boolean;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
}) {
|
||||
const isHuman = message.type === "human";
|
||||
@@ -101,6 +109,7 @@ export function MessageListItem({
|
||||
isLoading={isLoading}
|
||||
threadId={threadId}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
displaySources={displaySources}
|
||||
extraRehypePlugins={extraRehypePlugins}
|
||||
/>
|
||||
{!isLoading && (
|
||||
@@ -162,6 +171,7 @@ function MessageContent_({
|
||||
isLoading = false,
|
||||
threadId,
|
||||
tokenUsageEnabled = false,
|
||||
displaySources = [],
|
||||
extraRehypePlugins,
|
||||
}: {
|
||||
className?: string;
|
||||
@@ -169,6 +179,7 @@ function MessageContent_({
|
||||
isLoading?: boolean;
|
||||
threadId: string;
|
||||
tokenUsageEnabled?: boolean;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
}) {
|
||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||
@@ -176,6 +187,7 @@ function MessageContent_({
|
||||
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
|
||||
[baseRehypePlugins, extraRehypePlugins],
|
||||
);
|
||||
const askTimes = useChatAskTimes();
|
||||
const isHuman = message.type === "human";
|
||||
const resolveArtifact = useArtifactResolver(threadId);
|
||||
const components = useMemo(
|
||||
@@ -184,6 +196,12 @@ function MessageContent_({
|
||||
<MessageImage {...props} threadId={threadId} maxWidth="90%" />
|
||||
),
|
||||
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
if (href?.startsWith("#reference-")) {
|
||||
return <CitationLink href={href} {...props} />;
|
||||
}
|
||||
if (typeof props.children === "string" && /^citation:/i.test(props.children)) {
|
||||
return <CitationLink href={href} {...props} />;
|
||||
}
|
||||
if (href?.startsWith("/mnt/")) {
|
||||
const url = resolveArtifact(href);
|
||||
return (
|
||||
@@ -201,6 +219,11 @@ function MessageContent_({
|
||||
[threadId, resolveArtifact],
|
||||
);
|
||||
|
||||
const messageTime = formatAskedTime(
|
||||
message.additional_kwargs?.client_ts ??
|
||||
(message.id ? askTimes[message.id] : undefined),
|
||||
);
|
||||
|
||||
const rawContent = extractContentFromMessage(message);
|
||||
const reasoningContent = extractReasoningContentFromMessage(message);
|
||||
|
||||
@@ -220,8 +243,8 @@ function MessageContent_({
|
||||
if (isHuman) {
|
||||
return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : "";
|
||||
}
|
||||
return rawContent ?? "";
|
||||
}, [rawContent, isHuman]);
|
||||
return applyInlineCitations(rawContent ?? "", displaySources);
|
||||
}, [rawContent, isHuman, displaySources]);
|
||||
|
||||
const filesList =
|
||||
files && files.length > 0 ? (
|
||||
@@ -280,6 +303,7 @@ function MessageContent_({
|
||||
{messageResponse}
|
||||
</AIElementMessageContent>
|
||||
)}
|
||||
<MessageTimeStamp time={messageTime} align="right" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -287,22 +311,72 @@ function MessageContent_({
|
||||
return (
|
||||
<AIElementMessageContent className={className}>
|
||||
{filesList}
|
||||
<MarkdownContent
|
||||
content={contentToDisplay}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
|
||||
className="my-3"
|
||||
components={components}
|
||||
/>
|
||||
<div data-reference-batch-id={message.id} className="contents">
|
||||
<AssistantDisplayContent
|
||||
content={contentToDisplay}
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
renderSkillDisplayBlocks={false}
|
||||
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
|
||||
components={components}
|
||||
/>
|
||||
</div>
|
||||
<MessageTokenUsage
|
||||
enabled={tokenUsageEnabled}
|
||||
isLoading={isLoading}
|
||||
message={message}
|
||||
/>
|
||||
{!isLoading && contentToDisplay ? (
|
||||
<MessageTimeStamp time={messageTime} align="left" />
|
||||
) : null}
|
||||
</AIElementMessageContent>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a wall-clock message time as ``YYYY-MM-DD HH:mm`` for display, plus a
|
||||
* full localized string for the hover title. Returns ``null`` when no/invalid
|
||||
* timestamp is present.
|
||||
*/
|
||||
function formatAskedTime(
|
||||
clientTs: unknown,
|
||||
): { label: string; full: string } | null {
|
||||
if (typeof clientTs !== "string" || !clientTs) return null;
|
||||
const date = new Date(clientTs);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
const label =
|
||||
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
||||
` ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
return { label, full: date.toLocaleString() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Small date+time label shown under a message. ``align`` controls the corner:
|
||||
* ``right`` for the user's own question, ``left`` for the model's answer.
|
||||
*/
|
||||
function MessageTimeStamp({
|
||||
time,
|
||||
align = "right",
|
||||
}: {
|
||||
time: { label: string; full: string } | null;
|
||||
align?: "left" | "right";
|
||||
}) {
|
||||
if (!time) return null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-muted-foreground/60 flex items-center gap-1 text-[11px] leading-none tabular-nums",
|
||||
align === "right" ? "justify-end pr-0.5" : "justify-start pl-0.5",
|
||||
)}
|
||||
title={time.full}
|
||||
>
|
||||
<ClockIcon className="size-3 shrink-0" />
|
||||
<span>{time.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension and check helpers
|
||||
*/
|
||||
@@ -470,3 +544,172 @@ function RichFileCard({
|
||||
}
|
||||
|
||||
const MessageContent = memo(MessageContent_);
|
||||
|
||||
function AssistantDisplayContent({
|
||||
content,
|
||||
displaySources,
|
||||
isLoading,
|
||||
renderSkillDisplayBlocks,
|
||||
rehypePlugins,
|
||||
components,
|
||||
}: {
|
||||
content: string;
|
||||
displaySources: RuntimeDisplaySource[];
|
||||
isLoading: boolean;
|
||||
renderSkillDisplayBlocks: boolean;
|
||||
rehypePlugins: MarkdownContentProps["rehypePlugins"];
|
||||
components: MarkdownContentProps["components"];
|
||||
}) {
|
||||
const { parts, placedKeys } = useMemo(
|
||||
() =>
|
||||
splitSkillDisplayPlaceholders(
|
||||
content,
|
||||
displaySources,
|
||||
renderSkillDisplayBlocks,
|
||||
),
|
||||
[content, displaySources, renderSkillDisplayBlocks],
|
||||
);
|
||||
const fallbackBlocks = useMemo(() => {
|
||||
if (!renderSkillDisplayBlocks) {
|
||||
return [];
|
||||
}
|
||||
return groupDisplaySources(displaySources).filter(
|
||||
(group) => group.mode !== "citation" && !placedKeys.has(group.key),
|
||||
);
|
||||
}, [displaySources, placedKeys, renderSkillDisplayBlocks]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) =>
|
||||
part.type === "markdown" ? (
|
||||
<MarkdownContent
|
||||
key={`markdown-${index}`}
|
||||
content={part.content}
|
||||
isLoading={isLoading}
|
||||
rehypePlugins={rehypePlugins}
|
||||
className="my-3"
|
||||
components={components}
|
||||
/>
|
||||
) : (
|
||||
<SkillDisplayBlock
|
||||
key={`display-${index}`}
|
||||
mode={part.mode}
|
||||
sources={part.sources}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{fallbackBlocks.map((group) => (
|
||||
<SkillDisplayBlock
|
||||
key={`fallback-${group.key}`}
|
||||
mode={group.mode}
|
||||
sources={group.sources}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type DisplayPart =
|
||||
| { type: "markdown"; content: string }
|
||||
| {
|
||||
type: "display";
|
||||
key: string;
|
||||
mode: RuntimeDisplaySource["mode"];
|
||||
sources: RuntimeDisplaySource[];
|
||||
};
|
||||
|
||||
function splitSkillDisplayPlaceholders(
|
||||
content: string,
|
||||
displaySources: RuntimeDisplaySource[],
|
||||
renderSkillDisplayBlocks: boolean,
|
||||
): { parts: DisplayPart[]; placedKeys: Set<string> } {
|
||||
const pattern = /<skill-display\s+([^>]*?)\/>/g;
|
||||
const parts: DisplayPart[] = [];
|
||||
const placedKeys = new Set<string>();
|
||||
let cursor = 0;
|
||||
for (const match of content.matchAll(pattern)) {
|
||||
const start = match.index ?? 0;
|
||||
const before = content.slice(cursor, start);
|
||||
if (before.trim()) {
|
||||
parts.push({ type: "markdown", content: before });
|
||||
}
|
||||
const attrs = parseDisplayAttrs(match[1] ?? "");
|
||||
const group = findDisplayGroup(displaySources, attrs.tool, attrs.mode);
|
||||
if (group) {
|
||||
placedKeys.add(group.key);
|
||||
}
|
||||
if (group && renderSkillDisplayBlocks) {
|
||||
parts.push({
|
||||
type: "display",
|
||||
key: group.key,
|
||||
mode: group.mode,
|
||||
sources: group.sources,
|
||||
});
|
||||
}
|
||||
cursor = start + match[0].length;
|
||||
}
|
||||
const rest = content.slice(cursor);
|
||||
if (rest.trim() || parts.length === 0) {
|
||||
parts.push({ type: "markdown", content: rest });
|
||||
}
|
||||
return { parts, placedKeys };
|
||||
}
|
||||
|
||||
function parseDisplayAttrs(raw: string): { tool?: string; mode?: RuntimeDisplaySource["mode"] } {
|
||||
const attrs: Record<string, string> = {};
|
||||
for (const match of raw.matchAll(/([a-zA-Z][\w-]*)="([^"]*)"/g)) {
|
||||
attrs[match[1]!] = match[2]!;
|
||||
}
|
||||
return {
|
||||
tool: attrs.tool || attrs.skill || attrs.name,
|
||||
mode: attrs.mode as RuntimeDisplaySource["mode"] | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function findDisplayGroup(
|
||||
sources: RuntimeDisplaySource[],
|
||||
tool?: string,
|
||||
mode?: RuntimeDisplaySource["mode"],
|
||||
) {
|
||||
return groupDisplaySources(sources).find((group) => {
|
||||
if (mode && group.mode !== mode) {
|
||||
return false;
|
||||
}
|
||||
if (!tool) {
|
||||
return true;
|
||||
}
|
||||
const left = normalizeName(group.skillName);
|
||||
const right = normalizeName(tool);
|
||||
return left === right || left.includes(right) || right.includes(left);
|
||||
});
|
||||
}
|
||||
|
||||
function groupDisplaySources(sources: RuntimeDisplaySource[]) {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
key: string;
|
||||
skillName: string;
|
||||
mode: RuntimeDisplaySource["mode"];
|
||||
sources: RuntimeDisplaySource[];
|
||||
}
|
||||
>();
|
||||
for (const source of sources) {
|
||||
const key = `${source.skillName}:${source.mode}`;
|
||||
const group =
|
||||
groups.get(key) ??
|
||||
{
|
||||
key,
|
||||
skillName: source.skillName,
|
||||
mode: source.mode,
|
||||
sources: [],
|
||||
};
|
||||
group.sources.push(source);
|
||||
groups.set(key, group);
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
function normalizeName(name: string) {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { rememberAskTimes } from "@/core/chat-timestamps";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
@@ -20,6 +22,12 @@ import {
|
||||
hasToolCalls,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
collectRuntimeDisplaySources,
|
||||
} from "@/core/skill-display/runtime";
|
||||
import type { Subtask } from "@/core/tasks";
|
||||
import { useUpdateSubtask } from "@/core/tasks/context";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
@@ -63,7 +71,6 @@ export function MessageList({
|
||||
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||
const rehypePlugins = useMemo(
|
||||
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
|
||||
@@ -72,6 +79,31 @@ export function MessageList({
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
const messages = thread.messages;
|
||||
|
||||
// Stamp a wall-clock time onto every question/answer the first time it is
|
||||
// seen, in a single batched commit. Questions sent this session already carry
|
||||
// an accurate time (see useThreadStream); this also covers assistant answers
|
||||
// and messages loaded from history. First-write-wins keeps the earliest time.
|
||||
useEffect(() => {
|
||||
const now = new Date().toISOString();
|
||||
const entries: Record<string, string> = {};
|
||||
for (const message of messages) {
|
||||
if (message.type !== "human" && message.type !== "ai") continue;
|
||||
if (!message.id || message.id.startsWith("opt-")) continue;
|
||||
if (typeof message.additional_kwargs?.client_ts === "string") continue;
|
||||
entries[message.id] = now;
|
||||
}
|
||||
if (Object.keys(entries).length > 0) rememberAskTimes(entries);
|
||||
}, [messages]);
|
||||
|
||||
const [settings] = useLocalSettings();
|
||||
const ragModeEnabled =
|
||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
const { skills } = useSkills();
|
||||
const skillDisplayMap = useMemo(
|
||||
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||
[ragModeEnabled, skills],
|
||||
);
|
||||
|
||||
// Track which clarification messages are still awaiting an answer.
|
||||
// Pending = clarification tool messages that appear after the last human message
|
||||
// (no user reply has been sent for them yet).
|
||||
@@ -190,6 +222,16 @@ export function MessageList({
|
||||
{groupMessages(messages, (group) => {
|
||||
if (group.type === "human" || group.type === "assistant") {
|
||||
return group.messages.map((msg) => {
|
||||
const displaySources =
|
||||
ragModeEnabled &&
|
||||
group.type === "assistant" &&
|
||||
msg.type === "ai"
|
||||
? collectRuntimeDisplaySources(messages, skillDisplayMap, {
|
||||
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
|
||||
beforeMessageId: msg.id,
|
||||
includeBeforeMessage: true,
|
||||
})
|
||||
: [];
|
||||
return (
|
||||
<MessageListItem
|
||||
key={`${group.id}/${msg.id}`}
|
||||
@@ -197,6 +239,7 @@ export function MessageList({
|
||||
isLoading={thread.isLoading}
|
||||
threadId={threadId}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
displaySources={displaySources}
|
||||
extraRehypePlugins={extraRehypePlugins}
|
||||
/>
|
||||
);
|
||||
@@ -442,6 +485,24 @@ export type SetupAgentApprovalPayload = {
|
||||
skills?: string[];
|
||||
};
|
||||
|
||||
function findPreviousHumanMessageId(
|
||||
messages: Message[],
|
||||
beforeMessageId?: string,
|
||||
): string | undefined {
|
||||
const beforeIndex =
|
||||
beforeMessageId === undefined
|
||||
? messages.length
|
||||
: messages.findIndex((message) => message.id === beforeMessageId);
|
||||
const end = beforeIndex < 0 ? messages.length : beforeIndex;
|
||||
for (let index = end - 1; index >= 0; index--) {
|
||||
const message = messages[index];
|
||||
if (message?.type === "human") {
|
||||
return message.id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getSetupAgentApprovalPayload(
|
||||
message: unknown,
|
||||
): SetupAgentApprovalPayload | null {
|
||||
|
||||
@@ -163,7 +163,8 @@ export function RecentChatList() {
|
||||
const visibleThreads = threads.filter(
|
||||
(thread) =>
|
||||
thread.metadata?.thread_type !== "scheduler" &&
|
||||
thread.metadata?.thread_type !== "bootstrap",
|
||||
thread.metadata?.thread_type !== "bootstrap" &&
|
||||
thread.metadata?.thread_type !== "roundtable",
|
||||
);
|
||||
|
||||
if (visibleThreads.length === 0) {
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { MoonIcon, SunIcon } from "lucide-react";
|
||||
import { useMemo, type ComponentType, type SVGProps } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -14,9 +16,17 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import {
|
||||
useAppearanceSettings,
|
||||
useCitationDisplaySettings,
|
||||
useUpdateAppearanceSettings,
|
||||
useUpdateCitationDisplaySettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
@@ -31,6 +41,28 @@ export function AppearanceSettingsPage() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const currentTheme = theme;
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const { data: appearance } = useAppearanceSettings();
|
||||
const updateAppearance = useUpdateAppearanceSettings();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const updateCitationDisplay = useUpdateCitationDisplaySettings();
|
||||
const globalDefault = appearance?.default_theme ?? "light";
|
||||
const cleanupWords =
|
||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||
|
||||
const handleSetGlobalDefault = async (value: "light" | "dark") => {
|
||||
if (value === globalDefault) return;
|
||||
try {
|
||||
await updateAppearance.mutateAsync({ default_theme: value });
|
||||
toast.success(
|
||||
value === "light"
|
||||
? "已将系统默认主题设置为浅色"
|
||||
: "已将系统默认主题设置为深色",
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const themeOptions = useMemo(
|
||||
() => [
|
||||
@@ -76,6 +108,43 @@ export function AppearanceSettingsPage() {
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
<SettingsSection
|
||||
title="系统默认主题(管理员)"
|
||||
description="给所有还没有手动切换过主题的用户的默认主题;已经自己切换过的用户保留其个人设置不受影响。"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={globalDefault === "light" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => void handleSetGlobalDefault("light")}
|
||||
disabled={updateAppearance.isPending}
|
||||
>
|
||||
<SunIcon className="mr-1.5 size-4" />
|
||||
浅色
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={globalDefault === "dark" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => void handleSetGlobalDefault("dark")}
|
||||
disabled={updateAppearance.isPending}
|
||||
>
|
||||
<MoonIcon className="mr-1.5 size-4" />
|
||||
深色
|
||||
</Button>
|
||||
<p className="text-muted-foreground ml-2 text-xs">
|
||||
当前系统默认:
|
||||
{globalDefault === "light" ? "浅色" : "深色"}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
@@ -125,6 +194,44 @@ export function AppearanceSettingsPage() {
|
||||
aria-label="回答速度指示器"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">RAG 引用模式</div>
|
||||
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
||||
开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.display.ragModeEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings("display", { ragModeEnabled: checked })
|
||||
}
|
||||
aria-label="RAG 引用模式"
|
||||
/>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="mb-3">
|
||||
<div className="text-sm font-medium">参考文献去除词</div>
|
||||
<p className="text-muted-foreground mt-1 max-w-2xl text-xs leading-relaxed">
|
||||
管理员配置。每行一个词或短句,参考文献卡片和详情弹框会自动移除这些广告词、导航词或免责声明。
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
value={cleanupWords.join("\n")}
|
||||
onChange={(event) => {
|
||||
const words = event.currentTarget.value
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
setSettings("display", { citationCleanupWords: words });
|
||||
updateCitationDisplay.mutate({ cleanup_words: words });
|
||||
}}
|
||||
placeholder={"广告\n点击下载APP\n打开客户端\n免责声明\n责任编辑"}
|
||||
className="min-h-32 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -333,7 +333,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SkillRow({
|
||||
export function SkillRow({
|
||||
skill,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLinkIcon, Loader2, WandSparklesIcon } from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
emptyDisplayConfig,
|
||||
inferDisplayConfig,
|
||||
normalizeDisplaySources,
|
||||
} from "@/core/skill-display/display-adapter";
|
||||
import {
|
||||
getSkill,
|
||||
getPersistedSkillDisplaySample,
|
||||
getSkillDisplaySample,
|
||||
} from "@/core/skills/api";
|
||||
import { useSetSkillDisplay } from "@/core/skills/hooks";
|
||||
import type { Skill, SkillDisplayConfig, SkillDisplayMode, SkillLinkStrategy } from "@/core/skills/type";
|
||||
|
||||
const fieldLabels: Array<[keyof SkillDisplayConfig, string]> = [
|
||||
["resultPath", "结果列表路径"],
|
||||
["titleField", "标题字段"],
|
||||
["summaryField", "摘要字段"],
|
||||
["contentField", "正文内容字段"],
|
||||
["sourceField", "来源字段"],
|
||||
["authorField", "作者字段"],
|
||||
["typeField", "类型字段"],
|
||||
["timeField", "时间字段"],
|
||||
["scoreField", "相关度字段"],
|
||||
["urlField", "URL 跳转字段"],
|
||||
["idField", "ID 跳转字段"],
|
||||
["linkTemplate", "ID 跳转链接模板"],
|
||||
];
|
||||
|
||||
const displayModeLabels: Record<SkillDisplayMode, string> = {
|
||||
citation: "编号引用",
|
||||
list: "列表展示",
|
||||
table: "表格展示",
|
||||
card: "卡片展示",
|
||||
custom: "自定义展示",
|
||||
none: "不展示",
|
||||
};
|
||||
|
||||
const linkStrategyLabels: Record<SkillLinkStrategy, string> = {
|
||||
auto: "自动判断",
|
||||
url: "使用 URL 字段跳转",
|
||||
id: "使用 ID 模板跳转",
|
||||
};
|
||||
|
||||
function updateConfig<K extends keyof SkillDisplayConfig>(
|
||||
config: SkillDisplayConfig,
|
||||
key: K,
|
||||
value: SkillDisplayConfig[K],
|
||||
): SkillDisplayConfig {
|
||||
return { ...config, [key]: value };
|
||||
}
|
||||
|
||||
function formatSample(sample: Record<string, unknown> | null | undefined) {
|
||||
return sample ? JSON.stringify(sample, null, 2) : "";
|
||||
}
|
||||
|
||||
export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
const queryClient = useQueryClient();
|
||||
const setDisplay = useSetSkillDisplay();
|
||||
const lastSkillNameRef = useRef(skill.name);
|
||||
const [draft, setDraft] = useState<SkillDisplayConfig>(() => skill.display ?? emptyDisplayConfig());
|
||||
const [sampleText, setSampleText] = useState(() => formatSample(skill.displaySample));
|
||||
const [tableColumnsText, setTableColumnsText] = useState(() => JSON.stringify((skill.display ?? emptyDisplayConfig()).tableColumns ?? [], null, 2));
|
||||
const [loadingDbConfig, setLoadingDbConfig] = useState(false);
|
||||
|
||||
// Echo server state into the form — on mount, on skill switch, AND after
|
||||
// a successful save (the refetched skill object has a new `display` identity).
|
||||
// Important: this drives the "保存后回显" behavior. Without depending on
|
||||
// `skill.display`, any backend-side normalization (e.g. default values
|
||||
// backfilled by Pydantic, column reordering) would not flow back into the UI.
|
||||
useEffect(() => {
|
||||
const display = skill.display ?? emptyDisplayConfig();
|
||||
setDraft(display);
|
||||
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
|
||||
}, [skill.name, skill.display]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadDbSkillConfig() {
|
||||
setLoadingDbConfig(true);
|
||||
try {
|
||||
const fresh = await getSkill(skill.name);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const display = fresh.display ?? emptyDisplayConfig();
|
||||
setDraft(display);
|
||||
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
|
||||
setSampleText(formatSample(fresh.displaySample));
|
||||
queryClient.setQueryData<Skill | undefined>(["skill", skill.name], fresh);
|
||||
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
|
||||
Array.isArray(current)
|
||||
? current.map((item) => (item.name === skill.name ? { ...item, ...fresh } : item))
|
||||
: current,
|
||||
);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoadingDbConfig(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
void loadDbSkillConfig();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [queryClient, skill.name]);
|
||||
|
||||
// Sample text is editor-only state — never reset on save. Only seed it once
|
||||
// per skill, otherwise the user loses the JSON they just pulled in via
|
||||
// 自动识别 every time they hit 保存.
|
||||
useEffect(() => {
|
||||
if (lastSkillNameRef.current !== skill.name) {
|
||||
lastSkillNameRef.current = skill.name;
|
||||
setSampleText(formatSample(skill.displaySample));
|
||||
return;
|
||||
}
|
||||
if (skill.displaySample) {
|
||||
setSampleText(formatSample(skill.displaySample));
|
||||
}
|
||||
}, [skill.name, skill.displaySample]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadPersistedSample() {
|
||||
try {
|
||||
const response = await getPersistedSkillDisplaySample(skill.name);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (response.sample) {
|
||||
setSampleText(formatSample(response.sample));
|
||||
queryClient.setQueryData<Skill | undefined>(["skill", skill.name], (current) =>
|
||||
current ? { ...current, displaySample: response.sample } : current,
|
||||
);
|
||||
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
|
||||
Array.isArray(current)
|
||||
? current.map((item) =>
|
||||
item.name === skill.name
|
||||
? { ...item, displaySample: response.sample }
|
||||
: item,
|
||||
)
|
||||
: current,
|
||||
);
|
||||
} else if (!skill.displaySample) {
|
||||
setSampleText("");
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
void loadPersistedSample();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [queryClient, skill.name, skill.displaySample]);
|
||||
|
||||
const samplePayload = useMemo(() => {
|
||||
try {
|
||||
return JSON.parse(sampleText);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [sampleText]);
|
||||
|
||||
const previewItems = useMemo(() => (
|
||||
samplePayload ? normalizeDisplaySources(samplePayload, draft) : []
|
||||
), [draft, samplePayload]);
|
||||
|
||||
function generateDraft() {
|
||||
if (!samplePayload) {
|
||||
toast.error("样例返回不是有效的 JSON");
|
||||
return;
|
||||
}
|
||||
const nextDraft = inferDisplayConfig(samplePayload, skill.name);
|
||||
setDraft(nextDraft);
|
||||
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
|
||||
toast.success("已生成展示适配草稿");
|
||||
}
|
||||
|
||||
async function loadSampleAndGenerate() {
|
||||
try {
|
||||
const response = await getSkillDisplaySample(skill.name);
|
||||
const nextSampleText = JSON.stringify(response.sample, null, 2);
|
||||
setSampleText(nextSampleText);
|
||||
queryClient.setQueryData<Skill | undefined>(["skill", skill.name], (current) =>
|
||||
current ? { ...current, displaySample: response.sample } : current,
|
||||
);
|
||||
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
|
||||
Array.isArray(current)
|
||||
? current.map((item) =>
|
||||
item.name === skill.name
|
||||
? { ...item, displaySample: response.sample }
|
||||
: item,
|
||||
)
|
||||
: current,
|
||||
);
|
||||
const nextDraft = inferDisplayConfig(response.sample, skill.name);
|
||||
setDraft(nextDraft);
|
||||
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
|
||||
toast.success("已获取样例并生成展示适配");
|
||||
} catch (err) {
|
||||
generateDraft();
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
try {
|
||||
const saved = await setDisplay.mutateAsync({
|
||||
skillName: skill.name,
|
||||
display: draft,
|
||||
sample: samplePayload ?? undefined,
|
||||
});
|
||||
const display = saved.display ?? emptyDisplayConfig();
|
||||
setDraft(display);
|
||||
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
|
||||
setSampleText(formatSample(saved.displaySample));
|
||||
toast.success("展示适配已保存");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function clearDisplay() {
|
||||
try {
|
||||
await setDisplay.mutateAsync({ skillName: skill.name, display: null, sample: null });
|
||||
setDraft(emptyDisplayConfig());
|
||||
setTableColumnsText(JSON.stringify([], null, 2));
|
||||
toast.success("展示适配已清除");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border bg-muted/20 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">技能展示适配</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
从技能返回样例中自动识别字段,然后把技能结果显示为编号引用、列表、卡片或自定义样式。
|
||||
</p>
|
||||
{loadingDbConfig && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
正在从数据库读取已保存的配置...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">启用</span>
|
||||
<Switch
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(checked) => setDraft(updateConfig(draft, "enabled", checked))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">展示方式</span>
|
||||
<Select
|
||||
value={draft.mode}
|
||||
onValueChange={(value) => setDraft(updateConfig(draft, "mode", value as SkillDisplayMode))}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(displayModeLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">跳转方式</span>
|
||||
<Select
|
||||
value={draft.linkStrategy}
|
||||
onValueChange={(value) => setDraft(updateConfig(draft, "linkStrategy", value as SkillLinkStrategy))}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(linkStrategyLabels).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">最多展示条数</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={draft.maxItems}
|
||||
onChange={(event) => setDraft(updateConfig(draft, "maxItems", Number(event.currentTarget.value) || 1))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{fieldLabels.map(([field, label]) => (
|
||||
<label key={field} className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<Input
|
||||
value={(draft[field] as string | number | boolean | null | undefined)?.toString() ?? ""}
|
||||
onChange={(event) => setDraft(updateConfig(draft, field, event.currentTarget.value as never))}
|
||||
placeholder={field === "linkTemplate" ? "https://xxx.xxx.xxx?recuuid={recUuid}" : undefined}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{draft.mode === "table" && (
|
||||
<label className="mt-4 block space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">表格列配置 JSON</span>
|
||||
<Textarea
|
||||
value={tableColumnsText}
|
||||
onChange={(event) => {
|
||||
setTableColumnsText(event.currentTarget.value);
|
||||
try {
|
||||
const value = JSON.parse(event.currentTarget.value);
|
||||
if (Array.isArray(value)) {
|
||||
setDraft(updateConfig(draft, "tableColumns", value));
|
||||
}
|
||||
} catch {
|
||||
// Keep typing invalid JSON without crashing the panel.
|
||||
}
|
||||
}}
|
||||
placeholder={'[{"label":"任务名称","field":"title"},{"label":"状态","field":"status"}]'}
|
||||
className="min-h-24 font-mono text-xs"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{draft.mode === "custom" && (
|
||||
<label className="mt-4 block space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">自定义展示描述</span>
|
||||
<Textarea
|
||||
value={draft.customInstruction}
|
||||
onChange={(event) => setDraft(updateConfig(draft, "customInstruction", event.currentTarget.value))}
|
||||
placeholder="例如:按 publish_time 时间倒序显示为时间线,每条包含标题、摘要、来源和查看原文按钮。"
|
||||
className="min-h-24"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium">技能返回样例</div>
|
||||
<Button size="sm" variant="outline" onClick={() => void loadSampleAndGenerate()}>
|
||||
<WandSparklesIcon className="size-3.5" />
|
||||
自动识别
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={sampleText}
|
||||
onChange={(event) => setSampleText(event.currentTarget.value)}
|
||||
className="min-h-72 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">展示预览</div>
|
||||
<div className="min-h-72 space-y-2 rounded-lg border bg-muted/20 p-3">
|
||||
{!samplePayload ? (
|
||||
sampleText.trim() ? (
|
||||
<div className="text-sm text-destructive">样例 JSON 解析失败</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">数据库暂无技能返回样例,请点击自动识别</div>
|
||||
)
|
||||
) : previewItems.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">当前字段映射没有解析出结果</div>
|
||||
) : draft.mode === "citation" ? (
|
||||
<>
|
||||
<p className="text-sm leading-relaxed">
|
||||
编号引用预览:
|
||||
{previewItems.slice(0, 3).map((_item, idx) => (
|
||||
<button
|
||||
type="button"
|
||||
key={idx}
|
||||
className="mx-1 inline-flex h-6 min-w-6 items-center justify-center rounded-md border bg-background px-2 text-xs font-semibold"
|
||||
>
|
||||
[{idx + 1}]
|
||||
</button>
|
||||
))}
|
||||
</p>
|
||||
{previewItems.map((item, idx) => (
|
||||
<PreviewItem key={idx} index={idx + 1} item={item} />
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
previewItems.map((item, idx) => <PreviewItem key={idx} index={idx + 1} item={item} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => void clearDisplay()} disabled={setDisplay.isPending}>
|
||||
清除
|
||||
</Button>
|
||||
<Button onClick={() => void saveDraft()} disabled={setDisplay.isPending}>
|
||||
{setDisplay.isPending && <Loader2 className="size-3.5 animate-spin" />}
|
||||
保存展示适配
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewItem({
|
||||
index,
|
||||
item,
|
||||
}: {
|
||||
index: number;
|
||||
item: ReturnType<typeof normalizeDisplaySources>[number];
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border bg-background p-3 text-xs">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">
|
||||
[{index}] {item.title}
|
||||
</div>
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
{item.type}
|
||||
{item.source ? ` | ${item.source}` : ""}
|
||||
{item.time ? ` | ${item.time}` : ""}
|
||||
{typeof item.score === "number" ? ` | ${item.score.toFixed(3)}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
{item.url && (
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="打开链接"
|
||||
>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-3 leading-relaxed text-muted-foreground">
|
||||
{item.snippet || item.content || "暂无摘要"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { SkillDisplayTableColumn } from "@/core/skills/type";
|
||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { SourceCard } from "./skill-display-sources";
|
||||
|
||||
export function SkillDisplayBlock({
|
||||
mode,
|
||||
sources,
|
||||
className,
|
||||
}: {
|
||||
mode: RuntimeDisplaySource["mode"];
|
||||
sources: RuntimeDisplaySource[];
|
||||
className?: string;
|
||||
}) {
|
||||
if (sources.length === 0 || mode === "citation" || mode === "none") {
|
||||
return null;
|
||||
}
|
||||
if (mode === "table") {
|
||||
return <SkillDisplayTable sources={sources} className={className} />;
|
||||
}
|
||||
return (
|
||||
<div className={cn("not-prose my-4 grid gap-2", className)}>
|
||||
{sources.map((source) => (
|
||||
<SourceCard key={`${source.skillName}-${source.index}`} source={source} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillDisplayTable({
|
||||
sources,
|
||||
className,
|
||||
}: {
|
||||
sources: RuntimeDisplaySource[];
|
||||
className?: string;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<RuntimeDisplaySource | null>(null);
|
||||
const columns = useMemo(() => buildColumns(sources), [sources]);
|
||||
|
||||
return (
|
||||
<div className={cn("not-prose my-4 overflow-hidden rounded-md border bg-card", className)}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[560px] text-sm">
|
||||
<thead className="bg-muted/60 text-muted-foreground">
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.field}
|
||||
className="px-3 py-2 text-left font-medium"
|
||||
style={column.width ? { width: column.width } : undefined}
|
||||
>
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sources.map((source) => (
|
||||
<tr
|
||||
key={`${source.skillName}-${source.index}`}
|
||||
className="cursor-pointer border-t transition-colors hover:bg-accent/40"
|
||||
onClick={() => setSelected(source)}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<td key={column.field} className="max-w-[280px] px-3 py-2 align-top">
|
||||
<span className="line-clamp-2 break-words">
|
||||
{formatCell(readByPath(source.raw, column.field))}
|
||||
</span>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<SourceDetailDialog source={selected} onOpenChange={(open) => !open && setSelected(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceDetailDialog({
|
||||
source,
|
||||
onOpenChange,
|
||||
}: {
|
||||
source: RuntimeDisplaySource | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const content = source?.content || source?.snippet;
|
||||
const meta = source
|
||||
? [source.source, source.author ? `作者 ${source.author}` : null, source.type, source.time].filter(Boolean)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Dialog open={!!source} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
{source && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between gap-3 pr-8">
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="text-base leading-relaxed">
|
||||
{source.title}
|
||||
</DialogTitle>
|
||||
{meta.length > 0 && (
|
||||
<DialogDescription className="mt-1 flex flex-wrap gap-x-2 gap-y-1 text-xs">
|
||||
{meta.map((item, index) => (
|
||||
<span key={`${item}-${index}`}>{item}</span>
|
||||
))}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</div>
|
||||
{source.url && (
|
||||
<Button size="icon-sm" variant="outline" asChild>
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="打开来源"
|
||||
>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{content && (
|
||||
<div className="text-foreground max-h-[45vh] overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
<pre className="bg-muted/40 max-h-[32vh] overflow-auto rounded-md p-3 text-xs">
|
||||
{JSON.stringify(source.raw, null, 2)}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function buildColumns(sources: RuntimeDisplaySource[]): SkillDisplayTableColumn[] {
|
||||
const configured = sources[0]?.tableColumns?.filter((column) => column.field);
|
||||
if (configured && configured.length > 0) {
|
||||
return configured;
|
||||
}
|
||||
const raw = sources[0]?.raw ?? {};
|
||||
return Object.keys(raw)
|
||||
.slice(0, 6)
|
||||
.map((field) => ({ label: field, field }));
|
||||
}
|
||||
|
||||
function readByPath(value: unknown, path: string): unknown {
|
||||
if (!path) {
|
||||
return value;
|
||||
}
|
||||
return path.split(".").reduce<unknown>((acc, key) => {
|
||||
if (acc && typeof acc === "object") {
|
||||
return (acc as Record<string, unknown>)[key];
|
||||
}
|
||||
return undefined;
|
||||
}, value);
|
||||
}
|
||||
|
||||
function formatCell(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
export function SkillDisplaySources({
|
||||
highlightedReferenceId,
|
||||
sources,
|
||||
className,
|
||||
}: {
|
||||
highlightedReferenceId?: string | null;
|
||||
sources: RuntimeDisplaySource[];
|
||||
className?: string;
|
||||
}) {
|
||||
if (sources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("not-prose grid gap-2", className)}>
|
||||
{sources.map((source) => (
|
||||
<SourceCard
|
||||
key={`${source.skillName}-${source.index}`}
|
||||
highlighted={highlightedReferenceId === String(source.index)}
|
||||
source={source}
|
||||
referenceTarget
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SourceCard({
|
||||
highlighted = false,
|
||||
source,
|
||||
referenceTarget = false,
|
||||
}: {
|
||||
highlighted?: boolean;
|
||||
source: RuntimeDisplaySource;
|
||||
referenceTarget?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [settings] = useLocalSettings();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const cleanupWords =
|
||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
||||
const snippet = compactReferenceText(content).slice(0, 280);
|
||||
const paragraphs = segmentReferenceText(content);
|
||||
const score =
|
||||
typeof source.score === "number"
|
||||
? source.score <= 1
|
||||
? `${Math.round(source.score * 100)}%`
|
||||
: String(source.score)
|
||||
: null;
|
||||
const meta = [
|
||||
source.source,
|
||||
source.author ? `作者 ${source.author}` : null,
|
||||
source.type && source.type !== "result" ? source.type : null,
|
||||
source.time,
|
||||
score ? `相关度 ${score}` : null,
|
||||
].filter(Boolean);
|
||||
const isCitation = source.mode === "citation";
|
||||
const showReferenceNumber = isCitation;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
id={referenceTarget ? `reference-${source.index}` : undefined}
|
||||
type="button"
|
||||
className={cn(
|
||||
"group bg-card/60 hover:bg-card hover:border-primary/40 focus-visible:ring-primary/30 w-full scroll-mt-12 rounded-xl border px-3.5 py-3 text-left shadow-sm transition-all focus-visible:ring-2 focus-visible:outline-none",
|
||||
"target:border-amber-300 target:bg-amber-50/80",
|
||||
highlighted &&
|
||||
"border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]",
|
||||
)}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{showReferenceNumber && (
|
||||
<span className="bg-primary/10 text-primary border-primary/20 group-hover:bg-primary/15 mt-0.5 inline-flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums">
|
||||
{source.index}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="text-foreground line-clamp-2 text-sm leading-snug font-medium">
|
||||
{source.title}
|
||||
</div>
|
||||
{meta.length > 0 && (
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px]">
|
||||
{meta.map((item, index) => (
|
||||
<span key={`${item}-${index}`} className="inline-flex items-center">
|
||||
{index > 0 && <span className="mr-2 opacity-50">·</span>}
|
||||
<span className="truncate">{item}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{snippet && (
|
||||
<p className="text-muted-foreground/90 line-clamp-2 text-xs leading-relaxed">
|
||||
{snippet}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{source.url && (
|
||||
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between gap-3 pr-8">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start gap-2">
|
||||
{showReferenceNumber && (
|
||||
<span className="bg-primary/10 text-primary border-primary/20 mt-1 inline-flex size-6 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums">
|
||||
{source.index}
|
||||
</span>
|
||||
)}
|
||||
<DialogTitle className="text-base leading-relaxed">
|
||||
{source.title}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
{meta.length > 0 && (
|
||||
<DialogDescription className="mt-2 flex flex-wrap gap-x-2 gap-y-1 text-xs">
|
||||
{meta.map((item, index) => (
|
||||
<span key={`${item}-${index}`}>{item}</span>
|
||||
))}
|
||||
</DialogDescription>
|
||||
)}
|
||||
</div>
|
||||
{source.url && (
|
||||
<Button size="icon-sm" variant="outline" asChild>
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="打开来源"
|
||||
>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{paragraphs.length > 0 && (
|
||||
<div className="text-foreground max-h-[55vh] space-y-3 overflow-y-auto text-sm leading-relaxed">
|
||||
{paragraphs.map((paragraph, index) => (
|
||||
<p key={index} className="whitespace-pre-wrap">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{source.url && (
|
||||
<div className="text-muted-foreground border-t pt-3 text-xs break-all">
|
||||
{source.url}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function cleanReferenceText(
|
||||
raw: string | undefined,
|
||||
cleanupWords: string[],
|
||||
): string {
|
||||
let text = raw ?? "";
|
||||
const words = cleanupWords.map((word) => word.trim()).filter(Boolean);
|
||||
if (words.length > 0) {
|
||||
text = text
|
||||
.replace(/\r\n/g, "\n")
|
||||
.split("\n")
|
||||
.filter((line) => !words.some((word) => line.includes(word)))
|
||||
.join("\n");
|
||||
}
|
||||
return text
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function compactReferenceText(text: string): string {
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function segmentReferenceText(text: string): string[] {
|
||||
const explicit = text
|
||||
.split(/\n{2,}/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
const base = explicit.length > 1 ? explicit : splitLongParagraph(text);
|
||||
return base.slice(0, 80);
|
||||
}
|
||||
|
||||
function splitLongParagraph(text: string): string[] {
|
||||
const normalized = text
|
||||
.split(/\n+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (normalized.length <= 360) {
|
||||
return normalized ? [normalized] : [];
|
||||
}
|
||||
|
||||
const sentences = normalized
|
||||
.split(/(?<=[。!?!?;;])\s*/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
if (sentences.length <= 1) {
|
||||
return normalized.match(/[\s\S]{1,360}/g) ?? [];
|
||||
}
|
||||
|
||||
const paragraphs: string[] = [];
|
||||
let current = "";
|
||||
for (const sentence of sentences) {
|
||||
if ((current + sentence).length > 360 && current) {
|
||||
paragraphs.push(current.trim());
|
||||
current = "";
|
||||
}
|
||||
current += sentence;
|
||||
}
|
||||
if (current.trim()) {
|
||||
paragraphs.push(current.trim());
|
||||
}
|
||||
return paragraphs;
|
||||
}
|
||||
@@ -4,10 +4,12 @@ import {
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
FileCode2Icon,
|
||||
FilesIcon,
|
||||
FileTextIcon,
|
||||
GlobeIcon,
|
||||
Loader2,
|
||||
PencilIcon,
|
||||
SlidersHorizontalIcon,
|
||||
SparklesIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -25,12 +27,16 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { SkillDisplayAdapterPanel } from "@/components/workspace/skill-display-adapter-panel";
|
||||
import { SkillFilePreviewDialog } from "@/components/workspace/skills/skill-file-preview-dialog";
|
||||
import { SkillFileTree } from "@/components/workspace/skills/skill-file-tree";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
useSetSkillDetail,
|
||||
useSkill,
|
||||
useSkillContent,
|
||||
useSkillFiles,
|
||||
} from "@/core/skills/hooks";
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
@@ -62,6 +68,18 @@ function canManageSkill(skill: Skill | null | undefined): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isSkillOwner(skill: Skill | null | undefined): boolean {
|
||||
if (!skill) return false;
|
||||
const auth = getStoredAuth();
|
||||
if (!auth) return false;
|
||||
return (
|
||||
skill.category === "custom" &&
|
||||
!skill.builtin &&
|
||||
!!skill.owner_user_id &&
|
||||
skill.owner_user_id === auth.user_id
|
||||
);
|
||||
}
|
||||
|
||||
function stripYamlFrontmatter(content: string): string {
|
||||
return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trimStart();
|
||||
}
|
||||
@@ -83,22 +101,32 @@ export function SkillDetailDialog({
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<"detail" | "source" | null>(
|
||||
null,
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"detail" | "display" | "source" | "files" | null
|
||||
>(null);
|
||||
const [showRawSource, setShowRawSource] = useState(false);
|
||||
const [openedFilePath, setOpenedFilePath] = useState<string | null>(null);
|
||||
|
||||
const canManage = useMemo(() => canManageSkill(skill), [skill]);
|
||||
const isOwner = useMemo(() => isSkillOwner(skill), [skill]);
|
||||
const {
|
||||
data: contentData,
|
||||
isLoading: isContentLoading,
|
||||
error: contentError,
|
||||
} = useSkillContent(open && canManage ? skillName : null);
|
||||
} = useSkillContent(open ? skillName : null);
|
||||
|
||||
const filesEnabled = open && isOwner && activeTab === "files";
|
||||
const {
|
||||
data: filesData,
|
||||
isLoading: isFilesLoading,
|
||||
error: filesError,
|
||||
} = useSkillFiles(filesEnabled ? skillName : null);
|
||||
|
||||
useEffect(() => {
|
||||
setEditing(false);
|
||||
setActiveTab(null);
|
||||
setShowRawSource(false);
|
||||
setOpenedFilePath(null);
|
||||
}, [open, skillName]);
|
||||
|
||||
const skillBody = useMemo(() => {
|
||||
@@ -106,7 +134,7 @@ export function SkillDetailDialog({
|
||||
}, [contentData]);
|
||||
|
||||
const defaultTab: "detail" | "source" =
|
||||
canManage && skill && !skill.detail ? "source" : "detail";
|
||||
skill && !skill.detail ? "source" : "detail";
|
||||
const selectedTab = activeTab ?? defaultTab;
|
||||
|
||||
function startEditing() {
|
||||
@@ -131,6 +159,7 @@ export function SkillDetailDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="flex flex-col gap-0 overflow-hidden p-0"
|
||||
@@ -194,11 +223,21 @@ export function SkillDetailDialog({
|
||||
{tx.detailSectionTitle}
|
||||
</TabsTrigger>
|
||||
{canManage && (
|
||||
<TabsTrigger value="source" className="text-xs">
|
||||
<FileCode2Icon className="mr-1 size-3.5" />
|
||||
{tx.contentSectionTitle}
|
||||
<TabsTrigger value="display" className="text-xs">
|
||||
<SlidersHorizontalIcon className="mr-1 size-3.5" />
|
||||
展示适配
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{isOwner && (
|
||||
<TabsTrigger value="files" className="text-xs">
|
||||
<FilesIcon className="mr-1 size-3.5" />
|
||||
{tx.filesSectionTitle}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="source" className="text-xs">
|
||||
<FileCode2Icon className="mr-1 size-3.5" />
|
||||
{tx.contentSectionTitle}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
@@ -214,7 +253,6 @@ export function SkillDetailDialog({
|
||||
</Button>
|
||||
)}
|
||||
{selectedTab === "source" &&
|
||||
canManage &&
|
||||
!isContentLoading &&
|
||||
!contentError && (
|
||||
<>
|
||||
@@ -299,7 +337,7 @@ export function SkillDetailDialog({
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center">
|
||||
<FileTextIcon className="mx-auto mb-2 size-6 opacity-40" />
|
||||
<p className="text-sm">{tx.detailEmpty}</p>
|
||||
{canManage ? (
|
||||
{isOwner ? (
|
||||
<p className="mt-1 text-xs">
|
||||
Open the SKILL.md tab to view the skill source.
|
||||
</p>
|
||||
@@ -312,10 +350,47 @@ export function SkillDetailDialog({
|
||||
|
||||
{canManage && (
|
||||
<TabsContent
|
||||
value="source"
|
||||
value="display"
|
||||
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
|
||||
style={{ overflowY: "auto" }}
|
||||
>
|
||||
<SkillDisplayAdapterPanel skill={skill} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{isOwner && (
|
||||
<TabsContent
|
||||
value="files"
|
||||
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-4 data-[state=inactive]:hidden"
|
||||
style={{ overflowY: "auto" }}
|
||||
>
|
||||
{isFilesLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : filesError ? (
|
||||
<div className="text-destructive py-6 text-sm">
|
||||
{filesError instanceof Error
|
||||
? filesError.message
|
||||
: String(filesError)}
|
||||
</div>
|
||||
) : filesData ? (
|
||||
<SkillFileTree
|
||||
root={filesData.root}
|
||||
selectedPath={openedFilePath}
|
||||
onOpen={(path) => setOpenedFilePath(path)}
|
||||
emptyText={tx.filesEmpty}
|
||||
/>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent
|
||||
value="source"
|
||||
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
|
||||
style={{ overflowY: "auto" }}
|
||||
>
|
||||
{isContentLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
@@ -341,10 +416,19 @@ export function SkillDetailDialog({
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<SkillFilePreviewDialog
|
||||
skillName={skill?.name ?? null}
|
||||
path={openedFilePath}
|
||||
treeEditable={!!filesData?.editable}
|
||||
open={!!openedFilePath}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setOpenedFilePath(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
Loader2,
|
||||
PencilIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { getSkillFileDownloadURL } from "@/core/skills/api";
|
||||
import {
|
||||
useSaveSkillFile,
|
||||
useSkillFileContent,
|
||||
} from "@/core/skills/hooks";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
|
||||
const markdownClassName =
|
||||
"!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " +
|
||||
"[&_h1]:mt-0 [&_h1]:mb-3 [&_h1]:text-xl [&_h1]:font-semibold " +
|
||||
"[&_h2]:mt-6 [&_h2]:mb-2 [&_h2]:border-b [&_h2]:pb-2 [&_h2]:text-lg [&_h2]:font-semibold " +
|
||||
"[&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:text-base [&_h3]:font-semibold " +
|
||||
"[&_p]:my-3 [&_ul]:my-3 [&_ol]:my-3 [&_li]:my-1.5 " +
|
||||
"[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 " +
|
||||
"[&_blockquote]:my-4 [&_blockquote]:border-l-4 [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground " +
|
||||
"[&_code]:rounded [&_code]:border [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs " +
|
||||
"[&_pre]:my-4 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:bg-muted [&_pre]:p-4 " +
|
||||
"[&_pre_code]:border-0 [&_pre_code]:bg-transparent [&_pre_code]:p-0";
|
||||
|
||||
function isImageMime(mime: string): boolean {
|
||||
return mime.startsWith("image/");
|
||||
}
|
||||
|
||||
export function SkillFilePreviewDialog({
|
||||
skillName,
|
||||
path,
|
||||
treeEditable,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
skillName: string | null;
|
||||
path: string | null;
|
||||
/** From the tree response: whether *any* file in this skill is editable for the caller. */
|
||||
treeEditable: boolean;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const tx = t.settings.skills;
|
||||
|
||||
const { data, isLoading, error } = useSkillFileContent(
|
||||
open ? skillName : null,
|
||||
open ? path : null,
|
||||
);
|
||||
const save = useSaveSkillFile();
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
// Reset editor state whenever a different file is opened or the dialog closes.
|
||||
useEffect(() => {
|
||||
setEditing(false);
|
||||
setDraft("");
|
||||
}, [path, open]);
|
||||
|
||||
const fileEditable = !!data?.editable && treeEditable;
|
||||
const isMarkdown = useMemo(() => {
|
||||
if (!path) return false;
|
||||
const lower = path.toLowerCase();
|
||||
return lower.endsWith(".md") || lower.endsWith(".markdown");
|
||||
}, [path]);
|
||||
|
||||
const dirty = editing && data?.content !== undefined && draft !== data.content;
|
||||
|
||||
function requestClose(next: boolean) {
|
||||
if (!next && dirty) {
|
||||
if (!window.confirm(tx.filesDiscardConfirm)) return;
|
||||
}
|
||||
onOpenChange(next);
|
||||
}
|
||||
|
||||
function startEditing() {
|
||||
if (!data || data.content == null) return;
|
||||
setDraft(data.content);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
if (dirty && !window.confirm(tx.filesDiscardConfirm)) return;
|
||||
setEditing(false);
|
||||
setDraft("");
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!skillName || !path) return;
|
||||
try {
|
||||
await save.mutateAsync({ skillName, path, content: draft });
|
||||
toast.success(tx.filesSaveSuccess);
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyPath() {
|
||||
if (!path) return;
|
||||
await navigator.clipboard.writeText(path);
|
||||
toast.success(tx.filesCopyPathAction);
|
||||
}
|
||||
|
||||
const downloadHref =
|
||||
skillName && path ? getSkillFileDownloadURL(skillName, path) : undefined;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={requestClose}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="flex flex-col gap-0 overflow-hidden p-0"
|
||||
style={{
|
||||
width: "min(56rem, calc(100vw - 2rem))",
|
||||
maxWidth: "none",
|
||||
height: "min(720px, calc(100dvh - 2rem))",
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-5 py-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<DialogTitle className="truncate font-mono text-sm">
|
||||
{path ?? ""}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-0.5 truncate text-xs">
|
||||
{skillName}
|
||||
{data ? ` · ${formatBytes(data.size)} · ${data.mime}` : null}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{fileEditable && !editing && data?.is_text && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
|
||||
onClick={startEditing}
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
{tx.filesEditAction}
|
||||
</Button>
|
||||
)}
|
||||
{editing && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={cancelEditing}
|
||||
disabled={save.isPending}
|
||||
>
|
||||
{tx.filesCancelEditAction}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={save.isPending || !dirty}
|
||||
>
|
||||
{save.isPending && <Loader2 className="size-3.5 animate-spin" />}
|
||||
{tx.filesSaveAction}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!editing && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
|
||||
onClick={() => void handleCopyPath()}
|
||||
>
|
||||
<CopyIcon className="size-3.5" />
|
||||
</Button>
|
||||
{downloadHref && (
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
|
||||
>
|
||||
<a href={downloadHref} download>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground h-7 px-1.5 text-xs"
|
||||
onClick={() => requestClose(false)}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto px-5 py-4">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-destructive py-6 text-sm">
|
||||
{error instanceof Error ? error.message : String(error)}
|
||||
</div>
|
||||
) : data == null ? null : editing ? (
|
||||
<Textarea
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.currentTarget.value)}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
className="h-full min-h-[420px] w-full resize-none font-mono text-xs leading-relaxed"
|
||||
/>
|
||||
) : data.is_text ? (
|
||||
isMarkdown ? (
|
||||
<MarkdownContent
|
||||
className={markdownClassName}
|
||||
content={data.content ?? ""}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
/>
|
||||
) : (
|
||||
<pre className="text-foreground/80 bg-muted/40 max-w-full overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre">
|
||||
{data.content ?? ""}
|
||||
</pre>
|
||||
)
|
||||
) : isImageMime(data.mime) ? (
|
||||
<div className="flex items-center justify-center">
|
||||
{downloadHref && (
|
||||
<img
|
||||
src={downloadHref}
|
||||
alt={data.path}
|
||||
className="max-h-full max-w-full rounded border"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-12 text-center text-sm">
|
||||
<p>{tx.filesPreviewBinary}</p>
|
||||
{downloadHref && (
|
||||
<Button asChild size="sm" variant="outline" className="mt-3">
|
||||
<a href={downloadHref} download>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
{tx.filesDownloadAction}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
FileCode2Icon,
|
||||
FileIcon,
|
||||
FileJsonIcon,
|
||||
FileTextIcon,
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
ImageIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SkillFileNode } from "@/core/skills/api";
|
||||
|
||||
const CODE_EXTS = new Set([
|
||||
"py", "js", "jsx", "ts", "tsx", "mjs", "cjs",
|
||||
"sh", "bash", "zsh", "ps1",
|
||||
"html", "htm", "css", "scss", "sass", "xml", "svg",
|
||||
"sql", "ini", "toml", "cfg", "env", "yaml", "yml",
|
||||
]);
|
||||
const IMAGE_EXTS = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "tiff"]);
|
||||
|
||||
function fileIconFor(name: string) {
|
||||
const ext = name.includes(".") ? name.split(".").pop()!.toLowerCase() : "";
|
||||
if (name === "SKILL.md") return FileTextIcon;
|
||||
if (IMAGE_EXTS.has(ext)) return ImageIcon;
|
||||
if (ext === "md" || ext === "markdown" || ext === "txt" || ext === "rst") return FileTextIcon;
|
||||
if (ext === "json" || ext === "jsonl") return FileJsonIcon;
|
||||
if (CODE_EXTS.has(ext)) return FileCode2Icon;
|
||||
return FileIcon;
|
||||
}
|
||||
|
||||
function TreeRow({
|
||||
node,
|
||||
depth,
|
||||
selectedPath,
|
||||
onOpen,
|
||||
}: {
|
||||
node: SkillFileNode;
|
||||
depth: number;
|
||||
selectedPath: string | null;
|
||||
onOpen: (path: string) => void;
|
||||
}) {
|
||||
// Top-level (root) auto-expands; nested directories start collapsed.
|
||||
const [open, setOpen] = useState(depth === 0);
|
||||
|
||||
if (node.type === "dir") {
|
||||
const FolderGlyph = open ? FolderOpenIcon : FolderIcon;
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className={cn(
|
||||
"group hover:bg-muted/60 flex w-full items-center gap-1 rounded px-1.5 py-1 text-left text-sm",
|
||||
)}
|
||||
style={{ paddingLeft: 6 + depth * 14 }}
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"text-muted-foreground size-3.5 shrink-0 transition-transform",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
<FolderGlyph className="size-4 shrink-0 text-amber-500/90" />
|
||||
<span className="truncate font-medium">{node.name}</span>
|
||||
</button>
|
||||
{open && node.children && node.children.length > 0 && (
|
||||
<div className="border-border/60 ml-[14px] border-l border-dashed">
|
||||
{node.children.map((child) => (
|
||||
<TreeRow
|
||||
key={child.path || child.name}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
selectedPath={selectedPath}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = fileIconFor(node.name);
|
||||
const isSelected = selectedPath === node.path;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(node.path)}
|
||||
className={cn(
|
||||
"hover:bg-muted/60 flex w-full items-center gap-1.5 rounded px-1.5 py-1 text-left text-sm",
|
||||
isSelected && "bg-primary/10 hover:bg-primary/15",
|
||||
)}
|
||||
style={{ paddingLeft: 6 + depth * 14 + 14 }}
|
||||
title={node.path}
|
||||
>
|
||||
<Icon className="text-muted-foreground size-4 shrink-0" />
|
||||
<span className="truncate">{node.name}</span>
|
||||
{typeof node.size === "number" && (
|
||||
<span className="text-muted-foreground/70 ml-auto shrink-0 text-[10px] tabular-nums">
|
||||
{formatBytes(node.size)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function SkillFileTree({
|
||||
root,
|
||||
onOpen,
|
||||
selectedPath,
|
||||
emptyText,
|
||||
}: {
|
||||
root: SkillFileNode;
|
||||
onOpen: (path: string) => void;
|
||||
selectedPath: string | null;
|
||||
emptyText: string;
|
||||
}) {
|
||||
const isEmpty = useMemo(
|
||||
() => !root.children || root.children.length === 0,
|
||||
[root],
|
||||
);
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center text-sm">
|
||||
{emptyText}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="font-mono">
|
||||
<TreeRow
|
||||
node={root}
|
||||
depth={0}
|
||||
selectedPath={selectedPath}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -68,7 +68,7 @@ export function TagSearchBar({
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2.5", className)}>
|
||||
<div className="relative">
|
||||
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
|
||||
<SearchIcon className="text-muted-foreground dark:text-[#60a5fa]/90 absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
@@ -38,6 +39,21 @@ export function WorkspaceNavChatList({
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
const navigate = useNavigate();
|
||||
// 「新对话」点击时,如果已经在 /chats/new 路径,直接 navigate 会被 react-router
|
||||
// 当成同路径 no-op,不更新 location.key,挂在 location.key 上的下游 hook
|
||||
// (useThreadChat)就感知不到「用户想重开一个对话」,UI 不刷新。
|
||||
// 这里显式拦截:同路径时用 replace + 强制生成新 location.key 的方式重置。
|
||||
const handleNewChatClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.button === 1) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
navigate("/page/workspace/chats/new", {
|
||||
replace: true,
|
||||
state: { resetAt: Date.now() },
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<SidebarGroup className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}>
|
||||
@@ -49,7 +65,7 @@ export function WorkspaceNavChatList({
|
||||
asChild
|
||||
className={cn(NEW_CHAT_CLASS, showCollapseTrigger && "min-w-0 flex-1")}
|
||||
>
|
||||
<Link href="/page/workspace/chats/new">
|
||||
<Link href="/page/workspace/chats/new" onClick={handleNewChatClick}>
|
||||
<MessageSquarePlus />
|
||||
<span>{t.sidebar.newChat}</span>
|
||||
</Link>
|
||||
@@ -141,7 +157,7 @@ export function WorkspaceNavChatList({
|
||||
>
|
||||
<Link href="/page/workspace/scheduled/tasks">
|
||||
<ListChecks />
|
||||
<span>任务管理</span>
|
||||
<span>定时任务</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
@@ -149,7 +165,7 @@ export function WorkspaceNavChatList({
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
{!showCollapseTrigger && <WorkspaceNavStudio />}
|
||||
{/* {!showCollapseTrigger && <WorkspaceNavStudio />} */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
BellIcon,
|
||||
BotIcon,
|
||||
BrainIcon,
|
||||
CalendarClockIcon,
|
||||
ChevronsUpDown,
|
||||
@@ -11,6 +12,8 @@ import {
|
||||
MessageSquarePlusIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
ShieldCheckIcon,
|
||||
SparklesIcon,
|
||||
TagsIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
@@ -143,6 +146,16 @@ export function WorkspaceNavMenu() {
|
||||
问答前缀配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/settings/private-square")
|
||||
}
|
||||
>
|
||||
<ShieldCheckIcon />
|
||||
私密广场配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
@@ -171,6 +184,22 @@ export function WorkspaceNavMenu() {
|
||||
定时任务管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/agents")}
|
||||
>
|
||||
<BotIcon />
|
||||
智能体管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/skills")}
|
||||
>
|
||||
<SparklesIcon />
|
||||
技能管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||
|
||||
@@ -9,11 +9,26 @@ export interface ChatMessage {
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
export interface toolsUser {
|
||||
email: string;
|
||||
id: string;
|
||||
is_active: boolean;
|
||||
is_superuser: boolean;
|
||||
is_verified: boolean;
|
||||
pages_limit: number;
|
||||
pages_used: string;
|
||||
yUserId: string;
|
||||
}
|
||||
|
||||
interface ChatContextType {
|
||||
messages: ChatMessage[];
|
||||
addMessage: (role: 'user' | 'assistant', content: string, type?: 'general' | 'agent' | 'compare', agentName?: string) => void;
|
||||
clearMessages: () => void;
|
||||
deleteMessage: (id: string) => void;
|
||||
startConfig: unknown;
|
||||
setStartConfig: (val: unknown) => void;
|
||||
toolsUser: toolsUser | null;
|
||||
setToolsUser: (val: toolsUser) => void;
|
||||
}
|
||||
|
||||
const ChatContext = createContext<ChatContextType | undefined>(undefined);
|
||||
@@ -28,6 +43,9 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
});
|
||||
|
||||
const [startConfig, setStartConfig] = useState<unknown>(null);
|
||||
const [toolsUser, setToolsUser] = useState<toolsUser | null>(null);
|
||||
|
||||
const saveToStorage = useCallback((newMessages: ChatMessage[]) => {
|
||||
try {
|
||||
localStorage.setItem('qa-chat-history', JSON.stringify(newMessages));
|
||||
@@ -66,7 +84,18 @@ export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
}, [saveToStorage]);
|
||||
|
||||
return (
|
||||
<ChatContext.Provider value={{ messages, addMessage, clearMessages, deleteMessage }}>
|
||||
<ChatContext.Provider
|
||||
value={{
|
||||
messages,
|
||||
addMessage,
|
||||
clearMessages,
|
||||
deleteMessage,
|
||||
startConfig,
|
||||
setStartConfig,
|
||||
toolsUser,
|
||||
setToolsUser,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ChatContext.Provider>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,8 @@ import React, {
|
||||
} from 'react';
|
||||
import { useTheme as useNextTheme } from 'next-themes';
|
||||
|
||||
import { useAppearanceSettings } from '@/core/system-settings/hooks';
|
||||
|
||||
// 主题类型
|
||||
export type ThemeType = 'dark' | 'light';
|
||||
|
||||
@@ -46,16 +48,16 @@ const themes: Record<ThemeType, ThemeConfig> = {
|
||||
textSecondary: 'slate-400',
|
||||
primary: 'blue-500',
|
||||
primaryHover: 'blue-400',
|
||||
border: 'slate-700',
|
||||
accent: 'blue-600',
|
||||
border: 'blue-800',
|
||||
accent: 'cyan-400',
|
||||
success: 'emerald-500',
|
||||
warning: 'amber-500',
|
||||
error: 'rose-500',
|
||||
card: 'slate-800',
|
||||
cardHover: 'slate-700',
|
||||
input: 'slate-800',
|
||||
inputBorder: 'slate-600',
|
||||
overlay: 'slate-900/80',
|
||||
inputBorder: 'blue-800',
|
||||
overlay: 'slate-900/50',
|
||||
},
|
||||
},
|
||||
light: {
|
||||
@@ -83,6 +85,12 @@ const themes: Record<ThemeType, ThemeConfig> = {
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'strategy-theme';
|
||||
// Marker that the user has explicitly toggled the theme (via the appearance
|
||||
// settings page or any future toggle UI). When this flag is absent we treat
|
||||
// any value stored in ``strategy-theme`` as one we put there ourselves while
|
||||
// syncing the admin's default — so we'll re-sync on every boot, picking up
|
||||
// admin changes. When the flag is present we never override the user.
|
||||
const EXPLICIT_FLAG_KEY = 'strategy-theme-user-set';
|
||||
|
||||
function readPersistedTheme(): ThemeType {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -92,6 +100,16 @@ function readPersistedTheme(): ThemeType {
|
||||
return saved === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function hasExplicitChoice(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return localStorage.getItem(EXPLICIT_FLAG_KEY) === '1';
|
||||
}
|
||||
|
||||
function markExplicitChoice(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem(EXPLICIT_FLAG_KEY, '1');
|
||||
}
|
||||
|
||||
// 主题上下文
|
||||
interface ThemeContextType {
|
||||
theme: ThemeType;
|
||||
@@ -112,42 +130,68 @@ interface ThemeProviderProps {
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
const { resolvedTheme, setTheme: setNextTheme } = useNextTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
// Admin-configured global default. If the user hasn't explicitly chosen
|
||||
// a theme yet, this value drives the rendered theme — and re-syncs on
|
||||
// every mount so an admin change propagates without users clearing
|
||||
// localStorage by hand.
|
||||
const { data: appearance } = useAppearanceSettings();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 迁移 / 规范化 localStorage,避免与 next-themes 冲突导致闪回浅色
|
||||
// 迁移 / 规范化 localStorage:去掉旧的 'theme' 键,并清掉一切非法值。
|
||||
// 注意:不要在这里强制写入一个 fallback —— 那会让 next-themes 视作
|
||||
// 「用户已选过」,admin 默认就再也注入不进来了。
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
const current = localStorage.getItem(STORAGE_KEY);
|
||||
const legacy = localStorage.getItem('theme');
|
||||
|
||||
if (current !== 'light' && current !== 'dark') {
|
||||
const fallback =
|
||||
legacy === 'light' || legacy === 'dark' ? legacy : 'light';
|
||||
setNextTheme(fallback);
|
||||
} else if (!current && legacy && (legacy === 'light' || legacy === 'dark')) {
|
||||
setNextTheme(legacy);
|
||||
if (current && current !== 'light' && current !== 'dark') {
|
||||
// Garbage value — wipe it so the boot-sync below can take over.
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
if (!current && legacy && (legacy === 'light' || legacy === 'dark')) {
|
||||
// Migrate the legacy key as the user's explicit choice (it was set
|
||||
// long enough ago that we should preserve their pick rather than
|
||||
// override with admin default).
|
||||
setNextTheme(legacy);
|
||||
markExplicitChoice();
|
||||
}
|
||||
|
||||
if (legacy) {
|
||||
localStorage.removeItem('theme');
|
||||
}
|
||||
}, [mounted, setNextTheme]);
|
||||
|
||||
// Boot-time sync: when the user has never picked a theme themselves,
|
||||
// apply whatever the admin configured. Re-runs whenever the admin
|
||||
// setting changes (so a logged-in user sees the new default after
|
||||
// refreshing without having to clear cookies).
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
if (hasExplicitChoice()) return;
|
||||
const adminDefault = appearance?.default_theme;
|
||||
if (!adminDefault) return;
|
||||
if (resolvedTheme !== adminDefault) {
|
||||
setNextTheme(adminDefault);
|
||||
}
|
||||
}, [mounted, appearance?.default_theme, resolvedTheme, setNextTheme]);
|
||||
|
||||
const theme: ThemeType = mounted
|
||||
? resolvedTheme === 'light'
|
||||
? 'light'
|
||||
: 'dark'
|
||||
? resolvedTheme === 'dark'
|
||||
? 'dark'
|
||||
: 'light'
|
||||
: readPersistedTheme();
|
||||
|
||||
const toggleTheme = () => {
|
||||
setNextTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
markExplicitChoice();
|
||||
};
|
||||
|
||||
const setTheme = (newTheme: ThemeType) => {
|
||||
setNextTheme(newTheme);
|
||||
markExplicitChoice();
|
||||
};
|
||||
|
||||
const themeConfig = themes[theme];
|
||||
|
||||
@@ -38,6 +38,16 @@ export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]
|
||||
return data.agents;
|
||||
}
|
||||
|
||||
export async function listAllAgentsAdmin(): Promise<Agent[]> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/agents?admin=true`);
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to load agents: ${res.statusText}`);
|
||||
}
|
||||
const data = (await res.json()) as { agents: Agent[] };
|
||||
return data.agents;
|
||||
}
|
||||
|
||||
export async function getAgent(id: string): Promise<Agent> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}`);
|
||||
if (!res.ok) throw new Error(`Agent '${id}' not found`);
|
||||
@@ -86,6 +96,26 @@ export async function getAgentSelector(): Promise<AgentSelectorResponse> {
|
||||
return res.json() as Promise<AgentSelectorResponse>;
|
||||
}
|
||||
|
||||
export async function favoriteAgent(id: string): Promise<void> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to favorite agent: ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function unfavoriteAgent(id: string): Promise<void> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to unfavorite agent: ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function reorderFeaturedAgents(agentIds: string[]): Promise<void> {
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/agents/featured/order`,
|
||||
@@ -103,6 +133,46 @@ export async function reorderFeaturedAgents(agentIds: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPrivateAgents(password: string): Promise<Agent[]> {
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/agents/private-square/list`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(
|
||||
err.detail ?? `Failed to load private agents: ${res.statusText}`,
|
||||
);
|
||||
}
|
||||
const data = (await res.json()) as { agents: Agent[] };
|
||||
return data.agents;
|
||||
}
|
||||
|
||||
export async function setAgentPrivate(
|
||||
id: string,
|
||||
isPrivate: boolean,
|
||||
): Promise<Agent> {
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/agents/${id}/private`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ is_private: isPrivate }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(
|
||||
err.detail ?? `Failed to update agent privacy: ${res.statusText}`,
|
||||
);
|
||||
}
|
||||
return res.json() as Promise<Agent>;
|
||||
}
|
||||
|
||||
export async function setAgentFeatured(
|
||||
id: string,
|
||||
order: number | null,
|
||||
@@ -124,6 +194,31 @@ export async function setAgentFeatured(
|
||||
return res.json() as Promise<Agent>;
|
||||
}
|
||||
|
||||
export interface AgentSkillsSyncRemoval {
|
||||
agent_id: string;
|
||||
agent_name: string | null;
|
||||
user_id: string | null;
|
||||
removed_skills: string[];
|
||||
}
|
||||
|
||||
export interface AgentSkillsSyncResult {
|
||||
checked: number;
|
||||
modified: number;
|
||||
removals: AgentSkillsSyncRemoval[];
|
||||
}
|
||||
|
||||
export async function adminSyncAgentSkills(): Promise<AgentSkillsSyncResult> {
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/agents/admin/sync-skills`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to sync agent skills: ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<AgentSkillsSyncResult>;
|
||||
}
|
||||
|
||||
export async function checkAgentName(
|
||||
name: string,
|
||||
): Promise<{ available: boolean; name: string }> {
|
||||
|
||||
@@ -6,16 +6,40 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
adminSyncAgentSkills,
|
||||
createAgent,
|
||||
deleteAgent,
|
||||
favoriteAgent,
|
||||
getAgent,
|
||||
getAgentSelector,
|
||||
listAgents,
|
||||
listAllAgentsAdmin,
|
||||
listPrivateAgents,
|
||||
reorderFeaturedAgents,
|
||||
setAgentFeatured,
|
||||
setAgentPrivate,
|
||||
unfavoriteAgent,
|
||||
updateAgent,
|
||||
} from "./api";
|
||||
import type { CreateAgentRequest, UpdateAgentRequest } from "./types";
|
||||
import type { Agent, CreateAgentRequest, UpdateAgentRequest } from "./types";
|
||||
|
||||
/** Patch an updated agent into every cached agent listing immediately so
|
||||
* cards re-render before the background refetch finishes. Without this the
|
||||
* mutation just invalidates the query and the user has to wait for the
|
||||
* round-trip + render — which on a big list feels like nothing happened.
|
||||
*/
|
||||
function applyAgentToCache(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
next: Agent,
|
||||
) {
|
||||
queryClient.setQueriesData<Agent[] | undefined>(
|
||||
{ queryKey: ["agents"] },
|
||||
(old) => {
|
||||
if (!Array.isArray(old)) return old;
|
||||
return old.map((a) => (a.id === next.id ? { ...a, ...next } : a));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
|
||||
const search = params.search ?? "";
|
||||
@@ -28,6 +52,21 @@ export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
|
||||
return { agents: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only listing — returns every agent including unpublished agents owned
|
||||
* by other users and private-square entries. Caller must be ``system_role
|
||||
* === "admin"`` or the backend will 403.
|
||||
*/
|
||||
export function useAllAgentsAdmin(enabled: boolean) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents", "admin", "all"],
|
||||
queryFn: () => listAllAgentsAdmin(),
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
return { agents: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
export function useAgent(id: string | null | undefined) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents", id],
|
||||
@@ -57,7 +96,8 @@ export function useUpdateAgent() {
|
||||
id: string;
|
||||
request: UpdateAgentRequest;
|
||||
}) => updateAgent(id, request),
|
||||
onSuccess: (_data, { id }) => {
|
||||
onSuccess: (data, { id }) => {
|
||||
applyAgentToCache(queryClient, data);
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
|
||||
},
|
||||
@@ -74,6 +114,34 @@ export function useDeleteAgent() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Toggle a user's favorite (收藏) on an agent, with an optimistic flip of
|
||||
* ``is_favorite`` across every cached agent listing + detail so the star
|
||||
* reacts instantly before the round-trip completes. */
|
||||
export function useToggleAgentFavorite() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, favorite }: { id: string; favorite: boolean }) =>
|
||||
favorite ? favoriteAgent(id) : unfavoriteAgent(id),
|
||||
onMutate: ({ id, favorite }) => {
|
||||
queryClient.setQueriesData<Agent[] | undefined>(
|
||||
{ queryKey: ["agents"] },
|
||||
(old) =>
|
||||
Array.isArray(old)
|
||||
? old.map((a) =>
|
||||
a.id === id ? { ...a, is_favorite: favorite } : a,
|
||||
)
|
||||
: old,
|
||||
);
|
||||
queryClient.setQueryData<Agent | undefined>(["agents", id], (old) =>
|
||||
old ? { ...old, is_favorite: favorite } : old,
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAgentSelector() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents", "selector"],
|
||||
@@ -96,7 +164,8 @@ export function useSetAgentFeatured() {
|
||||
return useMutation({
|
||||
mutationFn: ({ id, order }: { id: string; order: number | null }) =>
|
||||
setAgentFeatured(id, order),
|
||||
onSuccess: (_data, { id }) => {
|
||||
onSuccess: (data, { id }) => {
|
||||
applyAgentToCache(queryClient, data);
|
||||
invalidateAgentQueries(queryClient);
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
|
||||
},
|
||||
@@ -110,3 +179,47 @@ export function useReorderFeaturedAgents() {
|
||||
onSuccess: () => invalidateAgentQueries(queryClient),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the password-gated private square. ``password`` is sent on every
|
||||
* fetch — wire ``enabled`` to false until the user has typed it so we don't
|
||||
* hit the endpoint with an empty body and get a 403.
|
||||
*/
|
||||
export function usePrivateAgents(password: string | null) {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ["agents", "private-square", password ?? ""],
|
||||
queryFn: () => listPrivateAgents(password!),
|
||||
enabled: !!password,
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
return { agents: data ?? [], isLoading, error, refetch };
|
||||
}
|
||||
|
||||
export function useSetAgentPrivate() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, isPrivate }: { id: string; isPrivate: boolean }) =>
|
||||
setAgentPrivate(id, isPrivate),
|
||||
onSuccess: (data, { id }) => {
|
||||
applyAgentToCache(queryClient, data);
|
||||
invalidateAgentQueries(queryClient);
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents", id] });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["agents", "private-square"],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminSyncAgentSkills() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: adminSyncAgentSkills,
|
||||
onSuccess: (result) => {
|
||||
if (result.modified > 0) {
|
||||
invalidateAgentQueries(queryClient);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export interface Agent {
|
||||
description: string;
|
||||
user_id: string | null;
|
||||
published: boolean;
|
||||
is_private: boolean;
|
||||
builtin: boolean;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
@@ -15,6 +16,8 @@ export interface Agent {
|
||||
skills?: string[] | null;
|
||||
tags?: Tag[];
|
||||
featured_order?: number | null;
|
||||
/** Whether the current user has favorited (收藏) this agent. */
|
||||
is_favorite?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentSelectorResponse {
|
||||
@@ -26,6 +29,7 @@ export interface CreateAgentRequest {
|
||||
description?: string;
|
||||
soul?: string;
|
||||
published?: boolean;
|
||||
is_private?: boolean;
|
||||
skills?: string[] | null;
|
||||
model?: string | null;
|
||||
tool_groups?: string[] | null;
|
||||
@@ -36,6 +40,7 @@ export interface UpdateAgentRequest {
|
||||
description?: string | null;
|
||||
soul?: string | null;
|
||||
published?: boolean | null;
|
||||
is_private?: boolean | null;
|
||||
skills?: string[] | null;
|
||||
model?: string | null;
|
||||
tool_groups?: string[] | null;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
|
||||
const AUTH_STORAGE_KEY = "deerflow.auth";
|
||||
|
||||
function readUserId(): string | null {
|
||||
return getStoredAuth()?.user_id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount once at the app root. Watches `localStorage["deerflow.auth"]` for
|
||||
* cross-tab changes — when another tab logs in as a different user, this tab
|
||||
* still holds the previous user's cached state (chat list, thread ids, etc.)
|
||||
* which then collides with the new token and surfaces as a 404 on "新对话".
|
||||
* Reloading the tab is the simplest way to resync React state with the new
|
||||
* identity.
|
||||
*/
|
||||
export function AuthSyncWatcher() {
|
||||
const lastUserIdRef = useRef<string | null>(readUserId());
|
||||
|
||||
useEffect(() => {
|
||||
function handleStorage(event: StorageEvent) {
|
||||
if (event.key !== null && event.key !== AUTH_STORAGE_KEY) {
|
||||
return;
|
||||
}
|
||||
const nextUserId = readUserId();
|
||||
if (nextUserId === lastUserIdRef.current) {
|
||||
return;
|
||||
}
|
||||
lastUserIdRef.current = nextUserId;
|
||||
if (nextUserId === null) {
|
||||
window.location.replace("#/login");
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
/**
|
||||
* Durable, browser-local record of when each chat question was asked, keyed by
|
||||
* the message id. Captured at send time and reconciled to the server-assigned
|
||||
* message id (see ``useThreadStream``), so the time survives reloads and does
|
||||
* not depend on the backend round-tripping a timestamp on the message.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = "deerflow.chat-ask-times";
|
||||
|
||||
type TimeMap = Record<string, string>;
|
||||
|
||||
function isBrowser(): boolean {
|
||||
return typeof window !== "undefined";
|
||||
}
|
||||
|
||||
let cache: TimeMap | null = null;
|
||||
const EMPTY: TimeMap = {};
|
||||
const listeners = new Set<() => void>();
|
||||
let storageBound = false;
|
||||
|
||||
function readFromStorage(): TimeMap {
|
||||
if (!isBrowser()) return EMPTY;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return parsed && typeof parsed === "object" ? (parsed as TimeMap) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function getSnapshot(): TimeMap {
|
||||
if (cache === null) cache = readFromStorage();
|
||||
return cache;
|
||||
}
|
||||
|
||||
function getServerSnapshot(): TimeMap {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
function commit(next: TimeMap): void {
|
||||
cache = next;
|
||||
if (isBrowser()) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
// Keep the in-memory cache even if persistence fails.
|
||||
}
|
||||
}
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
if (isBrowser() && !storageBound) {
|
||||
storageBound = true;
|
||||
window.addEventListener("storage", (event) => {
|
||||
if (event.key === STORAGE_KEY || event.key === null) {
|
||||
cache = readFromStorage();
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** Record the ask time for a message id. First write wins (the true ask time). */
|
||||
export function rememberAskTime(messageId: string, iso: string): void {
|
||||
if (!messageId) return;
|
||||
const current = getSnapshot();
|
||||
if (current[messageId]) return;
|
||||
commit({ ...current, [messageId]: iso });
|
||||
}
|
||||
|
||||
export function getAskTime(messageId: string): string | undefined {
|
||||
return getSnapshot()[messageId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Record several message times at once (first write wins per id). Used to stamp
|
||||
* a freshly loaded/streamed batch of messages in a single commit so a long
|
||||
* conversation doesn't trigger one storage write + re-render per message.
|
||||
*/
|
||||
export function rememberAskTimes(entries: Record<string, string>): void {
|
||||
const current = getSnapshot();
|
||||
let changed = false;
|
||||
const next: TimeMap = { ...current };
|
||||
for (const [id, iso] of Object.entries(entries)) {
|
||||
if (id && !next[id]) {
|
||||
next[id] = iso;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) commit(next);
|
||||
}
|
||||
|
||||
/** Reactive map of messageId → ISO ask time. */
|
||||
export function useChatAskTimes(): TimeMap {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
/** A user-defined reusable prompt, bound to the account (stored server-side). */
|
||||
export interface CustomPrompt {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
sort_order?: number;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
const QUERY_KEY = ["user-prompts"] as const;
|
||||
const EMPTY_PROMPTS: CustomPrompt[] = [];
|
||||
const baseUrl = () => `${getBackendBaseURL()}/api/user-prompts`;
|
||||
|
||||
async function listUserPrompts(): Promise<CustomPrompt[]> {
|
||||
const res = await apiFetch(baseUrl());
|
||||
if (!res.ok) throw new Error(`Failed to load prompts: ${res.statusText}`);
|
||||
const data = (await res.json()) as { prompts: CustomPrompt[] };
|
||||
return data.prompts;
|
||||
}
|
||||
|
||||
async function createUserPrompt(input: {
|
||||
title: string;
|
||||
content: string;
|
||||
}): Promise<CustomPrompt> {
|
||||
const res = await apiFetch(baseUrl(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to create prompt: ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<CustomPrompt>;
|
||||
}
|
||||
|
||||
async function updateUserPrompt(
|
||||
id: string,
|
||||
input: { title?: string; content?: string },
|
||||
): Promise<CustomPrompt> {
|
||||
const res = await apiFetch(`${baseUrl()}/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to update prompt: ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<CustomPrompt>;
|
||||
}
|
||||
|
||||
async function deleteUserPrompt(id: string): Promise<void> {
|
||||
const res = await apiFetch(`${baseUrl()}/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to delete prompt: ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reactive list of the current user's saved prompts (server-backed). */
|
||||
export function useCustomPrompts(): CustomPrompt[] {
|
||||
const { data } = useQuery({
|
||||
queryKey: QUERY_KEY,
|
||||
queryFn: listUserPrompts,
|
||||
});
|
||||
return data ?? EMPTY_PROMPTS;
|
||||
}
|
||||
|
||||
export function useCreateCustomPrompt() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: createUserPrompt,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCustomPrompt() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
input,
|
||||
}: {
|
||||
id: string;
|
||||
input: { title?: string; content?: string };
|
||||
}) => updateUserPrompt(id, input),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCustomPrompt() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteUserPrompt,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -501,6 +501,20 @@ export const enUS: Translations = {
|
||||
detailAdminOnlyHint:
|
||||
"Only an admin or the skill publisher can edit the details.",
|
||||
contentSectionTitle: "SKILL.md source",
|
||||
filesSectionTitle: "Files",
|
||||
filesEmpty: "This skill has no files to display.",
|
||||
filesLoadError: "Failed to load files",
|
||||
filesPreviewBinary: "Binary file cannot be previewed inline — use Download.",
|
||||
filesPreviewUnsupported: "This file type is not supported for inline preview.",
|
||||
filesPreviewTooLarge: "File is too large for inline preview.",
|
||||
filesEditAction: "Edit",
|
||||
filesSaveAction: "Save",
|
||||
filesCancelEditAction: "Cancel",
|
||||
filesDownloadAction: "Download",
|
||||
filesCopyPathAction: "Copy path",
|
||||
filesDiscardConfirm: "Discard unsaved changes?",
|
||||
filesSaveSuccess: "File saved",
|
||||
filesReadOnlyHint: "Built-in skills are read-only.",
|
||||
viewDetailAction: "View skill details",
|
||||
stateSheet: {
|
||||
archivedTitle: "Archived skills",
|
||||
|
||||
@@ -432,6 +432,20 @@ export interface Translations {
|
||||
detailSaveSuccess: string;
|
||||
detailAdminOnlyHint: string;
|
||||
contentSectionTitle: string;
|
||||
filesSectionTitle: string;
|
||||
filesEmpty: string;
|
||||
filesLoadError: string;
|
||||
filesPreviewBinary: string;
|
||||
filesPreviewUnsupported: string;
|
||||
filesPreviewTooLarge: string;
|
||||
filesEditAction: string;
|
||||
filesSaveAction: string;
|
||||
filesCancelEditAction: string;
|
||||
filesDownloadAction: string;
|
||||
filesCopyPathAction: string;
|
||||
filesDiscardConfirm: string;
|
||||
filesSaveSuccess: string;
|
||||
filesReadOnlyHint: string;
|
||||
viewDetailAction: string;
|
||||
stateSheet: {
|
||||
archivedTitle: string;
|
||||
|
||||
@@ -479,6 +479,20 @@ export const zhCN: Translations = {
|
||||
detailSaveSuccess: "详情已更新",
|
||||
detailAdminOnlyHint: "仅管理员或技能发布者可编辑详情。",
|
||||
contentSectionTitle: "SKILL.md 原文",
|
||||
filesSectionTitle: "文件",
|
||||
filesEmpty: "该技能目录下没有可显示的文件。",
|
||||
filesLoadError: "加载文件失败",
|
||||
filesPreviewBinary: "二进制文件无法预览,可点击下载查看。",
|
||||
filesPreviewUnsupported: "该文件类型暂不支持预览。",
|
||||
filesPreviewTooLarge: "文件过大,无法在线预览。",
|
||||
filesEditAction: "编辑",
|
||||
filesSaveAction: "保存",
|
||||
filesCancelEditAction: "取消",
|
||||
filesDownloadAction: "下载",
|
||||
filesCopyPathAction: "复制路径",
|
||||
filesDiscardConfirm: "有未保存的修改,确定放弃吗?",
|
||||
filesSaveSuccess: "文件已保存",
|
||||
filesReadOnlyHint: "内置技能为只读,仅可查看。",
|
||||
viewDetailAction: "查看技能详情",
|
||||
stateSheet: {
|
||||
archivedTitle: "归档的技能",
|
||||
|
||||
@@ -7,11 +7,16 @@
|
||||
*/
|
||||
|
||||
export type SearchResultItem = {
|
||||
id?: string;
|
||||
recUuid?: string;
|
||||
uuid?: string;
|
||||
docId?: string;
|
||||
title?: string;
|
||||
url?: string;
|
||||
snippet?: string;
|
||||
content?: string;
|
||||
source?: string;
|
||||
author?: string;
|
||||
published_date?: string;
|
||||
publishedDate?: string;
|
||||
date?: string;
|
||||
@@ -107,6 +112,15 @@ const SOURCE_ALIASES = [
|
||||
"kb",
|
||||
] as const;
|
||||
|
||||
const AUTHOR_ALIASES = [
|
||||
"author",
|
||||
"authors",
|
||||
"creator",
|
||||
"writer",
|
||||
"byline",
|
||||
"owner",
|
||||
] as const;
|
||||
|
||||
const DATE_ALIASES = [
|
||||
"published_date",
|
||||
"publishedDate",
|
||||
@@ -127,6 +141,8 @@ const CONTENT_KEY_PATTERN =
|
||||
const URL_KEY_PATTERN = /(?:^|_)(?:url|href|link|uri)(?:$|_)/i;
|
||||
const SOURCE_KEY_PATTERN =
|
||||
/(?:^|_)(?:source|origin|site|publisher|domain|from|channel|database|kb)(?:$|_)/i;
|
||||
const AUTHOR_KEY_PATTERN =
|
||||
/(?:^|_)(?:author|authors|creator|writer|byline|owner)(?:$|_)/i;
|
||||
const DATE_KEY_PATTERN =
|
||||
/(?:^|_)(?:date|time|published|publish|created|updated)(?:$|_)/i;
|
||||
|
||||
@@ -297,12 +313,22 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
|
||||
excludeUrl: true,
|
||||
maxLength: 120,
|
||||
});
|
||||
const author =
|
||||
pickFromLayers(layers, AUTHOR_ALIASES) ??
|
||||
pickByKeyPattern(layers, AUTHOR_KEY_PATTERN, {
|
||||
excludeUrl: true,
|
||||
maxLength: 120,
|
||||
});
|
||||
const date =
|
||||
pickFromLayers(layers, DATE_ALIASES) ??
|
||||
pickByKeyPattern(layers, DATE_KEY_PATTERN, { maxLength: 64 });
|
||||
const id = pickFromLayers(layers, ["id", "taskId", "recordId", "record_id"]);
|
||||
const recUuid = pickFromLayers(layers, ["recUuid", "rec_uuid"]);
|
||||
const uuid = pickFromLayers(layers, ["uuid"]);
|
||||
const docId = pickFromLayers(layers, ["docId", "doc_id", "documentId"]);
|
||||
|
||||
const reserved = new Set<string>();
|
||||
for (const value of [title, content, url, source, date]) {
|
||||
for (const value of [title, content, url, source, author, date]) {
|
||||
if (value) {
|
||||
reserved.add(value);
|
||||
}
|
||||
@@ -324,11 +350,16 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
recUuid,
|
||||
uuid,
|
||||
docId,
|
||||
title,
|
||||
url,
|
||||
snippet: content,
|
||||
content,
|
||||
source,
|
||||
author,
|
||||
published_date: date,
|
||||
publishedDate: date,
|
||||
date,
|
||||
|
||||
@@ -51,6 +51,7 @@ export async function createScheduledTask(data: {
|
||||
schedule: string;
|
||||
timezone?: string | null;
|
||||
execution_agent_name?: string | null;
|
||||
require_markdown?: boolean;
|
||||
}) {
|
||||
return parseResponse<ScheduledTask>(
|
||||
await apiFetch(baseUrl(), {
|
||||
@@ -210,6 +211,7 @@ export async function updateScheduledTask(
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
execution_agent_name: string | null;
|
||||
require_markdown: boolean;
|
||||
}>,
|
||||
) {
|
||||
return parseResponse<ScheduledTask>(
|
||||
@@ -337,6 +339,7 @@ export async function adminUpdateScheduledTask(
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
execution_agent_name: string | null;
|
||||
require_markdown: boolean;
|
||||
}>,
|
||||
) {
|
||||
return parseResponse<ScheduledTask>(
|
||||
|
||||
@@ -20,6 +20,12 @@ export type ScheduledTask = {
|
||||
cron_expr: string;
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Free-form execution context persisted with the task. ``require_markdown``
|
||||
* lives here (rather than a dedicated column) so the feature needs no schema
|
||||
* migration.
|
||||
*/
|
||||
execution_context?: { require_markdown?: boolean; [key: string]: unknown } | null;
|
||||
next_run_at?: string | null;
|
||||
last_run_at?: string | null;
|
||||
last_status?: string | null;
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { useLocalSettings, useThreadSettings } from "./hooks";
|
||||
export type { LocalSettings } from "./local";
|
||||
export { useLocalSettings, useThreadSettings } from "./hooks";
|
||||
export { isThreadRagModeLocked, lockThreadRagMode } from "./local";
|
||||
export type { LocalSettings } from "./local";
|
||||
@@ -4,9 +4,11 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
||||
notification: {
|
||||
enabled: true,
|
||||
},
|
||||
display: {
|
||||
streamSpeedMeterEnabled: true,
|
||||
},
|
||||
display: {
|
||||
streamSpeedMeterEnabled: true,
|
||||
ragModeEnabled: false,
|
||||
citationCleanupWords: [],
|
||||
},
|
||||
context: {
|
||||
model_name: undefined,
|
||||
mode: undefined,
|
||||
@@ -15,8 +17,9 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
||||
},
|
||||
};
|
||||
|
||||
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
|
||||
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
|
||||
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
|
||||
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
|
||||
export const THREAD_RAG_MODE_KEY_PREFIX = "deerflow.thread-rag-mode.";
|
||||
|
||||
function isBrowser(): boolean {
|
||||
return typeof window !== "undefined";
|
||||
@@ -33,7 +36,17 @@ export interface LocalSettings {
|
||||
* the appearance settings page.
|
||||
*/
|
||||
streamSpeedMeterEnabled: boolean;
|
||||
};
|
||||
/**
|
||||
* Global switch for skill result display adapters. When disabled, chat
|
||||
* messages render tool results with the legacy generic search UI only.
|
||||
*/
|
||||
ragModeEnabled: boolean;
|
||||
/**
|
||||
* Admin-maintained words or short phrases removed from reference snippets
|
||||
* and dialogs, one entry per line in the settings UI.
|
||||
*/
|
||||
citationCleanupWords: string[];
|
||||
};
|
||||
context: Omit<
|
||||
AgentThreadContext,
|
||||
| "thread_id"
|
||||
@@ -78,10 +91,10 @@ export function getThreadModelName(threadId: string): string | undefined {
|
||||
return localStorage.getItem(getThreadModelStorageKey(threadId)) ?? undefined;
|
||||
}
|
||||
|
||||
export function saveThreadModelName(
|
||||
threadId: string,
|
||||
modelName: string | undefined,
|
||||
) {
|
||||
export function saveThreadModelName(
|
||||
threadId: string,
|
||||
modelName: string | undefined,
|
||||
) {
|
||||
if (!isBrowser()) {
|
||||
return;
|
||||
}
|
||||
@@ -90,8 +103,26 @@ export function saveThreadModelName(
|
||||
localStorage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(key, modelName);
|
||||
}
|
||||
localStorage.setItem(key, modelName);
|
||||
}
|
||||
|
||||
function getThreadRagModeStorageKey(threadId: string): string {
|
||||
return `${THREAD_RAG_MODE_KEY_PREFIX}${threadId}`;
|
||||
}
|
||||
|
||||
export function isThreadRagModeLocked(threadId: string): boolean {
|
||||
if (!isBrowser() || !threadId) {
|
||||
return false;
|
||||
}
|
||||
return localStorage.getItem(getThreadRagModeStorageKey(threadId)) === "true";
|
||||
}
|
||||
|
||||
export function lockThreadRagMode(threadId: string) {
|
||||
if (!isBrowser() || !threadId) {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(getThreadRagModeStorageKey(threadId), "true");
|
||||
}
|
||||
|
||||
export function applyThreadModelOverride(
|
||||
settings: LocalSettings,
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import type { SkillDisplayConfig } from "@/core/skills/type";
|
||||
|
||||
const DEFAULT_CITATION_MAX_ITEMS = 100;
|
||||
const DEFAULT_LIST_MAX_ITEMS = 20;
|
||||
const MAX_DISPLAY_ITEMS = 100;
|
||||
|
||||
export interface DisplaySourceItem {
|
||||
id?: string;
|
||||
title: string;
|
||||
type: string;
|
||||
snippet: string;
|
||||
source?: string;
|
||||
author?: string;
|
||||
time?: string;
|
||||
url?: string;
|
||||
content?: string;
|
||||
score?: number;
|
||||
raw: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const emptyDisplayConfig = (): SkillDisplayConfig => ({
|
||||
enabled: false,
|
||||
mode: "none",
|
||||
resultPath: "results",
|
||||
titleField: null,
|
||||
summaryField: null,
|
||||
contentField: null,
|
||||
sourceField: null,
|
||||
authorField: null,
|
||||
typeField: null,
|
||||
timeField: null,
|
||||
scoreField: null,
|
||||
urlField: null,
|
||||
idField: null,
|
||||
linkStrategy: "auto",
|
||||
linkTemplate: null,
|
||||
citationToken: "doubleBracket",
|
||||
maxItems: DEFAULT_CITATION_MAX_ITEMS,
|
||||
customInstruction: "",
|
||||
tableColumns: [],
|
||||
});
|
||||
|
||||
const candidateFields: Record<string, string[]> = {
|
||||
titleField: ["title", "m_title", "name", "subject", "headline"],
|
||||
summaryField: ["summary", "snippet", "content_preview", "description", "abstract"],
|
||||
contentField: ["content", "page_content", "text", "content_preview", "body"],
|
||||
sourceField: ["source", "source_name", "site", "from", "origin"],
|
||||
authorField: ["author", "authors", "creator", "writer", "byline", "publisher", "owner"],
|
||||
typeField: ["type", "category", "source_category", "source1"],
|
||||
timeField: ["publish_time", "m_publish", "updatedAt", "createdAt", "date", "time"],
|
||||
scoreField: ["relevance_score", "rerank_score", "score", "similarity"],
|
||||
urlField: ["url", "link", "href", "source_url"],
|
||||
idField: ["recUuid", "rec_uuid", "uuid", "docId", "taskId", "recordId", "id"],
|
||||
};
|
||||
|
||||
function getByPath(value: unknown, path: string): unknown {
|
||||
if (!path) return value;
|
||||
return path.split(".").reduce<unknown>((acc, key) => {
|
||||
if (acc && typeof acc === "object") {
|
||||
return (acc as Record<string, unknown>)[key];
|
||||
}
|
||||
return undefined;
|
||||
}, value);
|
||||
}
|
||||
|
||||
function findFirstArray(
|
||||
value: unknown,
|
||||
path = "",
|
||||
): { path: string; items: Record<string, unknown>[] } | null {
|
||||
if (Array.isArray(value) && value.every((item) => item && typeof item === "object")) {
|
||||
return { path, items: value as Record<string, unknown>[] };
|
||||
}
|
||||
if (!value || typeof value !== "object") return null;
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
const found = findFirstArray(child, path ? `${path}.${key}` : key);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickField(sample: Record<string, unknown>, candidates: string[]): string | null {
|
||||
const keys = Object.keys(sample);
|
||||
for (const candidate of candidates) {
|
||||
const exact = keys.find((key) => key === candidate);
|
||||
if (exact) return exact;
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
const lowered = candidate.toLowerCase();
|
||||
const fuzzy = keys.find((key) => key.toLowerCase().includes(lowered));
|
||||
if (fuzzy) return fuzzy;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function inferDisplayConfig(samplePayload: unknown, skillName?: string): SkillDisplayConfig {
|
||||
const found = findFirstArray(samplePayload) ?? { path: "results", items: [] };
|
||||
const sample = found.items[0] ?? {};
|
||||
const inferred = emptyDisplayConfig();
|
||||
inferred.enabled = true;
|
||||
inferred.mode = skillName === "knowledge-search-v2" ? "citation" : "list";
|
||||
inferred.resultPath = found.path || "results";
|
||||
inferred.maxItems = inferMaxItems(found.items.length, inferred.mode);
|
||||
|
||||
for (const [target, candidates] of Object.entries(candidateFields)) {
|
||||
(inferred as unknown as Record<string, string | null>)[target] = pickField(sample, candidates);
|
||||
}
|
||||
|
||||
if (skillName === "knowledge-search-v2") {
|
||||
inferred.titleField = inferred.titleField || "title";
|
||||
inferred.summaryField = inferred.summaryField || "content_preview";
|
||||
inferred.contentField = inferred.contentField || "content_preview";
|
||||
inferred.sourceField = inferred.sourceField || "source";
|
||||
inferred.authorField = inferred.authorField || pickField(sample, candidateFields.authorField);
|
||||
inferred.typeField = inferred.typeField || "source_category";
|
||||
inferred.timeField = inferred.timeField || "publish_time";
|
||||
inferred.scoreField = inferred.scoreField || "relevance_score";
|
||||
inferred.idField = pickField(sample, ["recUuid", "rec_uuid", "uuid", "id"]) || inferred.idField;
|
||||
inferred.linkStrategy = inferred.urlField ? "auto" : "id";
|
||||
inferred.linkTemplate = "https://xxx.xxx.xxx?recuuid={recUuid}";
|
||||
}
|
||||
|
||||
inferred.tableColumns = buildDefaultTableColumns(sample, inferred);
|
||||
return inferred;
|
||||
}
|
||||
|
||||
function buildDefaultTableColumns(
|
||||
sample: Record<string, unknown>,
|
||||
config: SkillDisplayConfig,
|
||||
): NonNullable<SkillDisplayConfig["tableColumns"]> {
|
||||
const preferred = [
|
||||
config.titleField,
|
||||
config.typeField,
|
||||
config.sourceField,
|
||||
config.authorField,
|
||||
config.timeField,
|
||||
config.scoreField,
|
||||
].filter((field): field is string => !!field);
|
||||
const fallback = Object.keys(sample)
|
||||
.filter((field) => !preferred.includes(field))
|
||||
.slice(0, Math.max(0, 6 - preferred.length));
|
||||
return [...preferred, ...fallback].slice(0, 8).map((field) => ({
|
||||
label: field,
|
||||
field,
|
||||
}));
|
||||
}
|
||||
|
||||
function stringify(value: unknown, fallback = ""): string {
|
||||
if (value === null || value === undefined) return fallback;
|
||||
if (Array.isArray(value)) {
|
||||
const parts = value
|
||||
.map((item) =>
|
||||
typeof item === "string" || typeof item === "number" || typeof item === "boolean"
|
||||
? String(item).trim()
|
||||
: "",
|
||||
)
|
||||
.filter(Boolean);
|
||||
return parts.length > 0 ? parts.join("、") : fallback;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function renderTemplate(template: string, item: Record<string, unknown>): string {
|
||||
return template.replace(/\{([^}]+)\}/g, (_match, key: string) =>
|
||||
encodeURIComponent(stringify(item[key])),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveLink(item: Record<string, unknown>, config: SkillDisplayConfig): string | undefined {
|
||||
const url = config.urlField ? stringify(item[config.urlField]) : "";
|
||||
const id = config.idField ? stringify(item[config.idField]) : "";
|
||||
if (config.linkStrategy === "url") return url || undefined;
|
||||
if (config.linkStrategy === "id") {
|
||||
return id && config.linkTemplate ? renderTemplate(config.linkTemplate, item) : undefined;
|
||||
}
|
||||
return url || (id && config.linkTemplate ? renderTemplate(config.linkTemplate, item) : undefined) || undefined;
|
||||
}
|
||||
|
||||
export function normalizeDisplaySources(payload: unknown, config: SkillDisplayConfig): DisplaySourceItem[] {
|
||||
const rawItems = getByPath(payload, config.resultPath);
|
||||
if (!Array.isArray(rawItems)) return [];
|
||||
const maxItems = resolveMaxItems(config, rawItems.length);
|
||||
return rawItems
|
||||
.filter((item): item is Record<string, unknown> => !!item && typeof item === "object")
|
||||
.slice(0, maxItems)
|
||||
.map((item, index) => ({
|
||||
id: config.idField ? stringify(item[config.idField]) : undefined,
|
||||
title: config.titleField ? stringify(item[config.titleField], `结果 ${index + 1}`) : `结果 ${index + 1}`,
|
||||
type: config.typeField ? stringify(item[config.typeField], "result") : "result",
|
||||
snippet: config.summaryField ? stringify(item[config.summaryField]) : "",
|
||||
content: config.contentField ? stringify(item[config.contentField]) : undefined,
|
||||
source: config.sourceField ? stringify(item[config.sourceField]) : undefined,
|
||||
author: config.authorField ? stringify(item[config.authorField]) : undefined,
|
||||
time: config.timeField ? stringify(item[config.timeField]) : undefined,
|
||||
score:
|
||||
config.scoreField && typeof item[config.scoreField] === "number"
|
||||
? (item[config.scoreField] as number)
|
||||
: undefined,
|
||||
url: resolveLink(item, config),
|
||||
raw: item,
|
||||
}));
|
||||
}
|
||||
|
||||
function inferMaxItems(
|
||||
itemCount: number,
|
||||
mode: SkillDisplayConfig["mode"],
|
||||
): number {
|
||||
const fallback =
|
||||
mode === "citation" ? DEFAULT_CITATION_MAX_ITEMS : DEFAULT_LIST_MAX_ITEMS;
|
||||
return Math.min(
|
||||
MAX_DISPLAY_ITEMS,
|
||||
Math.max(fallback, itemCount || fallback),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMaxItems(config: SkillDisplayConfig, itemCount: number): number {
|
||||
const configured = Number(config.maxItems) || 0;
|
||||
if (config.mode === "citation" && configured <= 8 && itemCount > configured) {
|
||||
return Math.min(itemCount, MAX_DISPLAY_ITEMS);
|
||||
}
|
||||
return Math.min(
|
||||
MAX_DISPLAY_ITEMS,
|
||||
Math.max(1, configured || inferMaxItems(itemCount, config.mode)),
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { apiFetch } from "@/core/api";
|
||||
|
||||
import type { Skill, SkillState } from "./type";
|
||||
import type { Skill, SkillDisplayConfig, SkillState } from "./type";
|
||||
|
||||
export interface ListSkillsParams {
|
||||
search?: string;
|
||||
@@ -20,6 +20,17 @@ export async function loadSkills(params: ListSkillsParams = {}) {
|
||||
return json.skills as Skill[];
|
||||
}
|
||||
|
||||
export async function loadAllSkillsAdmin() {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills?admin=true`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to load skills");
|
||||
}
|
||||
const json = await response.json();
|
||||
return json.skills as Skill[];
|
||||
}
|
||||
|
||||
export async function getSkill(skillName: string): Promise<Skill> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`,
|
||||
@@ -46,6 +57,62 @@ export async function enableSkill(skillName: string, enabled: boolean) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function setSkillDisplay(
|
||||
skillName: string,
|
||||
display: SkillDisplayConfig | null,
|
||||
sample?: Record<string, unknown> | null,
|
||||
): Promise<Skill> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ display, sample }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to update skill display adapter");
|
||||
}
|
||||
return response.json() as Promise<Skill>;
|
||||
}
|
||||
|
||||
export interface SkillDisplaySampleResponse {
|
||||
skill_name: string;
|
||||
sample: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PersistedSkillDisplaySampleResponse {
|
||||
skill_name: string;
|
||||
sample: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export async function getPersistedSkillDisplaySample(
|
||||
skillName: string,
|
||||
): Promise<PersistedSkillDisplaySampleResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display-sample`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to get persisted skill display sample");
|
||||
}
|
||||
return response.json() as Promise<PersistedSkillDisplaySampleResponse>;
|
||||
}
|
||||
|
||||
export async function getSkillDisplaySample(
|
||||
skillName: string,
|
||||
): Promise<SkillDisplaySampleResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/display-sample`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to get skill display sample");
|
||||
}
|
||||
return response.json() as Promise<SkillDisplaySampleResponse>;
|
||||
}
|
||||
|
||||
export interface InstallSkillRequest {
|
||||
thread_id: string;
|
||||
path: string;
|
||||
@@ -239,6 +306,92 @@ export async function deleteCustomSkill(skillName: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export interface SkillFileNode {
|
||||
name: string;
|
||||
type: "file" | "dir";
|
||||
path: string;
|
||||
size?: number | null;
|
||||
mtime?: number | null;
|
||||
children?: SkillFileNode[] | null;
|
||||
}
|
||||
|
||||
export interface SkillFilesResponse {
|
||||
name: string;
|
||||
category: "public" | "custom";
|
||||
editable: boolean;
|
||||
root: SkillFileNode;
|
||||
}
|
||||
|
||||
export interface SkillFileContentResponse {
|
||||
name: string;
|
||||
category: "public" | "custom";
|
||||
path: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
mtime: number;
|
||||
is_text: boolean;
|
||||
editable: boolean;
|
||||
content?: string | null;
|
||||
}
|
||||
|
||||
export interface SkillFileSaveResponse {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
export async function getSkillFiles(skillName: string): Promise<SkillFilesResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to load skill files");
|
||||
}
|
||||
return response.json() as Promise<SkillFilesResponse>;
|
||||
}
|
||||
|
||||
export async function getSkillFileContent(
|
||||
skillName: string,
|
||||
path: string,
|
||||
): Promise<SkillFileContentResponse> {
|
||||
const url =
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/content` +
|
||||
`?path=${encodeURIComponent(path)}`;
|
||||
const response = await apiFetch(url);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to read skill file");
|
||||
}
|
||||
return response.json() as Promise<SkillFileContentResponse>;
|
||||
}
|
||||
|
||||
/** URL suitable for direct `<img>` / `<iframe>` / `<a download>` embedding. */
|
||||
export function getSkillFileDownloadURL(skillName: string, path: string): string {
|
||||
return (
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/download` +
|
||||
`?path=${encodeURIComponent(path)}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveSkillFile(
|
||||
skillName: string,
|
||||
path: string,
|
||||
content: string,
|
||||
): Promise<SkillFileSaveResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/files/content`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path, content }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to save skill file");
|
||||
}
|
||||
return response.json() as Promise<SkillFileSaveResponse>;
|
||||
}
|
||||
|
||||
export interface SetSkillDetailResponse {
|
||||
name: string;
|
||||
detail: string | null;
|
||||
|
||||
@@ -10,17 +10,21 @@ import {
|
||||
enableSkill,
|
||||
getSkill,
|
||||
getSkillContent,
|
||||
getSkillFileContent,
|
||||
getSkillFiles,
|
||||
installSkillUpload,
|
||||
reconcileCustomSkills,
|
||||
saveSkillFile,
|
||||
setSkillDetail,
|
||||
setSkillDisplay,
|
||||
setSkillPinned,
|
||||
setSkillPublished,
|
||||
setSkillState,
|
||||
validateSkillUpload,
|
||||
} from "./api";
|
||||
import type { SkillState } from "./type";
|
||||
import type { Skill, SkillState } from "./type";
|
||||
|
||||
import { loadSkills } from ".";
|
||||
import { loadAllSkillsAdmin, loadSkills } from ".";
|
||||
|
||||
export function useSkills(params: { search?: string; tagIds?: string[] } = {}) {
|
||||
const search = params.search ?? "";
|
||||
@@ -33,6 +37,21 @@ export function useSkills(params: { search?: string; tagIds?: string[] } = {}) {
|
||||
return { skills: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only listing — every skill in the system (built-in + every user's
|
||||
* custom skills, including unpublished ones owned by other users). Backend
|
||||
* 403s if the caller is not ``system_role === "admin"``.
|
||||
*/
|
||||
export function useAllSkillsAdmin(enabled: boolean) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["skills", "admin", "all"],
|
||||
queryFn: () => loadAllSkillsAdmin(),
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
return { skills: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
export function useSkillContent(skillName: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["skill-content", skillName],
|
||||
@@ -61,6 +80,31 @@ export function useSetSkillDetail() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetSkillDisplay() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
skillName,
|
||||
display,
|
||||
sample,
|
||||
}: {
|
||||
skillName: string;
|
||||
display: Parameters<typeof setSkillDisplay>[1];
|
||||
sample?: Parameters<typeof setSkillDisplay>[2];
|
||||
}) => setSkillDisplay(skillName, display, sample),
|
||||
onSuccess: (data, { skillName }) => {
|
||||
queryClient.setQueryData(["skill", skillName], data);
|
||||
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
|
||||
Array.isArray(current)
|
||||
? current.map((item) => (item.name === skillName ? { ...item, ...data } : item))
|
||||
: current,
|
||||
);
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useEnableSkill() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -118,6 +162,10 @@ export function useSetSkillPublished() {
|
||||
}) => setSkillPublished(skillName, published),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
// Unpublishing triggers the backend cascade that strips this skill
|
||||
// from every referencing agent's config.yaml, so the cached agent
|
||||
// list (agent.skills) is now stale.
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -150,6 +198,50 @@ export function useDeleteCustomSkill() {
|
||||
mutationFn: (skillName: string) => deleteCustomSkill(skillName),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
// Deletion triggers the backend cascade that strips this skill from
|
||||
// every referencing agent's config.yaml, so the cached agent list
|
||||
// (agent.skills) is now stale.
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSkillFiles(skillName: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["skill-files", skillName],
|
||||
queryFn: () => getSkillFiles(skillName!),
|
||||
enabled: !!skillName,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSkillFileContent(skillName: string | null, path: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["skill-file", skillName, path],
|
||||
queryFn: () => getSkillFileContent(skillName!, path!),
|
||||
enabled: !!skillName && !!path,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveSkillFile() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
skillName,
|
||||
path,
|
||||
content,
|
||||
}: {
|
||||
skillName: string;
|
||||
path: string;
|
||||
content: string;
|
||||
}) => saveSkillFile(skillName, path, content),
|
||||
onSuccess: (_data, { skillName, path }) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill-files", skillName] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill-file", skillName, path] });
|
||||
// SKILL.md edits also affect the source-tab view and skill metadata.
|
||||
if (path === "SKILL.md") {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill-content", skillName] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,37 @@ import type { Tag } from "@/core/tags";
|
||||
|
||||
export type SkillState = "active" | "stale" | "archived";
|
||||
export type SkillSource = "agent" | "upload";
|
||||
export type SkillDisplayMode = "citation" | "list" | "table" | "card" | "custom" | "none";
|
||||
export type SkillLinkStrategy = "auto" | "url" | "id";
|
||||
export type SkillCitationToken = "doubleBracket" | "singleBracket";
|
||||
|
||||
export interface SkillDisplayTableColumn {
|
||||
label: string;
|
||||
field: string;
|
||||
width?: string | null;
|
||||
}
|
||||
|
||||
export interface SkillDisplayConfig {
|
||||
enabled: boolean;
|
||||
mode: SkillDisplayMode;
|
||||
resultPath: string;
|
||||
titleField?: string | null;
|
||||
summaryField?: string | null;
|
||||
contentField?: string | null;
|
||||
sourceField?: string | null;
|
||||
authorField?: string | null;
|
||||
typeField?: string | null;
|
||||
timeField?: string | null;
|
||||
scoreField?: string | null;
|
||||
urlField?: string | null;
|
||||
idField?: string | null;
|
||||
linkStrategy: SkillLinkStrategy;
|
||||
linkTemplate?: string | null;
|
||||
citationToken: SkillCitationToken;
|
||||
maxItems: number;
|
||||
customInstruction: string;
|
||||
tableColumns?: SkillDisplayTableColumn[];
|
||||
}
|
||||
|
||||
export interface Skill {
|
||||
name: string;
|
||||
@@ -17,6 +48,8 @@ export interface Skill {
|
||||
// Long-form detail/introduction authored by the skill owner or an admin.
|
||||
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
|
||||
detail?: string | null;
|
||||
display?: SkillDisplayConfig | null;
|
||||
displaySample?: Record<string, unknown> | null;
|
||||
// Lifecycle fields — populated by curator. The main list filters on these
|
||||
// so stale / archived skills appear in dedicated drawers instead.
|
||||
state?: SkillState;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/** Strategy 模块外部 iframe / 嵌入页链接(来自 Vite 环境变量) */
|
||||
|
||||
function trimTrailingSlash(url: string): string {
|
||||
return url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function readEnv(key: string): string {
|
||||
const value = import.meta.env[key];
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
/** B2 网络 xwt 资源管理 iframe 基础地址 */
|
||||
export function getB2ActorBaseUrl(): string {
|
||||
return trimTrailingSlash(readEnv("VITE_AIPI_B2ACTOR_URL"));
|
||||
}
|
||||
|
||||
/** B2 网络 xwt 资源管理 iframe 路径 */
|
||||
export function getNetworkActorIframePath(): string {
|
||||
return readEnv("VITE_AIPI_B2ACTOR_XWT_PATH") || "/fromB/toXWT?isFrame=1";
|
||||
}
|
||||
|
||||
/** 网络 xwt 资源管理完整 iframe URL */
|
||||
export function getNetworkActorIframeUrl(): string {
|
||||
const base = getB2ActorBaseUrl();
|
||||
const path = getNetworkActorIframePath();
|
||||
if (!base) return "";
|
||||
return `${base}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
/** 知识库 iframe 地址换取 API */
|
||||
export function getKnowledgeIframeApiUrl(): string {
|
||||
return readEnv("VITE_AIPI_KNOWLEDGE_IFRAME_API_URL");
|
||||
}
|
||||
|
||||
/** 知识库 iframe 皮肤参数 */
|
||||
export function getKnowledgeIframeSkin(): string {
|
||||
return readEnv("VITE_AIPI_KNOWLEDGE_IFRAME_SKIN") || "red";
|
||||
}
|
||||
@@ -42,3 +42,127 @@ export async function updatePromptPrefixSettings(
|
||||
}
|
||||
return (await response.json()) as PromptPrefixSettings;
|
||||
}
|
||||
|
||||
export interface PrivateSquareStatus {
|
||||
enabled: boolean;
|
||||
password_set: boolean;
|
||||
}
|
||||
|
||||
export interface PrivateSquareUpdate {
|
||||
enabled?: boolean;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface CitationDisplaySettings {
|
||||
cleanup_words: string[];
|
||||
}
|
||||
|
||||
export interface CitationDisplayUpdate {
|
||||
cleanup_words?: string[];
|
||||
}
|
||||
|
||||
export async function loadPrivateSquareStatus(): Promise<PrivateSquareStatus> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/private-square`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to load private square settings: ${response.status}`,
|
||||
);
|
||||
}
|
||||
return (await response.json()) as PrivateSquareStatus;
|
||||
}
|
||||
|
||||
export type ThemeMode = "light" | "dark";
|
||||
|
||||
export interface AppearanceSettings {
|
||||
default_theme: ThemeMode;
|
||||
}
|
||||
|
||||
export interface AppearanceUpdate {
|
||||
default_theme?: ThemeMode;
|
||||
}
|
||||
|
||||
export async function loadAppearanceSettings(): Promise<AppearanceSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/appearance`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to load appearance settings: ${response.status}`,
|
||||
);
|
||||
}
|
||||
return (await response.json()) as AppearanceSettings;
|
||||
}
|
||||
|
||||
export async function updateAppearanceSettings(
|
||||
body: AppearanceUpdate,
|
||||
): Promise<AppearanceSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/appearance`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to update appearance" }));
|
||||
throw new Error(detail.detail ?? "Failed to update appearance");
|
||||
}
|
||||
return (await response.json()) as AppearanceSettings;
|
||||
}
|
||||
|
||||
export async function loadCitationDisplaySettings(): Promise<CitationDisplaySettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/citation-display`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to load citation display settings: ${response.status}`,
|
||||
);
|
||||
}
|
||||
return (await response.json()) as CitationDisplaySettings;
|
||||
}
|
||||
|
||||
export async function updateCitationDisplaySettings(
|
||||
body: CitationDisplayUpdate,
|
||||
): Promise<CitationDisplaySettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/citation-display`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to update citation display" }));
|
||||
throw new Error(detail.detail ?? "Failed to update citation display");
|
||||
}
|
||||
return (await response.json()) as CitationDisplaySettings;
|
||||
}
|
||||
|
||||
export async function updatePrivateSquareSettings(
|
||||
body: PrivateSquareUpdate,
|
||||
): Promise<PrivateSquareStatus> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/private-square`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to update private square" }));
|
||||
throw new Error(detail.detail ?? "Failed to update private square");
|
||||
}
|
||||
return (await response.json()) as PrivateSquareStatus;
|
||||
}
|
||||
@@ -1,12 +1,24 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
loadAppearanceSettings,
|
||||
loadCitationDisplaySettings,
|
||||
loadPrivateSquareStatus,
|
||||
loadPromptPrefixSettings,
|
||||
updateAppearanceSettings,
|
||||
updateCitationDisplaySettings,
|
||||
updatePrivateSquareSettings,
|
||||
updatePromptPrefixSettings,
|
||||
type AppearanceUpdate,
|
||||
type CitationDisplayUpdate,
|
||||
type PrivateSquareUpdate,
|
||||
type PromptPrefixUpdate,
|
||||
} from "./api";
|
||||
|
||||
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
|
||||
const PRIVATE_SQUARE_KEY = ["systemSettings", "privateSquare"] as const;
|
||||
const APPEARANCE_KEY = ["systemSettings", "appearance"] as const;
|
||||
const CITATION_DISPLAY_KEY = ["systemSettings", "citationDisplay"] as const;
|
||||
|
||||
export function usePromptPrefixSettings() {
|
||||
return useQuery({
|
||||
@@ -27,3 +39,66 @@ export function useUpdatePromptPrefixSettings() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePrivateSquareStatus() {
|
||||
return useQuery({
|
||||
queryKey: PRIVATE_SQUARE_KEY,
|
||||
queryFn: () => loadPrivateSquareStatus(),
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePrivateSquareSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: PrivateSquareUpdate) =>
|
||||
updatePrivateSquareSettings(body),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(PRIVATE_SQUARE_KEY, data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAppearanceSettings() {
|
||||
return useQuery({
|
||||
queryKey: APPEARANCE_KEY,
|
||||
queryFn: () => loadAppearanceSettings(),
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
// The provider runs even on the login page (where the user has no auth)
|
||||
// — gracefully tolerate the fetch failing rather than retrying.
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAppearanceSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: AppearanceUpdate) => updateAppearanceSettings(body),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(APPEARANCE_KEY, data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCitationDisplaySettings() {
|
||||
return useQuery({
|
||||
queryKey: CITATION_DISPLAY_KEY,
|
||||
queryFn: () => loadCitationDisplaySettings(),
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCitationDisplaySettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: CitationDisplayUpdate) =>
|
||||
updateCitationDisplaySettings(body),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(CITATION_DISPLAY_KEY, data);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -3,16 +3,20 @@ import type { ThreadsClient } from "@langchain/langgraph-sdk/client";
|
||||
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
|
||||
import { getAPIClient } from "../api";
|
||||
import { apiFetch } from "../api";
|
||||
import { getStoredAuth } from "../auth";
|
||||
import { rememberAskTime } from "../chat-timestamps";
|
||||
import { getBackendBaseURL } from "../config";
|
||||
import { useI18n } from "../i18n/hooks";
|
||||
import type { FileInMessage } from "../messages/utils";
|
||||
import type { LocalSettings } from "../settings";
|
||||
import { isThreadRagModeLocked, lockThreadRagMode, useLocalSettings } from "../settings";
|
||||
import { useUpdateSubtask } from "../tasks/context";
|
||||
import type { UploadedFileInfo } from "../uploads";
|
||||
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
|
||||
@@ -136,6 +140,42 @@ function getStreamErrorMessage(error: unknown): string {
|
||||
return "Request failed.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect "thread not found" / forbidden errors from the LangGraph SDK.
|
||||
*
|
||||
* Backend returns 404 both when a thread truly doesn't exist and when the
|
||||
* currently authenticated user doesn't own it — the SDK surfaces both as
|
||||
* ``HTTP 404: {"detail":"Thread <uuid> not found"}``. The most common cause
|
||||
* we see in practice is a stale tab whose URL still points at the previous
|
||||
* login session's thread. Raw-toasting the 404 just confuses the user, so
|
||||
* we re-frame it around the current login identity.
|
||||
*/
|
||||
function isThreadNotAccessibleError(message: string): boolean {
|
||||
const lowered = message.toLowerCase();
|
||||
if (/thread\s+[\w-]{8,}\s+not\s+found/i.test(message)) {
|
||||
return true;
|
||||
}
|
||||
// Fallback: a bare 404/403 from the threads endpoint when the SDK has
|
||||
// wrapped the detail differently.
|
||||
return (
|
||||
(lowered.includes("http 404") || lowered.includes("http 403")) &&
|
||||
lowered.includes("thread")
|
||||
);
|
||||
}
|
||||
|
||||
function buildCurrentUserLabel(): string {
|
||||
const auth = getStoredAuth();
|
||||
if (!auth) {
|
||||
return "未登录";
|
||||
}
|
||||
const email = auth.email?.trim();
|
||||
if (email) {
|
||||
return email;
|
||||
}
|
||||
const userId = auth.user_id?.trim();
|
||||
return userId ? userId : "未知用户";
|
||||
}
|
||||
|
||||
export function useThreadStream({
|
||||
threadId,
|
||||
context,
|
||||
@@ -146,6 +186,7 @@ export function useThreadStream({
|
||||
onToolEnd,
|
||||
}: ThreadStreamOptions) {
|
||||
const { t } = useI18n();
|
||||
const [localSettings] = useLocalSettings();
|
||||
// Track the thread ID that is currently streaming to handle thread changes during streaming
|
||||
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
|
||||
// Ref to track current thread ID across async callbacks without causing re-renders,
|
||||
@@ -193,6 +234,7 @@ export function useThreadStream({
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
const navigate = useNavigate();
|
||||
const runMetadataStorageRef = useRef<
|
||||
ReturnType<typeof getRunMetadataStorage> | undefined
|
||||
>(undefined);
|
||||
@@ -292,7 +334,40 @@ export function useThreadStream({
|
||||
},
|
||||
onError(error) {
|
||||
setOptimisticMessages([]);
|
||||
toast.error(getStreamErrorMessage(error));
|
||||
const message = getStreamErrorMessage(error);
|
||||
// A "thread not found" 404 almost always means the URL points at a
|
||||
// thread from a previous login session — either this tab outlived a
|
||||
// logout/re-login or the thread genuinely belongs to a different user.
|
||||
// Re-frame the error around the *current* logged-in identity and bounce
|
||||
// the user to a fresh chat instead of leaving them stuck on the stale
|
||||
// route looking at a raw HTTP 404.
|
||||
if (isThreadNotAccessibleError(message)) {
|
||||
const userLabel = buildCurrentUserLabel();
|
||||
toast.info(
|
||||
`当前登录用户为 ${userLabel},原对话不存在或无权访问,正在为你刷新页面…`,
|
||||
);
|
||||
// Prefer staying inside an agent chat context when the user is on an
|
||||
// agent thread URL — otherwise fall back to the global new chat.
|
||||
const hash = typeof window !== "undefined" ? window.location.hash : "";
|
||||
const agentMatch = hash.match(/#\/page\/workspace\/agents\/([^/]+)\//);
|
||||
const target = agentMatch
|
||||
? `/page/workspace/agents/${agentMatch[1]}/chats/new`
|
||||
: "/page/workspace/chats/new";
|
||||
// First navigate to a safe URL so the post-reload page lands on a
|
||||
// valid route. Then full-reload so TanStack Query cache, context
|
||||
// providers and any other state captured under the previous user's
|
||||
// identity get rebuilt against the current login.
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.hash = target;
|
||||
window.setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 900);
|
||||
} else {
|
||||
navigate(target, { replace: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
toast.error(message);
|
||||
},
|
||||
onFinish(state) {
|
||||
listeners.current.onFinish?.(state.values);
|
||||
@@ -304,12 +379,47 @@ export function useThreadStream({
|
||||
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const sendInFlightRef = useRef(false);
|
||||
// Carries the ask time of an in-flight send until the server-assigned human
|
||||
// message id shows up, so we can bind the timestamp to the real id (see the
|
||||
// reconciliation effect below). ``known`` is the set of human ids that
|
||||
// already existed at send time, used to spot the newly-arrived one.
|
||||
const pendingAskTimeRef = useRef<{ ts: string; known: Set<string> } | null>(
|
||||
null,
|
||||
);
|
||||
// Track message count before sending so we know when server has responded
|
||||
const prevMsgCountRef = useRef(thread.messages.length);
|
||||
|
||||
// Reset thread-local pending UI state when switching between threads so
|
||||
// optimistic messages and in-flight guards do not leak across chat views.
|
||||
// Keep a ref to the latest `thread` so the threadId-change effect can stop
|
||||
// an in-flight stream without re-running on every render that produces a
|
||||
// new `thread` identity.
|
||||
const threadStreamRef = useRef(thread);
|
||||
threadStreamRef.current = thread;
|
||||
|
||||
// Reset thread-local pending UI state when the caller actually switches AWAY
|
||||
// from a thread (threadId becomes nullish). This is the "click 新对话 / open
|
||||
// a fresh chat" signal — abort the in-flight SSE and clear optimistic state.
|
||||
//
|
||||
// We deliberately do NOT reset on every threadId change. During a normal
|
||||
// "user just sent the first message on /chats/new" flow, useThreadStream
|
||||
// sees threadId transition from undefined (the landing state) to the
|
||||
// server-assigned thread id once onCreated fires. Stopping the stream and
|
||||
// clearing optimistic messages in that case is exactly what made submitted
|
||||
// questions visually disappear — the live run that just started would be
|
||||
// aborted by the very effect intended to reset stale state.
|
||||
useEffect(() => {
|
||||
if (threadId != null) {
|
||||
// Forward direction: starting or continuing with a real thread.
|
||||
// No reset needed; the upstream useStream will load history for this id.
|
||||
return;
|
||||
}
|
||||
const prevThread = threadStreamRef.current;
|
||||
if (prevThread?.isLoading) {
|
||||
try {
|
||||
prevThread.stop();
|
||||
} catch {
|
||||
// best-effort; nothing useful to do if the SDK fails to abort
|
||||
}
|
||||
}
|
||||
startedRef.current = false;
|
||||
sendInFlightRef.current = false;
|
||||
prevMsgCountRef.current = 0;
|
||||
@@ -327,6 +437,26 @@ export function useThreadStream({
|
||||
}
|
||||
}, [thread.messages.length, optimisticMessages.length]);
|
||||
|
||||
// Bind a just-sent question's ask time to the server-assigned human message
|
||||
// id once it appears. Persists to localStorage so the time shows on every
|
||||
// question and survives reloads, independent of the backend echoing it back.
|
||||
useEffect(() => {
|
||||
const pending = pendingAskTimeRef.current;
|
||||
if (!pending) return;
|
||||
for (const message of thread.messages) {
|
||||
if (
|
||||
message.type === "human" &&
|
||||
message.id &&
|
||||
!message.id.startsWith("opt-") &&
|
||||
!pending.known.has(message.id)
|
||||
) {
|
||||
rememberAskTime(message.id, pending.ts);
|
||||
pendingAskTimeRef.current = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [thread.messages]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (
|
||||
threadId: string,
|
||||
@@ -340,6 +470,20 @@ export function useThreadStream({
|
||||
sendInFlightRef.current = true;
|
||||
|
||||
const text = message.text.trim();
|
||||
// Wall-clock time the question was asked. Stored on the human message so
|
||||
// the chat can show "几点提问" and have it survive a reload (it round-trips
|
||||
// through the checkpoint inside additional_kwargs, same as `files`).
|
||||
const clientTs = new Date().toISOString();
|
||||
// Remember which human messages already exist so the reconciliation
|
||||
// effect can bind ``clientTs`` to the new one once the server returns it.
|
||||
pendingAskTimeRef.current = {
|
||||
ts: clientTs,
|
||||
known: new Set(
|
||||
thread.messages
|
||||
.filter((m) => m.type === "human" && m.id)
|
||||
.map((m) => m.id as string),
|
||||
),
|
||||
};
|
||||
|
||||
// Capture current count before showing optimistic messages
|
||||
prevMsgCountRef.current = thread.messages.length;
|
||||
@@ -356,6 +500,7 @@ export function useThreadStream({
|
||||
const hideFromUI = options?.additionalKwargs?.hide_from_ui === true;
|
||||
const optimisticAdditionalKwargs = {
|
||||
...options?.additionalKwargs,
|
||||
client_ts: clientTs,
|
||||
...(optimisticFiles.length > 0 ? { files: optimisticFiles } : {}),
|
||||
};
|
||||
|
||||
@@ -433,7 +578,10 @@ export function useThreadStream({
|
||||
return [
|
||||
{
|
||||
...humanMessage,
|
||||
additional_kwargs: { files: uploadedFiles },
|
||||
additional_kwargs: {
|
||||
...humanMessage.additional_kwargs,
|
||||
files: uploadedFiles,
|
||||
},
|
||||
},
|
||||
...messages.slice(1),
|
||||
];
|
||||
@@ -464,6 +612,12 @@ export function useThreadStream({
|
||||
}),
|
||||
);
|
||||
|
||||
const ragModeEnabled =
|
||||
localSettings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
if (ragModeEnabled) {
|
||||
lockThreadRagMode(threadId);
|
||||
}
|
||||
|
||||
await thread.submit(
|
||||
{
|
||||
messages: [
|
||||
@@ -477,6 +631,7 @@ export function useThreadStream({
|
||||
],
|
||||
additional_kwargs: {
|
||||
...options?.additionalKwargs,
|
||||
client_ts: clientTs,
|
||||
...(filesForSubmit.length > 0
|
||||
? { files: filesForSubmit }
|
||||
: {}),
|
||||
@@ -507,6 +662,7 @@ export function useThreadStream({
|
||||
? "low"
|
||||
: undefined),
|
||||
memory_injection_enabled: context.memory_injection_enabled ?? true,
|
||||
rag_mode_enabled: ragModeEnabled,
|
||||
thread_id: threadId,
|
||||
},
|
||||
},
|
||||
@@ -520,7 +676,7 @@ export function useThreadStream({
|
||||
sendInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[thread, _handleOnStart, t.uploads.uploadingFiles, context, queryClient],
|
||||
[thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient],
|
||||
);
|
||||
|
||||
// Merge thread with optimistic messages for display
|
||||
|
||||
@@ -18,8 +18,9 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
writing_artifact_path?: string;
|
||||
memory_injection_enabled?: boolean;
|
||||
agent_id?: string;
|
||||
memory_injection_enabled?: boolean;
|
||||
rag_mode_enabled?: boolean;
|
||||
agent_id?: string;
|
||||
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */
|
||||
agent_name?: string;
|
||||
}
|
||||
|
||||
@@ -19,11 +19,18 @@ interface Shortcut {
|
||||
export function useGlobalShortcuts(shortcuts: Shortcut[]) {
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
// ``event.key`` is ``undefined`` for synthetic / IME-composition /
|
||||
// some extension-injected keydown events. Calling ``.toLowerCase()``
|
||||
// on it throws and kills every shortcut after this one — bail out
|
||||
// before that happens.
|
||||
if (typeof event.key !== "string") return;
|
||||
const meta = event.metaKey || event.ctrlKey;
|
||||
const pressed = event.key.toLowerCase();
|
||||
|
||||
for (const shortcut of shortcuts) {
|
||||
if (typeof shortcut.key !== "string") continue;
|
||||
if (
|
||||
event.key.toLowerCase() === shortcut.key.toLowerCase() &&
|
||||
pressed === shortcut.key.toLowerCase() &&
|
||||
meta === shortcut.meta &&
|
||||
(shortcut.shift ?? false) === event.shiftKey
|
||||
) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "./styles/globals.css";
|
||||
import "tdesign-react/es/style/index.css";
|
||||
import "katex/dist/katex.min.css";
|
||||
|
||||
import React from "react";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { PenLine } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { getBackendBaseURL } from '@/core/config';
|
||||
import { apiFetch } from '@/core/api';
|
||||
import { useModels } from '@/core/models/hooks';
|
||||
|
||||
import type { AIWritingRequest } from '../../types/ai-writing';
|
||||
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||
@@ -23,11 +22,6 @@ interface AIWritingFormProps {
|
||||
hideAdvancedFields?: boolean;
|
||||
}
|
||||
|
||||
interface ModelOption {
|
||||
name: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
const INPUT_CLASS =
|
||||
'flex h-12 w-full rounded-xl border border-[#e8dedd] bg-white px-4 py-2 text-sm shadow-sm ' +
|
||||
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
|
||||
@@ -52,10 +46,24 @@ export function AIWritingForm({
|
||||
const [wordCountInput, setWordCountInput] = useState('800');
|
||||
const [strictMode, setStrictMode] = useState(false);
|
||||
const [modelName, setModelName] = useState('');
|
||||
const [models, setModels] = useState<ModelOption[]>([]);
|
||||
const { models, isLoading: modelsLoading } = useModels();
|
||||
|
||||
const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes();
|
||||
|
||||
// 首次 /api/models 返回后默认选第一项;配置变更导致当前值不在列表时也回退到首项。
|
||||
useEffect(() => {
|
||||
if (models.length === 0) return;
|
||||
const stillExists = models.some((m) => m.name === modelName);
|
||||
if (!modelName || !stillExists) {
|
||||
setModelName(models[0].name);
|
||||
}
|
||||
}, [models, modelName]);
|
||||
|
||||
const selectedModel = useMemo(
|
||||
() => models.find((m) => m.name === modelName) ?? models[0],
|
||||
[models, modelName],
|
||||
);
|
||||
|
||||
// 初始化时加载文章类型
|
||||
useEffect(() => {
|
||||
refreshTypes();
|
||||
@@ -81,25 +89,6 @@ export function AIWritingForm({
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch(`${getBackendBaseURL()}/api/models`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const list: ModelOption[] = (data.models ?? []).map(
|
||||
(m: { name: string; display_name?: string }) => ({
|
||||
name: m.name,
|
||||
displayName: m.display_name || m.name,
|
||||
}),
|
||||
);
|
||||
setModels(list);
|
||||
if (list.length > 0 && !modelName) {
|
||||
setModelName(list[0].name);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function handleWordCountBlur() {
|
||||
const num = parseInt(wordCountInput, 10);
|
||||
if (isNaN(num) || num < 100) {
|
||||
@@ -122,7 +111,7 @@ export function AIWritingForm({
|
||||
wordCountTarget: wordCount,
|
||||
keywordCount: DEFAULT_KEYWORD_COUNT,
|
||||
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
|
||||
modelName: modelName || undefined,
|
||||
modelName: selectedModel?.name || modelName || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -192,17 +181,25 @@ export function AIWritingForm({
|
||||
<div className="mt-4 space-y-2">
|
||||
<label className={FIELD_LABEL_CLASS}>写作模型</label>
|
||||
<Select
|
||||
value={modelName}
|
||||
value={selectedModel?.name ?? ''}
|
||||
onValueChange={setModelName}
|
||||
disabled={disabled || models.length === 0}
|
||||
disabled={disabled || modelsLoading || models.length === 0}
|
||||
>
|
||||
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
|
||||
<SelectValue placeholder={models.length > 0 ? '选择模型…' : '模型加载中…'} />
|
||||
<SelectValue
|
||||
placeholder={
|
||||
modelsLoading
|
||||
? '模型加载中…'
|
||||
: models.length > 0
|
||||
? '选择模型…'
|
||||
: '暂无可用模型'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((m) => (
|
||||
<SelectItem key={m.name} value={m.name} className="text-sm">
|
||||
{m.displayName}
|
||||
{m.display_name || m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -38,6 +38,8 @@ const ENABLE_PAUSE_RESUME_UI = false;
|
||||
import { AIWritingForm } from './AIWritingForm';
|
||||
import { AIWritingTimeline } from './AIWritingTimeline';
|
||||
import { InterventionCard } from './InterventionCard';
|
||||
import { SkillStepsCard } from './SkillStepsCard';
|
||||
import { SkillSelectStreamingView } from './SkillSelectStreamingView';
|
||||
import { WritingSkeletonCard } from './WritingSkeletonCard';
|
||||
import { DraftStreamingView } from './DraftStreamingView';
|
||||
import { IntentResultCard } from './IntentResultCard';
|
||||
@@ -411,6 +413,8 @@ export function AIWritingPanel({
|
||||
reviewResult={state.reviewResult}
|
||||
draftTitle={state.currentDraftTitle}
|
||||
revisionCount={state.revisionCount}
|
||||
skillSearchSteps={state.skillSearchSteps}
|
||||
skillSearchInProgress={state.status === 'researching' && state.skillSearchSteps.length > 0}
|
||||
onSubmit={submitIntervention}
|
||||
disabled={false}
|
||||
/>
|
||||
@@ -444,10 +448,27 @@ export function AIWritingPanel({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 技能选择 LLM 流式打字(含 <think> 思考分离),select_skills 完成后自动清空 */}
|
||||
{state.status === 'researching' && (state.skillSelectStreamingText || state.skillSelectThinkingText) && (
|
||||
<SkillSelectStreamingView
|
||||
text={state.skillSelectStreamingText}
|
||||
thinkingText={state.skillSelectThinkingText}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 研究中 + 已有技能步骤:实时流式展示技能检索过程,避免 skeleton 卡片掩盖步骤 */}
|
||||
{state.status === 'researching' && state.skillSearchSteps.length > 0 && (
|
||||
<div className="px-4 pb-2">
|
||||
<SkillStepsCard steps={state.skillSearchSteps} inProgress />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSkeleton &&
|
||||
state.status !== 'writing_draft' &&
|
||||
!(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) &&
|
||||
!(state.status === 'reviewing' && state.streamingReviewItems.length > 0) &&
|
||||
/* 技能检索已经在流式展示步骤 / 选择打字机,不需要再叠加 skeleton */
|
||||
!(state.status === 'researching' && (state.skillSearchSteps.length > 0 || state.skillSelectStreamingText || state.skillSelectThinkingText)) &&
|
||||
/* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */
|
||||
!state.intentStreamingText && !state.intentThinkingText && (
|
||||
<div className="px-4 pb-4">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useRef, useState } from 'react';
|
||||
import {
|
||||
PauseCircle, CheckCircle2, RotateCcw, Search,
|
||||
RefreshCw, PenLine, ArrowRight, ShieldAlert, ClipboardEdit, SendHorizonal,
|
||||
Trash2, BookOpen,
|
||||
Trash2, BookOpen, Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -10,11 +10,13 @@ import type {
|
||||
AIMaterial, AIOutline, ReviewResult, ReviewIssue,
|
||||
ProgressEvent, UserInterventionPayload,
|
||||
BlockedSection, SectionDecisionKind,
|
||||
SkillSearchStep,
|
||||
} from '../../types/ai-writing';
|
||||
import { MaterialsCard } from './MaterialsCard';
|
||||
import { MaterialsToolbar } from './MaterialsToolbar';
|
||||
import { OutlineCard } from './OutlineCard';
|
||||
import { ReviewCard } from './ReviewCard';
|
||||
import { SkillStepsCard } from './SkillStepsCard';
|
||||
|
||||
interface InterventionCardProps {
|
||||
pauseEvent: ProgressEvent;
|
||||
@@ -23,18 +25,29 @@ interface InterventionCardProps {
|
||||
reviewResult: ReviewResult | null;
|
||||
draftTitle: string;
|
||||
revisionCount: number;
|
||||
skillSearchSteps?: SkillSearchStep[];
|
||||
/** 是否正在检索中(researching/awaiting 切换之间,前端用于流式展示步骤进行中) */
|
||||
skillSearchInProgress?: boolean;
|
||||
onSubmit: (payload: UserInterventionPayload) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function InterventionCard({
|
||||
pauseEvent, materials, outline, reviewResult,
|
||||
draftTitle, revisionCount, onSubmit, disabled,
|
||||
draftTitle, revisionCount, skillSearchSteps, skillSearchInProgress, onSubmit, disabled,
|
||||
}: InterventionCardProps) {
|
||||
const pausePoint = pauseEvent.pausePoint;
|
||||
|
||||
if (pausePoint === 'material_confirm') {
|
||||
return <MaterialConfirmCard materials={materials} onSubmit={onSubmit} disabled={disabled} />;
|
||||
return (
|
||||
<MaterialConfirmCard
|
||||
materials={materials}
|
||||
skillSearchSteps={skillSearchSteps ?? []}
|
||||
skillSearchInProgress={skillSearchInProgress}
|
||||
onSubmit={onSubmit}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (pausePoint === 'outline_confirm') {
|
||||
@@ -106,30 +119,40 @@ function formatSelectedIssues(issues: ReviewIssue[], selectedIndices: Set<number
|
||||
|
||||
interface MaterialConfirmCardProps {
|
||||
materials: AIMaterial[];
|
||||
skillSearchSteps: SkillSearchStep[];
|
||||
skillSearchInProgress?: boolean;
|
||||
onSubmit: (payload: UserInterventionPayload) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function MaterialConfirmCard({ materials, onSubmit, disabled }: MaterialConfirmCardProps) {
|
||||
function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgress, onSubmit, disabled }: MaterialConfirmCardProps) {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(
|
||||
() => new Set(materials.map((m) => m.id)),
|
||||
);
|
||||
const [extraKeywords, setExtraKeywords] = useState<string[]>([]);
|
||||
const [userQuery, setUserQuery] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleConfirm() {
|
||||
setSubmitting(true);
|
||||
await onSubmit({ action: 'confirm', approvedMaterialIds: [...selectedIds], extraKeywords });
|
||||
await onSubmit({ action: 'confirm', approvedMaterialIds: [...selectedIds], userQuery: userQuery.trim() || undefined });
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
async function handleReSearch() {
|
||||
setSubmitting(true);
|
||||
await onSubmit({ action: 're_search', approvedMaterialIds: [], extraKeywords });
|
||||
await onSubmit({ action: 're_search', approvedMaterialIds: [], userQuery: userQuery.trim() || undefined });
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
const hasExtra = extraKeywords.length > 0;
|
||||
async function handleEnableSkillSearch() {
|
||||
setSubmitting(true);
|
||||
await onSubmit({ action: 'enable_skill_search', userQuery: userQuery.trim() || undefined });
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
const hasQuery = userQuery.trim().length > 0;
|
||||
// 当前素材是否来自技能检索:根据素材 sourceType 推断(覆盖式替换后全是 skill)
|
||||
const isSkillResult = materials.length > 0 && materials.every((m) => m.sourceType === 'skill');
|
||||
|
||||
// 空数据态:未检索到任何素材,提示用户重新搜索或在无素材情况下继续
|
||||
if (materials.length === 0) {
|
||||
@@ -140,33 +163,45 @@ function MaterialConfirmCard({ materials, onSubmit, disabled }: MaterialConfirmC
|
||||
<div>
|
||||
<p className="text-xs font-medium">未检索到任何素材</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
素材收集专家本次没有检索到相关数据。你可以补充关键词重新搜索,或在无素材的情况下直接继续写作(文章将仅依据写作意图生成)。
|
||||
素材收集专家本次没有检索到相关数据。你可以补充关键词重新搜索、启用技能检索,或在无素材的情况下直接继续写作。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(skillSearchSteps.length > 0 || skillSearchInProgress) && (
|
||||
<SkillStepsCard steps={skillSearchSteps} inProgress={skillSearchInProgress} />
|
||||
)}
|
||||
|
||||
<MaterialsToolbar
|
||||
materials={materials}
|
||||
selectedIds={selectedIds}
|
||||
onSelectAll={() => setSelectedIds(new Set())}
|
||||
onDeselectAll={() => setSelectedIds(new Set())}
|
||||
extraKeywords={extraKeywords}
|
||||
onAddKeyword={(kw) => setExtraKeywords((prev) => [...prev, kw])}
|
||||
onRemoveKeyword={(kw) => setExtraKeywords((prev) => prev.filter((k) => k !== kw))}
|
||||
userQuery={userQuery}
|
||||
onUserQueryChange={setUserQuery}
|
||||
onSubmitQuery={handleReSearch}
|
||||
disabled={disabled || submitting}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<Button
|
||||
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
|
||||
type="button" size="sm" className="flex-1 min-w-[140px] h-8 text-xs gap-1.5"
|
||||
onClick={handleReSearch} disabled={disabled || submitting}
|
||||
>
|
||||
{hasExtra
|
||||
? <><Search className="size-3.5" />追加搜索</>
|
||||
{hasQuery
|
||||
? <><Search className="size-3.5" />按输入重新搜索</>
|
||||
: <><RotateCcw className="size-3.5" />重新搜索</>}
|
||||
</Button>
|
||||
<Button
|
||||
type="button" variant="outline" size="sm"
|
||||
className="h-8 text-xs gap-1.5 shrink-0 border-violet-200 text-violet-700 hover:bg-violet-50 dark:border-violet-800 dark:text-violet-300 dark:hover:bg-violet-950/30"
|
||||
onClick={handleEnableSkillSearch} disabled={disabled || submitting}
|
||||
>
|
||||
<Sparkles className="size-3.5" />技能检索
|
||||
</Button>
|
||||
<Button
|
||||
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
|
||||
onClick={handleConfirm} disabled={disabled || submitting}
|
||||
onClick={() => onSubmit({ action: 'confirm', approvedMaterialIds: [] })} disabled={disabled || submitting}
|
||||
>
|
||||
<ArrowRight className="size-3.5" />无数据继续写作
|
||||
</Button>
|
||||
@@ -182,11 +217,14 @@ function MaterialConfirmCard({ materials, onSubmit, disabled }: MaterialConfirmC
|
||||
<div>
|
||||
<p className="text-xs font-medium">需要您确认素材,才能继续</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
已搜集到 {materials.length} 条素材(默认全选)。您可以取消勾选不相关的素材,或补充关键词追加搜索。
|
||||
已搜集到 {materials.length} 条{isSkillResult ? '技能素材' : '素材'}(默认全选)。
|
||||
您可以取消勾选不相关的素材,或补充关键词追加搜索{isSkillResult ? '' : ',也可以启用技能检索从已启用的技能中获取素材'}。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{skillSearchSteps.length > 0 && <SkillStepsCard steps={skillSearchSteps} />}
|
||||
|
||||
<MaterialsCard materials={materials} selectedIds={selectedIds} onToggle={(id) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -200,24 +238,34 @@ function MaterialConfirmCard({ materials, onSubmit, disabled }: MaterialConfirmC
|
||||
selectedIds={selectedIds}
|
||||
onSelectAll={() => setSelectedIds(new Set(materials.map((m) => m.id)))}
|
||||
onDeselectAll={() => setSelectedIds(new Set())}
|
||||
extraKeywords={extraKeywords}
|
||||
onAddKeyword={(kw) => setExtraKeywords((prev) => [...prev, kw])}
|
||||
onRemoveKeyword={(kw) => setExtraKeywords((prev) => prev.filter((k) => k !== kw))}
|
||||
userQuery={userQuery}
|
||||
onUserQueryChange={setUserQuery}
|
||||
onSubmitQuery={handleReSearch}
|
||||
disabled={disabled || submitting}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<Button
|
||||
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
|
||||
type="button" size="sm" className="flex-1 min-w-[180px] h-8 text-xs gap-1.5"
|
||||
onClick={handleConfirm} disabled={disabled || submitting || selectedIds.size === 0}
|
||||
>
|
||||
<CheckCircle2 className="size-3.5" />确认素材,开始规划大纲
|
||||
</Button>
|
||||
{!isSkillResult && (
|
||||
<Button
|
||||
type="button" variant="outline" size="sm"
|
||||
className="h-8 text-xs gap-1.5 shrink-0 border-violet-200 text-violet-700 hover:bg-violet-50 dark:border-violet-800 dark:text-violet-300 dark:hover:bg-violet-950/30"
|
||||
onClick={handleEnableSkillSearch} disabled={disabled || submitting}
|
||||
>
|
||||
<Sparkles className="size-3.5" />技能检索
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
|
||||
onClick={handleReSearch} disabled={disabled || submitting}
|
||||
>
|
||||
{hasExtra
|
||||
? <><Search className="size-3.5" />追加搜索</>
|
||||
{hasQuery
|
||||
? <><Search className="size-3.5" />按输入重新搜索</>
|
||||
: <><RotateCcw className="size-3.5" />重新搜索</>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronRight, ExternalLink } from 'lucide-react';
|
||||
import { ChevronRight, ExternalLink, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buildIntranetSourceUrl } from '@/core/config';
|
||||
import type { AIMaterial } from '../../types/ai-writing';
|
||||
@@ -26,12 +26,14 @@ function relevanceLabel(score: number): string {
|
||||
export function MaterialItem({ material, checked, onToggle }: MaterialItemProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const pct = Math.round(material.relevanceScore * 100);
|
||||
const isSkill = material.sourceType === 'skill';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border text-xs transition-colors',
|
||||
checked ? 'border-border bg-background' : 'border-border/50 bg-muted/30',
|
||||
isSkill && checked && 'border-violet-300/70 dark:border-violet-800/60',
|
||||
)}
|
||||
>
|
||||
{/* 头部行 */}
|
||||
@@ -50,6 +52,12 @@ export function MaterialItem({ material, checked, onToggle }: MaterialItemProps)
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex flex-1 items-start gap-1.5 text-left"
|
||||
>
|
||||
{isSkill && (
|
||||
<span className="mt-0.5 inline-flex items-center gap-0.5 px-1 py-0.5 rounded text-[10px] font-medium bg-violet-100 text-violet-700 dark:bg-violet-900/60 dark:text-violet-200 shrink-0">
|
||||
<Sparkles className="size-2.5" />
|
||||
技能
|
||||
</span>
|
||||
)}
|
||||
<span className={cn('flex-1 font-medium leading-snug line-clamp-2', !checked && 'text-muted-foreground')}>
|
||||
{material.title}
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useRef } from 'react';
|
||||
import type { AIMaterial } from '../../types/ai-writing';
|
||||
|
||||
interface MaterialsToolbarProps {
|
||||
@@ -8,9 +6,13 @@ interface MaterialsToolbarProps {
|
||||
selectedIds: Set<string>;
|
||||
onSelectAll: () => void;
|
||||
onDeselectAll: () => void;
|
||||
extraKeywords: string[];
|
||||
onAddKeyword: (kw: string) => void;
|
||||
onRemoveKeyword: (kw: string) => void;
|
||||
/** 单输入框文本:可以是技能名、检索来源、补充检索意图 */
|
||||
userQuery: string;
|
||||
onUserQueryChange: (text: string) => void;
|
||||
/** 回车键提交(默认重新搜索;详细按钮交给父组件渲染) */
|
||||
onSubmitQuery?: () => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function MaterialsToolbar({
|
||||
@@ -18,18 +20,27 @@ export function MaterialsToolbar({
|
||||
selectedIds,
|
||||
onSelectAll,
|
||||
onDeselectAll,
|
||||
extraKeywords,
|
||||
onAddKeyword,
|
||||
onRemoveKeyword,
|
||||
userQuery,
|
||||
onUserQueryChange,
|
||||
onSubmitQuery,
|
||||
placeholder,
|
||||
disabled,
|
||||
}: MaterialsToolbarProps) {
|
||||
const [kwInput, setKwInput] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const allSelected = materials.length > 0 && selectedIds.size === materials.length;
|
||||
|
||||
function handleAddKeyword() {
|
||||
const kw = kwInput.trim();
|
||||
if (!kw || extraKeywords.includes(kw)) return;
|
||||
onAddKeyword(kw);
|
||||
setKwInput('');
|
||||
function handleInput(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||
onUserQueryChange(e.target.value);
|
||||
const el = e.target;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.min(el.scrollHeight, 96)}px`;
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (e.key === 'Enter' && !e.shiftKey && onSubmitQuery) {
|
||||
e.preventDefault();
|
||||
onSubmitQuery();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -51,50 +62,21 @@ export function MaterialsToolbar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 补充关键词 */}
|
||||
{/* 单输入框 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">补充搜索关键词(可选)</label>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={kwInput}
|
||||
onChange={(e) => setKwInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAddKeyword()}
|
||||
placeholder="输入关键词后按 Enter 或点击 +"
|
||||
className="flex-1 h-7 rounded-md border border-input bg-background px-2.5 py-1 text-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={handleAddKeyword}
|
||||
disabled={!kwInput.trim()}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 已添加关键词 Tag 列表 */}
|
||||
{extraKeywords.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{extraKeywords.map((kw) => (
|
||||
<span
|
||||
key={kw}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary/10 text-primary px-2 py-0.5 text-xs"
|
||||
>
|
||||
{kw}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveKeyword(kw)}
|
||||
className="hover:text-destructive leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<label className="text-xs text-muted-foreground">
|
||||
补充检索(可选)— 写下你想检索的内容,然后点"重新搜索"或"技能检索"
|
||||
</label>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
value={userQuery}
|
||||
onChange={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder ?? '例如:想查看各大平台销量 / 2025 年财报 / 行业头部公司近期动态…'}
|
||||
disabled={disabled}
|
||||
className="w-full resize-none rounded-md border border-input bg-background px-2.5 py-1.5 text-xs leading-5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50 min-h-[28px] max-h-24"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Sparkles, ChevronDown, ChevronRight, Brain, Zap } from 'lucide-react';
|
||||
|
||||
interface SkillSelectStreamingViewProps {
|
||||
text: string;
|
||||
thinkingText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从流式 JSON 文本里增量提取已经识别出的技能名。
|
||||
*
|
||||
* LLM 输出形如 `{"selected_skills": ["skill-a", "skill-b", "skill-c"], "reason": "..."}`,
|
||||
* 我们不等整段 JSON 解析完,逐字符扫描"已闭合"的双引号字符串:
|
||||
* - 找到 `"selected_skills"`(或末尾不完整时回退兜底)后取后续内容
|
||||
* - 在该位置之后的 `[ ... ]` 内逐个匹配 `"..."`
|
||||
* - 只取**已闭合**的字符串(结尾必须是 `"`),确保不把正在打字的半截名字露出来
|
||||
*/
|
||||
function extractStreamingSkillNames(text: string): string[] {
|
||||
if (!text) return [];
|
||||
// 定位 selected_skills 的值起点(容错:允许中英文引号 / 不同空白)
|
||||
const keyMatch = text.match(/["']?selected_skills["']?\s*:\s*\[/);
|
||||
if (!keyMatch) return [];
|
||||
const start = keyMatch.index! + keyMatch[0].length;
|
||||
// 截取数组段(找到对应的 ],或者文本结尾)
|
||||
const rest = text.slice(start);
|
||||
const endIdx = rest.indexOf(']');
|
||||
const arrBody = endIdx >= 0 ? rest.slice(0, endIdx) : rest;
|
||||
// 只提取已经"闭合"的字符串:[^"\\]*(?:\\.[^"\\]*)* 处理转义引号
|
||||
const names: string[] = [];
|
||||
const re = /"((?:[^"\\]|\\.)*)"/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(arrBody)) !== null) {
|
||||
const name = m[1].trim();
|
||||
if (name) names.push(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* 技能选择 LLM 流式打字机视图。
|
||||
*
|
||||
* - 思考过程(<think></think> 内)用 amber 折叠面板(与 OutlineStreamingView 风格一致)
|
||||
* - 正文 JSON 用 violet 主题(与 SkillStepsCard 同色系,强调"技能"语义)
|
||||
* - 两边都自动滚动到底;正文带闪烁光标,思考过程在正文未开始时也有光标
|
||||
*/
|
||||
export function SkillSelectStreamingView({ text, thinkingText }: SkillSelectStreamingViewProps) {
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
const thinkRef = useRef<HTMLPreElement>(null);
|
||||
const [thinkOpen, setThinkOpen] = useState(true);
|
||||
|
||||
// 实时从流式 JSON 中增量解析出已识别的技能名
|
||||
const streamingSkills = useMemo(() => extractStreamingSkillNames(text), [text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (preRef.current) preRef.current.scrollTop = preRef.current.scrollHeight;
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (thinkOpen && thinkRef.current) thinkRef.current.scrollTop = thinkRef.current.scrollHeight;
|
||||
}, [thinkingText, thinkOpen]);
|
||||
|
||||
if (!text && !thinkingText) return null;
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-2 space-y-2">
|
||||
{thinkingText && (
|
||||
<div className="rounded-xl border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setThinkOpen((v) => !v)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 border-b border-amber-200/60 dark:border-amber-900/60 bg-amber-100/40 dark:bg-amber-900/30 hover:bg-amber-100/70 dark:hover:bg-amber-900/50 transition-colors"
|
||||
>
|
||||
<Brain className="size-3.5 text-amber-500 shrink-0" />
|
||||
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||
思考过程
|
||||
</span>
|
||||
<span className="ml-auto text-amber-400">
|
||||
{thinkOpen
|
||||
? <ChevronDown className="size-3.5" />
|
||||
: <ChevronRight className="size-3.5" />}
|
||||
</span>
|
||||
</button>
|
||||
{thinkOpen && (
|
||||
<pre
|
||||
ref={thinkRef}
|
||||
className="text-[11px] font-mono leading-relaxed p-3 text-amber-700/70 dark:text-amber-300/60 whitespace-pre-wrap max-h-40 overflow-y-auto italic"
|
||||
>
|
||||
{thinkingText}
|
||||
{!text && (
|
||||
<span className="inline-block w-0.5 h-3.5 bg-amber-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
|
||||
)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{text && (
|
||||
<div className="rounded-xl border border-violet-200 dark:border-violet-900 bg-violet-50/50 dark:bg-violet-950/20 overflow-hidden shadow-sm">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-violet-200/60 dark:border-violet-900/60 bg-violet-100/40 dark:bg-violet-900/30">
|
||||
<Sparkles className="size-3.5 text-violet-500 shrink-0" />
|
||||
<span className="text-xs font-medium text-violet-600 dark:text-violet-400">
|
||||
正在筛选相关技能
|
||||
{streamingSkills.length > 0 && (
|
||||
<span className="ml-1 text-violet-500/80">
|
||||
· 已识别 {streamingSkills.length} 个
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="ml-auto inline-flex gap-1">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="size-1 rounded-full bg-violet-400 animate-bounce"
|
||||
style={{ animationDelay: `${i * 150}ms` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 已识别的技能名:每个新出现的 badge 会 fade-in,给用户即时反馈 */}
|
||||
{streamingSkills.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 px-3 py-2 border-b border-violet-200/40 dark:border-violet-900/40 bg-violet-50/40 dark:bg-violet-950/10">
|
||||
{streamingSkills.map((name, idx) => (
|
||||
<span
|
||||
key={`${name}-${idx}`}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-mono bg-violet-100 dark:bg-violet-900/60 text-violet-700 dark:text-violet-200 animate-[skill-fade-in_0.25s_ease-out]"
|
||||
style={{ animationFillMode: 'both' }}
|
||||
>
|
||||
<Zap className="size-2.5 shrink-0" />
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<pre
|
||||
ref={preRef}
|
||||
className="text-[11px] font-mono leading-relaxed p-3 text-muted-foreground whitespace-pre-wrap max-h-52 overflow-y-auto"
|
||||
>
|
||||
{text}
|
||||
<span className="inline-block w-0.5 h-3.5 bg-violet-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
@keyframes skill-fade-in {
|
||||
from { opacity: 0; transform: translateY(-2px) scale(0.95); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronRight, Sparkles, ListChecks, Zap, Loader2, CheckCircle2, CircleStop } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SkillSearchStep } from '../../types/ai-writing';
|
||||
import { CollapseTransition } from './CollapseTransition';
|
||||
|
||||
interface SkillStepsCardProps {
|
||||
steps: SkillSearchStep[];
|
||||
/** 是否仍在进行中(researching 状态下显示 spinner,让用户看到流式进展) */
|
||||
inProgress?: boolean;
|
||||
}
|
||||
|
||||
const STEP_ICON: Record<SkillSearchStep['type'], typeof Sparkles> = {
|
||||
list_skills: ListChecks,
|
||||
select_skills: Sparkles,
|
||||
invoke_skill: Zap,
|
||||
skill_result: CheckCircle2,
|
||||
stop_early: CircleStop,
|
||||
};
|
||||
|
||||
const STEP_LABEL: Record<SkillSearchStep['type'], string> = {
|
||||
list_skills: '列出可用技能',
|
||||
select_skills: '筛选相关技能',
|
||||
invoke_skill: '调用技能',
|
||||
skill_result: '检索结果',
|
||||
stop_early: '提前停止',
|
||||
};
|
||||
|
||||
export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
if (steps.length === 0 && !inProgress) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-violet-200 bg-violet-50/40 dark:border-violet-900/40 dark:bg-violet-950/20 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-medium hover:bg-violet-100/50 dark:hover:bg-violet-900/30 transition-colors text-left"
|
||||
>
|
||||
{inProgress
|
||||
? <Loader2 className="size-3.5 text-violet-500 shrink-0 animate-spin" />
|
||||
: <Sparkles className="size-3.5 text-violet-500 shrink-0" />}
|
||||
<span className="flex-1">
|
||||
技能检索过程
|
||||
<span className="ml-1 text-muted-foreground font-normal">
|
||||
(共 {steps.length} 步{inProgress ? ',进行中…' : ''})
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-3 text-muted-foreground transition-transform duration-200',
|
||||
expanded && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<CollapseTransition open={expanded}>
|
||||
<ol className="px-3 pb-2.5 pt-1 space-y-1.5 border-t border-violet-200/60 dark:border-violet-900/30">
|
||||
{steps.map((step, idx) => {
|
||||
const Icon = STEP_ICON[step.type] ?? Sparkles;
|
||||
const label = STEP_LABEL[step.type] ?? step.type;
|
||||
const isStopEarly = step.type === 'stop_early';
|
||||
const isResult = step.type === 'skill_result';
|
||||
return (
|
||||
<li key={idx} className="flex items-start gap-2 text-[11px]">
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 mt-0.5 inline-flex items-center justify-center size-4 rounded-full text-[10px] font-medium',
|
||||
isStopEarly
|
||||
? 'bg-amber-100 dark:bg-amber-900/60 text-amber-600 dark:text-amber-300'
|
||||
: isResult
|
||||
? 'bg-emerald-100 dark:bg-emerald-900/60 text-emerald-600 dark:text-emerald-300'
|
||||
: 'bg-violet-100 dark:bg-violet-900/60 text-violet-600 dark:text-violet-300',
|
||||
)}
|
||||
>
|
||||
{idx + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 font-medium',
|
||||
isStopEarly
|
||||
? 'text-amber-700 dark:text-amber-300'
|
||||
: isResult
|
||||
? 'text-emerald-700 dark:text-emerald-300'
|
||||
: 'text-violet-700 dark:text-violet-300',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3 shrink-0" />
|
||||
<span>{label}</span>
|
||||
{step.skillName && (
|
||||
<span className="font-mono text-violet-900 dark:text-violet-100">
|
||||
· {step.skillName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-0.5">{step.message}</div>
|
||||
{step.skillNames && step.skillNames.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{step.skillNames.map((name) => (
|
||||
<span
|
||||
key={name}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-mono bg-violet-100 dark:bg-violet-900/60 text-violet-700 dark:text-violet-200"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{step.reason && (
|
||||
<div className="mt-0.5 text-[10px] text-violet-600/80 dark:text-violet-400/80 italic">
|
||||
原因:{step.reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{inProgress && (
|
||||
<li className="flex items-center gap-2 text-[11px] text-muted-foreground italic">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
更多步骤生成中…
|
||||
</li>
|
||||
)}
|
||||
</ol>
|
||||
</CollapseTransition>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import type {
|
||||
ProgressEventType,
|
||||
ReviewIssue,
|
||||
ReviewResult,
|
||||
SkillSearchStep,
|
||||
StreamingReviewItem,
|
||||
UserInterventionPayload,
|
||||
WritingStatus,
|
||||
@@ -71,6 +72,9 @@ const INITIAL_STATE: AIWritingState = {
|
||||
intentThinkingText: '',
|
||||
parsedIntent: null,
|
||||
researchProgress: '',
|
||||
skillSearchSteps: [],
|
||||
skillSelectStreamingText: '',
|
||||
skillSelectThinkingText: '',
|
||||
streamingReviewItems: [],
|
||||
revisionSummary: null,
|
||||
pendingRevisionContext: null,
|
||||
@@ -105,7 +109,7 @@ function reducer(prev: AIWritingState, action: Action): AIWritingState {
|
||||
...prev,
|
||||
currentPause: null,
|
||||
status: ns,
|
||||
...(ns === 'researching' ? { researchProgress: '' } : {}),
|
||||
...(ns === 'researching' ? { researchProgress: '', skillSearchSteps: [], skillSelectStreamingText: '', skillSelectThinkingText: '' } : {}),
|
||||
...(ns === 'writing_outline' ? { outlineStreamingText: '', outlineThinkingText: '' } : {}),
|
||||
...(ns === 'writing_draft' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
|
||||
...(ns === 'reviewing' ? { streamingReviewItems: [] } : {}),
|
||||
@@ -191,12 +195,36 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
|
||||
case 'resumed':
|
||||
return { ...base, currentPause: null };
|
||||
|
||||
case 'materials_ready':
|
||||
case 'skill_search_step': {
|
||||
const step = event.skillStep;
|
||||
if (!step) return base;
|
||||
// select_skills 步骤到达 = LLM 选择完成,清空流式打字缓冲
|
||||
const clearStreaming = step.type === 'select_skills'
|
||||
? { skillSelectStreamingText: '', skillSelectThinkingText: '' }
|
||||
: {};
|
||||
return { ...base, skillSearchSteps: [...prev.skillSearchSteps, step], ...clearStreaming };
|
||||
}
|
||||
|
||||
case 'skill_select_chunk': {
|
||||
const text = (event as unknown as { text: string }).text ?? '';
|
||||
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
|
||||
return {
|
||||
...prev,
|
||||
skillSelectStreamingText: isThinking ? prev.skillSelectStreamingText : prev.skillSelectStreamingText + text,
|
||||
skillSelectThinkingText: isThinking ? prev.skillSelectThinkingText + text : prev.skillSelectThinkingText,
|
||||
};
|
||||
}
|
||||
|
||||
case 'materials_ready': {
|
||||
// 后端把 pkg.skill_search_steps 一并塞回来时,覆盖前端累积(保证刷新页面回放也对)
|
||||
const incomingSteps = (event as unknown as { skillSearchSteps?: SkillSearchStep[] }).skillSearchSteps;
|
||||
return {
|
||||
...base,
|
||||
materials: (event.materials ?? []) as AIMaterial[],
|
||||
researchProgress: '',
|
||||
...(incomingSteps ? { skillSearchSteps: incomingSteps } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
case 'outline_chunk': {
|
||||
const text = (event as unknown as { text: string }).text ?? '';
|
||||
@@ -337,6 +365,7 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
|
||||
// ── snake_case ↔ camelCase 归一化(custom 通道事件 + interrupt payload) ──────
|
||||
|
||||
function normalizeMaterial(m: Record<string, unknown>): AIMaterial {
|
||||
const extra = (m.extra_metadata ?? m.extraMetadata) as Record<string, unknown> | undefined;
|
||||
return {
|
||||
id: String(m.id ?? ''),
|
||||
title: String(m.title ?? ''),
|
||||
@@ -344,9 +373,21 @@ function normalizeMaterial(m: Record<string, unknown>): AIMaterial {
|
||||
source: String(m.source ?? ''),
|
||||
publishedAt: String(m.published_at ?? m.publishedAt ?? ''),
|
||||
relevanceScore: Number(m.relevance_score ?? m.relevanceScore ?? 0),
|
||||
sourceType: (m.source_type ?? m.sourceType ?? 'internet') as 'internet' | 'intranet',
|
||||
sourceType: (m.source_type ?? m.sourceType ?? 'internet') as AIMaterial['sourceType'],
|
||||
url: m.url as string | undefined,
|
||||
recUuid: (m.recUuid ?? m.rec_uuid) as string | undefined,
|
||||
extraMetadata: extra,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSkillStep(s: Record<string, unknown>): SkillSearchStep {
|
||||
return {
|
||||
type: (s.type as SkillSearchStep['type']) ?? 'invoke_skill',
|
||||
message: String(s.message ?? ''),
|
||||
skillNames: (s.skill_names ?? s.skillNames) as string[] | undefined,
|
||||
skillName: (s.skill_name ?? s.skillName) as string | undefined,
|
||||
reason: s.reason as string | undefined,
|
||||
matchedCount: (s.matched_count ?? s.matchedCount) as number | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -445,6 +486,7 @@ function customToProgressEvent(raw: unknown, sessionId: string): ProgressEvent |
|
||||
'intent_chunk', 'intent_ready',
|
||||
'research_progress', 'outline_chunk', 'draft_chunk',
|
||||
'review_chunk', 'review_item_done',
|
||||
'skill_search_step', 'skill_select_chunk',
|
||||
];
|
||||
if (!allowed.includes(type as ProgressEventType)) return null;
|
||||
|
||||
@@ -459,6 +501,10 @@ function customToProgressEvent(raw: unknown, sessionId: string): ProgressEvent |
|
||||
if (Array.isArray(merged.issues)) {
|
||||
merged.reviewItemIssues = (merged.issues as Record<string, unknown>[]).map(normalizeReviewIssue);
|
||||
}
|
||||
// skill_search_step:把字段折成 skillStep 子对象,方便 reducer / UI 取用
|
||||
if (type === 'skill_search_step') {
|
||||
merged.skillStep = normalizeSkillStep(o);
|
||||
}
|
||||
return merged as unknown as ProgressEvent;
|
||||
}
|
||||
|
||||
@@ -478,6 +524,9 @@ function nodeUpdatesToEvents(nodeOutput: unknown, sessionId: string): ProgressEv
|
||||
// ``material_package`` 出现 → materials_ready(节点 progress_events 里不带)
|
||||
if (out.material_package && typeof out.material_package === 'object') {
|
||||
const pkg = out.material_package as Record<string, unknown>;
|
||||
const skillSteps = Array.isArray(pkg.skill_search_steps ?? pkg.skillSearchSteps)
|
||||
? ((pkg.skill_search_steps ?? pkg.skillSearchSteps) as Record<string, unknown>[]).map(normalizeSkillStep)
|
||||
: undefined;
|
||||
events.push({
|
||||
type: 'materials_ready',
|
||||
sessionId,
|
||||
@@ -485,7 +534,8 @@ function nodeUpdatesToEvents(nodeOutput: unknown, sessionId: string): ProgressEv
|
||||
materials: Array.isArray(pkg.materials) ? (pkg.materials as Record<string, unknown>[]).map(normalizeMaterial) : [],
|
||||
keywords: (pkg.keywords ?? []) as string[],
|
||||
materialSummary: (pkg.summary as string) ?? '',
|
||||
});
|
||||
...(skillSteps ? { skillSearchSteps: skillSteps } as unknown as Partial<ProgressEvent> : {}),
|
||||
} as ProgressEvent);
|
||||
}
|
||||
|
||||
// ``current_outline`` 出现且 progress_events 里没 outline_ready → 合成一条
|
||||
@@ -733,7 +783,9 @@ export function useAIWriting() {
|
||||
// 乐观更新到下一阶段,跟老 hook 行为对齐
|
||||
let nextStatus: WritingStatus = state.status;
|
||||
if (pausePoint === 'material_confirm') {
|
||||
nextStatus = payload.action === 're_search' ? 'researching' : 'writing_outline';
|
||||
// re_search / enable_skill_search 都回到 researching;confirm 才进大纲
|
||||
nextStatus = (payload.action === 're_search' || payload.action === 'enable_skill_search')
|
||||
? 'researching' : 'writing_outline';
|
||||
} else if (pausePoint === 'outline_confirm') {
|
||||
nextStatus = payload.action === 're_outline' ? 'writing_outline' : 'writing_draft';
|
||||
} else if (pausePoint === 'draft_confirm') {
|
||||
@@ -754,6 +806,7 @@ export function useAIWriting() {
|
||||
};
|
||||
if (payload.approvedMaterialIds !== undefined) resumePayload.approvedMaterialIds = payload.approvedMaterialIds;
|
||||
if (payload.extraKeywords !== undefined) resumePayload.extraKeywords = payload.extraKeywords;
|
||||
if (payload.userQuery !== undefined) resumePayload.userQuery = payload.userQuery;
|
||||
if (payload.editedOutline !== undefined) resumePayload.editedOutline = payload.editedOutline;
|
||||
if (payload.outlineFeedback !== undefined) resumePayload.outlineFeedback = payload.outlineFeedback;
|
||||
if (payload.userRevisionNotes !== undefined) resumePayload.userRevisionNotes = payload.userRevisionNotes;
|
||||
|
||||
@@ -7,9 +7,26 @@ export interface AIMaterial {
|
||||
source: string;
|
||||
publishedAt: string;
|
||||
relevanceScore: number;
|
||||
sourceType: 'internet' | 'intranet';
|
||||
sourceType: 'internet' | 'intranet' | 'skill';
|
||||
url?: string;
|
||||
recUuid?: string;
|
||||
// skill 类素材的元信息(skill_name / container_path / category / skill_full_content 等)
|
||||
extraMetadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// 技能检索过程的单步记录(前端时间线展示"使用了 X 技能")
|
||||
// list_skills:列出全部 enabled 技能
|
||||
// select_skills:LLM 按相关度排序的候选
|
||||
// invoke_skill:开始调用某个技能去检索
|
||||
// skill_result:某个技能检索返回了 N 条素材
|
||||
// stop_early:累计素材达到上限,停止后续技能
|
||||
export interface SkillSearchStep {
|
||||
type: 'list_skills' | 'select_skills' | 'invoke_skill' | 'skill_result' | 'stop_early';
|
||||
message: string;
|
||||
skillNames?: string[];
|
||||
skillName?: string;
|
||||
reason?: string;
|
||||
matchedCount?: number;
|
||||
}
|
||||
|
||||
// ── 大纲 ─────────────────────────────────────────────────────────────────────
|
||||
@@ -139,6 +156,8 @@ export type ProgressEventType =
|
||||
| 'await_user'
|
||||
| 'resumed'
|
||||
| 'notice'
|
||||
| 'skill_search_step'
|
||||
| 'skill_select_chunk'
|
||||
| 'done'
|
||||
| 'error';
|
||||
|
||||
@@ -173,6 +192,8 @@ export interface ProgressEvent {
|
||||
pauseContext?: Record<string, unknown>;
|
||||
// section_help:素材不足、待用户决策的章节
|
||||
blockedSections?: BlockedSection[];
|
||||
// skill_search_step:技能检索步骤事件(list_skills / select_skills / invoke_skill)
|
||||
skillStep?: SkillSearchStep;
|
||||
}
|
||||
|
||||
// ── 用户干预提交体 ────────────────────────────────────────────────────────────
|
||||
@@ -180,7 +201,10 @@ export interface ProgressEvent {
|
||||
export interface UserInterventionPayload {
|
||||
action: string;
|
||||
approvedMaterialIds?: string[];
|
||||
/** @deprecated 旧版多关键词追加。新版统一用 userQuery 单输入框 */
|
||||
extraKeywords?: string[];
|
||||
/** PAUSE-1 素材确认:用户单输入框写的自然语言(技能名/来源/检索意图都行)。re_search / enable_skill_search 共用。 */
|
||||
userQuery?: string;
|
||||
editedOutline?: AIOutline;
|
||||
outlineFeedback?: string;
|
||||
userRevisionNotes?: string;
|
||||
@@ -291,6 +315,11 @@ export interface AIWritingState {
|
||||
parsedIntent: ParsedIntent | null;
|
||||
// 素材收集实时进度(最新一条提示文案)
|
||||
researchProgress: string;
|
||||
// 技能检索步骤(按发生顺序累积,pause_material 卡片里渲染时间线)
|
||||
skillSearchSteps: SkillSearchStep[];
|
||||
// 技能选择 LLM 流式打字内容(正文 / 思考分开),select_skills 完成后清空
|
||||
skillSelectStreamingText: string;
|
||||
skillSelectThinkingText: string;
|
||||
// 流式审核(逐条到达)
|
||||
streamingReviewItems: StreamingReviewItem[];
|
||||
// 修改摘要
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon, RefreshCwIcon, SearchIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
WorkspaceHeader,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import { AgentCard } from "@/components/workspace/agents/agent-card";
|
||||
import { useAdminSyncAgentSkills, useAllAgentsAdmin } from "@/core/agents";
|
||||
import type { AgentSkillsSyncResult } from "@/core/agents/api";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
export default function AdminAgentsPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const { agents, isLoading } = useAllAgentsAdmin(true);
|
||||
const syncSkills = useAdminSyncAgentSkills();
|
||||
const [syncResult, setSyncResult] = useState<AgentSkillsSyncResult | null>(
|
||||
null,
|
||||
);
|
||||
const [syncDialogOpen, setSyncDialogOpen] = useState(false);
|
||||
|
||||
async function handleSyncSkills() {
|
||||
try {
|
||||
const result = await syncSkills.mutateAsync();
|
||||
setSyncResult(result);
|
||||
setSyncDialogOpen(true);
|
||||
if (result.modified === 0) {
|
||||
toast.success("所有智能体的技能均有效,无需清理");
|
||||
} else {
|
||||
toast.success(
|
||||
`已清理 ${result.modified} 个智能体中的失效技能引用`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
const filteredAgents = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase();
|
||||
if (!needle) return agents;
|
||||
return agents.filter((a) =>
|
||||
desensitize(a.name).toLowerCase().includes(needle),
|
||||
);
|
||||
}, [agents, search]);
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceHeader />
|
||||
<WorkspaceBody>
|
||||
<div className="flex size-full min-h-0 flex-col">
|
||||
<div className="flex items-start justify-between gap-4 border-b px-6 py-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">智能体管理</h1>
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||
管理员视图:查看、编辑、删除所有用户的智能体(包括内置、用户自定义与私密广场条目)。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSyncSkills}
|
||||
disabled={syncSkills.isPending}
|
||||
title="扫描所有智能体的 config.yaml,移除指向已不存在技能的引用"
|
||||
>
|
||||
<RefreshCwIcon
|
||||
className={`mr-1.5 size-4 ${syncSkills.isPending ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{syncSkills.isPending ? "正在同步…" : "同步技能"}
|
||||
</Button>
|
||||
<div className="relative w-64">
|
||||
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="按名称搜索"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
||||
加载中…
|
||||
</div>
|
||||
) : filteredAgents.length === 0 ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<BotIcon className="text-muted-foreground h-7 w-7" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
当前没有可管理的智能体。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-muted-foreground mb-3 text-xs">
|
||||
共 {filteredAgents.length} 个智能体。
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{filteredAgents.map((agent) => (
|
||||
<AgentCard key={agent.id} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</WorkspaceBody>
|
||||
|
||||
<Dialog open={syncDialogOpen} onOpenChange={setSyncDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>同步技能 — 清理结果</DialogTitle>
|
||||
<DialogDescription>
|
||||
{syncResult
|
||||
? `扫描 ${syncResult.checked} 个智能体配置,清理 ${syncResult.modified} 个。`
|
||||
: ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{syncResult && syncResult.removals.length > 0 ? (
|
||||
<div className="max-h-[60vh] overflow-y-auto">
|
||||
<ul className="space-y-3 text-sm">
|
||||
{syncResult.removals.map((entry) => (
|
||||
<li
|
||||
key={entry.agent_id}
|
||||
className="rounded-md border px-3 py-2"
|
||||
>
|
||||
<div className="font-medium">
|
||||
{entry.agent_name
|
||||
? desensitize(entry.agent_name)
|
||||
: entry.agent_id}
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-xs">
|
||||
id: {entry.agent_id}
|
||||
{entry.user_id ? ` · owner: ${entry.user_id}` : " · built-in"}
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{entry.removed_skills.map((s) => (
|
||||
<span
|
||||
key={s}
|
||||
className="bg-muted rounded px-1.5 py-0.5 text-xs"
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
所有智能体引用的技能均存在,未做任何修改。
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSyncDialogOpen(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</WorkspaceContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
SearchIcon,
|
||||
SparklesIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
WorkspaceHeader,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import { SkillRow } from "@/components/workspace/settings/skill-settings-page";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useAllSkillsAdmin } from "@/core/skills/hooks";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
export default function AdminSkillsPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const { skills, isLoading, error } = useAllSkillsAdmin(true);
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
|
||||
// Hide archived/stale entries here — they remain accessible from the regular
|
||||
// skills settings dialog. The admin page is for live skills only.
|
||||
const filteredSkills = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase();
|
||||
return skills
|
||||
.filter((s) => (s.state ?? "active") === "active")
|
||||
.filter((s) => !needle || desensitize(s.name).toLowerCase().includes(needle));
|
||||
}, [skills, search]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredSkills.length / PAGE_SIZE));
|
||||
useEffect(() => {
|
||||
if (page > totalPages) setPage(totalPages);
|
||||
}, [page, totalPages]);
|
||||
const pagedSkills = useMemo(
|
||||
() => filteredSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||
[filteredSkills, page],
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceHeader />
|
||||
<WorkspaceBody>
|
||||
<div className="flex size-full min-h-0 flex-col">
|
||||
<div className="flex items-start justify-between gap-4 border-b px-6 py-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">技能管理</h1>
|
||||
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||
管理员视图:查看、编辑、删除所有用户的技能(包括内置技能与未发布的自定义技能)。
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative w-64 shrink-0">
|
||||
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="按名称搜索"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-6">
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
||||
加载中…
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-destructive text-sm">
|
||||
加载失败:{error.message}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !error && filteredSkills.length === 0 && (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<SparklesIcon className="text-muted-foreground h-7 w-7" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
当前没有可管理的技能。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !error && filteredSkills.length > 0 && (
|
||||
<>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
共 {filteredSkills.length} 个技能。
|
||||
</p>
|
||||
{pagedSkills.map((skill) => (
|
||||
<SkillRow
|
||||
key={skill.name}
|
||||
skill={skill}
|
||||
currentUserId={currentUserId}
|
||||
isAdmin
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{!isLoading && !error && totalPages > 1 && (
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
第 {page} / {totalPages} 页
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
下一页
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</WorkspaceBody>
|
||||
</WorkspaceContainer>
|
||||
);
|
||||
}
|
||||
@@ -231,14 +231,25 @@ export default function ChatPage() {
|
||||
</header>
|
||||
<main className="flex min-h-0 max-w-full grow flex-col">
|
||||
<div className="flex size-full justify-center">
|
||||
<MessageList
|
||||
className={cn("size-full", !isLandingState && "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
/>
|
||||
{/*
|
||||
Landing state covers two cases: (a) a brand-new chat that
|
||||
hasn't sent anything yet, and (b) the user clicking 「新对话」
|
||||
mid-stream. In (b) the underlying useStream's internal
|
||||
stream.values takes one or two effect ticks to clear, so
|
||||
rendering MessageList unconditionally would leak the previous
|
||||
conversation under the welcome card. Skip rendering during
|
||||
the landing state to guarantee a clean slate.
|
||||
*/}
|
||||
{!isLandingState && (
|
||||
<MessageList
|
||||
className={cn("size-full", "pt-10")}
|
||||
threadId={threadId}
|
||||
thread={thread}
|
||||
paddingBottom={messageListPaddingBottom}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
onClarificationSubmit={handleClarificationSubmit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
|
||||
<div
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function ChatsPage() {
|
||||
return threads?.filter((thread) => {
|
||||
return (
|
||||
thread.metadata?.thread_type !== "scheduler" &&
|
||||
thread.metadata?.thread_type !== "roundtable" &&
|
||||
titleOfThread(thread).toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
@@ -19,6 +19,13 @@ import {
|
||||
} from "@/components/ai-elements/prompt-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
@@ -115,10 +122,34 @@ export default function NewAgentPage() {
|
||||
return;
|
||||
}
|
||||
const first = models[0]!.name;
|
||||
setModelInput((prev) => prev || first);
|
||||
setChatModelInput((prev) => prev || first);
|
||||
setModelInput((prev) =>
|
||||
prev && models.some((m) => m.name === prev) ? prev : first,
|
||||
);
|
||||
setChatModelInput((prev) =>
|
||||
prev && models.some((m) => m.name === prev) ? prev : first,
|
||||
);
|
||||
}, [models]);
|
||||
|
||||
const resolvedModelInput = useMemo(() => {
|
||||
if (models.length === 0) {
|
||||
return "";
|
||||
}
|
||||
if (modelInput && models.some((m) => m.name === modelInput)) {
|
||||
return modelInput;
|
||||
}
|
||||
return models[0]!.name;
|
||||
}, [modelInput, models]);
|
||||
|
||||
const resolvedChatModelInput = useMemo(() => {
|
||||
if (models.length === 0) {
|
||||
return "";
|
||||
}
|
||||
if (chatModelInput && models.some((m) => m.name === chatModelInput)) {
|
||||
return chatModelInput;
|
||||
}
|
||||
return models[0]!.name;
|
||||
}, [chatModelInput, models]);
|
||||
|
||||
const [thread, sendMessage] = useThreadStream({
|
||||
threadId,
|
||||
assistantId: agentId || undefined,
|
||||
@@ -347,23 +378,42 @@ export default function NewAgentPage() {
|
||||
<span className="text-muted-foreground text-xs">
|
||||
当前对话模型
|
||||
</span>
|
||||
<select
|
||||
value={chatModelInput}
|
||||
onChange={(event) =>
|
||||
setChatModelInput(event.currentTarget.value)
|
||||
}
|
||||
disabled={isModelsLoading || thread.isLoading}
|
||||
className="border-input bg-background h-7 min-w-[10rem] rounded-md border px-2 text-xs shadow-xs outline-none"
|
||||
>
|
||||
<option value="" disabled>
|
||||
选择模型
|
||||
</option>
|
||||
{models.map((model) => (
|
||||
<option key={model.name} value={model.name}>
|
||||
{model.display_name || model.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isModelsLoading || models.length === 0 ? (
|
||||
<div className="border-input bg-background text-muted-foreground flex h-7 min-w-[10rem] items-center rounded-md border px-2 text-xs shadow-xs dark:border-[#1B477E] dark:bg-[#022248]">
|
||||
{isModelsLoading ? "加载中…" : "暂无模型"}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={resolvedChatModelInput}
|
||||
onValueChange={setChatModelInput}
|
||||
disabled={thread.isLoading}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"border-input bg-background h-7 min-w-[10rem] px-2 text-xs shadow-xs outline-none",
|
||||
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF] dark:hover:bg-[#022248]",
|
||||
"data-[placeholder]:text-muted-foreground",
|
||||
"[&_svg]:dark:text-[#AACEE2]",
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder="选择模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
className="w-[var(--radix-select-trigger-width)] dark:border-[#1B477E] dark:bg-[#022248]"
|
||||
>
|
||||
{models.map((model) => (
|
||||
<SelectItem
|
||||
key={model.name}
|
||||
value={model.name}
|
||||
className="dark:text-[#AACEFF] dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white"
|
||||
>
|
||||
{model.display_name || model.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
<PromptInput
|
||||
onSubmit={({ text }) => void handleChatSubmit(text)}
|
||||
@@ -415,21 +465,41 @@ export default function NewAgentPage() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">模型</label>
|
||||
<select
|
||||
value={modelInput}
|
||||
onChange={(event) => setModelInput(event.currentTarget.value)}
|
||||
disabled={isModelsLoading}
|
||||
className="border-input bg-background h-9 w-full rounded-md border px-3 text-sm shadow-xs outline-none"
|
||||
>
|
||||
<option value="" disabled>
|
||||
选择模型
|
||||
</option>
|
||||
{models.map((model) => (
|
||||
<option key={model.name} value={model.name}>
|
||||
{model.display_name || model.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isModelsLoading || models.length === 0 ? (
|
||||
<div className="border-input bg-background text-muted-foreground flex h-9 w-full items-center rounded-md border px-3 text-sm shadow-xs dark:border-[#1B477E] dark:bg-[#022248]">
|
||||
{isModelsLoading ? "加载模型…" : "暂无可用模型"}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={resolvedModelInput}
|
||||
onValueChange={setModelInput}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"border-input bg-background h-9 w-full shadow-xs outline-none",
|
||||
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF] dark:hover:bg-[#022248]",
|
||||
"data-[placeholder]:text-muted-foreground",
|
||||
"[&_svg]:dark:text-[#AACEE2]",
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder="选择模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
className="w-[var(--radix-select-trigger-width)] dark:border-[#1B477E] dark:bg-[#022248]"
|
||||
>
|
||||
{models.map((model) => (
|
||||
<SelectItem
|
||||
key={model.name}
|
||||
value={model.name}
|
||||
className="dark:text-[#AACEFF] dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white"
|
||||
>
|
||||
{model.display_name || model.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
该智能体被使用时调用的模型。
|
||||
</p>
|
||||
@@ -463,7 +533,7 @@ export default function NewAgentPage() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2">
|
||||
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2 dark:border-[#1B477E] dark:bg-[#022248]">
|
||||
{isSkillsLoading ? (
|
||||
<p className="text-muted-foreground text-sm">技能加载中...</p>
|
||||
) : enabledSkills.length === 0 ? (
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useMemo, useState } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
|
||||
import { PageSidebar } from "@/components/page-sidebar";
|
||||
import { PageSidebarDark } from "@/components/page-sidebar-dark";
|
||||
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
import WorkspaceRoutes from "./WorkspaceRoutes";
|
||||
import StrategyRoutes from "./StrategyRoutes";
|
||||
@@ -13,16 +15,25 @@ import RoundtableRoutes from "./RoundtableRoutes";
|
||||
function PageLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const sidebarEnabled = useMemo(() => readSidebarEnabled(), []);
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-muted/20">
|
||||
{sidebarEnabled && <Header collapsed={collapsed} />}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{sidebarEnabled && (
|
||||
<PageSidebar
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
|
||||
/>
|
||||
isDark ? (
|
||||
<PageSidebarDark
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
|
||||
/>
|
||||
) : (
|
||||
<PageSidebar
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<main
|
||||
className={`flex-1 overflow-hidden transition-[padding] duration-300 ${
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2Icon, ShieldIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
usePrivateSquareStatus,
|
||||
useUpdatePrivateSquareSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
|
||||
export default function PrivateSquareSettingsPage() {
|
||||
const { data, isLoading, error } = usePrivateSquareStatus();
|
||||
const update = useUpdatePrivateSquareSettings();
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
// The status response never contains the password itself, so re-syncing
|
||||
// ``enabled`` from a refetch wouldn't clobber any in-progress edit — but we
|
||||
// still gate the seed on a one-shot ref to match the prompt-prefix page's
|
||||
// semantics: the admin's draft must not flicker back to the server value if
|
||||
// react-query refetches while they're typing.
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || initializedRef.current) return;
|
||||
setEnabled(data.enabled);
|
||||
initializedRef.current = true;
|
||||
}, [data]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (password && password !== confirm) {
|
||||
toast.error("两次输入的密码不一致");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await update.mutateAsync({
|
||||
enabled,
|
||||
// Only send the password field when the admin typed something —
|
||||
// omitting it leaves the server-side hash untouched. The empty-string
|
||||
// sentinel that clears the password is sent via the explicit "清除
|
||||
// 密码" button below, not by leaving the field blank.
|
||||
...(password ? { password } : {}),
|
||||
});
|
||||
setPassword("");
|
||||
setConfirm("");
|
||||
toast.success("已保存");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearPassword = async () => {
|
||||
if (!window.confirm("确认清除私密广场密码?清除后任何人都将无法访问私密广场。")) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await update.mutateAsync({ password: "" });
|
||||
toast.success("已清除密码");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "清除失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">私密广场配置</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
管理员可在此开启「私密广场」,并配置访问密码。开启后,用户可将自己发布的智能体放入私密广场;
|
||||
其他用户只有输入下方设置的密码后,才能在「智能体 → 私密广场」页签下查看到这些智能体。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-destructive rounded-xl border border-dashed py-16 text-center text-sm">
|
||||
加载失败:{error.message}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">启用私密广场</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-xs">
|
||||
关闭后,「私密广场」页签不会显示;已放入私密广场的智能体也不会被列出。
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium">
|
||||
访问密码
|
||||
{data?.password_set ? (
|
||||
<span className="text-muted-foreground ml-2 text-xs font-normal">
|
||||
(已设置,留空则保持不变)
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-destructive ml-2 text-xs font-normal">
|
||||
(尚未设置)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={
|
||||
data?.password_set ? "留空表示不修改密码" : "请输入访问密码"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium">确认密码</p>
|
||||
<Input
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="再次输入新密码"
|
||||
disabled={!password}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={handleClearPassword}
|
||||
disabled={!data?.password_set || update.isPending}
|
||||
>
|
||||
<ShieldIcon className="mr-1.5 size-4" />
|
||||
清除密码
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={update.isPending}>
|
||||
{update.isPending && (
|
||||
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -84,6 +84,7 @@ const emptyForm = {
|
||||
timezone: "Asia/Shanghai",
|
||||
prompt: "",
|
||||
execution_agent_name: "",
|
||||
require_markdown: false,
|
||||
};
|
||||
|
||||
export default function ScheduledTasksPage() {
|
||||
@@ -173,6 +174,7 @@ export default function ScheduledTasksPage() {
|
||||
timezone: task.timezone,
|
||||
prompt: task.prompt,
|
||||
execution_agent_name: task.execution_agent_name ?? "",
|
||||
require_markdown: !!task.execution_context?.require_markdown,
|
||||
});
|
||||
// Prefer the canonical cron_expr over the user-typed schedule_text: the
|
||||
// latter may be natural language ("每天9点") that parseCron can't read,
|
||||
@@ -202,6 +204,7 @@ export default function ScheduledTasksPage() {
|
||||
timezone: form.timezone || "Asia/Shanghai",
|
||||
prompt: form.prompt,
|
||||
execution_agent_name: form.execution_agent_name || null,
|
||||
require_markdown: form.require_markdown,
|
||||
});
|
||||
setSelectedTaskId(task.task_id);
|
||||
toast.success("任务已创建");
|
||||
@@ -215,6 +218,7 @@ export default function ScheduledTasksPage() {
|
||||
timezone: form.timezone,
|
||||
prompt: form.prompt,
|
||||
execution_agent_name: form.execution_agent_name || null,
|
||||
require_markdown: form.require_markdown,
|
||||
},
|
||||
});
|
||||
toast.success("任务已更新");
|
||||
@@ -825,6 +829,20 @@ export default function ScheduledTasksPage() {
|
||||
}
|
||||
placeholder="任务 Prompt"
|
||||
/>
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="min-w-0 pr-3">
|
||||
<p className="text-sm font-medium">必须生成 Markdown 文件</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
开启后会提示大模型生成 Markdown(.md) 文件;系统会校验本次是否生成了 .md 文件且文件正文超过 100 字,未达到则记为失败。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.require_markdown}
|
||||
onCheckedChange={(v) =>
|
||||
setForm((value) => ({ ...value, require_markdown: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeDialog}>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getStoredAuth } from "@/core/auth";
|
||||
import { RouteBaseProvider } from "@/core/route-base";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
|
||||
import GeneralQA from "@/strategy-components/components/qa/GeneralQA";
|
||||
import GeneralQA from "@/strategy-components/components/chat-qa/GeneralQA";
|
||||
import { ChatRuntime } from "./WorkspaceRoutes";
|
||||
import AgentsPage from "./AgentsPage";
|
||||
import ChatsPage from "./ChatsPage";
|
||||
@@ -19,6 +19,7 @@ import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
|
||||
import LlmMetricsPage from "./LlmMetricsPage";
|
||||
import UsersPage from "./UsersPage";
|
||||
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
||||
@@ -27,141 +28,246 @@ import TagsPage from "./TagsPage";
|
||||
import LightAppNewNotePage from "./LightAppNewNotePage";
|
||||
import LightAppNoteListPage from "./LightAppNoteListPage";
|
||||
import LightAppNoteQAPage from "./LightAppNoteQAPage";
|
||||
import TaskXkFrom from "@/strategy-components/components/task-components/TaskXkFrom";
|
||||
import TaskManagement from "@/strategy-components/components/task-components/TaskManagement";
|
||||
import TaskPgFrom from "@/strategy-components/components/task-components/TaskPgFrom";
|
||||
import FanganTyPage from "@/strategy-components/components/task-components/FanganTy";
|
||||
import XingdongTyPage from "@/strategy-components/components/task-components/XingdongTy";
|
||||
import XXTyPage from "@/strategy-components/components/task-components/XXTy";
|
||||
import SentimentAnalyzes from "@/strategy-components/components/task-components/SentimentAnalyzes";
|
||||
import TaskAnalyzes from "@/strategy-components/components/task-components/TaskAnalyzes";
|
||||
import { ActionAnalyzes } from "@/strategy-components/components/task-components/ActionAnalyzes";
|
||||
import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement";
|
||||
import KnowledgeBaseResourceManagement from "@/strategy-components/components/resource-management/KnowledgeBaseResourceManagement";
|
||||
|
||||
export default function StrategyRoutes() {
|
||||
return (
|
||||
<RouteBaseProvider value="/page/strategy">
|
||||
<SidebarProvider>
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="qa/general" replace />} />
|
||||
<SidebarProvider>
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="qa/general" replace />} />
|
||||
|
||||
{/* 问答中心 */}
|
||||
<Route path="qa/general" element={<GeneralQA />} />
|
||||
{/* 问答中心 */}
|
||||
<Route path="qa/general" element={<GeneralQA />} />
|
||||
<Route
|
||||
path="qa/expert-qa"
|
||||
element={
|
||||
<GeneralQA
|
||||
chatHeadTitle="专家问答"
|
||||
chatDesign="支持单轮RAG、多轮RAG、深度检索及结果对比"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{/* 任务管理 */}
|
||||
<Route path="task-management" element={<TaskManagement />}>
|
||||
<Route index element={<Navigate to="xk-task-jc" replace />} />
|
||||
{/* XK任务 */}
|
||||
<Route
|
||||
path="xk-task-jc"
|
||||
element={<TaskXkFrom gopath="#/pageIndie/pageHome/pageSwrw" />}
|
||||
/>
|
||||
<Route
|
||||
path="xk-task-dispatch"
|
||||
element={<TaskXkFrom gopath="#/pageIndie/pageJczl" />}
|
||||
/>
|
||||
{/* pg中任务 */}
|
||||
<Route
|
||||
path="effect-task-pg"
|
||||
element={<TaskPgFrom gopath="task" />}
|
||||
/>
|
||||
<Route
|
||||
path="effect-action-pg"
|
||||
element={<TaskPgFrom gopath="xd" />}
|
||||
/>
|
||||
<Route
|
||||
path="effect-info-pg"
|
||||
element={<TaskPgFrom gopath="info" />}
|
||||
/>
|
||||
{/* COH中任务 */}
|
||||
<Route
|
||||
path="plan-task-sentiment"
|
||||
element={
|
||||
<SentimentAnalyzes
|
||||
title="3QFX"
|
||||
category="规划任务"
|
||||
subCategory="规划任务"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="plan-task/plan-task-fx"
|
||||
element={
|
||||
<TaskAnalyzes
|
||||
title="任务FX"
|
||||
category="规划任务"
|
||||
subCategory="规划任务"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="plan-task/plan-task-action"
|
||||
element={
|
||||
<ActionAnalyzes
|
||||
title="xdfx"
|
||||
category="规划任务"
|
||||
subCategory="规划任务"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{/* MNTY任务 */}
|
||||
<Route path="mnty-task-mechanism" element={<XingdongTyPage />} />
|
||||
<Route path="mnty-task-plan" element={<FanganTyPage />} />
|
||||
<Route path="mnty-task-zsty" element={<XXTyPage />} />
|
||||
</Route>
|
||||
{/* 资源管理 */}
|
||||
<Route
|
||||
path="resource-management/resource-network-actor"
|
||||
element={<NetworkActorResourceManagement />}
|
||||
/>
|
||||
<Route
|
||||
path="resource-management/resource-knowledge-base"
|
||||
element={<KnowledgeBaseResourceManagement />}
|
||||
/>
|
||||
<Route
|
||||
path="resource-management/resource-evaluation-index"
|
||||
element={
|
||||
<TaskXkFrom gopath="#/pageIndie/pageZyjc/pageZyzl" />
|
||||
}
|
||||
/>
|
||||
{/* 轻应用 */}
|
||||
<Route path="light-app/new-note" element={<LightAppNewNotePage />} />
|
||||
<Route path="light-app/notes" element={<LightAppNoteListPage />} />
|
||||
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} />
|
||||
|
||||
{/* 轻应用 */}
|
||||
<Route path="light-app/new-note" element={<LightAppNewNotePage />} />
|
||||
<Route path="light-app/notes" element={<LightAppNoteListPage />} />
|
||||
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} />
|
||||
{/* 对话 */}
|
||||
<Route path="chats" element={<ChatsPage />} />
|
||||
<Route
|
||||
path="chats/:thread_id"
|
||||
element={
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 对话 */}
|
||||
<Route path="chats" element={<ChatsPage />} />
|
||||
<Route
|
||||
path="chats/:thread_id"
|
||||
element={
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
{/* 智能体管理 */}
|
||||
<Route path="agents" element={<AgentsPage />} />
|
||||
<Route path="agents/new" element={<NewAgentPage />} />
|
||||
<Route
|
||||
path="agents/:agent_id/chats"
|
||||
element={<Navigate to="/page/strategy/agents" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="agents/:agent_id/chats/:thread_id"
|
||||
element={
|
||||
<ChatRuntime>
|
||||
<AgentChatPage />
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 智能体管理 */}
|
||||
<Route path="agents" element={<AgentsPage />} />
|
||||
<Route path="agents/new" element={<NewAgentPage />} />
|
||||
<Route
|
||||
path="agents/:agent_id/chats"
|
||||
element={<Navigate to="/page/strategy/agents" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="agents/:agent_id/chats/:thread_id"
|
||||
element={
|
||||
<ChatRuntime>
|
||||
<AgentChatPage />
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
{/* 技能 / 记忆 */}
|
||||
<Route path="skills" element={<SkillsPage />} />
|
||||
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
|
||||
<Route path="memory" element={<MemoryPage />} />
|
||||
|
||||
{/* 技能 / 记忆 */}
|
||||
<Route path="skills" element={<SkillsPage />} />
|
||||
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
|
||||
<Route path="memory" element={<MemoryPage />} />
|
||||
{/* 定时任务 */}
|
||||
<Route
|
||||
path="scheduled"
|
||||
element={<Navigate to="/page/strategy/scheduled/tasks" replace />}
|
||||
/>
|
||||
<Route path="scheduled/tasks" element={<ScheduledTasksPage />} />
|
||||
<Route
|
||||
path="scheduled/runs/:run_id"
|
||||
element={<ScheduledTaskRunDetailPage />}
|
||||
/>
|
||||
|
||||
{/* 定时任务 */}
|
||||
<Route
|
||||
path="scheduled"
|
||||
element={<Navigate to="/page/strategy/scheduled/tasks" replace />}
|
||||
/>
|
||||
<Route path="scheduled/tasks" element={<ScheduledTasksPage />} />
|
||||
<Route
|
||||
path="scheduled/runs/:run_id"
|
||||
element={<ScheduledTaskRunDetailPage />}
|
||||
/>
|
||||
|
||||
{/* 设置 / 管理 — 权限与 workspace 侧一致,非 admin 重定向到 chats/new */}
|
||||
<Route
|
||||
path="settings/recommended-questions"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<RecommendedQuestionsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/prompt-prefix"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<PromptPrefixSettingsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/llm-metrics"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<LlmMetricsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/users"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<UsersPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/scheduled-tasks"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<AdminScheduledTasksPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/user-activity"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<AdminUserActivityPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/tags"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<TagsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
<Toaster position="top-center" />
|
||||
{/* 设置 / 管理 — 权限与 workspace 侧一致,非 admin 重定向到 chats/new */}
|
||||
<Route
|
||||
path="settings/recommended-questions"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<RecommendedQuestionsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/prompt-prefix"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<PromptPrefixSettingsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/private-square"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<PrivateSquareSettingsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/llm-metrics"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<LlmMetricsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/users"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<UsersPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/scheduled-tasks"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<AdminScheduledTasksPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/user-activity"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<AdminUserActivityPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/tags"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<TagsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
<Toaster position="top-center" />
|
||||
</RouteBaseProvider>
|
||||
);
|
||||
}
|
||||
@@ -18,12 +18,15 @@ import ChatPage from "./ChatPage";
|
||||
import AgentsPage from "./AgentsPage";
|
||||
import AgentChatPage from "./AgentChatPage";
|
||||
import NewAgentPage from "./NewAgentPage";
|
||||
import AdminAgentsPage from "./AdminAgentsPage";
|
||||
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
||||
import AdminSkillsPage from "./AdminSkillsPage";
|
||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||
import LlmMetricsPage from "./LlmMetricsPage";
|
||||
import MemoryPage from "./MemoryPage";
|
||||
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
|
||||
import SkillEvolutionPage from "./SkillEvolutionPage";
|
||||
import SkillsPage from "./SkillsPage";
|
||||
import UsersPage from "./UsersPage";
|
||||
@@ -217,7 +220,9 @@ export default function WorkspaceRoutes({
|
||||
path="roundtable/planning"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<RoundtablePlanningPage />
|
||||
<ChatRuntime>
|
||||
<RoundtablePlanningPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
@@ -279,6 +284,30 @@ export default function WorkspaceRoutes({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/agents"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminAgentsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/skills"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminSkillsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/tags"
|
||||
element={
|
||||
@@ -315,6 +344,18 @@ export default function WorkspaceRoutes({
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/private-square"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<PrivateSquareSettingsPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/llm-metrics"
|
||||
element={
|
||||
|
||||
@@ -260,6 +260,69 @@ export interface StreamMultiAgentResult {
|
||||
clarification: ClarificationPayload | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort partial JSON parser for accumulated `tool_call_chunks.args`.
|
||||
*
|
||||
* LangChain emits tool args as a series of string fragments (one delta per
|
||||
* chunk). To get progressive `args.path` / `args.content` during streaming —
|
||||
* the way `useStream` does in the main chat — we concatenate the deltas and
|
||||
* try to parse. When the JSON is still mid-token (e.g. `{"path":"/mnt`) we
|
||||
* close any open string + brackets and retry, so callers can read partially
|
||||
* available keys even before the full payload arrives.
|
||||
*
|
||||
* Returns null when nothing useful can be extracted yet.
|
||||
*/
|
||||
function parsePartialJson(s: string): Record<string, unknown> | null {
|
||||
if (!s) return null;
|
||||
try {
|
||||
const v = JSON.parse(s);
|
||||
return v && typeof v === "object" ? (v as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
// fall through to best-effort close + retry
|
||||
}
|
||||
let trimmed = s;
|
||||
// A dangling `\` would turn our injected closing `"` into an escape; drop it.
|
||||
if (trimmed.endsWith("\\")) trimmed = trimmed.slice(0, -1);
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
const stack: string[] = [];
|
||||
for (const ch of trimmed) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (inString) {
|
||||
if (ch === '"') inString = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === "{") stack.push("}");
|
||||
else if (ch === "[") stack.push("]");
|
||||
else if (ch === "}" || ch === "]") stack.pop();
|
||||
}
|
||||
let closed = trimmed;
|
||||
if (inString) {
|
||||
closed += '"';
|
||||
} else {
|
||||
// Mid-key / dangling separator (`{"path":`) can't be balanced cleanly.
|
||||
closed = closed.replace(/[,:]\s*$/, "");
|
||||
}
|
||||
while (stack.length) closed += stack.pop();
|
||||
try {
|
||||
const v = JSON.parse(closed);
|
||||
return v && typeof v === "object" ? (v as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify the final status frame our backend appends after the LangGraph
|
||||
* stream. Required keys are `status` (array for leader, string for special)
|
||||
@@ -394,6 +457,61 @@ export async function streamMultiAgent(
|
||||
req.onPhaseChange?.(next, name, args);
|
||||
};
|
||||
|
||||
/**
|
||||
* 累积 tool_call_chunks(delta args 字符串) —— 与 LangChain `AIMessageChunk`
|
||||
* 合并的内部行为对齐,补主聊天靠 `useStream` 暗中做的那部分工作。Key 是 chunk.index
|
||||
* (并发 tool 调用通常 0/1/2 区分);chunk.id 变化时视作"同一 index 上的新调用"并重置。
|
||||
* 每帧累积后用 `parsePartialJson` 尽力 parse,成功就把合成的 `tool_calls[]` 注入
|
||||
* 到 messages-tuple 帧的 chunkObj 上,让上层 `onUpstreamFrame` 和 `onPhaseChange`
|
||||
* 都能拿到完整的 `args.path` / `args.content` 来做"流式期间打开沙箱 + 实时预览"。
|
||||
*/
|
||||
const toolCallAcc = new Map<
|
||||
number,
|
||||
{ id?: string; name: string; argsStr: string }
|
||||
>();
|
||||
|
||||
type SyntheticToolCall = {
|
||||
id?: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const ingestToolCallChunks = (chunks: unknown): void => {
|
||||
if (!Array.isArray(chunks)) return;
|
||||
for (const tcc of chunks) {
|
||||
if (!tcc || typeof tcc !== "object") continue;
|
||||
const tccObj = tcc as Record<string, unknown>;
|
||||
const idx = typeof tccObj.index === "number" ? tccObj.index : 0;
|
||||
let entry = toolCallAcc.get(idx);
|
||||
if (!entry) {
|
||||
entry = { name: "", argsStr: "" };
|
||||
toolCallAcc.set(idx, entry);
|
||||
}
|
||||
if (typeof tccObj.id === "string" && tccObj.id) {
|
||||
if (entry.id && entry.id !== tccObj.id) {
|
||||
// 同一 index 上换了新 tool_call,重置累积(不太常见,defensive)。
|
||||
entry.name = "";
|
||||
entry.argsStr = "";
|
||||
}
|
||||
entry.id = tccObj.id;
|
||||
}
|
||||
if (typeof tccObj.name === "string" && tccObj.name) entry.name = tccObj.name;
|
||||
if (typeof tccObj.args === "string") entry.argsStr += tccObj.args;
|
||||
}
|
||||
};
|
||||
|
||||
const buildSyntheticToolCalls = (): SyntheticToolCall[] => {
|
||||
const out: SyntheticToolCall[] = [];
|
||||
for (const entry of toolCallAcc.values()) {
|
||||
if (!entry.name) continue;
|
||||
const parsed = parsePartialJson(entry.argsStr);
|
||||
if (parsed) {
|
||||
out.push({ id: entry.id, name: entry.name, args: parsed });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
@@ -441,26 +559,51 @@ export async function streamMultiAgent(
|
||||
const toolCalls = chunkObj.tool_calls;
|
||||
const toolCallChunks = chunkObj.tool_call_chunks;
|
||||
const content = chunkObj.content;
|
||||
|
||||
// 1) 累积本帧的 tool_call_chunks delta(若有),再合成可能可读的 tool_calls。
|
||||
// 主聊天 useStream 在 SDK 层做了这件事,我们在自定义 SSE 里手动补上。
|
||||
ingestToolCallChunks(toolCallChunks);
|
||||
const hasCompleteToolCalls =
|
||||
Array.isArray(toolCalls) && toolCalls.length > 0;
|
||||
let synthetic: SyntheticToolCall[] = [];
|
||||
if (!hasCompleteToolCalls && toolCallAcc.size > 0) {
|
||||
synthetic = buildSyntheticToolCalls();
|
||||
if (synthetic.length > 0) {
|
||||
// 把累积出的 tool_calls 注入回帧的 chunkObj —— 这样下面 onUpstreamFrame
|
||||
// 那边的 handleUpstreamFrame 只读 tool_calls 字段就能拿到完整 args,
|
||||
// 不必再单独做一份累积。
|
||||
chunkObj.tool_calls = synthetic;
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveToolCalls: unknown[] | null = hasCompleteToolCalls
|
||||
? (toolCalls as unknown[])
|
||||
: synthetic.length > 0
|
||||
? synthetic
|
||||
: null;
|
||||
|
||||
const hasToolCall =
|
||||
(Array.isArray(toolCalls) && toolCalls.length > 0) ||
|
||||
(effectiveToolCalls !== null && effectiveToolCalls.length > 0) ||
|
||||
(Array.isArray(toolCallChunks) && toolCallChunks.length > 0);
|
||||
|
||||
if (chunkType === "tool") {
|
||||
setPhase("tool_result");
|
||||
} else if (hasToolCall) {
|
||||
// Pick the most informative name we can find in either field.
|
||||
// 优先用 tool_calls(完整对象,含解析好的 args),tool_call_chunks 的 args
|
||||
// 通常是局部 JSON 字符串(LangGraph 流式期间逐字符发),没法直接用。
|
||||
const firstCall = Array.isArray(toolCalls) && toolCalls.length > 0
|
||||
? (toolCalls[0] as Record<string, unknown>)
|
||||
: Array.isArray(toolCallChunks) && toolCallChunks.length > 0
|
||||
? (toolCallChunks[0] as Record<string, unknown>)
|
||||
: null;
|
||||
const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : "";
|
||||
// 仅当来自 tool_calls(完整对象)时透传 args;tool_call_chunks 的 args 是
|
||||
// string 拼接中,不传上去给 UI,等下一个完整 chunk 再带。
|
||||
const fromCompleteToolCalls = Array.isArray(toolCalls) && toolCalls.length > 0;
|
||||
// Pick the most informative name + args we have so far.
|
||||
// 优先用累积出的 / 完整的 tool_calls(含可读 args 对象);若都还不到火候,
|
||||
// 回退到 tool_call_chunks 拿名字(args 还是 string,不透传)。
|
||||
const firstCall =
|
||||
effectiveToolCalls && effectiveToolCalls.length > 0
|
||||
? (effectiveToolCalls[0] as Record<string, unknown>)
|
||||
: Array.isArray(toolCallChunks) && toolCallChunks.length > 0
|
||||
? (toolCallChunks[0] as Record<string, unknown>)
|
||||
: null;
|
||||
const toolName =
|
||||
firstCall && typeof firstCall.name === "string" ? firstCall.name : "";
|
||||
const args =
|
||||
fromCompleteToolCalls && firstCall && typeof firstCall.args === "object" && firstCall.args !== null
|
||||
firstCall &&
|
||||
typeof firstCall.args === "object" &&
|
||||
firstCall.args !== null
|
||||
? (firstCall.args as Record<string, unknown>)
|
||||
: undefined;
|
||||
setPhase("tool_calling", toolName, args);
|
||||
|
||||
@@ -68,13 +68,20 @@ export interface RecommendCandidate {
|
||||
* Agent ids that must never enter the candidate pool. Centralized here so
|
||||
* every caller (recommend dialog, future tools) applies the same rule.
|
||||
*
|
||||
* - `roundtable-intent` — Step 1 clarification specialist, no role in research.
|
||||
* - `roundtable-recommender`— the recommender itself, can't recommend itself.
|
||||
* - `roundtable-coordinator-*` — disposable Step 2 coordinator shells (DB
|
||||
* accumulates one per session); meaningless as roundtable participants.
|
||||
* - `roundtable-intent` — Step 1 clarification specialist, no role in research.
|
||||
* - `roundtable-recommender` — the recommender itself, can't recommend itself.
|
||||
* - `roundtable-coordinator` — Step 2 coordinator singleton (2026-05 改造后固定 id,
|
||||
* ensure 自动落地;它是"协调者",不能反过来作为席位被协调)。
|
||||
* - `roundtable-coordinator-*` 前缀 — 历史残留:2026-05 之前每次 init 会创建
|
||||
* `roundtable-coordinator-<ts36>` 形式的临时 agent,DB 仍可能保留这些遗留记录。
|
||||
* 保留前缀过滤可以在管理员清理之前先把它们隐藏起来,避免出现在推荐弹窗候选池里。
|
||||
*/
|
||||
const EXCLUDED_AGENT_ID_PREFIX = "roundtable-coordinator-";
|
||||
const EXCLUDED_AGENT_IDS = new Set<string>(["roundtable-intent", "roundtable-recommender"]);
|
||||
const EXCLUDED_AGENT_IDS = new Set<string>([
|
||||
"roundtable-intent",
|
||||
"roundtable-recommender",
|
||||
"roundtable-coordinator",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Drop excluded ids from a raw `GET /api/agents` list and project each entry
|
||||
|
||||
@@ -202,6 +202,8 @@ export interface ReasoningBlockProps {
|
||||
/** 用于显示「思考用时」的可选时长(秒)。流式期间不显示。 */
|
||||
durationSec?: number;
|
||||
className?: string;
|
||||
/** 展开内容区额外 class,例如 max-h + overflow-y-auto。 */
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
const REASONING_AUTO_CLOSE_DELAY_MS = 1000;
|
||||
@@ -211,6 +213,7 @@ export function ReasoningBlock({
|
||||
streaming = false,
|
||||
durationSec,
|
||||
className,
|
||||
contentClassName,
|
||||
}: ReasoningBlockProps) {
|
||||
// open 初始值跟 streaming 同步:流式中默认展开方便观察思考过程,完成后会自动收起一次。
|
||||
const [open, setOpen] = useState<boolean>(streaming);
|
||||
@@ -260,7 +263,12 @@ export function ReasoningBlock({
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2 text-[11.5px] text-slate-600 leading-relaxed">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2 text-[11.5px] text-slate-600 leading-relaxed",
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
<MarkdownContent>{children}</MarkdownContent>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -44,6 +44,10 @@ export interface MessageStepsCardProps {
|
||||
/** 是否流式中 —— 决定最后一步显示 `active` 还是 `complete`。 */
|
||||
streaming?: boolean;
|
||||
className?: string;
|
||||
/** 点击 write_file / present_files 等产物相关步骤时打开沙箱。 */
|
||||
onArtifactStepClick?: (step: BubbleStep) => void;
|
||||
/** 把 agent_orchestration 派活的 agent id 解析成中文席位名(用于 chip 显示)。 */
|
||||
resolveAgentName?: (idOrName: string) => string;
|
||||
}
|
||||
|
||||
/** 工具调用阶段的 step 才进入折叠卡;非工具步骤(connecting/streaming/tool_result)归到「思考」段。 */
|
||||
@@ -51,7 +55,13 @@ function isToolStep(step: BubbleStep): boolean {
|
||||
return step.key.startsWith("tool_calling:") && step.key.length > "tool_calling:".length;
|
||||
}
|
||||
|
||||
export function MessageStepsCard({ steps, streaming, className }: MessageStepsCardProps) {
|
||||
export function MessageStepsCard({
|
||||
steps,
|
||||
streaming,
|
||||
className,
|
||||
onArtifactStepClick,
|
||||
resolveAgentName,
|
||||
}: MessageStepsCardProps) {
|
||||
// 把步骤拆成「工具调用」与「思考过程」两类 —— 与主聊天 MessageGroup 的 `lastToolCallStep` /
|
||||
// `lastReasoningStep` 划分对齐。
|
||||
const toolSteps = steps.filter(isToolStep);
|
||||
@@ -119,11 +129,19 @@ export function MessageStepsCard({ steps, streaming, className }: MessageStepsCa
|
||||
<ChainOfThoughtContent className="px-3 pb-2">
|
||||
{showAbove &&
|
||||
priorToolSteps.map((s) => (
|
||||
<ToolStep key={s.id} step={s} status="complete" />
|
||||
<ToolStep
|
||||
key={s.id}
|
||||
step={s}
|
||||
status="complete"
|
||||
onArtifactStepClick={onArtifactStepClick}
|
||||
resolveAgentName={resolveAgentName}
|
||||
/>
|
||||
))}
|
||||
<ToolStep
|
||||
step={lastToolStep}
|
||||
status={streaming && trailingReasoning.length === 0 ? "active" : "complete"}
|
||||
onArtifactStepClick={onArtifactStepClick}
|
||||
resolveAgentName={resolveAgentName}
|
||||
/>
|
||||
</ChainOfThoughtContent>
|
||||
)}
|
||||
@@ -141,16 +159,35 @@ export function MessageStepsCard({ steps, streaming, className }: MessageStepsCa
|
||||
function ToolStep({
|
||||
step,
|
||||
status,
|
||||
onArtifactStepClick,
|
||||
resolveAgentName,
|
||||
}: {
|
||||
step: BubbleStep;
|
||||
status: "complete" | "active";
|
||||
onArtifactStepClick?: (step: BubbleStep) => void;
|
||||
resolveAgentName?: (idOrName: string) => string;
|
||||
}) {
|
||||
const display = getStepDisplay(step.key, step.label, step.args);
|
||||
const argNode = renderStepArgs(step.key, step.args);
|
||||
const argNode = renderStepArgs(step.key, step.args, resolveAgentName);
|
||||
const [, toolName] = step.key.split(":");
|
||||
// read_file 也可点 —— 验证类「查看文件…」步骤常出现在最后一条,点击后在沙箱里
|
||||
// 查看该文件内容(走虚拟预览或 outputs HTTP)。主聊天只让 write 类可点,这里增强。
|
||||
const isArtifactStep =
|
||||
toolName === "write_file" ||
|
||||
toolName === "str_replace" ||
|
||||
toolName === "present_files" ||
|
||||
toolName === "read_file";
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
icon={display.icon}
|
||||
status={status}
|
||||
className={isArtifactStep && onArtifactStepClick ? "cursor-pointer" : undefined}
|
||||
onClick={
|
||||
isArtifactStep && onArtifactStepClick
|
||||
? () => onArtifactStepClick(step)
|
||||
: undefined
|
||||
}
|
||||
label={
|
||||
<span className="text-[12px] text-slate-700">
|
||||
<span className="font-medium">{display.label}</span>
|
||||
|
||||
@@ -36,7 +36,9 @@ import {
|
||||
type RecommendCandidate,
|
||||
type RecommendStreamPhase,
|
||||
} from "../api/recommend";
|
||||
import { useAutoScroll } from "../hooks/useAutoScroll";
|
||||
import { splitInlineReasoning } from "../lib/reasoning";
|
||||
import { ScrollToBottomButton } from "./ScrollToBottomButton";
|
||||
|
||||
/**
|
||||
* Picks-selection dialog that sits between Step 1 (intent) and Step 2
|
||||
@@ -430,8 +432,8 @@ export function RecommendAgentsDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col overflow-hidden gap-3">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<SparklesIcon className="size-5 text-purple-500" />
|
||||
选择参会智能体
|
||||
@@ -449,8 +451,11 @@ export function RecommendAgentsDialog({
|
||||
让 UI 焦点转到下方推荐卡片;用户手动切换后不再自动收起(尊重偏好)。
|
||||
============================================================ */}
|
||||
<CollapsibleModelOutput
|
||||
streaming={state.phase === "recommending"}
|
||||
isLoading={
|
||||
state.phase === "loading_agents" || state.phase === "recommending"
|
||||
}
|
||||
phase={state.phase}
|
||||
scrollDeps={[state.rationale, state.steps, state.streamPhase]}
|
||||
>
|
||||
<RecommenderOutput state={state} />
|
||||
</CollapsibleModelOutput>
|
||||
@@ -464,7 +469,7 @@ export function RecommendAgentsDialog({
|
||||
<CheckIcon className="size-3 text-emerald-500" />
|
||||
推荐参会智能体
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto pr-1 -mr-1 space-y-4">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar pr-1 -mr-1 space-y-4">
|
||||
{state.phase === "error" ? (
|
||||
<div className="text-sm text-destructive p-4 border border-destructive/30 rounded-md bg-destructive/5">
|
||||
{state.errorMessage ?? "未知错误"}
|
||||
@@ -534,7 +539,7 @@ export function RecommendAgentsDialog({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DialogFooter className="flex-row sm:justify-between items-center gap-2 flex-wrap">
|
||||
<DialogFooter className="shrink-0 flex-row sm:justify-between items-center gap-2 flex-wrap border-t pt-3 mt-1">
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<span
|
||||
className={cn(
|
||||
@@ -576,39 +581,51 @@ export function RecommendAgentsDialog({
|
||||
* 「模型生成内容」整段的可折叠外壳。
|
||||
*
|
||||
* 默认行为:
|
||||
* - 流式期间(`streaming=true`)默认展开,让用户看到实时生成
|
||||
* - 流式结束(切到 `ready`/`fallback`/`error`)后延迟 1s 自动收起一次,
|
||||
* - 「正在加载候选」+「正在生成推荐」期间(`isLoading=true`)始终保持展开,
|
||||
* 让用户看到内部 spinner / 实时生成内容,不会在中途被自动收起。
|
||||
* - 推荐完成(切到 `ready`/`fallback`/`error`)后延迟 1s 自动收起一次,
|
||||
* 把焦点让给下方推荐卡片
|
||||
* - 用户**手动**点击 toggle 后,`userTouchedRef` 置 true,后续不再自动改变
|
||||
*
|
||||
* 收起态 header 上显示状态徽标(「正在生成 / 已生成 / 已使用兜底 / 推荐失败」),
|
||||
* 让用户不用展开就知道当前阶段。
|
||||
* 收起态 header 上显示:
|
||||
* - 加载中/生成中 → 旋转 spinner 图标(替代静态 ✨),即便折叠也能看到在加载
|
||||
* - 完成态 → 静态 ✨ + 状态徽标(已生成 / 已使用兜底 / 推荐失败)
|
||||
*/
|
||||
const MODEL_OUTPUT_AUTO_CLOSE_DELAY_MS = 1000;
|
||||
|
||||
function CollapsibleModelOutput({
|
||||
streaming,
|
||||
isLoading,
|
||||
phase,
|
||||
scrollDeps,
|
||||
children,
|
||||
}: {
|
||||
streaming: boolean;
|
||||
/** True 时面板保持展开 + header 显示 spinner;涵盖加载候选 + 流式生成。 */
|
||||
isLoading: boolean;
|
||||
phase: DialogPhase;
|
||||
/** 内容变化时触发自动滚到底(与 Step1/Step2 对话区同一套 useAutoScroll)。 */
|
||||
scrollDeps: React.DependencyList;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState<boolean>(true);
|
||||
const userTouchedRef = useRef<boolean>(false);
|
||||
const hasAutoClosedRef = useRef<boolean>(false);
|
||||
const modelOutputScrollRef = useRef<HTMLDivElement>(null);
|
||||
const { atBottom: modelOutputAtBottom, scrollToBottom: scrollModelOutputToBottom } =
|
||||
useAutoScroll(modelOutputScrollRef, scrollDeps);
|
||||
|
||||
// 流式开始 / 进行中 → 默认展开(只在用户没动过时生效)。
|
||||
// 流式结束 → 延迟自动收起一次(仅一次)。
|
||||
// 加载中 → 保持展开(用户没手动动过的前提下)。
|
||||
// 加载完成 → 延迟自动收起一次(仅一次),让出焦点给下方推荐卡片。
|
||||
// 关键区分:idle / loading_agents 早期阶段以前会被 1s 计时器误关——
|
||||
// 现在只有真正进入「已完成/兜底/失败」三种终态才触发自动收起。
|
||||
useEffect(() => {
|
||||
if (userTouchedRef.current) return;
|
||||
if (streaming) {
|
||||
if (isLoading) {
|
||||
setOpen(true);
|
||||
hasAutoClosedRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!hasAutoClosedRef.current && open) {
|
||||
const isCompleted = phase === "ready" || phase === "fallback" || phase === "error";
|
||||
if (isCompleted && !hasAutoClosedRef.current && open) {
|
||||
const timer = setTimeout(() => {
|
||||
setOpen(false);
|
||||
hasAutoClosedRef.current = true;
|
||||
@@ -616,12 +633,12 @@ function CollapsibleModelOutput({
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [streaming, open]);
|
||||
}, [isLoading, phase, open]);
|
||||
|
||||
const statusBadge = (() => {
|
||||
switch (phase) {
|
||||
case "loading_agents":
|
||||
return { label: "加载中", className: "bg-slate-100 text-slate-600" };
|
||||
return { label: "加载候选", className: "bg-slate-100 text-slate-600" };
|
||||
case "recommending":
|
||||
return { label: "正在生成", className: "bg-purple-50 text-purple-700 border border-purple-200" };
|
||||
case "ready":
|
||||
@@ -636,21 +653,20 @@ function CollapsibleModelOutput({
|
||||
})();
|
||||
|
||||
return (
|
||||
<section className="border rounded-md bg-muted/20 shrink-0">
|
||||
<section className="border rounded-md bg-muted/20 shrink-0 max-h-[38vh] flex flex-col min-h-0 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
userTouchedRef.current = true;
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase tracking-wider hover:bg-muted/40 transition-colors cursor-pointer"
|
||||
className="w-full shrink-0 flex items-center gap-2 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase tracking-wider hover:bg-muted/40 transition-colors cursor-pointer"
|
||||
>
|
||||
<SparklesIcon
|
||||
className={cn(
|
||||
"size-3 text-purple-500 shrink-0",
|
||||
streaming && "animate-pulse",
|
||||
)}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Loader2Icon className="size-3 text-purple-500 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<SparklesIcon className="size-3 text-purple-500 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 text-left">模型生成内容</span>
|
||||
{statusBadge && (
|
||||
<span
|
||||
@@ -669,7 +685,19 @@ function CollapsibleModelOutput({
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{open && <div className="px-3 pb-3">{children}</div>}
|
||||
{open && (
|
||||
<div className="relative min-h-0 flex-1 flex flex-col">
|
||||
<div
|
||||
ref={modelOutputScrollRef}
|
||||
className="px-3 pb-3 min-h-0 flex-1 overflow-y-auto custom-scrollbar"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{!modelOutputAtBottom && (
|
||||
<ScrollToBottomButton onClick={scrollModelOutputToBottom} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -738,7 +766,15 @@ function RecommenderOutput({ state }: { state: InternalState }) {
|
||||
)}
|
||||
|
||||
{hasReasoning && (
|
||||
<ReasoningBlock streaming={isStreaming}>{reasoning!}</ReasoningBlock>
|
||||
<ReasoningBlock
|
||||
streaming={isStreaming}
|
||||
contentClassName={cn(
|
||||
"overflow-y-auto custom-scrollbar",
|
||||
isStreaming ? "max-h-none" : "max-h-48",
|
||||
)}
|
||||
>
|
||||
{reasoning!}
|
||||
</ReasoningBlock>
|
||||
)}
|
||||
|
||||
{!hasRationale && !hasReasoning && isStreaming && (
|
||||
@@ -746,7 +782,12 @@ function RecommenderOutput({ state }: { state: InternalState }) {
|
||||
)}
|
||||
|
||||
{hasRationale && (
|
||||
<div className="max-h-72 overflow-y-auto rounded-md border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-700 leading-relaxed">
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-y-auto custom-scrollbar rounded-md border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-700 leading-relaxed",
|
||||
isStreaming ? "max-h-none" : "max-h-40",
|
||||
)}
|
||||
>
|
||||
<MarkdownContent>{cleanedRationale}</MarkdownContent>
|
||||
{isStreaming && <StreamingCursor />}
|
||||
</div>
|
||||
|
||||
@@ -353,7 +353,13 @@ function Step1Message({
|
||||
<div className="text-xs text-slate-700 leading-relaxed font-medium break-words space-y-2 roundtable-md">
|
||||
{introText && <MarkdownContent>{introText}</MarkdownContent>}
|
||||
{parts.summary && <IntentReadySummaryCard summary={parts.summary} />}
|
||||
{parts.after && <MarkdownContent>{parts.after}</MarkdownContent>}
|
||||
{/*
|
||||
parts.after 是模型违反 SOUL §0「硬规则 3」在 [INTENT_READY] 块后
|
||||
继续输出的内容(典型:"那么我先帮你列一下大纲……")。后端 intent.py
|
||||
的终态帧已经把 content 截断到 marker 结尾,但流式期间 LangGraph
|
||||
messages-tuple 还是会把那段废话逐 token 推过来——故意在 UI 层
|
||||
把它丢弃,让用户看到的就是"复述 + 摘要卡",干净结束。
|
||||
*/}
|
||||
{isStreaming && <StreamingCursor />}
|
||||
{isPendingClarification && clarification && (
|
||||
<IntentClarificationCard
|
||||
|
||||
Loaded 100 of 772 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user