This commit is contained in:
许海洋 committed 2026-06-01 07:56:34 +08:00
commit dadb0bbe68
782 files changed
+160067 -3424

No files matched your search

+10
View File
@@ -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
View File
@@ -7,6 +7,8 @@ frontend-web/.vscode/
# Local runtime logs and pid files # Local runtime logs and pid files
.runtime/ .runtime/
logs/
offline-backend-20260512/backend/logs/
# Frontend local/runtime artifacts # Frontend local/runtime artifacts
frontend-web/.env frontend-web/.env
@@ -81,3 +83,6 @@ hermes-agent-main/
offline-backend-20260512/backend/tests/* offline-backend-20260512/backend/tests/*
deploy deploy
.history .history
# Frontend skills components must be tracked (excluded from the broad skills/ ignore above)
!frontend-web/src/components/workspace/skills/
+21 -1
View File
@@ -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 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) # 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
# VITE_CANVAS_LANGGRAPH_BASE_URL= # VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -3,5 +3,21 @@
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001 VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api 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 # VITE_NOTE_API_BASE_URL=http://127.0.0.1:8000
+5
View File
@@ -24,6 +24,11 @@
# --- 生产打包(见 .env.production)--- # --- 生产打包(见 .env.production)---
# 部署前修改 .env.production 中的地址,再执行 pnpm build # 部署前修改 .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_CANVAS_LANGGRAPH_BASE_URL=
# VITE_STATIC_WEBSITE_ONLY= # VITE_STATIC_WEBSITE_ONLY=
+16
View File
@@ -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_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 地址 # 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL= # VITE_CANVAS_LANGGRAPH_BASE_URL=
+65 -36
View File
@@ -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)` 工厂统一构造。 - 三个圆桌 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 全部移除。 - 历史包袱:早期实现走 `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)。 - **席位 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 目录在部署机上**不会存在**——任何环境都必须把 `roundtable-intent` + `roundtable-recommender` 两个**功能型 agent** 持久化进发布物,否则 `/api/intent/stream` 报 500。**互联网 demo 还需要 6 个示例研讨席位**(`roundtable-intelligence` 等),**内网部署用客户自有 agent 替代**这些席位,无需打包。详见 §11.7。 - **`.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` 是 SSE 而非一次性 JSON**:用 `asyncio.as_completed` 按真实完成顺序推送 `seat_ready`,前端因此能逐个点亮席位。
- **`init_done.thread_ids` 包含协调智能体自身**(`agent_threads[main_agent_name] = coord_thread`),前端 Step 3 列文件时需自行过滤。 - **`init_done.thread_ids` 包含协调智能体自身**(`agent_threads[main_agent_name] = coord_thread`),前端 Step 3 列文件时需自行过滤。
- **澄清协议统一**:Step 1 与 Step 2 leader 都借 `ask_clarification` + 终态 `clarification` 帧驱动同一套选项卡片组件;`allow_multiple` 默认 false(单选、点击即发送)。 - **澄清协议统一**:Step 1 与 Step 2 leader 都借 `ask_clarification` + 终态 `clarification` 帧驱动同一套选项卡片组件;`allow_multiple` 默认 false(单选、点击即发送)。
@@ -282,33 +282,50 @@ offline-backend-20260512/backend/
请求体: 请求体:
{ {
"agent_names": ["roundtable-intelligence", ...], // 用户选的席位 id "agent_names": ["roundtable-intelligence", ...], // 用户选的席位 id
"main_agent_name": "roundtable-coordinator-xxx", // 当次总控 id,前端生成 "main_agent_name": "<可选,已废弃>", // 历史字段;2026-05 改造后后端忽略,统一用 COORDINATOR_AGENT_ID
"model": "deepseek-chat" // 可选,写入 coordinator agent "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): **早期校验**(同步抛 HTTP 4xx,**不**进 SSE):
- `agent_names` 必须非空 - `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()` 后比较 / 存储 - 所有 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) asyncio.as_completed(各席位 _prepare_sub_agent)
每完成一个 → data: {"event":"seat_ready","agent_name","thread_id","display_name","description"} 每完成一个 → data: {"event":"seat_ready","agent_name","thread_id","display_name","description"}
(按真实完成顺序,先完成先发,前端因此能逐个点亮) (按真实完成顺序,先完成先发,前端因此能逐个点亮)
异常时:取消所有未完成的 task,避免泄露到 AsyncClient close 之后 异常时:取消所有未完成的 task,避免泄露到 AsyncClient close 之后
协调智能体: 协调智能体(单例):
POST /api/threads → coord_thread main_agent_name = COORDINATOR_AGENT_ID # 固定常量,不再每次新建 agent
POST /api/agents coord_thread = _create_thread_direct(...) # 仍然每次新 thread,会话独立
skills=["agent_orchestration"]
soul=动态拼接(本次 init 的席位列表 + 派活规则)
id=main_agent_name(前端生成的 roundtable-coordinator-<ts36>)
agent_threads[main_agent_name] = coord_thread // ⚠️ thread_ids 含 coordinator 自身 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":"coordinator_ready","main_agent_name","thread_id"}
→ data: {"event":"init_done","main_agent_name","thread_ids":{...}} → data: {"event":"init_done","main_agent_name","thread_ids":{...}}
@@ -316,28 +333,26 @@ asyncio.as_completed(各席位 _prepare_sub_agent)
失败 → data: {"event":"error","detail":"..."} 失败 → 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_name 通过 agent_orchestration 技能派活,不要派给不在清单内的 agent:
用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总, - agent_name: roundtable-intelligence(情报收集):<description>
当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果 - agent_name: roundtable-environment(环境评估):<description>
#调度方法:
通过使用 agent_orchestration 技能调用其他智能体干活,使用技能时传入
agent_name 和需要做的活。
⚠️ agent_name 必须严格使用下方列出的英文 id(例如 roundtable-intelligence),
不要把括号里的中文显示名拼进 agent_name 里。
#可调度智能体:
- 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`) ### 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 后这个问题在物理上已不存在——根本没走网络栈。 **已废止的 `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 澄清协议统一 ### 9.3 澄清协议统一
@@ -705,15 +727,22 @@ offline-backend-20260512/backend/.deer-flow/*
| Agent | 角色 | 是否必须打包发布 | | Agent | 角色 | 是否必须打包发布 |
|-------|------|------------------| |-------|------|------------------|
| `roundtable-intent` | **功能型**:Step 1 任务理解,所有部署都用它 | ✅ **必须**(任何环境) | | `roundtable-intent` | **功能型**:Step 1 任务理解,所有部署都用它 | ✅ seeder 自动落地(无需手工打包) |
| `roundtable-recommender` | **功能型**:Step 1→2 之间从候选池里挑推荐,所有部署都用它 | ✅ **必须**(任何环境) | | `roundtable-recommender` | **功能型**:Step 1→2 之间从候选池里挑推荐,所有部署都用它 | ✅ seeder 自动落地(无需手工打包) |
| `roundtable-coordinator` | **功能型单例**:Step 2 总控协调,2026-05 起为单例(id 固定) | ✅ seeder 自动落地(无需手工打包) |
| `roundtable-intelligence` | 示例研讨席位(情报收集) | ⚠️ **互联网 demo 用**,内网不需要 | | `roundtable-intelligence` | 示例研讨席位(情报收集) | ⚠️ **互联网 demo 用**,内网不需要 |
| `roundtable-environment` | 示例研讨席位(环境评估) | ⚠️ 同上 | | `roundtable-environment` | 示例研讨席位(环境评估) | ⚠️ 同上 |
| `roundtable-solution-design` | 示例研讨席位(方案设计) | ⚠️ 同上 | | `roundtable-solution-design` | 示例研讨席位(方案设计) | ⚠️ 同上 |
| `roundtable-risk-review` | 示例研讨席位(风险审查) | ⚠️ 同上 | | `roundtable-risk-review` | 示例研讨席位(风险审查) | ⚠️ 同上 |
| `roundtable-execution-plan` | 示例研讨席位(执行规划) | ⚠️ 同上 | | `roundtable-execution-plan` | 示例研讨席位(执行规划) | ⚠️ 同上 |
| `roundtable-summary` | 示例研讨席位(总结陈述) | ⚠️ 同上 | | `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 + 推荐流程跑不起来,跟客户业务无关 - `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`)。 - **不使用 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` 配自家模型即可,前后端均无需改动**。 - **不写死模型名**:与 `/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`)。 - **席位区间硬约束**:推荐弹窗要求 **2 ≤ 选中数 ≤ 8**(`MIN_SELECTED` / `MAX_SELECTED`)。
- **协调智能体单例(2026-05)**:`COORDINATOR_AGENT_ID = "roundtable-coordinator"` 是固定常量,不再每会话生成新 id。后端 seeder 自动落地,「本次席位列表」改由后端通过 thread 历史 human 消息注入,前端无感。`newCoordinatorName()` 仅作为兼容性壳保留。
- **总控编排循环上限**:`MAX_CYCLES = 8`,超出后强制停止并 toast。 - **总控编排循环上限**:`MAX_CYCLES = 8`,超出后强制停止并 toast。
- **席位派活策略**:**顺序**(不是并行)。旧实现并行触发多路 SSE 会让浏览器卡顿,现已改为顺序。 - **席位派活策略**:**顺序**(不是并行)。旧实现并行触发多路 SSE 会让浏览器卡顿,现已改为顺序。
- **调试开关**:`window.__MULTI_AGENT_INIT_TIMING__` 与 `window.__MULTI_AGENT_DEBUG__`(**不是** `__MULTI_AGENT_STREAM_DEBUG__`,文档历史版本有误)。 - **调试开关**:`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 | | `agentConfigs` | `Map<seatId, {model, weight, prompt, temp}>` Persona 覆盖;下一轮该席位的派活会用此 model |
| `isUpperPanelCollapsed` | 是否折叠圆桌可视化 + 调控台,给对话区让出垂直空间 | | `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)` ### 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
+13 -7
View File
@@ -3,6 +3,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
import { ThemeProvider as NextThemesProvider } from "next-themes"; import { ThemeProvider as NextThemesProvider } from "next-themes";
import { QueryClientProvider } from "@/components/query-client-provider"; import { QueryClientProvider } from "@/components/query-client-provider";
import { AuthSyncWatcher } from "@/core/auth/sync";
import { I18nProvider } from "@/core/i18n/context"; import { I18nProvider } from "@/core/i18n/context";
import { detectLocale } from "@/core/i18n/locale"; import { detectLocale } from "@/core/i18n/locale";
import { ChatProvider } from "@/contexts/ChatContext"; import { ChatProvider } from "@/contexts/ChatContext";
@@ -31,13 +32,18 @@ function AppProviders({ children }: { children: React.ReactNode }) {
disableTransitionOnChange disableTransitionOnChange
forcedTheme={forcedTheme} forcedTheme={forcedTheme}
> >
<I18nProvider initialLocale={locale}> {/* QueryClientProvider must wrap ThemeProvider because the custom
<ThemeProvider> ThemeProvider reads the admin-configured default via useQuery. */}
<ChatProvider> <QueryClientProvider>
<QueryClientProvider>{children}</QueryClientProvider> <I18nProvider initialLocale={locale}>
</ChatProvider> <ThemeProvider>
</ThemeProvider> <ChatProvider>
</I18nProvider> <AuthSyncWatcher />
{children}
</ChatProvider>
</ThemeProvider>
</I18nProvider>
</QueryClientProvider>
</NextThemesProvider> </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

+185
View File
@@ -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>
</>
);
}
+94 -15
View File
@@ -26,6 +26,7 @@ import {
ListTodo, ListTodo,
MessageSquareCode, MessageSquareCode,
MessageSquarePlus, MessageSquarePlus,
ShieldCheck,
Network, Network,
NotebookPen, NotebookPen,
Plus, Plus,
@@ -72,6 +73,7 @@ const menuItems: MenuItem[] = [
icon: HelpCircle, icon: HelpCircle,
children: [ children: [
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/workspace/chats/new" }, { 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,只是不在侧边栏出现。 // 「圆桌规划」入口暂时隐藏 —— 功能仍在 /page/roundtable/planning,只是不在侧边栏出现。
// 需要重新开放时取消注释即可。 // 需要重新开放时取消注释即可。
// { // {
@@ -129,8 +131,18 @@ const menuItems: MenuItem[] = [
label: "XK中任务", label: "XK中任务",
icon: Folder, icon: Folder,
children: [ 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中任务", label: "COH中任务",
icon: Folder, icon: Folder,
children: [ children: [
{ id: "coh-task-3qfx", label: "3QFX", icon: ListTodo }, {
{ id: "coh-task-fx", label: "任务FX", icon: ListTodo }, id: "coh-task-3qfx",
{ id: "coh-task-xdfx", label: "XDFX", icon: ListTodo }, 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中任务", label: "PG中任务",
icon: Folder, icon: Folder,
children: [ children: [
{ id: "pg-task-pg", label: "任务PG", icon: ListTodo }, {
{ id: "pg-task-xdpg", label: "XDPG", icon: ListTodo }, id: "pg-task-pg",
{ id: "pg-task-info-pg", label: "信息PG", icon: ListTodo }, 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中任务", label: "TY中任务",
icon: Folder, icon: Folder,
children: [ children: [
{ id: "ty-task-xdty", label: "XDTY任务", icon: ListTodo }, {
{ id: "ty-task-plan", label: "方案TY任务", icon: ListTodo }, id: "ty-task-xdty",
{ id: "ty-task-xxty", label: "XXTY任务", icon: ListTodo }, 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-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" },
{ {
id: "mgmt-tasks", id: "mgmt-tasks",
label: "任务管理", label: "定时任务",
icon: ListChecks, icon: ListChecks,
path: "/page/strategy/scheduled/tasks", path: "/page/strategy/scheduled/tasks",
}, },
@@ -193,6 +250,13 @@ const menuItems: MenuItem[] = [
path: "/page/strategy/settings/prompt-prefix", path: "/page/strategy/settings/prompt-prefix",
adminOnly: true, adminOnly: true,
}, },
{
id: "mgmt-private-square",
label: "私密广场配置",
icon: ShieldCheck,
path: "/page/strategy/settings/private-square",
adminOnly: true,
},
{ {
id: "mgmt-llm-metrics", id: "mgmt-llm-metrics",
label: "大模型调用统计", label: "大模型调用统计",
@@ -228,9 +292,24 @@ const menuItems: MenuItem[] = [
label: "资源管理", label: "资源管理",
icon: Database, icon: Database,
children: [ children: [
{ id: "resource-network-xwt", label: "网络xwt资源管理", icon: Network }, {
{ id: "resource-knowledge-base", label: "知识库资源管理", icon: Library }, id: "resource-network-xwt",
{ id: "resource-zz", label: "ZZ资源管理", icon: FileBox }, 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",
},
], ],
}, },
]; ];
+3 -3
View File
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="card" data-slot="card"
className={cn( 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, className,
)} )}
{...props} {...props}
@@ -42,7 +42,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-description" data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm dark:text-[#F2F3F5]", className)}
{...props} {...props}
/> />
); );
@@ -65,7 +65,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-content" data-slot="card-content"
className={cn("px-6", className)} className={cn("px-6 ", className)}
{...props} {...props}
/> />
); );
@@ -14,7 +14,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
data-slot="input-group" data-slot="input-group"
role="group" role="group"
className={cn( 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", "h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment. // Variants based on alignment.
+6 -3
View File
@@ -8,9 +8,12 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( 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", "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",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "border-input placeholder:text-muted-foreground",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "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, className,
)} )}
{...props} {...props}
+60 -54
View File
@@ -30,7 +30,8 @@ const tabsListVariants = cva(
{ {
variants: { variants: {
variant: { 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", line: "gap-1 bg-transparent",
}, },
}, },
@@ -40,60 +41,65 @@ const tabsListVariants = cva(
}, },
); );
const TabsList = React.forwardRef< const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>, React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> & React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants> VariantProps<typeof tabsListVariants>
>(({ className, variant = "default", ...props }, ref) => { >(({ className, variant = "default", ...props }, ref) => {
return ( return (
<TabsPrimitive.List <TabsPrimitive.List
ref={ref} ref={ref}
data-slot="tabs-list" data-slot="tabs-list"
data-variant={variant} data-variant={variant}
className={cn(tabsListVariants({ variant }), className)} className={cn(tabsListVariants({ variant }), className)}
{...props} {...props}
/> />
); );
}); });
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef< TabsList.displayName = TabsPrimitive.List.displayName;
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;
const TabsContent = React.forwardRef< const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>, React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
return ( return (
<TabsPrimitive.Content <TabsPrimitive.Trigger
ref={ref} ref={ref}
data-slot="tabs-content" data-slot="tabs-trigger"
className={cn("flex-1 outline-none", className)} className={cn(
{...props} "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",
TabsContent.displayName = TabsPrimitive.Content.displayName; "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 }; export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
+3 -1
View File
@@ -7,7 +7,9 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea <textarea
data-slot="textarea" data-slot="textarea"
className={cn( 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, className,
)} )}
{...props} {...props}
@@ -6,8 +6,10 @@ import {
InfoIcon, InfoIcon,
LockIcon, LockIcon,
MessageSquareIcon, MessageSquareIcon,
ShieldIcon,
SparklesIcon, SparklesIcon,
SquarePenIcon, SquarePenIcon,
StarIcon,
TagsIcon, TagsIcon,
Trash2Icon, Trash2Icon,
} from "lucide-react"; } 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 { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog"; import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { import {
useAgent,
useDeleteAgent, useDeleteAgent,
useSetAgentFeatured, useSetAgentFeatured,
useSetAgentPrivate,
useToggleAgentFavorite,
useUpdateAgent, useUpdateAgent,
} from "@/core/agents"; } from "@/core/agents";
import type { Agent } from "@/core/agents"; import type { Agent } from "@/core/agents";
@@ -61,6 +66,8 @@ export function AgentCard({ agent }: AgentCardProps) {
const deleteAgent = useDeleteAgent(); const deleteAgent = useDeleteAgent();
const updateAgent = useUpdateAgent(); const updateAgent = useUpdateAgent();
const setFeatured = useSetAgentFeatured(); const setFeatured = useSetAgentFeatured();
const setPrivate = useSetAgentPrivate();
const toggleFavorite = useToggleAgentFavorite();
const { skills, isLoading: isSkillsLoading } = useSkills(); const { skills, isLoading: isSkillsLoading } = useSkills();
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
@@ -69,11 +76,19 @@ export function AgentCard({ agent }: AgentCardProps) {
const [editDescription, setEditDescription] = useState(agent.description ?? ""); const [editDescription, setEditDescription] = useState(agent.description ?? "");
const [editSoul, setEditSoul] = useState(agent.soul ?? ""); const [editSoul, setEditSoul] = useState(agent.soul ?? "");
const [editPublished, setEditPublished] = useState(agent.published); const [editPublished, setEditPublished] = useState(agent.published);
const [editIsPrivate, setEditIsPrivate] = useState(agent.is_private);
const [selectedSkills, setSelectedSkills] = useState<string[]>( const [selectedSkills, setSelectedSkills] = useState<string[]>(
agent.skills ?? [], agent.skills ?? [],
); );
const [detailSkillName, setDetailSkillName] = useState<string | null>(null); 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 storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null; const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin"; const isAdmin = storedAuth?.system_role === "admin";
@@ -81,11 +96,16 @@ export function AgentCard({ agent }: AgentCardProps) {
const isOwned = const isOwned =
!isBuiltin && agent.user_id !== null && agent.user_id === currentUserId; !isBuiltin && agent.user_id !== null && agent.user_id === currentUserId;
const isFromOthers = !isBuiltin && !isOwned; 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 // Publish-toggle stays owner-only — built-ins are inherently visible to
// everyone so toggling published is moot. // everyone so toggling published is moot.
const canEdit = isOwned || (isBuiltin && isAdmin); const canEdit = isOwned || isAdmin;
const canDelete = isOwned || (isBuiltin && 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(() => { useEffect(() => {
if (!editOpen) { if (!editOpen) {
@@ -93,18 +113,32 @@ export function AgentCard({ agent }: AgentCardProps) {
} }
setEditName(agent.name); setEditName(agent.name);
setEditDescription(agent.description ?? ""); setEditDescription(agent.description ?? "");
setEditSoul(agent.soul ?? "");
setEditPublished(agent.published); setEditPublished(agent.published);
setEditIsPrivate(agent.is_private);
setSelectedSkills(agent.skills ?? []); setSelectedSkills(agent.skills ?? []);
}, [ }, [
agent.description, agent.description,
agent.is_private,
agent.name, agent.name,
agent.published, agent.published,
agent.skills, agent.skills,
agent.soul,
editOpen, 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() { function handleChat() {
router.push(`/page/workspace/agents/${agent.id}/chats/new`); router.push(`/page/workspace/agents/${agent.id}/chats/new`);
} }
@@ -125,6 +159,10 @@ export function AgentCard({ agent }: AgentCardProps) {
toast.error(t.agents.nameStepInvalidError ?? "名称不能为空"); toast.error(t.agents.nameStepInvalidError ?? "名称不能为空");
return; return;
} }
if (editIsPrivate && editPublished) {
toast.warning("私密广场与公开发布互斥,请只勾选其一");
return;
}
try { try {
await updateAgent.mutateAsync({ await updateAgent.mutateAsync({
id: agent.id, id: agent.id,
@@ -132,7 +170,10 @@ export function AgentCard({ agent }: AgentCardProps) {
name: trimmedName, name: trimmedName,
description: editDescription, description: editDescription,
soul: editSoul, 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, skills: selectedSkills,
}, },
}); });
@@ -144,6 +185,13 @@ export function AgentCard({ agent }: AgentCardProps) {
} }
async function handleTogglePublished() { 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 { try {
await updateAgent.mutateAsync({ await updateAgent.mutateAsync({
id: agent.id, 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() { async function handleToggleFeatured() {
// One-click on/off — ordering is handled by the dedicated 管理 Agent 列表 // 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 // dialog. Featured agents get appended to the end on add (Date.now() is a
@@ -188,15 +267,18 @@ export function AgentCard({ agent }: AgentCardProps) {
return ( return (
<> <>
<Card className="group flex flex-col transition-shadow hover:shadow-md"> <Card className="group flex min-w-0 flex-col transition-shadow hover:shadow-md">
<CardHeader className="pb-3"> <CardHeader className="min-w-0 pb-3">
<div className="flex items-start justify-between gap-2"> <div className="flex min-w-0 items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center 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"> <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" /> <BotIcon className="h-5 w-5" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1 overflow-hidden">
<CardTitle className="truncate text-base" title={desensitize(agent.name)}> <CardTitle
className="block w-full min-w-0 overflow-hidden text-base text-ellipsis whitespace-nowrap"
title={desensitize(agent.name)}
>
{desensitize(agent.name)} {desensitize(agent.name)}
</CardTitle> </CardTitle>
{agent.model && ( {agent.model && (
@@ -229,7 +311,7 @@ export function AgentCard({ agent }: AgentCardProps) {
来自他人 来自他人
</Badge> </Badge>
)} )}
{isOwned && agent.published && ( {isOwned && agent.published && !agent.is_private && (
<Badge <Badge
variant="outline" variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs" className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
@@ -239,6 +321,16 @@ export function AgentCard({ agent }: AgentCardProps) {
已发布 已发布
</Badge> </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 && ( {agent.featured_order != null && (
<Badge <Badge
variant="outline" variant="outline"
@@ -294,13 +386,26 @@ export function AgentCard({ agent }: AgentCardProps) {
</CardContent> </CardContent>
)} )}
<CardFooter className="mt-auto flex items-center justify-between gap-2 pt-3"> <CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-3">
<Button size="sm" className="flex-1" onClick={handleChat}> {(canEdit || canDelete || isOwned || isAdmin || canFavorite) && (
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" /> <div className="flex flex-wrap items-center justify-end gap-1">
{t.agents.chat} {canFavorite && (
</Button> <Button
{(canEdit || canDelete || isOwned || isAdmin) && ( size="icon"
<div className="flex gap-1"> 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 && ( {isAdmin && (
<Button <Button
size="icon" size="icon"
@@ -348,6 +453,24 @@ export function AgentCard({ agent }: AgentCardProps) {
)} )}
</Button> </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 && ( {canEdit && (
<Button <Button
size="icon" size="icon"
@@ -372,6 +495,10 @@ export function AgentCard({ agent }: AgentCardProps) {
)} )}
</div> </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> </CardFooter>
</Card> </Card>
@@ -459,7 +586,11 @@ export function AgentCard({ agent }: AgentCardProps) {
<Textarea <Textarea
value={editSoul ?? ""} value={editSoul ?? ""}
onChange={(e) => setEditSoul(e.target.value)} onChange={(e) => setEditSoul(e.target.value)}
placeholder="输入 SOUL.md 内容" placeholder={
isAgentDetailLoading && !agentDetail
? "正在加载 SOUL.md..."
: "输入 SOUL.md 内容"
}
className="min-h-40 font-mono text-xs" className="min-h-40 font-mono text-xs"
/> />
</div> </div>
@@ -467,12 +598,41 @@ export function AgentCard({ agent }: AgentCardProps) {
<div> <div>
<p className="text-sm font-medium">公开发布</p> <p className="text-sm font-medium">公开发布</p>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
打开后,该智能体对所有用户可见。 {editIsPrivate
? "已加入私密广场,不可发布;请先关闭私密广场。"
: "打开后,该智能体对所有用户可见。"}
</p> </p>
</div> </div>
<Switch <Switch
checked={editPublished} checked={editPublished && !editIsPrivate}
onCheckedChange={setEditPublished} 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>
</div> </div>
@@ -1,44 +1,141 @@
"use client"; "use client";
import { BotIcon, PlusIcon } from "lucide-react"; import { BotIcon, LockIcon, PlusIcon, ShieldIcon } from "lucide-react";
import { useRouter } from "next/navigation"; 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar"; 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 { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { usePrivateSquareStatus } from "@/core/system-settings/hooks";
import { desensitize } from "@/lib/desensitize";
import { AgentCard } from "./agent-card"; 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() { export function AgentGallery() {
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const [activeTab, setActiveTab] = useState<"all" | "mine">("mine"); const [activeTab, setActiveTab] = useState<"all" | "mine" | "private">(
"mine",
);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]); const [tagIds, setTagIds] = useState<string[]>([]);
// Name + tag filtering is resolved server-side via the /api/agents query. // Tag filtering stays server-side; name search must run on the *desensitized*
const { agents, isLoading } = useAgents({ search, tagIds }); // 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 storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null; 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( 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( 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 = () => { const handleNewAgent = () => {
router.push("/page/workspace/agents/new"); 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) => ( 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"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{list.map((agent) => ( {list.map((agent) => (
@@ -49,15 +146,21 @@ export function AgentGallery() {
const renderEmpty = (description: string, showNewButton = false) => ( const renderEmpty = (description: string, showNewButton = false) => (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center"> <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"> <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" /> <BotIcon className="text-muted-foreground h-7 w-7 dark:text-[#AACEFF]" />
</div> </div>
<div> <div>
<p className="font-medium">{t.agents.emptyTitle}</p> <p className="font-medium dark:text-[#AACEFF]">{t.agents.emptyTitle}</p>
<p className="text-muted-foreground mt-1 text-sm">{description}</p> <p className="text-muted-foreground mt-1 text-sm dark:text-[#55A6FE]">
{description}
</p>
</div> </div>
{showNewButton && ( {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" /> <PlusIcon className="mr-1.5 h-4 w-4" />
{t.agents.newAgent} {t.agents.newAgent}
</Button> </Button>
@@ -65,6 +168,78 @@ export function AgentGallery() {
</div> </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 ( return (
<div className="flex size-full flex-col"> <div className="flex size-full flex-col">
{/* Page header */} {/* Page header */}
@@ -84,14 +259,35 @@ export function AgentGallery() {
{/* Tabs */} {/* Tabs */}
<Tabs <Tabs
value={activeTab} 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" className="flex flex-1 flex-col overflow-hidden"
> >
<div className="mx-6 mt-4 flex flex-col gap-3"> <div className="mx-6 mt-4 flex flex-col gap-3">
<TabsList className="w-auto self-start"> <div className="flex items-center justify-between gap-3">
<TabsTrigger value="mine">我的</TabsTrigger> <TabsList className="w-auto self-start">
<TabsTrigger value="all">广场</TabsTrigger> <TabsTrigger value="mine">我的</TabsTrigger>
</TabsList> <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 <TagSearchBar
targetType="agent" targetType="agent"
onSearchChange={setSearch} onSearchChange={setSearch}
@@ -123,6 +319,15 @@ export function AgentGallery() {
renderGrid(myAgents) renderGrid(myAgents)
)} )}
</TabsContent> </TabsContent>
{privateFeatureAvailable && (
<TabsContent
value="private"
className="flex-1 overflow-y-auto p-6 mt-0"
>
{renderPrivateTab()}
</TabsContent>
)}
</Tabs> </Tabs>
</div> </div>
); );
@@ -61,7 +61,12 @@ export function AgentManageDialog({
const candidates = useMemo(() => { const candidates = useMemo(() => {
const existing = new Set(agents.map((a) => a.id)); 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]); }, [agents, allAgents]);
const busy = setFeaturedMutation.isPending || reorderMutation.isPending; const busy = setFeaturedMutation.isPending || reorderMutation.isPending;
@@ -32,7 +32,10 @@ interface ArtifactsProviderProps {
} }
export function ArtifactsProvider({ children }: 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 [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
const [autoSelect, setAutoSelect] = useState(true); const [autoSelect, setAutoSelect] = useState(true);
const [open, setOpen] = useState( const [open, setOpen] = useState(
@@ -1,183 +1,769 @@
import { FilesIcon, XIcon } from "lucide-react"; import type { Message } from "@langchain/langgraph-sdk";
import { usePathname } from "next/navigation"; import { FilesIcon, XIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react"; import { usePathname } from "next/navigation";
import type { GroupImperativeHandle } from "react-resizable-panels"; 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 { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { import { Button } from "@/components/ui/button";
ResizableHandle, import {
ResizablePanel, ResizableHandle,
ResizablePanelGroup, ResizablePanel,
} from "@/components/ui/resizable"; ResizablePanelGroup,
import { env } from "@/env"; } from "@/components/ui/resizable";
import { cn } from "@/lib/utils"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { apiFetch } from "@/core/api";
import { import { getBackendBaseURL } from "@/core/config";
ArtifactFileDetail, import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
ArtifactFileList, import { useSkills } from "@/core/skills/hooks";
useArtifacts, import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
} from "../artifacts"; import {
import { useThread } from "../messages/context"; buildSkillDisplayMap,
collectRuntimeDisplaySources,
const CLOSE_MODE = { chat: 100, artifacts: 0 }; } from "@/core/skill-display/runtime";
const OPEN_MODE = { chat: 60, artifacts: 40 }; import { env } from "@/env";
import { cn } from "@/lib/utils";
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
children, import {
threadId, ArtifactFileDetail,
}) => { ArtifactFileList,
const { thread } = useThread(); useArtifacts,
const pathname = usePathname(); } from "../artifacts";
const threadIdRef = useRef(threadId); import { useThread } from "../messages/context";
const layoutRef = useRef<GroupImperativeHandle>(null); import { SkillDisplaySources } from "../skill-display-sources";
const { const CLOSE_MODE = { chat: 100, artifacts: 0 };
artifacts, const OPEN_MODE = { chat: 60, artifacts: 40 };
open: artifactsOpen, const SHOW_REFERENCE_EVENT = "workspace:show-reference";
setOpen: setArtifactsOpen,
setArtifacts, type BackendReferenceSource = Parameters<typeof SkillDisplaySources>[0]["sources"][number];
select: selectArtifact,
deselect, type BackendReferenceBatch = {
selectedArtifact, id: string;
} = useArtifacts(); assistant_message_id?: string;
sources: BackendReferenceSource[];
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); };
useEffect(() => {
if (threadIdRef.current !== threadId) { function logReferenceDebug(
threadIdRef.current = threadId; event: string,
deselect(); threadId: string,
} payload: Record<string, unknown>,
) {
// Update artifacts from the current thread console.info(`[RAG引用调试] ${event}`, payload);
setArtifacts(thread.values.artifacts); void apiFetch(`${getBackendBaseURL()}/api/threads/reference-debug-log`, {
method: "POST",
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now. headers: { "Content-Type": "application/json" },
// if ( body: JSON.stringify({
// selectedArtifact && event,
// !thread.values.artifacts?.includes(selectedArtifact) thread_id: threadId,
// ) { payload,
// deselect(); }),
// } }).catch(() => {
// Debug logging must never affect the chat flow.
if ( });
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && }
autoSelectFirstArtifact
) { const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
if (thread?.values?.artifacts?.length > 0) { children,
setAutoSelectFirstArtifact(false); threadId,
selectArtifact(thread.values.artifacts[0]!); }) => {
} const { thread } = useThread();
} const pathname = usePathname();
}, [ const threadIdRef = useRef(threadId);
threadId, const artifactsCountRef = useRef(0);
autoSelectFirstArtifact, const layoutRef = useRef<GroupImperativeHandle>(null);
deselect, const latestReferenceBatchIdRef = useRef<string | null>(null);
selectArtifact, const manualReferenceBatchRef = useRef(false);
selectedArtifact, const referenceDebugSignatureRef = useRef<string>("");
setArtifacts, const [sideTab, setSideTab] = useState("document");
thread.values.artifacts, const [referencesOpen, setReferencesOpen] = useState(true);
]); const [activeReferenceBatchId, setActiveReferenceBatchId] = useState<string | null>(null);
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
const artifactPanelOpen = useMemo(() => { const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") { const [settings] = useLocalSettings();
return artifactsOpen && artifacts?.length > 0; const ragModeEnabled =
} settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
return artifactsOpen; const { skills } = useSkills();
}, [artifactsOpen, artifacts]);
const {
const resizableIdBase = useMemo(() => { artifacts,
const raw = pathname open: artifactsOpen,
.replace(/[^a-zA-Z0-9_-]+/g, "-") setOpen: setArtifactsOpen,
.replace(/^-+|-+$/g, ""); setArtifacts,
return raw.length > 0 ? raw : `thread-${threadId}`; select: selectArtifact,
}, [pathname, threadId]); deselect,
selectedArtifact,
useEffect(() => { } = useArtifacts();
if (layoutRef.current) {
if (artifactPanelOpen) { const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
layoutRef.current.setLayout(OPEN_MODE); const skillDisplayMap = useMemo(
} else { () => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
layoutRef.current.setLayout(CLOSE_MODE); [ragModeEnabled, skills],
} );
} const fallbackReferenceBatches = useMemo(() => {
}, [artifactPanelOpen]); if (!ragModeEnabled) {
return [];
return ( }
<ResizablePanelGroup return buildTurnReferenceBatches(
id={`${resizableIdBase}-panels`} thread.values.messages ?? [],
orientation="horizontal" skillDisplayMap,
defaultLayout={{ chat: 100, artifacts: 0 }} thread.isLoading,
groupRef={layoutRef} );
> }, [ragModeEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
<ResizablePanel className="relative" defaultSize={100} id="chat"> useEffect(() => {
{children} if (!ragModeEnabled) {
</ResizablePanel> setBackendReferenceBatches([]);
<ResizableHandle return;
id={`${resizableIdBase}-separator`} }
className={cn( if (thread.isLoading) {
"opacity-33 hover:opacity-100", return;
!artifactPanelOpen && "pointer-events-none opacity-0", }
)} const controller = new AbortController();
/> logReferenceDebug("开始拉取后端参考文献", threadId, {
<ResizablePanel messageCount: thread.values.messages?.length ?? 0,
className={cn( });
"bg-[#fffdfa] transition-all duration-300 ease-in-out", apiFetch(
!artifactsOpen && "opacity-0", `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/reference-batches`,
)} { signal: controller.signal },
id="artifacts" )
> .then(async (response) => {
<div if (!response.ok) {
className={cn( throw new Error(`Failed to load reference batches: ${response.status}`);
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out", }
artifactPanelOpen ? "translate-x-0" : "translate-x-full", return (await response.json()) as { batches?: BackendReferenceBatch[] };
)} })
> .then((data) => {
{selectedArtifact ? ( const batches = data.batches ?? [];
<ArtifactFileDetail logReferenceDebug("后端参考文献返回", threadId, {
className="size-full" batchCount: batches.length,
filepath={selectedArtifact} sourceCounts: batches.map((batch) => batch.sources?.length ?? 0),
threadId={threadId} batchIds: batches.map((batch) => batch.id),
/> });
) : ( setBackendReferenceBatches(batches);
<div className="relative flex size-full justify-center"> })
<div className="absolute top-1 right-1 z-30"> .catch((error) => {
<Button if (!controller.signal.aborted) {
size="icon-sm" console.warn("Failed to load backend reference batches", error);
variant="ghost" }
onClick={() => { });
setArtifactsOpen(false); return () => controller.abort();
}} }, [ragModeEnabled, thread.isLoading, threadId, thread.values.messages]);
> const adaptedBackendReferenceBatches = useMemo(
<XIcon /> () =>
</Button> adaptBackendReferenceBatches(
</div> backendReferenceBatches,
{thread.values.artifacts?.length === 0 ? ( skillDisplayMap,
<ConversationEmptyState ),
icon={<FilesIcon />} [backendReferenceBatches, skillDisplayMap],
title="No artifact selected" );
description="Select an artifact to view its details" const referenceBatches = useMemo(
/> () => {
) : ( if (adaptedBackendReferenceBatches.length > 0) {
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8"> return adaptedBackendReferenceBatches;
<header className="shrink-0"> }
<h2 className="text-lg font-medium">Artifacts</h2> return fallbackReferenceBatches;
</header> },
<main className="min-h-0 grow"> [adaptedBackendReferenceBatches, fallbackReferenceBatches],
<ArtifactFileList );
className="max-w-(--container-width-sm) p-4 pt-12" useEffect(() => {
files={thread.values.artifacts ?? []} if (!ragModeEnabled) {
threadId={threadId} return;
/> }
</main> const signature = JSON.stringify({
</div> backend: backendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
)} adaptedBackend: adaptedBackendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
</div> fallback: fallbackReferenceBatches.map((batch) => batch.sources?.length ?? 0),
)} final: referenceBatches.map((batch) => batch.sources?.length ?? 0),
</div> });
</ResizablePanel> if (referenceDebugSignatureRef.current === signature) {
</ResizablePanelGroup> return;
); }
}; referenceDebugSignatureRef.current = signature;
logReferenceDebug("前端参考文献来源选择", threadId, {
export { ChatBox }; 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 { useParams, usePathname, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import { uuid } from "@/core/utils/uuid"; import { uuid } from "@/core/utils/uuid";
export function useThreadChat() { export function useThreadChat() {
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>(); const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const pathname = usePathname(); 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 searchParams = useSearchParams();
const [threadId, setThreadId] = useState(() => { const [threadId, setThreadId] = useState(() => {
@@ -37,7 +44,7 @@ export function useThreadChat() {
} }
setIsNewThread(false); setIsNewThread(false);
setThreadId(threadIdFromPath); setThreadId(threadIdFromPath);
}, [pathname, threadIdFromPath]); }, [pathname, threadIdFromPath, locationKey]);
const isMock = searchParams.get("mock") === "true"; const isMock = searchParams.get("mock") === "true";
return { threadId, setThreadId, isNewThread, setIsNewThread, isMock }; return { threadId, setThreadId, isNewThread, setIsNewThread, isMock };
} }
@@ -1,82 +1,77 @@
import { ExternalLinkIcon } from "lucide-react"; import type { ComponentProps } from "react";
import type { ComponentProps } from "react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { const SHOW_REFERENCE_EVENT = "workspace:show-reference";
HoverCard,
HoverCardContent, export function CitationLink({
HoverCardTrigger, href,
} from "@/components/ui/hover-card"; children,
import { cn } from "@/lib/utils"; className,
...props
export function CitationLink({ }: ComponentProps<"a">) {
href, const childrenText = normalizeCitationText(children);
children, const displayText = childrenText ?? "";
...props const referenceId = href?.startsWith("#reference-")
}: ComponentProps<"a">) { ? href.slice("#reference-".length)
const domain = extractDomain(href ?? ""); : null;
// Priority: children > domain if (referenceId) {
const childrenText = const badgeLabel = displayText.replace(/^\[|\]$/g, "") || referenceId;
typeof children === "string" return (
? children.replace(/^citation:\s*/i, "") <a
: null; href={href}
const isGenericText = childrenText === "Source" || childrenText === "来源"; title={`查看参考文献 ${badgeLabel}`}
const displayText = (!isGenericText && childrenText) ?? domain; 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",
return ( className,
<HoverCard closeDelay={0} openDelay={0}> )}
<HoverCardTrigger asChild> onClick={(event) => {
<a event.preventDefault();
href={href} const batchId =
target="_blank" event.currentTarget.closest<HTMLElement>("[data-reference-batch-id]")
rel="noopener noreferrer" ?.dataset.referenceBatchId;
className="inline-flex items-center" window.dispatchEvent(
onClick={(e) => e.stopPropagation()} new CustomEvent(SHOW_REFERENCE_EVENT, {
{...props} detail: { batchId, referenceId },
> }),
<Badge );
variant="secondary" const scroll = () => {
className="hover:bg-secondary/80 mx-0.5 cursor-pointer gap-1 rounded-full px-2 py-0.5 text-xs font-normal" const target = document.getElementById(`reference-${referenceId}`);
> target?.scrollIntoView({ behavior: "smooth", block: "center" });
{displayText} };
<ExternalLinkIcon className="size-3" /> window.setTimeout(scroll, 240);
</Badge> }}
</a> {...props}
</HoverCardTrigger> >
<HoverCardContent className={cn("relative w-80 p-0", props.className)}> {badgeLabel}
<div className="p-3"> </a>
<div className="space-y-1"> );
{displayText && ( }
<h4 className="truncate text-sm leading-tight font-medium">
{displayText} return (
</h4> <a
)} href={href}
{href && ( target={href?.startsWith("http") ? "_blank" : undefined}
<p className="text-muted-foreground truncate text-xs break-all"> rel={href?.startsWith("http") ? "noopener noreferrer" : undefined}
{href} className={cn(
</p> "text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
)} className,
</div> )}
<a {...props}
href={href} >
target="_blank" {displayText || children}
rel="noopener noreferrer" </a>
className="text-primary mt-2 inline-flex items-center gap-1 text-xs hover:underline" );
> }
Visit source
<ExternalLinkIcon className="size-3" /> function normalizeCitationText(children: ComponentProps<"a">["children"]) {
</a> if (typeof children !== "string") {
</div> return null;
</HoverCardContent> }
</HoverCard> let text = children.trim();
); while (/^citation:\s*/i.test(text)) {
} text = text.replace(/^citation:\s*/i, "");
}
function extractDomain(url: string): string { return text;
try { }
return new URL(url).hostname.replace(/^www\./i, "");
} catch {
return url;
}
}
@@ -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 type { ChatStatus } from "ai";
import { import {
BrainCircuitIcon, BrainCircuitIcon,
BookMarkedIcon,
CheckIcon, CheckIcon,
FileTextIcon, FileTextIcon,
GraduationCapIcon, GraduationCapIcon,
@@ -86,8 +87,14 @@ import {
} from "../ui/dropdown-menu"; } from "../ui/dropdown-menu";
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks"; 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 { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide"; import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip"; import { Tooltip } from "./tooltip";
@@ -177,6 +184,7 @@ export function InputBox({
}; };
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const [localSettings, setLocalSettings] = useLocalSettings();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [modelDialogOpen, setModelDialogOpen] = useState(false); const [modelDialogOpen, setModelDialogOpen] = useState(false);
const { models } = useModels(); const { models } = useModels();
@@ -297,6 +305,15 @@ export function InputBox({
}); });
}, [context, memoryInjectionEnabled, onContextChange]); }, [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( const handleSubmit = useCallback(
async (message: PromptInputMessage) => { async (message: PromptInputMessage) => {
if (status === "streaming") { if (status === "streaming") {
@@ -309,6 +326,9 @@ export function InputBox({
setFollowups([]); setFollowups([]);
setFollowupsHidden(false); setFollowupsHidden(false);
setFollowupsLoading(false); setFollowupsLoading(false);
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
// Guard against submitting before the initial model auto-selection // Guard against submitting before the initial model auto-selection
// effect has flushed thread settings to storage/state. // effect has flushed thread settings to storage/state.
@@ -333,8 +353,10 @@ export function InputBox({
onSubmit, onSubmit,
onStop, onStop,
resolvedModelName, resolvedModelName,
ragModeEnabled,
selectedModel?.supports_thinking, selectedModel?.supports_thinking,
status, status,
threadId,
], ],
); );
@@ -538,6 +560,21 @@ export function InputBox({
</div> </div>
</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")}> <div className={cn(isNewThread && "gemini-gradient-border gemini-shadow")}>
<PromptInput <PromptInput
className={cn( className={cn(
@@ -622,6 +659,33 @@ export function InputBox({
<span className="text-xs font-normal">记忆</span> <span className="text-xs font-normal">记忆</span>
</PromptInputButton> </PromptInputButton>
</Tooltip> </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> <PromptInputActionMenu>
<ModeHoverGuide <ModeHoverGuide
mode={ mode={
@@ -997,6 +1061,7 @@ export function InputBox({
)} )}
</PromptInput> </PromptInput>
</div> </div>
</div>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}> <Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent> <DialogContent>
@@ -36,10 +36,13 @@ export function MarkdownContent({
}: MarkdownContentProps) { }: MarkdownContentProps) {
const components = useMemo(() => { const components = useMemo(() => {
return { return {
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => { a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (typeof props.children === "string") { if (props.href?.startsWith("#reference-")) {
const match = /^citation:(.+)$/.exec(props.children); return <CitationLink {...props} />;
if (match) { }
if (typeof props.children === "string") {
const match = /^citation:(.+)$/.exec(props.children);
if (match) {
const [, text] = match; const [, text] = match;
return <CitationLink {...props}>{text}</CitationLink>; return <CitationLink {...props}>{text}</CitationLink>;
} }
@@ -47,6 +47,17 @@ import {
findToolCallResult, findToolCallResult,
} from "@/core/messages/utils"; } from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; 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 { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { env } from "@/env"; import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -54,6 +65,7 @@ import { cn } from "@/lib/utils";
import { useArtifactsOptional } from "../artifacts"; import { useArtifactsOptional } from "../artifacts";
import { FlipDisplay } from "../flip-display"; import { FlipDisplay } from "../flip-display";
import { Tooltip } from "../tooltip"; import { Tooltip } from "../tooltip";
import { SourceCard } from "../skill-display-sources";
import { MarkdownContent } from "./markdown-content"; import { MarkdownContent } from "./markdown-content";
@@ -74,6 +86,12 @@ export function MessageGroup({
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true", env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
); );
const steps = useMemo(() => convertToSteps(messages), [messages]); 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 lastToolCallStep = useMemo(() => {
const filteredSteps = steps.filter((step) => step.type === "toolCall"); const filteredSteps = steps.filter((step) => step.type === "toolCall");
return filteredSteps[filteredSteps.length - 1]; return filteredSteps[filteredSteps.length - 1];
@@ -142,7 +160,13 @@ export function MessageGroup({
} }
></ChainOfThoughtStep> ></ChainOfThoughtStep>
) : ( ) : (
<ToolCall key={step.id} {...step} isLoading={isLoading} /> <ToolCall
key={step.id}
{...step}
isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
ragModeEnabled={settings.display.ragModeEnabled}
/>
), ),
)} )}
{lastToolCallStep && ( {lastToolCallStep && (
@@ -152,6 +176,8 @@ export function MessageGroup({
{...lastToolCallStep} {...lastToolCallStep}
isLast={true} isLast={true}
isLoading={isLoading} isLoading={isLoading}
skillDisplayMap={skillDisplayMap}
ragModeEnabled={settings.display.ragModeEnabled}
/> />
</FlipDisplay> </FlipDisplay>
)} )}
@@ -209,6 +235,8 @@ function ToolCall({
result, result,
isLast = false, isLast = false,
isLoading = false, isLoading = false,
skillDisplayMap = new Map(),
ragModeEnabled = false,
}: { }: {
id?: string; id?: string;
messageId?: string; messageId?: string;
@@ -217,11 +245,56 @@ function ToolCall({
result?: string | Record<string, unknown>; result?: string | Record<string, unknown>;
isLast?: boolean; isLast?: boolean;
isLoading?: boolean; isLoading?: boolean;
skillDisplayMap?: ReturnType<typeof buildSkillDisplayMap>;
ragModeEnabled?: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const artifacts = useArtifactsOptional(); 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); 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) { if (name === "web_search" || searchItems.length > 0) {
const query = resolveSearchQuery(args, result); const query = resolveSearchQuery(args, result);
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo; 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 }) { function SearchResultCard({ item }: { item: SearchResultItem }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const content = item.snippet ?? item.content; const content = item.snippet ?? item.content;
@@ -1,5 +1,5 @@
import type { Message } from "@langchain/langgraph-sdk"; import type { Message } from "@langchain/langgraph-sdk";
import { FileIcon, Loader2Icon } from "lucide-react"; import { ClockIcon, FileIcon, Loader2Icon } from "lucide-react";
import { import {
memo, memo,
useCallback, useCallback,
@@ -25,6 +25,7 @@ import {
import { Task, TaskTrigger } from "@/components/ai-elements/task"; import { Task, TaskTrigger } from "@/components/ai-elements/task";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { resolveArtifactURL } from "@/core/artifacts/utils"; import { resolveArtifactURL } from "@/core/artifacts/utils";
import { useChatAskTimes } from "@/core/chat-timestamps";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
@@ -36,15 +37,20 @@ import {
type FileInMessage, type FileInMessage,
} from "@/core/messages/utils"; } from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import {
applyInlineCitations,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
import { humanMessagePlugins } from "@/core/streamdown"; import { humanMessagePlugins } from "@/core/streamdown";
import { desensitize } from "@/lib/desensitize"; import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link";
import { CopyButton } from "../copy-button"; import { CopyButton } from "../copy-button";
import { SkillDisplayBlock } from "../skill-display-block";
import { ThreadContext } from "./context"; import { ThreadContext } from "./context";
import { DownloadWordButton } from "./download-word-button"; import { DownloadWordButton } from "./download-word-button";
import { MarkdownContent } from "./markdown-content"; import { MarkdownContent, type MarkdownContentProps } from "./markdown-content";
import { MessageTokenUsage } from "./message-token-usage"; import { MessageTokenUsage } from "./message-token-usage";
/** /**
@@ -76,12 +82,16 @@ export function MessageListItem({
isLoading, isLoading,
threadId, threadId,
tokenUsageEnabled = false, tokenUsageEnabled = false,
displaySources = [],
extraRehypePlugins,
}: { }: {
className?: string; className?: string;
message: Message; message: Message;
isLoading?: boolean; isLoading?: boolean;
threadId: string; threadId: string;
tokenUsageEnabled?: boolean; tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
}) { }) {
const isHuman = message.type === "human"; const isHuman = message.type === "human";
const messageMarkdown = const messageMarkdown =
@@ -99,6 +109,8 @@ export function MessageListItem({
isLoading={isLoading} isLoading={isLoading}
threadId={threadId} threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled} tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins}
/> />
{!isLoading && ( {!isLoading && (
<MessageToolbar <MessageToolbar
@@ -159,14 +171,23 @@ function MessageContent_({
isLoading = false, isLoading = false,
threadId, threadId,
tokenUsageEnabled = false, tokenUsageEnabled = false,
displaySources = [],
extraRehypePlugins,
}: { }: {
className?: string; className?: string;
message: Message; message: Message;
isLoading?: boolean; isLoading?: boolean;
threadId: string; threadId: string;
tokenUsageEnabled?: boolean; tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
}) { }) {
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const rehypePlugins = useMemo(
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
[baseRehypePlugins, extraRehypePlugins],
);
const askTimes = useChatAskTimes();
const isHuman = message.type === "human"; const isHuman = message.type === "human";
const resolveArtifact = useArtifactResolver(threadId); const resolveArtifact = useArtifactResolver(threadId);
const components = useMemo( const components = useMemo(
@@ -175,6 +196,12 @@ function MessageContent_({
<MessageImage {...props} threadId={threadId} maxWidth="90%" /> <MessageImage {...props} threadId={threadId} maxWidth="90%" />
), ),
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => { 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/")) { if (href?.startsWith("/mnt/")) {
const url = resolveArtifact(href); const url = resolveArtifact(href);
return ( return (
@@ -192,6 +219,11 @@ function MessageContent_({
[threadId, resolveArtifact], [threadId, resolveArtifact],
); );
const messageTime = formatAskedTime(
message.additional_kwargs?.client_ts ??
(message.id ? askTimes[message.id] : undefined),
);
const rawContent = extractContentFromMessage(message); const rawContent = extractContentFromMessage(message);
const reasoningContent = extractReasoningContentFromMessage(message); const reasoningContent = extractReasoningContentFromMessage(message);
@@ -211,8 +243,8 @@ function MessageContent_({
if (isHuman) { if (isHuman) {
return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : ""; return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : "";
} }
return rawContent ?? ""; return applyInlineCitations(rawContent ?? "", displaySources);
}, [rawContent, isHuman]); }, [rawContent, isHuman, displaySources]);
const filesList = const filesList =
files && files.length > 0 ? ( files && files.length > 0 ? (
@@ -271,6 +303,7 @@ function MessageContent_({
{messageResponse} {messageResponse}
</AIElementMessageContent> </AIElementMessageContent>
)} )}
<MessageTimeStamp time={messageTime} align="right" />
</div> </div>
); );
} }
@@ -278,22 +311,72 @@ function MessageContent_({
return ( return (
<AIElementMessageContent className={className}> <AIElementMessageContent className={className}>
{filesList} {filesList}
<MarkdownContent <div data-reference-batch-id={message.id} className="contents">
content={contentToDisplay} <AssistantDisplayContent
isLoading={isLoading} content={contentToDisplay}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]} displaySources={displaySources}
className="my-3" isLoading={isLoading}
components={components} renderSkillDisplayBlocks={false}
/> rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
components={components}
/>
</div>
<MessageTokenUsage <MessageTokenUsage
enabled={tokenUsageEnabled} enabled={tokenUsageEnabled}
isLoading={isLoading} isLoading={isLoading}
message={message} message={message}
/> />
{!isLoading && contentToDisplay ? (
<MessageTimeStamp time={messageTime} align="left" />
) : null}
</AIElementMessageContent> </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 * Get file extension and check helpers
*/ */
@@ -461,3 +544,172 @@ function RichFileCard({
} }
const MessageContent = memo(MessageContent_); 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 type { BaseStream } from "@langchain/langgraph-sdk/react";
import { CheckIcon, SendIcon, XIcon } from "lucide-react"; import { CheckIcon, SendIcon, XIcon } from "lucide-react";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { import {
Conversation, Conversation,
@@ -8,6 +9,7 @@ import {
} from "@/components/ai-elements/conversation"; } from "@/components/ai-elements/conversation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { rememberAskTimes } from "@/core/chat-timestamps";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
extractContentFromMessage, extractContentFromMessage,
@@ -20,6 +22,12 @@ import {
hasToolCalls, hasToolCalls,
} from "@/core/messages/utils"; } from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; 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 type { Subtask } from "@/core/tasks";
import { useUpdateSubtask } from "@/core/tasks/context"; import { useUpdateSubtask } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads"; import type { AgentThreadState } from "@/core/threads";
@@ -48,6 +56,7 @@ export function MessageList({
thread, thread,
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM, paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
tokenUsageEnabled = false, tokenUsageEnabled = false,
extraRehypePlugins,
onClarificationSubmit, onClarificationSubmit,
onSetupAgentApprove, onSetupAgentApprove,
onSetupAgentReject, onSetupAgentReject,
@@ -57,15 +66,44 @@ export function MessageList({
thread: BaseStream<AgentThreadState>; thread: BaseStream<AgentThreadState>;
paddingBottom?: number; paddingBottom?: number;
tokenUsageEnabled?: boolean; tokenUsageEnabled?: boolean;
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onClarificationSubmit?: (answer: string) => void | Promise<void>; onClarificationSubmit?: (answer: string) => void | Promise<void>;
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>; onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>; onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
}) { }) {
const { t } = useI18n(); const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const rehypePlugins = useMemo(
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
[baseRehypePlugins, extraRehypePlugins],
);
const updateSubtask = useUpdateSubtask(); const updateSubtask = useUpdateSubtask();
const messages = thread.messages; 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. // Track which clarification messages are still awaiting an answer.
// Pending = clarification tool messages that appear after the last human message // Pending = clarification tool messages that appear after the last human message
// (no user reply has been sent for them yet). // (no user reply has been sent for them yet).
@@ -184,6 +222,16 @@ export function MessageList({
{groupMessages(messages, (group) => { {groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") { if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => { return group.messages.map((msg) => {
const displaySources =
ragModeEnabled &&
group.type === "assistant" &&
msg.type === "ai"
? collectRuntimeDisplaySources(messages, skillDisplayMap, {
afterMessageId: findPreviousHumanMessageId(messages, msg.id),
beforeMessageId: msg.id,
includeBeforeMessage: true,
})
: [];
return ( return (
<MessageListItem <MessageListItem
key={`${group.id}/${msg.id}`} key={`${group.id}/${msg.id}`}
@@ -191,6 +239,8 @@ export function MessageList({
isLoading={thread.isLoading} isLoading={thread.isLoading}
threadId={threadId} threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled} tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins}
/> />
); );
}); });
@@ -435,6 +485,24 @@ export type SetupAgentApprovalPayload = {
skills?: string[]; 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( function getSetupAgentApprovalPayload(
message: unknown, message: unknown,
): SetupAgentApprovalPayload | null { ): SetupAgentApprovalPayload | null {
@@ -163,7 +163,8 @@ export function RecentChatList() {
const visibleThreads = threads.filter( const visibleThreads = threads.filter(
(thread) => (thread) =>
thread.metadata?.thread_type !== "scheduler" && thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "bootstrap", thread.metadata?.thread_type !== "bootstrap" &&
thread.metadata?.thread_type !== "roundtable",
); );
if (visibleThreads.length === 0) { if (visibleThreads.length === 0) {
@@ -2,9 +2,11 @@
import { MoonIcon, SunIcon } from "lucide-react"; import { MoonIcon, SunIcon } from "lucide-react";
import { useMemo, type ComponentType, type SVGProps } from "react"; import { useMemo, type ComponentType, type SVGProps } from "react";
import { toast } from "sonner";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
import { Button } from "@/components/ui/button";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -14,9 +16,17 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch"; 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 { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useLocalSettings } from "@/core/settings"; import { useLocalSettings } from "@/core/settings";
import {
useAppearanceSettings,
useCitationDisplaySettings,
useUpdateAppearanceSettings,
useUpdateCitationDisplaySettings,
} from "@/core/system-settings/hooks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SettingsSection } from "./settings-section"; import { SettingsSection } from "./settings-section";
@@ -31,6 +41,28 @@ export function AppearanceSettingsPage() {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const currentTheme = theme; const currentTheme = theme;
const [settings, setSettings] = useLocalSettings(); 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( const themeOptions = useMemo(
() => [ () => [
@@ -76,6 +108,43 @@ export function AppearanceSettingsPage() {
</div> </div>
</SettingsSection> </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 /> <Separator />
<SettingsSection <SettingsSection
@@ -125,6 +194,44 @@ export function AppearanceSettingsPage() {
aria-label="回答速度指示器" aria-label="回答速度指示器"
/> />
</div> </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> </SettingsSection>
</div> </div>
); );
@@ -333,7 +333,7 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
); );
} }
function SkillRow({ export function SkillRow({
skill, skill,
currentUserId, currentUserId,
isAdmin, 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, CopyIcon,
EyeIcon, EyeIcon,
FileCode2Icon, FileCode2Icon,
FilesIcon,
FileTextIcon, FileTextIcon,
GlobeIcon, GlobeIcon,
Loader2, Loader2,
PencilIcon, PencilIcon,
SlidersHorizontalIcon,
SparklesIcon, SparklesIcon,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
@@ -25,12 +27,16 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; 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 { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
useSetSkillDetail, useSetSkillDetail,
useSkill, useSkill,
useSkillContent, useSkillContent,
useSkillFiles,
} from "@/core/skills/hooks"; } from "@/core/skills/hooks";
import type { Skill } from "@/core/skills/type"; import type { Skill } from "@/core/skills/type";
import { reasoningPlugins } from "@/core/streamdown"; 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 { function stripYamlFrontmatter(content: string): string {
return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trimStart(); 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 [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [activeTab, setActiveTab] = useState<"detail" | "source" | null>( const [activeTab, setActiveTab] = useState<
null, "detail" | "display" | "source" | "files" | null
); >(null);
const [showRawSource, setShowRawSource] = useState(false); const [showRawSource, setShowRawSource] = useState(false);
const [openedFilePath, setOpenedFilePath] = useState<string | null>(null);
const canManage = useMemo(() => canManageSkill(skill), [skill]); const canManage = useMemo(() => canManageSkill(skill), [skill]);
const isOwner = useMemo(() => isSkillOwner(skill), [skill]);
const { const {
data: contentData, data: contentData,
isLoading: isContentLoading, isLoading: isContentLoading,
error: contentError, 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(() => { useEffect(() => {
setEditing(false); setEditing(false);
setActiveTab(null); setActiveTab(null);
setShowRawSource(false); setShowRawSource(false);
setOpenedFilePath(null);
}, [open, skillName]); }, [open, skillName]);
const skillBody = useMemo(() => { const skillBody = useMemo(() => {
@@ -106,7 +134,7 @@ export function SkillDetailDialog({
}, [contentData]); }, [contentData]);
const defaultTab: "detail" | "source" = const defaultTab: "detail" | "source" =
canManage && skill && !skill.detail ? "source" : "detail"; skill && !skill.detail ? "source" : "detail";
const selectedTab = activeTab ?? defaultTab; const selectedTab = activeTab ?? defaultTab;
function startEditing() { function startEditing() {
@@ -131,6 +159,7 @@ export function SkillDetailDialog({
} }
return ( return (
<>
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent <DialogContent
className="flex flex-col gap-0 overflow-hidden p-0" className="flex flex-col gap-0 overflow-hidden p-0"
@@ -194,11 +223,21 @@ export function SkillDetailDialog({
{tx.detailSectionTitle} {tx.detailSectionTitle}
</TabsTrigger> </TabsTrigger>
{canManage && ( {canManage && (
<TabsTrigger value="source" className="text-xs"> <TabsTrigger value="display" className="text-xs">
<FileCode2Icon className="mr-1 size-3.5" /> <SlidersHorizontalIcon className="mr-1 size-3.5" />
{tx.contentSectionTitle} 展示适配
</TabsTrigger> </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> </TabsList>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">
@@ -214,7 +253,6 @@ export function SkillDetailDialog({
</Button> </Button>
)} )}
{selectedTab === "source" && {selectedTab === "source" &&
canManage &&
!isContentLoading && !isContentLoading &&
!contentError && ( !contentError && (
<> <>
@@ -299,7 +337,7 @@ export function SkillDetailDialog({
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center"> <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" /> <FileTextIcon className="mx-auto mb-2 size-6 opacity-40" />
<p className="text-sm">{tx.detailEmpty}</p> <p className="text-sm">{tx.detailEmpty}</p>
{canManage ? ( {isOwner ? (
<p className="mt-1 text-xs"> <p className="mt-1 text-xs">
Open the SKILL.md tab to view the skill source. Open the SKILL.md tab to view the skill source.
</p> </p>
@@ -312,10 +350,47 @@ export function SkillDetailDialog({
{canManage && ( {canManage && (
<TabsContent <TabsContent
value="source" value="display"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden" className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
style={{ overflowY: "auto" }} 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 ? ( {isContentLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm"> <div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
@@ -341,10 +416,19 @@ export function SkillDetailDialog({
/> />
)} )}
</TabsContent> </TabsContent>
)}
</Tabs> </Tabs>
)} )}
</DialogContent> </DialogContent>
</Dialog> </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 ( return (
<div className={cn("flex flex-col gap-2.5", className)}> <div className={cn("flex flex-col gap-2.5", className)}>
<div className="relative"> <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 <Input
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
@@ -4,6 +4,7 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useNavigate } from "react-router-dom";
import { import {
SidebarGroup, SidebarGroup,
@@ -16,6 +17,7 @@ import { useI18n } from "@/core/i18n/hooks";
import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon, Users } from "lucide-react"; import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon, Users } from "lucide-react";
import { SidebarCollapseButton } from "./workspace-header"; import { SidebarCollapseButton } from "./workspace-header";
import { WorkspaceNavStudio } from "./workspace-nav-studio";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标 // 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
@@ -37,7 +39,23 @@ export function WorkspaceNavChatList({
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const pathname = usePathname(); 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 ( return (
<>
<SidebarGroup className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}> <SidebarGroup className={cn("px-2", showCollapseTrigger ? "pt-3" : "pt-2")}>
<SidebarMenu className="gap-1"> <SidebarMenu className="gap-1">
<SidebarMenuItem> <SidebarMenuItem>
@@ -47,7 +65,7 @@ export function WorkspaceNavChatList({
asChild asChild
className={cn(NEW_CHAT_CLASS, showCollapseTrigger && "min-w-0 flex-1")} 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 /> <MessageSquarePlus />
<span>{t.sidebar.newChat}</span> <span>{t.sidebar.newChat}</span>
</Link> </Link>
@@ -139,7 +157,7 @@ export function WorkspaceNavChatList({
> >
<Link href="/page/workspace/scheduled/tasks"> <Link href="/page/workspace/scheduled/tasks">
<ListChecks /> <ListChecks />
<span>任务管理</span> <span>定时任务</span>
</Link> </Link>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
@@ -147,5 +165,7 @@ export function WorkspaceNavChatList({
)} )}
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
{/* {!showCollapseTrigger && <WorkspaceNavStudio />} */}
</>
); );
} }
@@ -2,6 +2,7 @@
import { import {
BellIcon, BellIcon,
BotIcon,
BrainIcon, BrainIcon,
CalendarClockIcon, CalendarClockIcon,
ChevronsUpDown, ChevronsUpDown,
@@ -11,6 +12,8 @@ import {
MessageSquarePlusIcon, MessageSquarePlusIcon,
Settings2Icon, Settings2Icon,
SettingsIcon, SettingsIcon,
ShieldCheckIcon,
SparklesIcon,
TagsIcon, TagsIcon,
UsersIcon, UsersIcon,
} from "lucide-react"; } from "lucide-react";
@@ -143,6 +146,16 @@ export function WorkspaceNavMenu() {
问答前缀配置 问答前缀配置
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/private-square")
}
>
<ShieldCheckIcon />
私密广场配置
</DropdownMenuItem>
)}
{isAdmin && ( {isAdmin && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
@@ -171,6 +184,22 @@ export function WorkspaceNavMenu() {
定时任务管理 定时任务管理
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/agents")}
>
<BotIcon />
智能体管理
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/skills")}
>
<SparklesIcon />
技能管理
</DropdownMenuItem>
)}
{isAdmin && ( {isAdmin && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => navigate("/page/workspace/admin/tags")} onClick={() => navigate("/page/workspace/admin/tags")}
@@ -0,0 +1,179 @@
"use client";
import { useState } from "react";
import { BookOpen, ChevronDown, Loader2, Plus } from "lucide-react";
import { Link, useLocation } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { useStudioNotebooks } from "@/core/studio/hooks";
import { createSearchSpace } from "@/core/studio/api";
import { getStoredAuth } from "@/core/auth";
const NAV_ITEM_CLASS =
"text-sidebar-foreground/80 hover:text-primary rounded-xl h-9 px-3 transition-colors hover:bg-primary/10 " +
"data-[active=true]:bg-primary/15 data-[active=true]:text-primary data-[active=true]:shadow-none " +
"dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25 dark:data-[active=true]:text-primary-foreground";
const SIDEBAR_PREVIEW_COUNT = 5;
export function WorkspaceNavStudio() {
const { notebooks, isLoading, isError } = useStudioNotebooks();
const { pathname } = useLocation();
const queryClient = useQueryClient();
const [open, setOpen] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [name, setName] = useState("");
const [creating, setCreating] = useState(false);
const [error, setError] = useState("");
const handleCreate = async () => {
const trimmed = name.trim();
if (!trimmed) return;
setCreating(true);
setError("");
try {
const email = getStoredAuth()?.email ?? "";
const token = queryClient.getQueryData<string>(["studio", "token", email]);
if (!token) throw new Error("未登录或 token 已过期,请刷新页面");
await createSearchSpace(trimmed, token);
setDialogOpen(false);
setName("");
await queryClient.invalidateQueries({ queryKey: ["studio", "notebooks"] });
} catch (e) {
setError(e instanceof Error ? e.message : "创建失败");
} finally {
setCreating(false);
}
};
if (isError || (!isLoading && notebooks.length === 0)) {
return null;
}
const preview = notebooks.slice(0, SIDEBAR_PREVIEW_COUNT);
return (
<>
<Collapsible open={open} onOpenChange={setOpen}>
<SidebarGroup className="px-2 pt-2">
<CollapsibleTrigger asChild>
<SidebarGroupLabel className="flex cursor-pointer select-none items-center justify-between px-1 text-xs font-medium text-sidebar-foreground/50 hover:text-sidebar-foreground/80 transition-colors">
<span>工作室</span>
<ChevronDown
className={cn(
"size-3.5 transition-transform duration-200",
open ? "rotate-0" : "-rotate-90",
)}
/>
</SidebarGroupLabel>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenu className="mt-0.5 gap-0.5">
{/* 新建笔记本 */}
<SidebarMenuItem>
<SidebarMenuButton
className={cn(NAV_ITEM_CLASS)}
onClick={() => { setDialogOpen(true); setError(""); setName(""); }}
>
<Plus className="shrink-0" />
<span>新建笔记本</span>
</SidebarMenuButton>
</SidebarMenuItem>
{/* 笔记本列表(最多展示 SIDEBAR_PREVIEW_COUNT 条) */}
{isLoading
? Array.from({ length: 3 }).map((_, i) => (
<SidebarMenuItem key={i}>
<div className="mx-3 my-1 h-5 animate-pulse rounded-md bg-sidebar-accent/40" />
</SidebarMenuItem>
))
: preview.map((nb) => (
<SidebarMenuItem key={nb.id}>
<SidebarMenuButton
asChild
isActive={pathname === `/page/workspace/studio/notebooks/${nb.id}`}
className={cn(NAV_ITEM_CLASS)}
>
<Link
to={`/page/workspace/studio/notebooks/${nb.id}`}
title={nb.description || nb.name}
>
<BookOpen className="shrink-0 size-4" />
<span className="truncate">{nb.name}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
{/* 所有笔记本 */}
{!isLoading && notebooks.length > 0 && (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={pathname === "/page/workspace/studio/notebooks"}
className={cn(NAV_ITEM_CLASS)}
>
<Link to="/page/workspace/studio/notebooks">
<span className="mr-0.5 text-base leading-none">···</span>
<span>所有笔记本</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
<Dialog open={dialogOpen} onOpenChange={(v) => !creating && setDialogOpen(v)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>新建笔记本</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2">
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") void handleCreate(); }}
placeholder="笔记本名称"
disabled={creating}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
/>
{error && <p className="text-xs text-destructive">{error}</p>}
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" disabled={creating} onClick={() => setDialogOpen(false)}>
取消
</Button>
<Button size="sm" disabled={!name.trim() || creating} onClick={() => void handleCreate()}>
{creating ? <Loader2 className="size-4 animate-spin" /> : "创建"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
+30 -1
View File
@@ -9,11 +9,26 @@ export interface ChatMessage {
agentName?: string; 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 { interface ChatContextType {
messages: ChatMessage[]; messages: ChatMessage[];
addMessage: (role: 'user' | 'assistant', content: string, type?: 'general' | 'agent' | 'compare', agentName?: string) => void; addMessage: (role: 'user' | 'assistant', content: string, type?: 'general' | 'agent' | 'compare', agentName?: string) => void;
clearMessages: () => void; clearMessages: () => void;
deleteMessage: (id: string) => void; deleteMessage: (id: string) => void;
startConfig: unknown;
setStartConfig: (val: unknown) => void;
toolsUser: toolsUser | null;
setToolsUser: (val: toolsUser) => void;
} }
const ChatContext = createContext<ChatContextType | undefined>(undefined); 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[]) => { const saveToStorage = useCallback((newMessages: ChatMessage[]) => {
try { try {
localStorage.setItem('qa-chat-history', JSON.stringify(newMessages)); localStorage.setItem('qa-chat-history', JSON.stringify(newMessages));
@@ -66,7 +84,18 @@ export function ChatProvider({ children }: { children: ReactNode }) {
}, [saveToStorage]); }, [saveToStorage]);
return ( return (
<ChatContext.Provider value={{ messages, addMessage, clearMessages, deleteMessage }}> <ChatContext.Provider
value={{
messages,
addMessage,
clearMessages,
deleteMessage,
startConfig,
setStartConfig,
toolsUser,
setToolsUser,
}}
>
{children} {children}
</ChatContext.Provider> </ChatContext.Provider>
); );
+59 -15
View File
@@ -7,6 +7,8 @@ import React, {
} from 'react'; } from 'react';
import { useTheme as useNextTheme } from 'next-themes'; import { useTheme as useNextTheme } from 'next-themes';
import { useAppearanceSettings } from '@/core/system-settings/hooks';
// 主题类型 // 主题类型
export type ThemeType = 'dark' | 'light'; export type ThemeType = 'dark' | 'light';
@@ -46,16 +48,16 @@ const themes: Record<ThemeType, ThemeConfig> = {
textSecondary: 'slate-400', textSecondary: 'slate-400',
primary: 'blue-500', primary: 'blue-500',
primaryHover: 'blue-400', primaryHover: 'blue-400',
border: 'slate-700', border: 'blue-800',
accent: 'blue-600', accent: 'cyan-400',
success: 'emerald-500', success: 'emerald-500',
warning: 'amber-500', warning: 'amber-500',
error: 'rose-500', error: 'rose-500',
card: 'slate-800', card: 'slate-800',
cardHover: 'slate-700', cardHover: 'slate-700',
input: 'slate-800', input: 'slate-800',
inputBorder: 'slate-600', inputBorder: 'blue-800',
overlay: 'slate-900/80', overlay: 'slate-900/50',
}, },
}, },
light: { light: {
@@ -83,6 +85,12 @@ const themes: Record<ThemeType, ThemeConfig> = {
}; };
const STORAGE_KEY = 'strategy-theme'; 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 { function readPersistedTheme(): ThemeType {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -92,6 +100,16 @@ function readPersistedTheme(): ThemeType {
return saved === 'dark' ? 'dark' : 'light'; 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 { interface ThemeContextType {
theme: ThemeType; theme: ThemeType;
@@ -112,42 +130,68 @@ interface ThemeProviderProps {
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => { export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const { resolvedTheme, setTheme: setNextTheme } = useNextTheme(); const { resolvedTheme, setTheme: setNextTheme } = useNextTheme();
const [mounted, setMounted] = useState(false); 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(() => { useEffect(() => {
setMounted(true); setMounted(true);
}, []); }, []);
// 迁移 / 规范化 localStorage,避免与 next-themes 冲突导致闪回浅色 // 迁移 / 规范化 localStorage:去掉旧的 'theme' 键,并清掉一切非法值。
// 注意:不要在这里强制写入一个 fallback —— 那会让 next-themes 视作
// 「用户已选过」,admin 默认就再也注入不进来了。
useEffect(() => { useEffect(() => {
if (!mounted) return; if (!mounted) return;
const current = localStorage.getItem(STORAGE_KEY); const current = localStorage.getItem(STORAGE_KEY);
const legacy = localStorage.getItem('theme'); const legacy = localStorage.getItem('theme');
if (current !== 'light' && current !== 'dark') { if (current && current !== 'light' && current !== 'dark') {
const fallback = // Garbage value — wipe it so the boot-sync below can take over.
legacy === 'light' || legacy === 'dark' ? legacy : 'light'; localStorage.removeItem(STORAGE_KEY);
setNextTheme(fallback); }
} else if (!current && legacy && (legacy === 'light' || legacy === 'dark')) { if (!current && legacy && (legacy === 'light' || legacy === 'dark')) {
setNextTheme(legacy); // 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) { if (legacy) {
localStorage.removeItem('theme'); localStorage.removeItem('theme');
} }
}, [mounted, setNextTheme]); }, [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 const theme: ThemeType = mounted
? resolvedTheme === 'light' ? resolvedTheme === 'dark'
? 'light' ? 'dark'
: 'dark' : 'light'
: readPersistedTheme(); : readPersistedTheme();
const toggleTheme = () => { const toggleTheme = () => {
setNextTheme(theme === 'dark' ? 'light' : 'dark'); setNextTheme(theme === 'dark' ? 'light' : 'dark');
markExplicitChoice();
}; };
const setTheme = (newTheme: ThemeType) => { const setTheme = (newTheme: ThemeType) => {
setNextTheme(newTheme); setNextTheme(newTheme);
markExplicitChoice();
}; };
const themeConfig = themes[theme]; const themeConfig = themes[theme];
+95
View File
@@ -38,6 +38,16 @@ export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]
return data.agents; 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> { export async function getAgent(id: string): Promise<Agent> {
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}`); const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}`);
if (!res.ok) throw new Error(`Agent '${id}' not found`); 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>; 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> { export async function reorderFeaturedAgents(agentIds: string[]): Promise<void> {
const res = await apiFetch( const res = await apiFetch(
`${getBackendBaseURL()}/api/agents/featured/order`, `${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( export async function setAgentFeatured(
id: string, id: string,
order: number | null, order: number | null,
@@ -124,6 +194,31 @@ export async function setAgentFeatured(
return res.json() as Promise<Agent>; 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( export async function checkAgentName(
name: string, name: string,
): Promise<{ available: boolean; name: string }> { ): Promise<{ available: boolean; name: string }> {
+116 -3
View File
@@ -6,16 +6,40 @@ import {
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { import {
adminSyncAgentSkills,
createAgent, createAgent,
deleteAgent, deleteAgent,
favoriteAgent,
getAgent, getAgent,
getAgentSelector, getAgentSelector,
listAgents, listAgents,
listAllAgentsAdmin,
listPrivateAgents,
reorderFeaturedAgents, reorderFeaturedAgents,
setAgentFeatured, setAgentFeatured,
setAgentPrivate,
unfavoriteAgent,
updateAgent, updateAgent,
} from "./api"; } 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[] } = {}) { export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
const search = params.search ?? ""; const search = params.search ?? "";
@@ -28,6 +52,21 @@ export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
return { agents: data ?? [], isLoading, error }; 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) { export function useAgent(id: string | null | undefined) {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["agents", id], queryKey: ["agents", id],
@@ -57,7 +96,8 @@ export function useUpdateAgent() {
id: string; id: string;
request: UpdateAgentRequest; request: UpdateAgentRequest;
}) => updateAgent(id, request), }) => updateAgent(id, request),
onSuccess: (_data, { id }) => { onSuccess: (data, { id }) => {
applyAgentToCache(queryClient, data);
void queryClient.invalidateQueries({ queryKey: ["agents"] }); void queryClient.invalidateQueries({ queryKey: ["agents"] });
void queryClient.invalidateQueries({ queryKey: ["agents", id] }); 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() { export function useAgentSelector() {
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["agents", "selector"], queryKey: ["agents", "selector"],
@@ -96,7 +164,8 @@ export function useSetAgentFeatured() {
return useMutation({ return useMutation({
mutationFn: ({ id, order }: { id: string; order: number | null }) => mutationFn: ({ id, order }: { id: string; order: number | null }) =>
setAgentFeatured(id, order), setAgentFeatured(id, order),
onSuccess: (_data, { id }) => { onSuccess: (data, { id }) => {
applyAgentToCache(queryClient, data);
invalidateAgentQueries(queryClient); invalidateAgentQueries(queryClient);
void queryClient.invalidateQueries({ queryKey: ["agents", id] }); void queryClient.invalidateQueries({ queryKey: ["agents", id] });
}, },
@@ -110,3 +179,47 @@ export function useReorderFeaturedAgents() {
onSuccess: () => invalidateAgentQueries(queryClient), 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);
}
},
});
}
+5
View File
@@ -6,6 +6,7 @@ export interface Agent {
description: string; description: string;
user_id: string | null; user_id: string | null;
published: boolean; published: boolean;
is_private: boolean;
builtin: boolean; builtin: boolean;
created_at?: string | null; created_at?: string | null;
updated_at?: string | null; updated_at?: string | null;
@@ -15,6 +16,8 @@ export interface Agent {
skills?: string[] | null; skills?: string[] | null;
tags?: Tag[]; tags?: Tag[];
featured_order?: number | null; featured_order?: number | null;
/** Whether the current user has favorited (收藏) this agent. */
is_favorite?: boolean;
} }
export interface AgentSelectorResponse { export interface AgentSelectorResponse {
@@ -26,6 +29,7 @@ export interface CreateAgentRequest {
description?: string; description?: string;
soul?: string; soul?: string;
published?: boolean; published?: boolean;
is_private?: boolean;
skills?: string[] | null; skills?: string[] | null;
model?: string | null; model?: string | null;
tool_groups?: string[] | null; tool_groups?: string[] | null;
@@ -36,6 +40,7 @@ export interface UpdateAgentRequest {
description?: string | null; description?: string | null;
soul?: string | null; soul?: string | null;
published?: boolean | null; published?: boolean | null;
is_private?: boolean | null;
skills?: string[] | null; skills?: string[] | null;
model?: string | null; model?: string | null;
tool_groups?: string[] | null; tool_groups?: string[] | null;
+47
View File
@@ -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);
}
+8
View File
@@ -69,6 +69,14 @@ export function getLangGraphBaseURL(isMock?: boolean) {
} }
} }
/** 笔记本外部系统(Studio)API 基础地址,通过 VITE_NOTEBOOK_API_BASE_URL 配置。 */
export function getNotebookApiBaseURL(): string {
if (env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL) {
return env.NEXT_PUBLIC_NOTEBOOK_API_BASE_URL.replace(/\/+$/, "");
}
return "";
}
/** 轻应用「新建笔记」系列接口的基础地址(与 DeerFlow Gateway 分离)。 */ /** 轻应用「新建笔记」系列接口的基础地址(与 DeerFlow Gateway 分离)。 */
export function getNoteApiBaseURL(): string { export function getNoteApiBaseURL(): string {
if (env.NEXT_PUBLIC_NOTE_API_BASE_URL) { if (env.NEXT_PUBLIC_NOTE_API_BASE_URL) {
@@ -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: detailAdminOnlyHint:
"Only an admin or the skill publisher can edit the details.", "Only an admin or the skill publisher can edit the details.",
contentSectionTitle: "SKILL.md source", 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", viewDetailAction: "View skill details",
stateSheet: { stateSheet: {
archivedTitle: "Archived skills", archivedTitle: "Archived skills",
@@ -432,6 +432,20 @@ export interface Translations {
detailSaveSuccess: string; detailSaveSuccess: string;
detailAdminOnlyHint: string; detailAdminOnlyHint: string;
contentSectionTitle: 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; viewDetailAction: string;
stateSheet: { stateSheet: {
archivedTitle: string; archivedTitle: string;
@@ -479,6 +479,20 @@ export const zhCN: Translations = {
detailSaveSuccess: "详情已更新", detailSaveSuccess: "详情已更新",
detailAdminOnlyHint: "仅管理员或技能发布者可编辑详情。", detailAdminOnlyHint: "仅管理员或技能发布者可编辑详情。",
contentSectionTitle: "SKILL.md 原文", contentSectionTitle: "SKILL.md 原文",
filesSectionTitle: "文件",
filesEmpty: "该技能目录下没有可显示的文件。",
filesLoadError: "加载文件失败",
filesPreviewBinary: "二进制文件无法预览,可点击下载查看。",
filesPreviewUnsupported: "该文件类型暂不支持预览。",
filesPreviewTooLarge: "文件过大,无法在线预览。",
filesEditAction: "编辑",
filesSaveAction: "保存",
filesCancelEditAction: "取消",
filesDownloadAction: "下载",
filesCopyPathAction: "复制路径",
filesDiscardConfirm: "有未保存的修改,确定放弃吗?",
filesSaveSuccess: "文件已保存",
filesReadOnlyHint: "内置技能为只读,仅可查看。",
viewDetailAction: "查看技能详情", viewDetailAction: "查看技能详情",
stateSheet: { stateSheet: {
archivedTitle: "归档的技能", archivedTitle: "归档的技能",
@@ -7,11 +7,16 @@
*/ */
export type SearchResultItem = { export type SearchResultItem = {
id?: string;
recUuid?: string;
uuid?: string;
docId?: string;
title?: string; title?: string;
url?: string; url?: string;
snippet?: string; snippet?: string;
content?: string; content?: string;
source?: string; source?: string;
author?: string;
published_date?: string; published_date?: string;
publishedDate?: string; publishedDate?: string;
date?: string; date?: string;
@@ -107,6 +112,15 @@ const SOURCE_ALIASES = [
"kb", "kb",
] as const; ] as const;
const AUTHOR_ALIASES = [
"author",
"authors",
"creator",
"writer",
"byline",
"owner",
] as const;
const DATE_ALIASES = [ const DATE_ALIASES = [
"published_date", "published_date",
"publishedDate", "publishedDate",
@@ -127,6 +141,8 @@ const CONTENT_KEY_PATTERN =
const URL_KEY_PATTERN = /(?:^|_)(?:url|href|link|uri)(?:$|_)/i; const URL_KEY_PATTERN = /(?:^|_)(?:url|href|link|uri)(?:$|_)/i;
const SOURCE_KEY_PATTERN = const SOURCE_KEY_PATTERN =
/(?:^|_)(?:source|origin|site|publisher|domain|from|channel|database|kb)(?:$|_)/i; /(?:^|_)(?: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 = const DATE_KEY_PATTERN =
/(?:^|_)(?:date|time|published|publish|created|updated)(?:$|_)/i; /(?:^|_)(?:date|time|published|publish|created|updated)(?:$|_)/i;
@@ -297,12 +313,22 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
excludeUrl: true, excludeUrl: true,
maxLength: 120, maxLength: 120,
}); });
const author =
pickFromLayers(layers, AUTHOR_ALIASES) ??
pickByKeyPattern(layers, AUTHOR_KEY_PATTERN, {
excludeUrl: true,
maxLength: 120,
});
const date = const date =
pickFromLayers(layers, DATE_ALIASES) ?? pickFromLayers(layers, DATE_ALIASES) ??
pickByKeyPattern(layers, DATE_KEY_PATTERN, { maxLength: 64 }); 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>(); 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) { if (value) {
reserved.add(value); reserved.add(value);
} }
@@ -324,11 +350,16 @@ function normalizeSearchResultItem(raw: unknown): SearchResultItem | null {
} }
return { return {
id,
recUuid,
uuid,
docId,
title, title,
url, url,
snippet: content, snippet: content,
content, content,
source, source,
author,
published_date: date, published_date: date,
publishedDate: date, publishedDate: date,
date, date,
+49
View File
@@ -3,6 +3,55 @@ import { useMemo } from "react";
import { visit } from "unist-util-visit"; import { visit } from "unist-util-visit";
import type { BuildVisitor } from "unist-util-visit"; import type { BuildVisitor } from "unist-util-visit";
const CITE_RE = /\[\[cite:(\d+)\]\]/g;
export function rehypeCitations(sources: Array<{ id: number; title: string }>) {
const sourceMap = new Map(sources.map((s) => [s.id, s.title]));
return (tree: Root) => {
visit(tree, "text", (node, index, parent) => {
if (!CITE_RE.test(node.value)) return;
CITE_RE.lastIndex = 0;
const newNodes: ElementContent[] = [];
const citeCounter = new Map<number, number>();
let citeIndex = 0;
let last = 0;
let match: RegExpExecArray | null;
while ((match = CITE_RE.exec(node.value)) !== null) {
if (match.index > last) {
newNodes.push({ type: "text", value: node.value.slice(last, match.index) });
}
const docId = Number(match[1]);
if (!citeCounter.has(docId)) citeCounter.set(docId, ++citeIndex);
const num = citeCounter.get(docId)!;
const title = sourceMap.get(docId) ?? `来源 ${docId}`;
newNodes.push({
type: "element",
tagName: "sup",
properties: {
title,
"data-chunk-id": String(docId),
style:
"display:inline-flex;align-items:center;justify-content:center;width:1rem;height:1rem;border-radius:9999px;background:hsl(var(--primary));color:hsl(var(--primary-foreground));font-size:10px;font-weight:700;margin-left:1px;cursor:pointer;vertical-align:super;",
},
children: [{ type: "text", value: String(num) }],
});
last = match.index + match[0].length;
}
if (last < node.value.length) {
newNodes.push({ type: "text", value: node.value.slice(last) });
}
if (parent && index !== undefined && newNodes.length > 0) {
(parent.children as ElementContent[]).splice(index, 1, ...newNodes);
return index + newNodes.length;
}
});
};
}
const CJK_TEXT_RE = const CJK_TEXT_RE =
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u; /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
@@ -51,6 +51,7 @@ export async function createScheduledTask(data: {
schedule: string; schedule: string;
timezone?: string | null; timezone?: string | null;
execution_agent_name?: string | null; execution_agent_name?: string | null;
require_markdown?: boolean;
}) { }) {
return parseResponse<ScheduledTask>( return parseResponse<ScheduledTask>(
await apiFetch(baseUrl(), { await apiFetch(baseUrl(), {
@@ -210,6 +211,7 @@ export async function updateScheduledTask(
timezone: string; timezone: string;
enabled: boolean; enabled: boolean;
execution_agent_name: string | null; execution_agent_name: string | null;
require_markdown: boolean;
}>, }>,
) { ) {
return parseResponse<ScheduledTask>( return parseResponse<ScheduledTask>(
@@ -337,6 +339,7 @@ export async function adminUpdateScheduledTask(
timezone: string; timezone: string;
enabled: boolean; enabled: boolean;
execution_agent_name: string | null; execution_agent_name: string | null;
require_markdown: boolean;
}>, }>,
) { ) {
return parseResponse<ScheduledTask>( return parseResponse<ScheduledTask>(
@@ -20,6 +20,12 @@ export type ScheduledTask = {
cron_expr: string; cron_expr: string;
timezone: string; timezone: string;
enabled: boolean; 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; next_run_at?: string | null;
last_run_at?: string | null; last_run_at?: string | null;
last_status?: string | null; last_status?: string | null;
+3 -2
View File
@@ -1,2 +1,3 @@
export { useLocalSettings, useThreadSettings } from "./hooks"; export { useLocalSettings, useThreadSettings } from "./hooks";
export type { LocalSettings } from "./local"; export { isThreadRagModeLocked, lockThreadRagMode } from "./local";
export type { LocalSettings } from "./local";
+43 -12
View File
@@ -4,9 +4,11 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
notification: { notification: {
enabled: true, enabled: true,
}, },
display: { display: {
streamSpeedMeterEnabled: true, streamSpeedMeterEnabled: true,
}, ragModeEnabled: false,
citationCleanupWords: [],
},
context: { context: {
model_name: undefined, model_name: undefined,
mode: undefined, mode: undefined,
@@ -15,8 +17,9 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
}, },
}; };
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings"; export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model."; export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
export const THREAD_RAG_MODE_KEY_PREFIX = "deerflow.thread-rag-mode.";
function isBrowser(): boolean { function isBrowser(): boolean {
return typeof window !== "undefined"; return typeof window !== "undefined";
@@ -33,7 +36,17 @@ export interface LocalSettings {
* the appearance settings page. * the appearance settings page.
*/ */
streamSpeedMeterEnabled: boolean; 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< context: Omit<
AgentThreadContext, AgentThreadContext,
| "thread_id" | "thread_id"
@@ -78,10 +91,10 @@ export function getThreadModelName(threadId: string): string | undefined {
return localStorage.getItem(getThreadModelStorageKey(threadId)) ?? undefined; return localStorage.getItem(getThreadModelStorageKey(threadId)) ?? undefined;
} }
export function saveThreadModelName( export function saveThreadModelName(
threadId: string, threadId: string,
modelName: string | undefined, modelName: string | undefined,
) { ) {
if (!isBrowser()) { if (!isBrowser()) {
return; return;
} }
@@ -90,8 +103,26 @@ export function saveThreadModelName(
localStorage.removeItem(key); localStorage.removeItem(key);
return; 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( export function applyThreadModelOverride(
settings: LocalSettings, 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
+154 -1
View File
@@ -1,7 +1,7 @@
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import { apiFetch } from "@/core/api"; import { apiFetch } from "@/core/api";
import type { Skill, SkillState } from "./type"; import type { Skill, SkillDisplayConfig, SkillState } from "./type";
export interface ListSkillsParams { export interface ListSkillsParams {
search?: string; search?: string;
@@ -20,6 +20,17 @@ export async function loadSkills(params: ListSkillsParams = {}) {
return json.skills as Skill[]; 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> { export async function getSkill(skillName: string): Promise<Skill> {
const response = await apiFetch( const response = await apiFetch(
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`, `${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`,
@@ -46,6 +57,62 @@ export async function enableSkill(skillName: string, enabled: boolean) {
return response.json(); 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 { export interface InstallSkillRequest {
thread_id: string; thread_id: string;
path: 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 { export interface SetSkillDetailResponse {
name: string; name: string;
detail: string | null; detail: string | null;
+94 -2
View File
@@ -10,17 +10,21 @@ import {
enableSkill, enableSkill,
getSkill, getSkill,
getSkillContent, getSkillContent,
getSkillFileContent,
getSkillFiles,
installSkillUpload, installSkillUpload,
reconcileCustomSkills, reconcileCustomSkills,
saveSkillFile,
setSkillDetail, setSkillDetail,
setSkillDisplay,
setSkillPinned, setSkillPinned,
setSkillPublished, setSkillPublished,
setSkillState, setSkillState,
validateSkillUpload, validateSkillUpload,
} from "./api"; } 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[] } = {}) { export function useSkills(params: { search?: string; tagIds?: string[] } = {}) {
const search = params.search ?? ""; const search = params.search ?? "";
@@ -33,6 +37,21 @@ export function useSkills(params: { search?: string; tagIds?: string[] } = {}) {
return { skills: data ?? [], isLoading, error }; 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) { export function useSkillContent(skillName: string | null) {
return useQuery({ return useQuery({
queryKey: ["skill-content", skillName], 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() { export function useEnableSkill() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
@@ -118,6 +162,10 @@ export function useSetSkillPublished() {
}) => setSkillPublished(skillName, published), }) => setSkillPublished(skillName, published),
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] }); 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), mutationFn: (skillName: string) => deleteCustomSkill(skillName),
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["skills"] }); 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] });
}
}, },
}); });
} }
+33
View File
@@ -2,6 +2,37 @@ import type { Tag } from "@/core/tags";
export type SkillState = "active" | "stale" | "archived"; export type SkillState = "active" | "stale" | "archived";
export type SkillSource = "agent" | "upload"; 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 { export interface Skill {
name: string; name: string;
@@ -17,6 +48,8 @@ export interface Skill {
// Long-form detail/introduction authored by the skill owner or an admin. // Long-form detail/introduction authored by the skill owner or an admin.
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown. // Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
detail?: string | null; detail?: string | null;
display?: SkillDisplayConfig | null;
displaySample?: Record<string, unknown> | null;
// Lifecycle fields — populated by curator. The main list filters on these // Lifecycle fields — populated by curator. The main list filters on these
// so stale / archived skills appear in dedicated drawers instead. // so stale / archived skills appear in dedicated drawers instead.
state?: SkillState; 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";
}
+94
View File
@@ -0,0 +1,94 @@
import { getNotebookApiBaseURL } from "@/core/config";
import type { SourceItem, StudioNotebook } from "./types";
function base(): string {
return getNotebookApiBaseURL();
}
export async function fetchStudioToken(email: string): Promise<string> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
const res = await fetch(
`${apiBase}/api/getTokenByEmail?email=${encodeURIComponent(email)}`,
);
if (!res.ok) throw new Error(`获取 Token 失败:${res.status}`);
const data = (await res.json()) as { token?: string } | string;
const token = typeof data === "string" ? data : data?.token;
if (!token) throw new Error("接口未返回有效 token");
return token;
}
export async function fetchStudioNotebooks(token: string): Promise<StudioNotebook[]> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/searchspaces`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`获取笔记本列表失败:${res.status}`);
return res.json() as Promise<StudioNotebook[]>;
}
export async function createSearchSpace(
name: string,
token: string,
): Promise<void> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/searchspaces`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ name, description: "", citations_enabled: true }),
});
if (!res.ok) throw new Error(`创建失败:${res.status}`);
}
export interface ChunkRead {
id: number;
content: string;
document_id: number;
created_at: string;
}
export interface DocumentWithChunks {
id: number;
title: string;
document_type: string;
file_type: string;
chunks: ChunkRead[];
}
export async function fetchDocumentByChunk(
chunkId: number,
token: string,
): Promise<DocumentWithChunks> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
const res = await fetch(`${apiBase}/api/v1/documents/by-chunk/${chunkId}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`获取文档失败:${res.status}`);
return res.json() as Promise<DocumentWithChunks>;
}
export async function fetchNotebookSources(
notebookId: number,
token: string,
): Promise<SourceItem[]> {
const apiBase = base();
if (!apiBase) throw new Error("VITE_NOTEBOOK_API_BASE_URL 未配置");
const res = await fetch(
`${apiBase}/api/v1/source/search-space/${notebookId}?limit=100`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!res.ok) throw new Error(`获取来源失败:${res.status}`);
return res.json() as Promise<SourceItem[]>;
}
+146
View File
@@ -0,0 +1,146 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useStream } from "@langchain/langgraph-sdk/react";
import { getStoredAuth } from "@/core/auth";
import { getAPIClient } from "@/core/api";
import { fetchNotebookSources, fetchStudioNotebooks, fetchStudioToken } from "./api";
import type { AgentThreadState } from "@/core/threads/types";
import type { SourceItem, StudioNotebook } from "./types";
export function useStudioNotebooks(): {
notebooks: StudioNotebook[];
isLoading: boolean;
isError: boolean;
} {
const email = getStoredAuth()?.email ?? "";
const tokenQuery = useQuery({
queryKey: ["studio", "token", email],
queryFn: () => fetchStudioToken(email),
enabled: Boolean(email),
staleTime: 5 * 60 * 1000,
retry: 1,
});
const notebooksQuery = useQuery({
queryKey: ["studio", "notebooks", tokenQuery.data],
queryFn: () => fetchStudioNotebooks(tokenQuery.data!),
enabled: Boolean(tokenQuery.data),
staleTime: 60 * 1000,
retry: 1,
});
return {
notebooks: (notebooksQuery.data ?? []).filter((nb) => nb.is_owner),
isLoading: tokenQuery.isLoading || notebooksQuery.isLoading,
isError: tokenQuery.isError || notebooksQuery.isError,
};
}
export function useNotebookThreads(notebookId: number | null) {
const client = getAPIClient();
return useQuery({
queryKey: ["notebook-threads", notebookId],
queryFn: () =>
client.threads.search({
metadata: { thread_type: "notebook", note_id: notebookId },
limit: 20,
sortBy: "updated_at",
sortOrder: "desc",
}),
enabled: notebookId !== null,
staleTime: 30 * 1000,
refetchOnWindowFocus: false,
});
}
export function useNotebookChat(
notebookId: number,
sources: SourceItem[],
modelName?: string,
initialThreadId?: string,
) {
const [threadId, setThreadId] = useState<string | undefined>(initialThreadId);
const email = getStoredAuth()?.email ?? "";
const { data: token } = useQuery({
queryKey: ["studio", "token", email],
queryFn: () => fetchStudioToken(email),
enabled: Boolean(email),
staleTime: 5 * 60 * 1000,
retry: 1,
});
const thread = useStream<AgentThreadState>({
client: getAPIClient(),
assistantId: "lead_agent",
threadId,
fetchStateHistory: { limit: 1 },
onCreated(meta) {
setThreadId(meta.thread_id);
void getAPIClient()
.threads.update(meta.thread_id, {
metadata: { thread_type: "notebook", note_id: notebookId },
})
.catch(() => ({}));
},
});
const send = async (text: string, selectedSourceIds: number[]) => {
if (thread.isLoading || !token) return;
const sourceIds =
selectedSourceIds.length > 0
? selectedSourceIds
: sources.map((s) => s.id).filter(Boolean);
await thread.submit(
{ messages: [{ type: "human", content: text }] },
{
threadId,
streamSubgraphs: true,
config: {
configurable: {
notebook_search_space_id: notebookId,
notebook_source_ids: sourceIds,
notebook_token: token,
...(modelName ? { model_name: modelName } : {}),
},
},
},
);
};
return { thread, messages: thread.messages, isStreaming: thread.isLoading, send, threadId, setThreadId };
}
export function useNotebookSources(notebookId: number | null): {
sources: SourceItem[];
isLoading: boolean;
isError: boolean;
} {
const email = getStoredAuth()?.email ?? "";
// 复用与 useStudioNotebooks 相同的 token query key,命中缓存不重复请求
const tokenQuery = useQuery({
queryKey: ["studio", "token", email],
queryFn: () => fetchStudioToken(email),
enabled: Boolean(email),
staleTime: 5 * 60 * 1000,
retry: 1,
});
const sourcesQuery = useQuery({
queryKey: ["studio", "sources", notebookId],
queryFn: () => fetchNotebookSources(notebookId!, tokenQuery.data!),
enabled: notebookId !== null && Boolean(tokenQuery.data),
staleTime: 60 * 1000,
retry: 1,
});
return {
sources: sourcesQuery.data ?? [],
isLoading: tokenQuery.isLoading || sourcesQuery.isLoading,
isError: tokenQuery.isError || sourcesQuery.isError,
};
}
+36
View File
@@ -0,0 +1,36 @@
export interface StudioTokenResponse {
token: string;
}
export interface DocumentRead {
id: number;
title: string;
document_type: string;
file_type: string | null;
created_at: string | null;
}
export interface SourceItem {
id: number;
search_space_id: number;
source_type: string;
title: string | null;
status: string | null;
created_at: string;
document: DocumentRead | null;
}
export interface StudioNotebook {
id: number;
name: string;
description: string;
user_id: string;
owner_email: string;
member_count: number;
source_count: number;
is_shared: boolean;
is_owner: boolean;
membership_role: string;
created_at: string;
avatar_url: string | null;
}
@@ -42,3 +42,127 @@ export async function updatePromptPrefixSettings(
} }
return (await response.json()) as PromptPrefixSettings; 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
loadAppearanceSettings,
loadCitationDisplaySettings,
loadPrivateSquareStatus,
loadPromptPrefixSettings, loadPromptPrefixSettings,
updateAppearanceSettings,
updateCitationDisplaySettings,
updatePrivateSquareSettings,
updatePromptPrefixSettings, updatePromptPrefixSettings,
type AppearanceUpdate,
type CitationDisplayUpdate,
type PrivateSquareUpdate,
type PromptPrefixUpdate, type PromptPrefixUpdate,
} from "./api"; } from "./api";
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const; 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() { export function usePromptPrefixSettings() {
return useQuery({ 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);
},
});
}
+164 -6
View File
@@ -3,16 +3,20 @@ import type { ThreadsClient } from "@langchain/langgraph-sdk/client";
import { useStream } from "@langchain/langgraph-sdk/react"; import { useStream } from "@langchain/langgraph-sdk/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { getAPIClient } from "../api"; import { getAPIClient } from "../api";
import { apiFetch } from "../api"; import { apiFetch } from "../api";
import { getStoredAuth } from "../auth";
import { rememberAskTime } from "../chat-timestamps";
import { getBackendBaseURL } from "../config"; import { getBackendBaseURL } from "../config";
import { useI18n } from "../i18n/hooks"; import { useI18n } from "../i18n/hooks";
import type { FileInMessage } from "../messages/utils"; import type { FileInMessage } from "../messages/utils";
import type { LocalSettings } from "../settings"; import type { LocalSettings } from "../settings";
import { isThreadRagModeLocked, lockThreadRagMode, useLocalSettings } from "../settings";
import { useUpdateSubtask } from "../tasks/context"; import { useUpdateSubtask } from "../tasks/context";
import type { UploadedFileInfo } from "../uploads"; import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads"; import { promptInputFilePartToFile, uploadFiles } from "../uploads";
@@ -136,6 +140,42 @@ function getStreamErrorMessage(error: unknown): string {
return "Request failed."; 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({ export function useThreadStream({
threadId, threadId,
context, context,
@@ -146,6 +186,7 @@ export function useThreadStream({
onToolEnd, onToolEnd,
}: ThreadStreamOptions) { }: ThreadStreamOptions) {
const { t } = useI18n(); const { t } = useI18n();
const [localSettings] = useLocalSettings();
// Track the thread ID that is currently streaming to handle thread changes during streaming // Track the thread ID that is currently streaming to handle thread changes during streaming
const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId); const [onStreamThreadId, setOnStreamThreadId] = useState(() => threadId);
// Ref to track current thread ID across async callbacks without causing re-renders, // Ref to track current thread ID across async callbacks without causing re-renders,
@@ -193,6 +234,7 @@ export function useThreadStream({
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const updateSubtask = useUpdateSubtask(); const updateSubtask = useUpdateSubtask();
const navigate = useNavigate();
const runMetadataStorageRef = useRef< const runMetadataStorageRef = useRef<
ReturnType<typeof getRunMetadataStorage> | undefined ReturnType<typeof getRunMetadataStorage> | undefined
>(undefined); >(undefined);
@@ -292,7 +334,40 @@ export function useThreadStream({
}, },
onError(error) { onError(error) {
setOptimisticMessages([]); 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) { onFinish(state) {
listeners.current.onFinish?.(state.values); listeners.current.onFinish?.(state.values);
@@ -304,12 +379,47 @@ export function useThreadStream({
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]); const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const sendInFlightRef = useRef(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 // Track message count before sending so we know when server has responded
const prevMsgCountRef = useRef(thread.messages.length); const prevMsgCountRef = useRef(thread.messages.length);
// Reset thread-local pending UI state when switching between threads so // Keep a ref to the latest `thread` so the threadId-change effect can stop
// optimistic messages and in-flight guards do not leak across chat views. // 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(() => { 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; startedRef.current = false;
sendInFlightRef.current = false; sendInFlightRef.current = false;
prevMsgCountRef.current = 0; prevMsgCountRef.current = 0;
@@ -327,6 +437,26 @@ export function useThreadStream({
} }
}, [thread.messages.length, optimisticMessages.length]); }, [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( const sendMessage = useCallback(
async ( async (
threadId: string, threadId: string,
@@ -340,6 +470,20 @@ export function useThreadStream({
sendInFlightRef.current = true; sendInFlightRef.current = true;
const text = message.text.trim(); 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 // Capture current count before showing optimistic messages
prevMsgCountRef.current = thread.messages.length; prevMsgCountRef.current = thread.messages.length;
@@ -356,6 +500,7 @@ export function useThreadStream({
const hideFromUI = options?.additionalKwargs?.hide_from_ui === true; const hideFromUI = options?.additionalKwargs?.hide_from_ui === true;
const optimisticAdditionalKwargs = { const optimisticAdditionalKwargs = {
...options?.additionalKwargs, ...options?.additionalKwargs,
client_ts: clientTs,
...(optimisticFiles.length > 0 ? { files: optimisticFiles } : {}), ...(optimisticFiles.length > 0 ? { files: optimisticFiles } : {}),
}; };
@@ -433,7 +578,10 @@ export function useThreadStream({
return [ return [
{ {
...humanMessage, ...humanMessage,
additional_kwargs: { files: uploadedFiles }, additional_kwargs: {
...humanMessage.additional_kwargs,
files: uploadedFiles,
},
}, },
...messages.slice(1), ...messages.slice(1),
]; ];
@@ -464,6 +612,12 @@ export function useThreadStream({
}), }),
); );
const ragModeEnabled =
localSettings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
if (ragModeEnabled) {
lockThreadRagMode(threadId);
}
await thread.submit( await thread.submit(
{ {
messages: [ messages: [
@@ -477,6 +631,7 @@ export function useThreadStream({
], ],
additional_kwargs: { additional_kwargs: {
...options?.additionalKwargs, ...options?.additionalKwargs,
client_ts: clientTs,
...(filesForSubmit.length > 0 ...(filesForSubmit.length > 0
? { files: filesForSubmit } ? { files: filesForSubmit }
: {}), : {}),
@@ -507,6 +662,7 @@ export function useThreadStream({
? "low" ? "low"
: undefined), : undefined),
memory_injection_enabled: context.memory_injection_enabled ?? true, memory_injection_enabled: context.memory_injection_enabled ?? true,
rag_mode_enabled: ragModeEnabled,
thread_id: threadId, thread_id: threadId,
}, },
}, },
@@ -520,7 +676,7 @@ export function useThreadStream({
sendInFlightRef.current = false; 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 // Merge thread with optimistic messages for display
@@ -596,7 +752,9 @@ export function useThreads(
offset += response.length; offset += response.length;
} }
return threads; return threads.filter(
(t) => (t.metadata as Record<string, unknown> | null)?.thread_type !== "notebook",
);
}, },
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
+3 -2
View File
@@ -18,8 +18,9 @@ export interface AgentThreadContext extends Record<string, unknown> {
reasoning_effort?: "minimal" | "low" | "medium" | "high"; reasoning_effort?: "minimal" | "low" | "medium" | "high";
writing_mode?: boolean; writing_mode?: boolean;
writing_artifact_path?: string; writing_artifact_path?: string;
memory_injection_enabled?: boolean; memory_injection_enabled?: boolean;
agent_id?: string; rag_mode_enabled?: boolean;
agent_id?: string;
/** @deprecated kept for reading legacy thread metadata; prefer agent_id */ /** @deprecated kept for reading legacy thread metadata; prefer agent_id */
agent_name?: string; agent_name?: string;
} }
+2
View File
@@ -6,6 +6,7 @@ function readEnv(
| "VITE_LANGGRAPH_BASE_URL" | "VITE_LANGGRAPH_BASE_URL"
| "VITE_CANVAS_LANGGRAPH_BASE_URL" | "VITE_CANVAS_LANGGRAPH_BASE_URL"
| "VITE_NOTE_API_BASE_URL" | "VITE_NOTE_API_BASE_URL"
| "VITE_NOTEBOOK_API_BASE_URL"
| "VITE_STATIC_WEBSITE_ONLY" | "VITE_STATIC_WEBSITE_ONLY"
| "VITE_GITHUB_OAUTH_TOKEN", | "VITE_GITHUB_OAUTH_TOKEN",
): EnvValue { ): EnvValue {
@@ -20,6 +21,7 @@ export const env = {
// routed (e.g. a separate canvas-agent server). Falls back to the main URL. // routed (e.g. a separate canvas-agent server). Falls back to the main URL.
NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL: readEnv("VITE_CANVAS_LANGGRAPH_BASE_URL"), NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL: readEnv("VITE_CANVAS_LANGGRAPH_BASE_URL"),
NEXT_PUBLIC_NOTE_API_BASE_URL: readEnv("VITE_NOTE_API_BASE_URL"), NEXT_PUBLIC_NOTE_API_BASE_URL: readEnv("VITE_NOTE_API_BASE_URL"),
NEXT_PUBLIC_NOTEBOOK_API_BASE_URL: readEnv("VITE_NOTEBOOK_API_BASE_URL"),
NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readEnv("VITE_STATIC_WEBSITE_ONLY"), NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readEnv("VITE_STATIC_WEBSITE_ONLY"),
GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"), GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"),
}; };
@@ -19,11 +19,18 @@ interface Shortcut {
export function useGlobalShortcuts(shortcuts: Shortcut[]) { export function useGlobalShortcuts(shortcuts: Shortcut[]) {
useEffect(() => { useEffect(() => {
function handleKeyDown(event: KeyboardEvent) { 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 meta = event.metaKey || event.ctrlKey;
const pressed = event.key.toLowerCase();
for (const shortcut of shortcuts) { for (const shortcut of shortcuts) {
if (typeof shortcut.key !== "string") continue;
if ( if (
event.key.toLowerCase() === shortcut.key.toLowerCase() && pressed === shortcut.key.toLowerCase() &&
meta === shortcut.meta && meta === shortcut.meta &&
(shortcut.shift ?? false) === event.shiftKey (shortcut.shift ?? false) === event.shiftKey
) { ) {
+1
View File
@@ -1,4 +1,5 @@
import "./styles/globals.css"; import "./styles/globals.css";
import "tdesign-react/es/style/index.css";
import "katex/dist/katex.min.css"; import "katex/dist/katex.min.css";
import React from "react"; 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 { PenLine } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -10,8 +10,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { getBackendBaseURL } from '@/core/config'; import { useModels } from '@/core/models/hooks';
import { apiFetch } from '@/core/api';
import type { AIWritingRequest } from '../../types/ai-writing'; import type { AIWritingRequest } from '../../types/ai-writing';
import { useArticleTypes } from '../../hooks/useArticleTypes'; import { useArticleTypes } from '../../hooks/useArticleTypes';
@@ -23,11 +22,6 @@ interface AIWritingFormProps {
hideAdvancedFields?: boolean; hideAdvancedFields?: boolean;
} }
interface ModelOption {
name: string;
displayName: string;
}
const INPUT_CLASS = const INPUT_CLASS =
'flex h-12 w-full rounded-xl border border-[#e8dedd] bg-white px-4 py-2 text-sm shadow-sm ' + '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 ' + '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 [wordCountInput, setWordCountInput] = useState('800');
const [strictMode, setStrictMode] = useState(false); const [strictMode, setStrictMode] = useState(false);
const [modelName, setModelName] = useState(''); const [modelName, setModelName] = useState('');
const [models, setModels] = useState<ModelOption[]>([]); const { models, isLoading: modelsLoading } = useModels();
const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes(); 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(() => { useEffect(() => {
refreshTypes(); 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() { function handleWordCountBlur() {
const num = parseInt(wordCountInput, 10); const num = parseInt(wordCountInput, 10);
if (isNaN(num) || num < 100) { if (isNaN(num) || num < 100) {
@@ -122,7 +111,7 @@ export function AIWritingForm({
wordCountTarget: wordCount, wordCountTarget: wordCount,
keywordCount: DEFAULT_KEYWORD_COUNT, keywordCount: DEFAULT_KEYWORD_COUNT,
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose', 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"> <div className="mt-4 space-y-2">
<label className={FIELD_LABEL_CLASS}>写作模型</label> <label className={FIELD_LABEL_CLASS}>写作模型</label>
<Select <Select
value={modelName} value={selectedModel?.name ?? ''}
onValueChange={setModelName} onValueChange={setModelName}
disabled={disabled || models.length === 0} disabled={disabled || modelsLoading || models.length === 0}
> >
<SelectTrigger className={FIELD_TRIGGER_CLASS}> <SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder={models.length > 0 ? '选择模型…' : '模型加载中…'} /> <SelectValue
placeholder={
modelsLoading
? '模型加载中…'
: models.length > 0
? '选择模型…'
: '暂无可用模型'
}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{models.map((m) => ( {models.map((m) => (
<SelectItem key={m.name} value={m.name} className="text-sm"> <SelectItem key={m.name} value={m.name} className="text-sm">
{m.displayName} {m.display_name || m.name}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -38,6 +38,8 @@ const ENABLE_PAUSE_RESUME_UI = false;
import { AIWritingForm } from './AIWritingForm'; import { AIWritingForm } from './AIWritingForm';
import { AIWritingTimeline } from './AIWritingTimeline'; import { AIWritingTimeline } from './AIWritingTimeline';
import { InterventionCard } from './InterventionCard'; import { InterventionCard } from './InterventionCard';
import { SkillStepsCard } from './SkillStepsCard';
import { SkillSelectStreamingView } from './SkillSelectStreamingView';
import { WritingSkeletonCard } from './WritingSkeletonCard'; import { WritingSkeletonCard } from './WritingSkeletonCard';
import { DraftStreamingView } from './DraftStreamingView'; import { DraftStreamingView } from './DraftStreamingView';
import { IntentResultCard } from './IntentResultCard'; import { IntentResultCard } from './IntentResultCard';
@@ -411,6 +413,8 @@ export function AIWritingPanel({
reviewResult={state.reviewResult} reviewResult={state.reviewResult}
draftTitle={state.currentDraftTitle} draftTitle={state.currentDraftTitle}
revisionCount={state.revisionCount} revisionCount={state.revisionCount}
skillSearchSteps={state.skillSearchSteps}
skillSearchInProgress={state.status === 'researching' && state.skillSearchSteps.length > 0}
onSubmit={submitIntervention} onSubmit={submitIntervention}
disabled={false} 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 && {showSkeleton &&
state.status !== 'writing_draft' && state.status !== 'writing_draft' &&
!(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) && !(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) &&
!(state.status === 'reviewing' && state.streamingReviewItems.length > 0) && !(state.status === 'reviewing' && state.streamingReviewItems.length > 0) &&
/* 技能检索已经在流式展示步骤 / 选择打字机,不需要再叠加 skeleton */
!(state.status === 'researching' && (state.skillSearchSteps.length > 0 || state.skillSelectStreamingText || state.skillSelectThinkingText)) &&
/* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */ /* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */
!state.intentStreamingText && !state.intentThinkingText && ( !state.intentStreamingText && !state.intentThinkingText && (
<div className="px-4 pb-4"> <div className="px-4 pb-4">
@@ -2,7 +2,7 @@ import { useRef, useState } from 'react';
import { import {
PauseCircle, CheckCircle2, RotateCcw, Search, PauseCircle, CheckCircle2, RotateCcw, Search,
RefreshCw, PenLine, ArrowRight, ShieldAlert, ClipboardEdit, SendHorizonal, RefreshCw, PenLine, ArrowRight, ShieldAlert, ClipboardEdit, SendHorizonal,
Trash2, BookOpen, Trash2, BookOpen, Sparkles,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -10,11 +10,13 @@ import type {
AIMaterial, AIOutline, ReviewResult, ReviewIssue, AIMaterial, AIOutline, ReviewResult, ReviewIssue,
ProgressEvent, UserInterventionPayload, ProgressEvent, UserInterventionPayload,
BlockedSection, SectionDecisionKind, BlockedSection, SectionDecisionKind,
SkillSearchStep,
} from '../../types/ai-writing'; } from '../../types/ai-writing';
import { MaterialsCard } from './MaterialsCard'; import { MaterialsCard } from './MaterialsCard';
import { MaterialsToolbar } from './MaterialsToolbar'; import { MaterialsToolbar } from './MaterialsToolbar';
import { OutlineCard } from './OutlineCard'; import { OutlineCard } from './OutlineCard';
import { ReviewCard } from './ReviewCard'; import { ReviewCard } from './ReviewCard';
import { SkillStepsCard } from './SkillStepsCard';
interface InterventionCardProps { interface InterventionCardProps {
pauseEvent: ProgressEvent; pauseEvent: ProgressEvent;
@@ -23,18 +25,29 @@ interface InterventionCardProps {
reviewResult: ReviewResult | null; reviewResult: ReviewResult | null;
draftTitle: string; draftTitle: string;
revisionCount: number; revisionCount: number;
skillSearchSteps?: SkillSearchStep[];
/** 是否正在检索中(researching/awaiting 切换之间,前端用于流式展示步骤进行中) */
skillSearchInProgress?: boolean;
onSubmit: (payload: UserInterventionPayload) => void; onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean; disabled?: boolean;
} }
export function InterventionCard({ export function InterventionCard({
pauseEvent, materials, outline, reviewResult, pauseEvent, materials, outline, reviewResult,
draftTitle, revisionCount, onSubmit, disabled, draftTitle, revisionCount, skillSearchSteps, skillSearchInProgress, onSubmit, disabled,
}: InterventionCardProps) { }: InterventionCardProps) {
const pausePoint = pauseEvent.pausePoint; const pausePoint = pauseEvent.pausePoint;
if (pausePoint === 'material_confirm') { 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') { if (pausePoint === 'outline_confirm') {
@@ -106,30 +119,40 @@ function formatSelectedIssues(issues: ReviewIssue[], selectedIndices: Set<number
interface MaterialConfirmCardProps { interface MaterialConfirmCardProps {
materials: AIMaterial[]; materials: AIMaterial[];
skillSearchSteps: SkillSearchStep[];
skillSearchInProgress?: boolean;
onSubmit: (payload: UserInterventionPayload) => void; onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean; disabled?: boolean;
} }
function MaterialConfirmCard({ materials, onSubmit, disabled }: MaterialConfirmCardProps) { function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgress, onSubmit, disabled }: MaterialConfirmCardProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>( const [selectedIds, setSelectedIds] = useState<Set<string>>(
() => new Set(materials.map((m) => m.id)), () => new Set(materials.map((m) => m.id)),
); );
const [extraKeywords, setExtraKeywords] = useState<string[]>([]); const [userQuery, setUserQuery] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
async function handleConfirm() { async function handleConfirm() {
setSubmitting(true); setSubmitting(true);
await onSubmit({ action: 'confirm', approvedMaterialIds: [...selectedIds], extraKeywords }); await onSubmit({ action: 'confirm', approvedMaterialIds: [...selectedIds], userQuery: userQuery.trim() || undefined });
setSubmitting(false); setSubmitting(false);
} }
async function handleReSearch() { async function handleReSearch() {
setSubmitting(true); setSubmitting(true);
await onSubmit({ action: 're_search', approvedMaterialIds: [], extraKeywords }); await onSubmit({ action: 're_search', approvedMaterialIds: [], userQuery: userQuery.trim() || undefined });
setSubmitting(false); 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) { if (materials.length === 0) {
@@ -140,33 +163,45 @@ function MaterialConfirmCard({ materials, onSubmit, disabled }: MaterialConfirmC
<div> <div>
<p className="text-xs font-medium">未检索到任何素材</p> <p className="text-xs font-medium">未检索到任何素材</p>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
素材收集专家本次没有检索到相关数据。你可以补充关键词重新搜索,或在无素材的情况下直接继续写作(文章将仅依据写作意图生成)。 素材收集专家本次没有检索到相关数据。你可以补充关键词重新搜索、启用技能检索,或在无素材的情况下直接继续写作。
</p> </p>
</div> </div>
</div> </div>
{(skillSearchSteps.length > 0 || skillSearchInProgress) && (
<SkillStepsCard steps={skillSearchSteps} inProgress={skillSearchInProgress} />
)}
<MaterialsToolbar <MaterialsToolbar
materials={materials} materials={materials}
selectedIds={selectedIds} selectedIds={selectedIds}
onSelectAll={() => setSelectedIds(new Set())} onSelectAll={() => setSelectedIds(new Set())}
onDeselectAll={() => setSelectedIds(new Set())} onDeselectAll={() => setSelectedIds(new Set())}
extraKeywords={extraKeywords} userQuery={userQuery}
onAddKeyword={(kw) => setExtraKeywords((prev) => [...prev, kw])} onUserQueryChange={setUserQuery}
onRemoveKeyword={(kw) => setExtraKeywords((prev) => prev.filter((k) => k !== kw))} onSubmitQuery={handleReSearch}
disabled={disabled || submitting}
/> />
<div className="flex gap-2 pt-1"> <div className="flex flex-wrap gap-2 pt-1">
<Button <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} onClick={handleReSearch} disabled={disabled || submitting}
> >
{hasExtra {hasQuery
? <><Search className="size-3.5" />追加搜索</> ? <><Search className="size-3.5" />按输入重新搜索</>
: <><RotateCcw className="size-3.5" />重新搜索</>} : <><RotateCcw className="size-3.5" />重新搜索</>}
</Button> </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 <Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0" 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" />无数据继续写作 <ArrowRight className="size-3.5" />无数据继续写作
</Button> </Button>
@@ -182,11 +217,14 @@ function MaterialConfirmCard({ materials, onSubmit, disabled }: MaterialConfirmC
<div> <div>
<p className="text-xs font-medium">需要您确认素材,才能继续</p> <p className="text-xs font-medium">需要您确认素材,才能继续</p>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
已搜集到 {materials.length} 条素材(默认全选)。您可以取消勾选不相关的素材,或补充关键词追加搜索。 已搜集到 {materials.length} 条{isSkillResult ? '技能素材' : '素材'}(默认全选)。
您可以取消勾选不相关的素材,或补充关键词追加搜索{isSkillResult ? '' : ',也可以启用技能检索从已启用的技能中获取素材'}。
</p> </p>
</div> </div>
</div> </div>
{skillSearchSteps.length > 0 && <SkillStepsCard steps={skillSearchSteps} />}
<MaterialsCard materials={materials} selectedIds={selectedIds} onToggle={(id) => { <MaterialsCard materials={materials} selectedIds={selectedIds} onToggle={(id) => {
setSelectedIds((prev) => { setSelectedIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -200,24 +238,34 @@ function MaterialConfirmCard({ materials, onSubmit, disabled }: MaterialConfirmC
selectedIds={selectedIds} selectedIds={selectedIds}
onSelectAll={() => setSelectedIds(new Set(materials.map((m) => m.id)))} onSelectAll={() => setSelectedIds(new Set(materials.map((m) => m.id)))}
onDeselectAll={() => setSelectedIds(new Set())} onDeselectAll={() => setSelectedIds(new Set())}
extraKeywords={extraKeywords} userQuery={userQuery}
onAddKeyword={(kw) => setExtraKeywords((prev) => [...prev, kw])} onUserQueryChange={setUserQuery}
onRemoveKeyword={(kw) => setExtraKeywords((prev) => prev.filter((k) => k !== kw))} onSubmitQuery={handleReSearch}
disabled={disabled || submitting}
/> />
<div className="flex gap-2 pt-1"> <div className="flex flex-wrap gap-2 pt-1">
<Button <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} onClick={handleConfirm} disabled={disabled || submitting || selectedIds.size === 0}
> >
<CheckCircle2 className="size-3.5" />确认素材,开始规划大纲 <CheckCircle2 className="size-3.5" />确认素材,开始规划大纲
</Button> </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 <Button
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0" type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
onClick={handleReSearch} disabled={disabled || submitting} onClick={handleReSearch} disabled={disabled || submitting}
> >
{hasExtra {hasQuery
? <><Search className="size-3.5" />追加搜索</> ? <><Search className="size-3.5" />按输入重新搜索</>
: <><RotateCcw className="size-3.5" />重新搜索</>} : <><RotateCcw className="size-3.5" />重新搜索</>}
</Button> </Button>
</div> </div>
@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { ChevronRight, ExternalLink } from 'lucide-react'; import { ChevronRight, ExternalLink, Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { buildIntranetSourceUrl } from '@/core/config'; import { buildIntranetSourceUrl } from '@/core/config';
import type { AIMaterial } from '../../types/ai-writing'; import type { AIMaterial } from '../../types/ai-writing';
@@ -26,12 +26,14 @@ function relevanceLabel(score: number): string {
export function MaterialItem({ material, checked, onToggle }: MaterialItemProps) { export function MaterialItem({ material, checked, onToggle }: MaterialItemProps) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const pct = Math.round(material.relevanceScore * 100); const pct = Math.round(material.relevanceScore * 100);
const isSkill = material.sourceType === 'skill';
return ( return (
<div <div
className={cn( className={cn(
'rounded-md border text-xs transition-colors', 'rounded-md border text-xs transition-colors',
checked ? 'border-border bg-background' : 'border-border/50 bg-muted/30', 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)} onClick={() => setExpanded((v) => !v)}
className="flex flex-1 items-start gap-1.5 text-left" 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')}> <span className={cn('flex-1 font-medium leading-snug line-clamp-2', !checked && 'text-muted-foreground')}>
{material.title} {material.title}
</span> </span>
@@ -1,6 +1,4 @@
import { useState } from 'react'; import { useRef } from 'react';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { AIMaterial } from '../../types/ai-writing'; import type { AIMaterial } from '../../types/ai-writing';
interface MaterialsToolbarProps { interface MaterialsToolbarProps {
@@ -8,9 +6,13 @@ interface MaterialsToolbarProps {
selectedIds: Set<string>; selectedIds: Set<string>;
onSelectAll: () => void; onSelectAll: () => void;
onDeselectAll: () => void; onDeselectAll: () => void;
extraKeywords: string[]; /** 单输入框文本:可以是技能名、检索来源、补充检索意图 */
onAddKeyword: (kw: string) => void; userQuery: string;
onRemoveKeyword: (kw: string) => void; onUserQueryChange: (text: string) => void;
/** 回车键提交(默认重新搜索;详细按钮交给父组件渲染) */
onSubmitQuery?: () => void;
placeholder?: string;
disabled?: boolean;
} }
export function MaterialsToolbar({ export function MaterialsToolbar({
@@ -18,18 +20,27 @@ export function MaterialsToolbar({
selectedIds, selectedIds,
onSelectAll, onSelectAll,
onDeselectAll, onDeselectAll,
extraKeywords, userQuery,
onAddKeyword, onUserQueryChange,
onRemoveKeyword, onSubmitQuery,
placeholder,
disabled,
}: MaterialsToolbarProps) { }: MaterialsToolbarProps) {
const [kwInput, setKwInput] = useState(''); const textareaRef = useRef<HTMLTextAreaElement>(null);
const allSelected = materials.length > 0 && selectedIds.size === materials.length; const allSelected = materials.length > 0 && selectedIds.size === materials.length;
function handleAddKeyword() { function handleInput(e: React.ChangeEvent<HTMLTextAreaElement>) {
const kw = kwInput.trim(); onUserQueryChange(e.target.value);
if (!kw || extraKeywords.includes(kw)) return; const el = e.target;
onAddKeyword(kw); el.style.height = 'auto';
setKwInput(''); 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 ( return (
@@ -51,50 +62,21 @@ export function MaterialsToolbar({
</div> </div>
)} )}
{/* 补充关键词 */} {/* 单输入框 */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs text-muted-foreground">补充搜索关键词(可选)</label> <label className="text-xs text-muted-foreground">
<div className="flex gap-1.5"> 补充检索(可选)— 写下你想检索的内容,然后点"重新搜索"或"技能检索"
<input </label>
type="text" <textarea
value={kwInput} ref={textareaRef}
onChange={(e) => setKwInput(e.target.value)} rows={1}
onKeyDown={(e) => e.key === 'Enter' && handleAddKeyword()} value={userQuery}
placeholder="输入关键词后按 Enter 或点击 +" onChange={handleInput}
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" onKeyDown={handleKeyDown}
/> placeholder={placeholder ?? '例如:想查看各大平台销量 / 2025 年财报 / 行业头部公司近期动态…'}
<Button disabled={disabled}
type="button" 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"
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>
)}
</div> </div>
</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, ProgressEventType,
ReviewIssue, ReviewIssue,
ReviewResult, ReviewResult,
SkillSearchStep,
StreamingReviewItem, StreamingReviewItem,
UserInterventionPayload, UserInterventionPayload,
WritingStatus, WritingStatus,
@@ -71,6 +72,9 @@ const INITIAL_STATE: AIWritingState = {
intentThinkingText: '', intentThinkingText: '',
parsedIntent: null, parsedIntent: null,
researchProgress: '', researchProgress: '',
skillSearchSteps: [],
skillSelectStreamingText: '',
skillSelectThinkingText: '',
streamingReviewItems: [], streamingReviewItems: [],
revisionSummary: null, revisionSummary: null,
pendingRevisionContext: null, pendingRevisionContext: null,
@@ -105,7 +109,7 @@ function reducer(prev: AIWritingState, action: Action): AIWritingState {
...prev, ...prev,
currentPause: null, currentPause: null,
status: ns, status: ns,
...(ns === 'researching' ? { researchProgress: '' } : {}), ...(ns === 'researching' ? { researchProgress: '', skillSearchSteps: [], skillSelectStreamingText: '', skillSelectThinkingText: '' } : {}),
...(ns === 'writing_outline' ? { outlineStreamingText: '', outlineThinkingText: '' } : {}), ...(ns === 'writing_outline' ? { outlineStreamingText: '', outlineThinkingText: '' } : {}),
...(ns === 'writing_draft' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}), ...(ns === 'writing_draft' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
...(ns === 'reviewing' ? { streamingReviewItems: [] } : {}), ...(ns === 'reviewing' ? { streamingReviewItems: [] } : {}),
@@ -191,12 +195,36 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
case 'resumed': case 'resumed':
return { ...base, currentPause: null }; 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 { return {
...base, ...base,
materials: (event.materials ?? []) as AIMaterial[], materials: (event.materials ?? []) as AIMaterial[],
researchProgress: '', researchProgress: '',
...(incomingSteps ? { skillSearchSteps: incomingSteps } : {}),
}; };
}
case 'outline_chunk': { case 'outline_chunk': {
const text = (event as unknown as { text: string }).text ?? ''; 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) ────── // ── snake_case ↔ camelCase 归一化(custom 通道事件 + interrupt payload) ──────
function normalizeMaterial(m: Record<string, unknown>): AIMaterial { function normalizeMaterial(m: Record<string, unknown>): AIMaterial {
const extra = (m.extra_metadata ?? m.extraMetadata) as Record<string, unknown> | undefined;
return { return {
id: String(m.id ?? ''), id: String(m.id ?? ''),
title: String(m.title ?? ''), title: String(m.title ?? ''),
@@ -344,9 +373,21 @@ function normalizeMaterial(m: Record<string, unknown>): AIMaterial {
source: String(m.source ?? ''), source: String(m.source ?? ''),
publishedAt: String(m.published_at ?? m.publishedAt ?? ''), publishedAt: String(m.published_at ?? m.publishedAt ?? ''),
relevanceScore: Number(m.relevance_score ?? m.relevanceScore ?? 0), 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, url: m.url as string | undefined,
recUuid: (m.recUuid ?? m.rec_uuid) 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', 'intent_chunk', 'intent_ready',
'research_progress', 'outline_chunk', 'draft_chunk', 'research_progress', 'outline_chunk', 'draft_chunk',
'review_chunk', 'review_item_done', 'review_chunk', 'review_item_done',
'skill_search_step', 'skill_select_chunk',
]; ];
if (!allowed.includes(type as ProgressEventType)) return null; if (!allowed.includes(type as ProgressEventType)) return null;
@@ -459,6 +501,10 @@ function customToProgressEvent(raw: unknown, sessionId: string): ProgressEvent |
if (Array.isArray(merged.issues)) { if (Array.isArray(merged.issues)) {
merged.reviewItemIssues = (merged.issues as Record<string, unknown>[]).map(normalizeReviewIssue); 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; return merged as unknown as ProgressEvent;
} }
@@ -478,6 +524,9 @@ function nodeUpdatesToEvents(nodeOutput: unknown, sessionId: string): ProgressEv
// ``material_package`` 出现 → materials_ready(节点 progress_events 里不带) // ``material_package`` 出现 → materials_ready(节点 progress_events 里不带)
if (out.material_package && typeof out.material_package === 'object') { if (out.material_package && typeof out.material_package === 'object') {
const pkg = out.material_package as Record<string, unknown>; 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({ events.push({
type: 'materials_ready', type: 'materials_ready',
sessionId, 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) : [], materials: Array.isArray(pkg.materials) ? (pkg.materials as Record<string, unknown>[]).map(normalizeMaterial) : [],
keywords: (pkg.keywords ?? []) as string[], keywords: (pkg.keywords ?? []) as string[],
materialSummary: (pkg.summary as string) ?? '', materialSummary: (pkg.summary as string) ?? '',
}); ...(skillSteps ? { skillSearchSteps: skillSteps } as unknown as Partial<ProgressEvent> : {}),
} as ProgressEvent);
} }
// ``current_outline`` 出现且 progress_events 里没 outline_ready → 合成一条 // ``current_outline`` 出现且 progress_events 里没 outline_ready → 合成一条
@@ -733,7 +783,9 @@ export function useAIWriting() {
// 乐观更新到下一阶段,跟老 hook 行为对齐 // 乐观更新到下一阶段,跟老 hook 行为对齐
let nextStatus: WritingStatus = state.status; let nextStatus: WritingStatus = state.status;
if (pausePoint === 'material_confirm') { 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') { } else if (pausePoint === 'outline_confirm') {
nextStatus = payload.action === 're_outline' ? 'writing_outline' : 'writing_draft'; nextStatus = payload.action === 're_outline' ? 'writing_outline' : 'writing_draft';
} else if (pausePoint === 'draft_confirm') { } else if (pausePoint === 'draft_confirm') {
@@ -754,6 +806,7 @@ export function useAIWriting() {
}; };
if (payload.approvedMaterialIds !== undefined) resumePayload.approvedMaterialIds = payload.approvedMaterialIds; if (payload.approvedMaterialIds !== undefined) resumePayload.approvedMaterialIds = payload.approvedMaterialIds;
if (payload.extraKeywords !== undefined) resumePayload.extraKeywords = payload.extraKeywords; 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.editedOutline !== undefined) resumePayload.editedOutline = payload.editedOutline;
if (payload.outlineFeedback !== undefined) resumePayload.outlineFeedback = payload.outlineFeedback; if (payload.outlineFeedback !== undefined) resumePayload.outlineFeedback = payload.outlineFeedback;
if (payload.userRevisionNotes !== undefined) resumePayload.userRevisionNotes = payload.userRevisionNotes; if (payload.userRevisionNotes !== undefined) resumePayload.userRevisionNotes = payload.userRevisionNotes;
@@ -7,9 +7,26 @@ export interface AIMaterial {
source: string; source: string;
publishedAt: string; publishedAt: string;
relevanceScore: number; relevanceScore: number;
sourceType: 'internet' | 'intranet'; sourceType: 'internet' | 'intranet' | 'skill';
url?: string; url?: string;
recUuid?: 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' | 'await_user'
| 'resumed' | 'resumed'
| 'notice' | 'notice'
| 'skill_search_step'
| 'skill_select_chunk'
| 'done' | 'done'
| 'error'; | 'error';
@@ -173,6 +192,8 @@ export interface ProgressEvent {
pauseContext?: Record<string, unknown>; pauseContext?: Record<string, unknown>;
// section_help:素材不足、待用户决策的章节 // section_help:素材不足、待用户决策的章节
blockedSections?: BlockedSection[]; blockedSections?: BlockedSection[];
// skill_search_step:技能检索步骤事件(list_skills / select_skills / invoke_skill)
skillStep?: SkillSearchStep;
} }
// ── 用户干预提交体 ──────────────────────────────────────────────────────────── // ── 用户干预提交体 ────────────────────────────────────────────────────────────
@@ -180,7 +201,10 @@ export interface ProgressEvent {
export interface UserInterventionPayload { export interface UserInterventionPayload {
action: string; action: string;
approvedMaterialIds?: string[]; approvedMaterialIds?: string[];
/** @deprecated 旧版多关键词追加。新版统一用 userQuery 单输入框 */
extraKeywords?: string[]; extraKeywords?: string[];
/** PAUSE-1 素材确认:用户单输入框写的自然语言(技能名/来源/检索意图都行)。re_search / enable_skill_search 共用。 */
userQuery?: string;
editedOutline?: AIOutline; editedOutline?: AIOutline;
outlineFeedback?: string; outlineFeedback?: string;
userRevisionNotes?: string; userRevisionNotes?: string;
@@ -291,6 +315,11 @@ export interface AIWritingState {
parsedIntent: ParsedIntent | null; parsedIntent: ParsedIntent | null;
// 素材收集实时进度(最新一条提示文案) // 素材收集实时进度(最新一条提示文案)
researchProgress: string; researchProgress: string;
// 技能检索步骤(按发生顺序累积,pause_material 卡片里渲染时间线)
skillSearchSteps: SkillSearchStep[];
// 技能选择 LLM 流式打字内容(正文 / 思考分开),select_skills 完成后清空
skillSelectStreamingText: string;
skillSelectThinkingText: string;
// 流式审核(逐条到达) // 流式审核(逐条到达)
streamingReviewItems: StreamingReviewItem[]; streamingReviewItems: StreamingReviewItem[];
// 修改摘要 // 修改摘要
+183
View File
@@ -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>
);
}
+140
View File
@@ -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>
);
}
+19 -8
View File
@@ -231,14 +231,25 @@ export default function ChatPage() {
</header> </header>
<main className="flex min-h-0 max-w-full grow flex-col"> <main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center"> <div className="flex size-full justify-center">
<MessageList {/*
className={cn("size-full", !isLandingState && "pt-10")} Landing state covers two cases: (a) a brand-new chat that
threadId={threadId} hasn't sent anything yet, and (b) the user clicking 「新对话」
thread={thread} mid-stream. In (b) the underlying useStream's internal
paddingBottom={messageListPaddingBottom} stream.values takes one or two effect ticks to clear, so
tokenUsageEnabled={tokenUsageEnabled} rendering MessageList unconditionally would leak the previous
onClarificationSubmit={handleClarificationSubmit} 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>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4"> <div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div <div
+1
View File
@@ -34,6 +34,7 @@ export default function ChatsPage() {
return threads?.filter((thread) => { return threads?.filter((thread) => {
return ( return (
thread.metadata?.thread_type !== "scheduler" && thread.metadata?.thread_type !== "scheduler" &&
thread.metadata?.thread_type !== "roundtable" &&
titleOfThread(thread).toLowerCase().includes(search.toLowerCase()) titleOfThread(thread).toLowerCase().includes(search.toLowerCase())
); );
}); });
+105 -35
View File
@@ -19,6 +19,13 @@ import {
} from "@/components/ai-elements/prompt-input"; } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { ArtifactsProvider } from "@/components/workspace/artifacts"; import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { import {
@@ -115,10 +122,34 @@ export default function NewAgentPage() {
return; return;
} }
const first = models[0]!.name; const first = models[0]!.name;
setModelInput((prev) => prev || first); setModelInput((prev) =>
setChatModelInput((prev) => prev || first); prev && models.some((m) => m.name === prev) ? prev : first,
);
setChatModelInput((prev) =>
prev && models.some((m) => m.name === prev) ? prev : first,
);
}, [models]); }, [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({ const [thread, sendMessage] = useThreadStream({
threadId, threadId,
assistantId: agentId || undefined, assistantId: agentId || undefined,
@@ -347,23 +378,42 @@ export default function NewAgentPage() {
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
当前对话模型 当前对话模型
</span> </span>
<select {isModelsLoading || models.length === 0 ? (
value={chatModelInput} <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]">
onChange={(event) => {isModelsLoading ? "加载中…" : "暂无模型"}
setChatModelInput(event.currentTarget.value) </div>
} ) : (
disabled={isModelsLoading || thread.isLoading} <Select
className="border-input bg-background h-7 min-w-[10rem] rounded-md border px-2 text-xs shadow-xs outline-none" value={resolvedChatModelInput}
> onValueChange={setChatModelInput}
<option value="" disabled> disabled={thread.isLoading}
选择模型 >
</option> <SelectTrigger
{models.map((model) => ( className={cn(
<option key={model.name} value={model.name}> "border-input bg-background h-7 min-w-[10rem] px-2 text-xs shadow-xs outline-none",
{model.display_name || model.name} "dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF] dark:hover:bg-[#022248]",
</option> "data-[placeholder]:text-muted-foreground",
))} "[&_svg]:dark:text-[#AACEE2]",
</select> )}
>
<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> </div>
<PromptInput <PromptInput
onSubmit={({ text }) => void handleChatSubmit(text)} onSubmit={({ text }) => void handleChatSubmit(text)}
@@ -415,21 +465,41 @@ export default function NewAgentPage() {
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">模型</label> <label className="text-sm font-medium">模型</label>
<select {isModelsLoading || models.length === 0 ? (
value={modelInput} <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]">
onChange={(event) => setModelInput(event.currentTarget.value)} {isModelsLoading ? "加载模型…" : "暂无可用模型"}
disabled={isModelsLoading} </div>
className="border-input bg-background h-9 w-full rounded-md border px-3 text-sm shadow-xs outline-none" ) : (
> <Select
<option value="" disabled> value={resolvedModelInput}
选择模型 onValueChange={setModelInput}
</option> >
{models.map((model) => ( <SelectTrigger
<option key={model.name} value={model.name}> className={cn(
{model.display_name || model.name} "border-input bg-background h-9 w-full shadow-xs outline-none",
</option> "dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF] dark:hover:bg-[#022248]",
))} "data-[placeholder]:text-muted-foreground",
</select> "[&_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 className="text-muted-foreground text-xs">
该智能体被使用时调用的模型。 该智能体被使用时调用的模型。
</p> </p>
@@ -463,7 +533,7 @@ export default function NewAgentPage() {
/> />
</div> </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 ? ( {isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p> <p className="text-muted-foreground text-sm">技能加载中...</p>
) : enabledSkills.length === 0 ? ( ) : enabledSkills.length === 0 ? (
+15 -4
View File
@@ -2,7 +2,9 @@ import { useMemo, useState } from "react";
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { PageSidebar } from "@/components/page-sidebar"; import { PageSidebar } from "@/components/page-sidebar";
import { PageSidebarDark } from "@/components/page-sidebar-dark";
import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled"; import { readSidebarEnabled } from "@/core/page-layout/sidebar-enabled";
import { useTheme } from "@/contexts/ThemeContext";
import WorkspaceRoutes from "./WorkspaceRoutes"; import WorkspaceRoutes from "./WorkspaceRoutes";
import StrategyRoutes from "./StrategyRoutes"; import StrategyRoutes from "./StrategyRoutes";
@@ -13,16 +15,25 @@ import RoundtableRoutes from "./RoundtableRoutes";
function PageLayout() { function PageLayout() {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const sidebarEnabled = useMemo(() => readSidebarEnabled(), []); const sidebarEnabled = useMemo(() => readSidebarEnabled(), []);
const { theme } = useTheme();
const isDark = theme === "dark";
return ( return (
<div className="h-screen flex flex-col bg-muted/20"> <div className="h-screen flex flex-col bg-muted/20">
{sidebarEnabled && <Header collapsed={collapsed} />} {sidebarEnabled && <Header collapsed={collapsed} />}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{sidebarEnabled && ( {sidebarEnabled && (
<PageSidebar isDark ? (
collapsed={collapsed} <PageSidebarDark
onToggleCollapsed={() => setCollapsed((prev) => !prev)} collapsed={collapsed}
/> onToggleCollapsed={() => setCollapsed((prev) => !prev)}
/>
) : (
<PageSidebar
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((prev) => !prev)}
/>
)
)} )}
<main <main
className={`flex-1 overflow-hidden transition-[padding] duration-300 ${ 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", timezone: "Asia/Shanghai",
prompt: "", prompt: "",
execution_agent_name: "", execution_agent_name: "",
require_markdown: false,
}; };
export default function ScheduledTasksPage() { export default function ScheduledTasksPage() {
@@ -173,6 +174,7 @@ export default function ScheduledTasksPage() {
timezone: task.timezone, timezone: task.timezone,
prompt: task.prompt, prompt: task.prompt,
execution_agent_name: task.execution_agent_name ?? "", 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 // Prefer the canonical cron_expr over the user-typed schedule_text: the
// latter may be natural language ("每天9点") that parseCron can't read, // latter may be natural language ("每天9点") that parseCron can't read,
@@ -202,6 +204,7 @@ export default function ScheduledTasksPage() {
timezone: form.timezone || "Asia/Shanghai", timezone: form.timezone || "Asia/Shanghai",
prompt: form.prompt, prompt: form.prompt,
execution_agent_name: form.execution_agent_name || null, execution_agent_name: form.execution_agent_name || null,
require_markdown: form.require_markdown,
}); });
setSelectedTaskId(task.task_id); setSelectedTaskId(task.task_id);
toast.success("任务已创建"); toast.success("任务已创建");
@@ -215,6 +218,7 @@ export default function ScheduledTasksPage() {
timezone: form.timezone, timezone: form.timezone,
prompt: form.prompt, prompt: form.prompt,
execution_agent_name: form.execution_agent_name || null, execution_agent_name: form.execution_agent_name || null,
require_markdown: form.require_markdown,
}, },
}); });
toast.success("任务已更新"); toast.success("任务已更新");
@@ -825,6 +829,20 @@ export default function ScheduledTasksPage() {
} }
placeholder="任务 Prompt" 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> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={closeDialog}> <Button variant="outline" onClick={closeDialog}>
+232 -126
View File
@@ -5,7 +5,7 @@ import { getStoredAuth } from "@/core/auth";
import { RouteBaseProvider } from "@/core/route-base"; import { RouteBaseProvider } from "@/core/route-base";
import { SidebarProvider } from "@/components/ui/sidebar"; 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 { ChatRuntime } from "./WorkspaceRoutes";
import AgentsPage from "./AgentsPage"; import AgentsPage from "./AgentsPage";
import ChatsPage from "./ChatsPage"; import ChatsPage from "./ChatsPage";
@@ -19,6 +19,7 @@ import ScheduledTasksPage from "./ScheduledTasksPage";
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage"; import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage"; import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage"; import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
import LlmMetricsPage from "./LlmMetricsPage"; import LlmMetricsPage from "./LlmMetricsPage";
import UsersPage from "./UsersPage"; import UsersPage from "./UsersPage";
import AdminScheduledTasksPage from "./AdminScheduledTasksPage"; import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
@@ -27,141 +28,246 @@ import TagsPage from "./TagsPage";
import LightAppNewNotePage from "./LightAppNewNotePage"; import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage"; import LightAppNoteListPage from "./LightAppNoteListPage";
import LightAppNoteQAPage from "./LightAppNoteQAPage"; 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() { export default function StrategyRoutes() {
return ( return (
<RouteBaseProvider value="/page/strategy"> <RouteBaseProvider value="/page/strategy">
<SidebarProvider> <SidebarProvider>
<div className="h-full w-full overflow-hidden"> <div className="h-full w-full overflow-hidden">
<Routes> <Routes>
<Route index element={<Navigate to="qa/general" replace />} /> <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="chats" element={<ChatsPage />} />
<Route path="light-app/notes" element={<LightAppNoteListPage />} /> <Route
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} /> path="chats/:thread_id"
element={
<ChatRuntime>
<ChatPage />
</ChatRuntime>
}
/>
{/* 对话 */} {/* 智能体管理 */}
<Route path="chats" element={<ChatsPage />} /> <Route path="agents" element={<AgentsPage />} />
<Route <Route path="agents/new" element={<NewAgentPage />} />
path="chats/:thread_id" <Route
element={ path="agents/:agent_id/chats"
<ChatRuntime> element={<Navigate to="/page/strategy/agents" replace />}
<ChatPage /> />
</ChatRuntime> <Route
} path="agents/:agent_id/chats/:thread_id"
/> element={
<ChatRuntime>
<AgentChatPage />
</ChatRuntime>
}
/>
{/* 智能体管理 */} {/* 技能 / 记忆 */}
<Route path="agents" element={<AgentsPage />} /> <Route path="skills" element={<SkillsPage />} />
<Route path="agents/new" element={<NewAgentPage />} /> <Route path="skills/evolution" element={<SkillEvolutionPage />} />
<Route <Route path="memory" element={<MemoryPage />} />
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
<Route path="skills/evolution" element={<SkillEvolutionPage />} /> path="scheduled"
<Route path="memory" element={<MemoryPage />} /> 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 <Route
path="scheduled" path="settings/recommended-questions"
element={<Navigate to="/page/strategy/scheduled/tasks" replace />} element={
/> getStoredAuth()?.system_role === "admin" ? (
<Route path="scheduled/tasks" element={<ScheduledTasksPage />} /> <RecommendedQuestionsPage />
<Route ) : (
path="scheduled/runs/:run_id" <Navigate to="/page/workspace/chats/new" replace />
element={<ScheduledTaskRunDetailPage />} )
/> }
/>
{/* 设置 / 管理 — 权限与 workspace 侧一致,非 admin 重定向到 chats/new */} <Route
<Route path="settings/prompt-prefix"
path="settings/recommended-questions" element={
element={ getStoredAuth()?.system_role === "admin" ? (
getStoredAuth()?.system_role === "admin" ? ( <PromptPrefixSettingsPage />
<RecommendedQuestionsPage /> ) : (
) : ( <Navigate to="/page/workspace/chats/new" replace />
<Navigate to="/page/workspace/chats/new" replace /> )
) }
} />
/> <Route
<Route path="settings/private-square"
path="settings/prompt-prefix" element={
element={ getStoredAuth()?.system_role === "admin" ? (
getStoredAuth()?.system_role === "admin" ? ( <PrivateSquareSettingsPage />
<PromptPrefixSettingsPage /> ) : (
) : ( <Navigate to="/page/workspace/chats/new" replace />
<Navigate to="/page/workspace/chats/new" replace /> )
) }
} />
/> <Route
<Route path="settings/llm-metrics"
path="settings/llm-metrics" element={
element={ getStoredAuth()?.system_role === "admin" ? (
getStoredAuth()?.system_role === "admin" ? ( <LlmMetricsPage />
<LlmMetricsPage /> ) : (
) : ( <Navigate to="/page/workspace/chats/new" replace />
<Navigate to="/page/workspace/chats/new" replace /> )
) }
} />
/> <Route
<Route path="admin/users"
path="admin/users" element={
element={ getStoredAuth()?.system_role === "admin" ? (
getStoredAuth()?.system_role === "admin" ? ( <UsersPage />
<UsersPage /> ) : (
) : ( <Navigate to="/page/workspace/chats/new" replace />
<Navigate to="/page/workspace/chats/new" replace /> )
) }
} />
/> <Route
<Route path="admin/scheduled-tasks"
path="admin/scheduled-tasks" element={
element={ getStoredAuth()?.system_role === "admin" ? (
getStoredAuth()?.system_role === "admin" ? ( <AdminScheduledTasksPage />
<AdminScheduledTasksPage /> ) : (
) : ( <Navigate to="/page/workspace/chats/new" replace />
<Navigate to="/page/workspace/chats/new" replace /> )
) }
} />
/> <Route
<Route path="admin/user-activity"
path="admin/user-activity" element={
element={ getStoredAuth()?.system_role === "admin" ? (
getStoredAuth()?.system_role === "admin" ? ( <AdminUserActivityPage />
<AdminUserActivityPage /> ) : (
) : ( <Navigate to="/page/workspace/chats/new" replace />
<Navigate to="/page/workspace/chats/new" replace /> )
) }
} />
/> <Route
<Route path="admin/tags"
path="admin/tags" element={
element={ getStoredAuth()?.system_role === "admin" ? (
getStoredAuth()?.system_role === "admin" ? ( <TagsPage />
<TagsPage /> ) : (
) : ( <Navigate to="/page/workspace/chats/new" replace />
<Navigate to="/page/workspace/chats/new" replace /> )
) }
} />
/> </Routes>
</Routes> </div>
</div> </SidebarProvider>
</SidebarProvider> <Toaster position="top-center" />
<Toaster position="top-center" />
</RouteBaseProvider> </RouteBaseProvider>
); );
} }
Loaded 100 of 782 files, more files were not shown because too many files have changed in this diff. Show more