Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into zhujingyi
This commit is contained in:
commit
ee8e2b3225
298 files changed
+90756
-864
No files matched your search
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"PowerShell(uv --version)",
|
||||||
|
"PowerShell(netstat -ano)",
|
||||||
|
"PowerShell(Get-NetTCPConnection -LocalPort 8001 -ErrorAction SilentlyContinue)",
|
||||||
|
"PowerShell(Get-Process -Id 20560 -ErrorAction SilentlyContinue | Select-Object Id,ProcessName,Path,StartTime; try { \\(Invoke-WebRequest -Uri \"http://127.0.0.1:8001/health\" -UseBasicParsing -TimeoutSec 5\\).Content } catch { \"health check failed: $_\" })"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/
|
||||||
@@ -7,6 +7,20 @@ 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=htttps://ty.b.uat.4.cn
|
||||||
|
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,18 @@
|
|||||||
|
|
||||||
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=htttps://ty.b.uat.4.cn
|
||||||
|
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
|
||||||
@@ -7,6 +7,19 @@ 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=htttps://ty.b.uat.4.cn
|
||||||
|
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=
|
||||||
@@ -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`」。
|
||||||
@@ -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,24 @@
|
|||||||
|
.sidebar-contents{
|
||||||
|
width:98px;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-nav{
|
||||||
|
width:78px;
|
||||||
|
height:70px;
|
||||||
|
background:rgba(0, 12, 28, 0.15);
|
||||||
|
border:1px solid;
|
||||||
|
border-color:rgba(85, 166, 254, 0.2);
|
||||||
|
line-height:8px;
|
||||||
|
font-family:Noto Sans S Chinese;
|
||||||
|
font-weight:500;
|
||||||
|
color:rgba(255, 255, 255, 0.8);
|
||||||
|
font-size:20px;
|
||||||
|
padding-bottom:18px;
|
||||||
|
text-align:center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import { useEffect, 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: "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 },
|
||||||
|
{ id: "coh-task-fx", label: "任务FX", icon: ListTodo },
|
||||||
|
{ id: "coh-task-xdfx", label: "XDFX", icon: ListTodo },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 },
|
||||||
|
{ id: "resource-knowledge-base", label: "知识库资源管理", icon: Library },
|
||||||
|
{ id: "resource-zz", label: "ZZ资源管理", icon: FileBox },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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 = filterMenuItemsByRole(menuItems, !!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 toggleExpanded = (id: string) => {
|
||||||
|
setExpandedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
} else {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
问答管理
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
ListTodo,
|
ListTodo,
|
||||||
MessageSquareCode,
|
MessageSquareCode,
|
||||||
MessageSquarePlus,
|
MessageSquarePlus,
|
||||||
|
ShieldCheck,
|
||||||
Network,
|
Network,
|
||||||
NotebookPen,
|
NotebookPen,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -175,7 +176,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 +194,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: "大模型调用统计",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -232,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"
|
||||||
@@ -242,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"
|
||||||
@@ -297,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"
|
||||||
@@ -351,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"
|
||||||
@@ -375,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>
|
||||||
|
|
||||||
@@ -462,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>
|
||||||
@@ -470,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) => (
|
||||||
@@ -71,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 */}
|
||||||
@@ -90,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}
|
||||||
@@ -129,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,3 +1,4 @@
|
|||||||
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
import { FilesIcon, XIcon } from "lucide-react";
|
import { FilesIcon, XIcon } from "lucide-react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -10,6 +11,16 @@ import {
|
|||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
ResizablePanelGroup,
|
ResizablePanelGroup,
|
||||||
} from "@/components/ui/resizable";
|
} from "@/components/ui/resizable";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { apiFetch } from "@/core/api";
|
||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||||
|
import { useSkills } from "@/core/skills/hooks";
|
||||||
|
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
|
||||||
|
import {
|
||||||
|
buildSkillDisplayMap,
|
||||||
|
collectRuntimeDisplaySources,
|
||||||
|
} from "@/core/skill-display/runtime";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -19,9 +30,38 @@ import {
|
|||||||
useArtifacts,
|
useArtifacts,
|
||||||
} from "../artifacts";
|
} from "../artifacts";
|
||||||
import { useThread } from "../messages/context";
|
import { useThread } from "../messages/context";
|
||||||
|
import { SkillDisplaySources } from "../skill-display-sources";
|
||||||
|
|
||||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||||
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
||||||
|
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
|
||||||
|
|
||||||
|
type BackendReferenceSource = Parameters<typeof SkillDisplaySources>[0]["sources"][number];
|
||||||
|
|
||||||
|
type BackendReferenceBatch = {
|
||||||
|
id: string;
|
||||||
|
assistant_message_id?: string;
|
||||||
|
sources: BackendReferenceSource[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function logReferenceDebug(
|
||||||
|
event: string,
|
||||||
|
threadId: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
console.info(`[RAG引用调试] ${event}`, payload);
|
||||||
|
void apiFetch(`${getBackendBaseURL()}/api/threads/reference-debug-log`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
event,
|
||||||
|
thread_id: threadId,
|
||||||
|
payload,
|
||||||
|
}),
|
||||||
|
}).catch(() => {
|
||||||
|
// Debug logging must never affect the chat flow.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||||
children,
|
children,
|
||||||
@@ -30,7 +70,20 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
const { thread } = useThread();
|
const { thread } = useThread();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const threadIdRef = useRef(threadId);
|
const threadIdRef = useRef(threadId);
|
||||||
|
const artifactsCountRef = useRef(0);
|
||||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||||
|
const latestReferenceBatchIdRef = useRef<string | null>(null);
|
||||||
|
const manualReferenceBatchRef = useRef(false);
|
||||||
|
const referenceDebugSignatureRef = useRef<string>("");
|
||||||
|
const [sideTab, setSideTab] = useState("document");
|
||||||
|
const [referencesOpen, setReferencesOpen] = useState(true);
|
||||||
|
const [activeReferenceBatchId, setActiveReferenceBatchId] = useState<string | null>(null);
|
||||||
|
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
||||||
|
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
||||||
|
const [settings] = useLocalSettings();
|
||||||
|
const ragModeEnabled =
|
||||||
|
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||||
|
const { skills } = useSkills();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
artifacts,
|
artifacts,
|
||||||
@@ -43,14 +96,191 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
} = useArtifacts();
|
} = useArtifacts();
|
||||||
|
|
||||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||||
|
const skillDisplayMap = useMemo(
|
||||||
|
() => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()),
|
||||||
|
[ragModeEnabled, skills],
|
||||||
|
);
|
||||||
|
const fallbackReferenceBatches = useMemo(() => {
|
||||||
|
if (!ragModeEnabled) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return buildTurnReferenceBatches(
|
||||||
|
thread.values.messages ?? [],
|
||||||
|
skillDisplayMap,
|
||||||
|
thread.isLoading,
|
||||||
|
);
|
||||||
|
}, [ragModeEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ragModeEnabled) {
|
||||||
|
setBackendReferenceBatches([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (thread.isLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
logReferenceDebug("开始拉取后端参考文献", threadId, {
|
||||||
|
messageCount: thread.values.messages?.length ?? 0,
|
||||||
|
});
|
||||||
|
apiFetch(
|
||||||
|
`${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/reference-batches`,
|
||||||
|
{ signal: controller.signal },
|
||||||
|
)
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load reference batches: ${response.status}`);
|
||||||
|
}
|
||||||
|
return (await response.json()) as { batches?: BackendReferenceBatch[] };
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
const batches = data.batches ?? [];
|
||||||
|
logReferenceDebug("后端参考文献返回", threadId, {
|
||||||
|
batchCount: batches.length,
|
||||||
|
sourceCounts: batches.map((batch) => batch.sources?.length ?? 0),
|
||||||
|
batchIds: batches.map((batch) => batch.id),
|
||||||
|
});
|
||||||
|
setBackendReferenceBatches(batches);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
console.warn("Failed to load backend reference batches", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [ragModeEnabled, thread.isLoading, threadId, thread.values.messages]);
|
||||||
|
const adaptedBackendReferenceBatches = useMemo(
|
||||||
|
() =>
|
||||||
|
adaptBackendReferenceBatches(
|
||||||
|
backendReferenceBatches,
|
||||||
|
skillDisplayMap,
|
||||||
|
),
|
||||||
|
[backendReferenceBatches, skillDisplayMap],
|
||||||
|
);
|
||||||
|
const referenceBatches = useMemo(
|
||||||
|
() => {
|
||||||
|
if (adaptedBackendReferenceBatches.length > 0) {
|
||||||
|
return adaptedBackendReferenceBatches;
|
||||||
|
}
|
||||||
|
return fallbackReferenceBatches;
|
||||||
|
},
|
||||||
|
[adaptedBackendReferenceBatches, fallbackReferenceBatches],
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ragModeEnabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const signature = JSON.stringify({
|
||||||
|
backend: backendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||||
|
adaptedBackend: adaptedBackendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||||
|
fallback: fallbackReferenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||||
|
final: referenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||||
|
});
|
||||||
|
if (referenceDebugSignatureRef.current === signature) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
referenceDebugSignatureRef.current = signature;
|
||||||
|
logReferenceDebug("前端参考文献来源选择", threadId, {
|
||||||
|
using: backendReferenceBatches.length > 0 ? "后端接口" : "前端兜底",
|
||||||
|
backendBatchCount: backendReferenceBatches.length,
|
||||||
|
backendSourceCounts: backendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||||
|
adaptedBackendSourceCounts: adaptedBackendReferenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||||
|
fallbackBatchCount: fallbackReferenceBatches.length,
|
||||||
|
fallbackSourceCounts: fallbackReferenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||||
|
finalBatchCount: referenceBatches.length,
|
||||||
|
finalSourceCounts: referenceBatches.map((batch) => batch.sources?.length ?? 0),
|
||||||
|
backendBatches: debugReferenceBatches(backendReferenceBatches),
|
||||||
|
adaptedBackendBatches: debugReferenceBatches(adaptedBackendReferenceBatches),
|
||||||
|
fallbackBatches: debugReferenceBatches(fallbackReferenceBatches),
|
||||||
|
finalBatches: debugReferenceBatches(referenceBatches),
|
||||||
|
});
|
||||||
|
}, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, ragModeEnabled, referenceBatches, threadId]);
|
||||||
|
const latestReferenceBatch =
|
||||||
|
referenceBatches.length > 0
|
||||||
|
? referenceBatches[referenceBatches.length - 1]!
|
||||||
|
: null;
|
||||||
|
const activeReferenceBatch =
|
||||||
|
referenceBatches.find((batch) => batch.id === activeReferenceBatchId) ??
|
||||||
|
latestReferenceBatch;
|
||||||
|
const referenceSources = activeReferenceBatch?.sources ?? [];
|
||||||
|
const hasCitationReferences = referenceSources.length > 0;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ragModeEnabled || !activeReferenceBatch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logReferenceDebug("frontend-active-reference-batch-payload", threadId, {
|
||||||
|
activeBatchId: activeReferenceBatch.id,
|
||||||
|
assistantMessageId: activeReferenceBatch.assistant_message_id,
|
||||||
|
sourceCount: activeReferenceBatch.sources.length,
|
||||||
|
sources: activeReferenceBatch.sources.map(debugReferenceSource),
|
||||||
|
});
|
||||||
|
}, [activeReferenceBatch, ragModeEnabled, threadId]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasCitationReferences) {
|
||||||
|
setReferencesOpen(true);
|
||||||
|
}
|
||||||
|
}, [hasCitationReferences]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!latestReferenceBatch) {
|
||||||
|
setActiveReferenceBatchId(null);
|
||||||
|
latestReferenceBatchIdRef.current = null;
|
||||||
|
manualReferenceBatchRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previousLatestId = latestReferenceBatchIdRef.current;
|
||||||
|
latestReferenceBatchIdRef.current = latestReferenceBatch.id;
|
||||||
|
setActiveReferenceBatchId((current) =>
|
||||||
|
current &&
|
||||||
|
referenceBatches.some((batch) => batch.id === current) &&
|
||||||
|
manualReferenceBatchRef.current &&
|
||||||
|
current !== previousLatestId
|
||||||
|
? current
|
||||||
|
: latestReferenceBatch.id,
|
||||||
|
);
|
||||||
|
}, [latestReferenceBatch?.id, referenceBatches]);
|
||||||
|
useEffect(() => {
|
||||||
|
const handleShowReference = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<{ batchId?: string; referenceId?: string }>).detail;
|
||||||
|
if (detail?.batchId) {
|
||||||
|
manualReferenceBatchRef.current = true;
|
||||||
|
setActiveReferenceBatchId(detail.batchId);
|
||||||
|
}
|
||||||
|
setPinnedReferenceId(null);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setPinnedReferenceId(detail?.referenceId ?? null);
|
||||||
|
}, 0);
|
||||||
|
setReferencesOpen(true);
|
||||||
|
setSideTab("references");
|
||||||
|
};
|
||||||
|
window.addEventListener(SHOW_REFERENCE_EVENT, handleShowReference);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(SHOW_REFERENCE_EVENT, handleShowReference);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (threadIdRef.current !== threadId) {
|
if (threadIdRef.current !== threadId) {
|
||||||
threadIdRef.current = threadId;
|
threadIdRef.current = threadId;
|
||||||
|
artifactsCountRef.current = 0;
|
||||||
deselect();
|
deselect();
|
||||||
|
setReferencesOpen(false);
|
||||||
|
setActiveReferenceBatchId(null);
|
||||||
|
setPinnedReferenceId(null);
|
||||||
|
setBackendReferenceBatches([]);
|
||||||
|
latestReferenceBatchIdRef.current = null;
|
||||||
|
manualReferenceBatchRef.current = false;
|
||||||
|
referenceDebugSignatureRef.current = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update artifacts from the current thread
|
// Update artifacts from the current thread
|
||||||
setArtifacts(thread.values.artifacts);
|
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.
|
// DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now.
|
||||||
// if (
|
// if (
|
||||||
@@ -85,6 +315,16 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
}
|
}
|
||||||
return artifactsOpen;
|
return artifactsOpen;
|
||||||
}, [artifactsOpen, artifacts]);
|
}, [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 resizableIdBase = useMemo(() => {
|
||||||
const raw = pathname
|
const raw = pathname
|
||||||
@@ -95,13 +335,13 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (layoutRef.current) {
|
if (layoutRef.current) {
|
||||||
if (artifactPanelOpen) {
|
if (sidePanelOpen) {
|
||||||
layoutRef.current.setLayout(OPEN_MODE);
|
layoutRef.current.setLayout(OPEN_MODE);
|
||||||
} else {
|
} else {
|
||||||
layoutRef.current.setLayout(CLOSE_MODE);
|
layoutRef.current.setLayout(CLOSE_MODE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [artifactPanelOpen]);
|
}, [sidePanelOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResizablePanelGroup
|
<ResizablePanelGroup
|
||||||
@@ -117,28 +357,79 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
id={`${resizableIdBase}-separator`}
|
id={`${resizableIdBase}-separator`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"opacity-33 hover:opacity-100",
|
"opacity-33 hover:opacity-100",
|
||||||
!artifactPanelOpen && "pointer-events-none opacity-0",
|
!sidePanelOpen && "pointer-events-none opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<ResizablePanel
|
<ResizablePanel
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
|
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
|
||||||
!artifactsOpen && "opacity-0",
|
!sidePanelOpen && "opacity-0",
|
||||||
)}
|
)}
|
||||||
id="artifacts"
|
id="artifacts"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
|
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
|
||||||
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
|
sidePanelOpen ? "translate-x-0" : "translate-x-full",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{selectedArtifact ? (
|
{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
|
<ArtifactFileDetail
|
||||||
className="size-full"
|
className="size-full"
|
||||||
filepath={selectedArtifact}
|
filepath={selectedArtifact}
|
||||||
threadId={threadId}
|
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="relative flex size-full justify-center">
|
||||||
<div className="absolute top-1 right-1 z-30">
|
<div className="absolute top-1 right-1 z-30">
|
||||||
@@ -180,4 +471,299 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 };
|
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 { Badge } from "@/components/ui/badge";
|
|
||||||
import {
|
|
||||||
HoverCard,
|
|
||||||
HoverCardContent,
|
|
||||||
HoverCardTrigger,
|
|
||||||
} from "@/components/ui/hover-card";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
|
||||||
|
|
||||||
export function CitationLink({
|
export function CitationLink({
|
||||||
href,
|
href,
|
||||||
children,
|
children,
|
||||||
|
className,
|
||||||
...props
|
...props
|
||||||
}: ComponentProps<"a">) {
|
}: ComponentProps<"a">) {
|
||||||
const domain = extractDomain(href ?? "");
|
const childrenText = normalizeCitationText(children);
|
||||||
|
const displayText = childrenText ?? "";
|
||||||
|
const referenceId = href?.startsWith("#reference-")
|
||||||
|
? href.slice("#reference-".length)
|
||||||
|
: 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",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const batchId =
|
||||||
|
event.currentTarget.closest<HTMLElement>("[data-reference-batch-id]")
|
||||||
|
?.dataset.referenceBatchId;
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(SHOW_REFERENCE_EVENT, {
|
||||||
|
detail: { batchId, referenceId },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const scroll = () => {
|
||||||
|
const target = document.getElementById(`reference-${referenceId}`);
|
||||||
|
target?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
};
|
||||||
|
window.setTimeout(scroll, 240);
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{badgeLabel}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HoverCard closeDelay={0} openDelay={0}>
|
<a
|
||||||
<HoverCardTrigger asChild>
|
href={href}
|
||||||
<a
|
target={href?.startsWith("http") ? "_blank" : undefined}
|
||||||
href={href}
|
rel={href?.startsWith("http") ? "noopener noreferrer" : undefined}
|
||||||
target="_blank"
|
className={cn(
|
||||||
rel="noopener noreferrer"
|
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
|
||||||
className="inline-flex items-center"
|
className,
|
||||||
onClick={(e) => e.stopPropagation()}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Badge
|
{displayText || children}
|
||||||
variant="secondary"
|
</a>
|
||||||
className="hover:bg-secondary/80 mx-0.5 cursor-pointer gap-1 rounded-full px-2 py-0.5 text-xs font-normal"
|
|
||||||
>
|
|
||||||
{displayText}
|
|
||||||
<ExternalLinkIcon className="size-3" />
|
|
||||||
</Badge>
|
|
||||||
</a>
|
|
||||||
</HoverCardTrigger>
|
|
||||||
<HoverCardContent className={cn("relative w-80 p-0", props.className)}>
|
|
||||||
<div className="p-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
{displayText && (
|
|
||||||
<h4 className="truncate text-sm leading-tight font-medium">
|
|
||||||
{displayText}
|
|
||||||
</h4>
|
|
||||||
)}
|
|
||||||
{href && (
|
|
||||||
<p className="text-muted-foreground truncate text-xs break-all">
|
|
||||||
{href}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-primary mt-2 inline-flex items-center gap-1 text-xs hover:underline"
|
|
||||||
>
|
|
||||||
Visit source
|
|
||||||
<ExternalLinkIcon className="size-3" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</HoverCardContent>
|
|
||||||
</HoverCard>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractDomain(url: string): string {
|
function normalizeCitationText(children: ComponentProps<"a">["children"]) {
|
||||||
try {
|
if (typeof children !== "string") {
|
||||||
return new URL(url).hostname.replace(/^www\./i, "");
|
return null;
|
||||||
} catch {
|
|
||||||
return url;
|
|
||||||
}
|
}
|
||||||
|
let text = children.trim();
|
||||||
|
while (/^citation:\s*/i.test(text)) {
|
||||||
|
text = text.replace(/^citation:\s*/i, "");
|
||||||
|
}
|
||||||
|
return text;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
PencilIcon,
|
||||||
|
Settings2Icon,
|
||||||
|
Trash2Icon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
useCreateCustomPrompt,
|
||||||
|
useCustomPrompts,
|
||||||
|
useDeleteCustomPrompt,
|
||||||
|
useUpdateCustomPrompt,
|
||||||
|
} from "@/core/custom-prompts";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bar above the chat input showing the user's saved prompts as centered,
|
||||||
|
* clickable chips. When there are too many to fit, they become a horizontal
|
||||||
|
* carousel (arrows appear). A gear pinned to the far right opens the manage
|
||||||
|
* dialog. Prompts are bound to the account (server-backed), and this coexists
|
||||||
|
* with the admin-configured global prompt prefix.
|
||||||
|
*/
|
||||||
|
export function CustomPromptBar({
|
||||||
|
onInsert,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
onInsert: (content: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const prompts = useCustomPrompts();
|
||||||
|
const [manageOpen, setManageOpen] = useState(false);
|
||||||
|
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [canLeft, setCanLeft] = useState(false);
|
||||||
|
const [canRight, setCanRight] = useState(false);
|
||||||
|
|
||||||
|
const updateArrows = useCallback(() => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
setCanLeft(el.scrollLeft > 4);
|
||||||
|
setCanRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateArrows();
|
||||||
|
window.addEventListener("resize", updateArrows);
|
||||||
|
return () => window.removeEventListener("resize", updateArrows);
|
||||||
|
}, [updateArrows, prompts]);
|
||||||
|
|
||||||
|
const scrollByDir = (dir: 1 | -1) => {
|
||||||
|
scrollRef.current?.scrollBy({ left: dir * 220, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const overflowing = canLeft || canRight;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={cn("flex items-center gap-1", className)}>
|
||||||
|
{prompts.length > 0 && (
|
||||||
|
<>
|
||||||
|
{overflowing && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground hover:text-foreground h-7 w-7 shrink-0 rounded-full"
|
||||||
|
disabled={!canLeft}
|
||||||
|
onClick={() => scrollByDir(-1)}
|
||||||
|
aria-label="上一组"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
onScroll={updateArrows}
|
||||||
|
className="flex-1 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||||
|
>
|
||||||
|
<div className="mx-auto flex w-max items-center gap-1.5 px-0.5 py-0.5">
|
||||||
|
{prompts.map((prompt) => (
|
||||||
|
<button
|
||||||
|
key={prompt.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onInsert(prompt.content)}
|
||||||
|
title={prompt.content}
|
||||||
|
className={cn(
|
||||||
|
"border-border/60 bg-muted/40 text-foreground/80 hover:bg-muted hover:text-foreground hover:border-border max-w-[200px] shrink-0 truncate rounded-full border px-3 py-1 text-xs font-medium shadow-sm transition-colors",
|
||||||
|
"dark:border-slate-600/70 dark:bg-slate-700/40 dark:text-slate-200 dark:hover:bg-slate-700",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{prompt.title || prompt.content || "未命名提示词"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{overflowing && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground hover:text-foreground h-7 w-7 shrink-0 rounded-full"
|
||||||
|
disabled={!canRight}
|
||||||
|
onClick={() => scrollByDir(1)}
|
||||||
|
aria-label="下一组"
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{prompts.length === 0 && <div className="flex-1" />}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground hover:text-foreground hover:bg-muted/70 h-7 w-7 shrink-0 rounded-full"
|
||||||
|
title="管理常用提示词"
|
||||||
|
aria-label="管理常用提示词"
|
||||||
|
onClick={() => setManageOpen(true)}
|
||||||
|
>
|
||||||
|
<Settings2Icon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ManagePromptsDialog open={manageOpen} onOpenChange={setManageOpen} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ManagePromptsDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const prompts = useCustomPrompts();
|
||||||
|
const createPrompt = useCreateCustomPrompt();
|
||||||
|
const updatePrompt = useUpdateCustomPrompt();
|
||||||
|
const deletePrompt = useDeleteCustomPrompt();
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
|
||||||
|
const saving = createPrompt.isPending || updatePrompt.isPending;
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setEditingId(null);
|
||||||
|
setTitle("");
|
||||||
|
setContent("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const trimmedTitle = title.trim();
|
||||||
|
if (!content.trim()) {
|
||||||
|
toast.error("提示词内容不能为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (editingId) {
|
||||||
|
await updatePrompt.mutateAsync({
|
||||||
|
id: editingId,
|
||||||
|
input: { title: trimmedTitle, content },
|
||||||
|
});
|
||||||
|
toast.success("提示词已更新");
|
||||||
|
} else {
|
||||||
|
await createPrompt.mutateAsync({ title: trimmedTitle, content });
|
||||||
|
toast.success("提示词已添加");
|
||||||
|
}
|
||||||
|
resetForm();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (id: string) => {
|
||||||
|
const target = prompts.find((p) => p.id === id);
|
||||||
|
if (!target) return;
|
||||||
|
setEditingId(id);
|
||||||
|
setTitle(target.title);
|
||||||
|
setContent(target.content);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await deletePrompt.mutateAsync(id);
|
||||||
|
if (editingId === id) resetForm();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!next) resetForm();
|
||||||
|
onOpenChange(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>管理常用提示词</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
保存常用的提示词,保存后会以小标签形式显示在输入框上方,点击即可填入。提示词与你的账号绑定,可跨设备使用。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 rounded-md border p-3">
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="名称(可选,例如:周报总结)"
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
placeholder="提示词内容"
|
||||||
|
className="min-h-28"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{editingId && (
|
||||||
|
<Button variant="outline" size="sm" onClick={resetForm} disabled={saving}>
|
||||||
|
取消编辑
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="sm" onClick={handleSubmit} disabled={saving}>
|
||||||
|
{editingId ? "保存修改" : "添加提示词"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{prompts.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||||
|
暂无常用提示词
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
prompts.map((prompt) => (
|
||||||
|
<div
|
||||||
|
key={prompt.id}
|
||||||
|
className="flex items-start justify-between gap-2 rounded-md border p-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium">
|
||||||
|
{prompt.title || "未命名提示词"}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs whitespace-pre-wrap">
|
||||||
|
{prompt.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => handleEdit(prompt.id)}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<PencilIcon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive hover:text-destructive h-8 w-8"
|
||||||
|
onClick={() => void handleDelete(prompt.id)}
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import type { ChatStatus } from "ai";
|
import 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>
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export function MarkdownContent({
|
|||||||
const components = useMemo(() => {
|
const components = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||||
|
if (props.href?.startsWith("#reference-")) {
|
||||||
|
return <CitationLink {...props} />;
|
||||||
|
}
|
||||||
if (typeof props.children === "string") {
|
if (typeof props.children === "string") {
|
||||||
const match = /^citation:(.+)$/.exec(props.children);
|
const match = /^citation:(.+)$/.exec(props.children);
|
||||||
if (match) {
|
if (match) {
|
||||||
|
|||||||
@@ -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,6 +82,7 @@ export function MessageListItem({
|
|||||||
isLoading,
|
isLoading,
|
||||||
threadId,
|
threadId,
|
||||||
tokenUsageEnabled = false,
|
tokenUsageEnabled = false,
|
||||||
|
displaySources = [],
|
||||||
extraRehypePlugins,
|
extraRehypePlugins,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -83,6 +90,7 @@ export function MessageListItem({
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
tokenUsageEnabled?: boolean;
|
tokenUsageEnabled?: boolean;
|
||||||
|
displaySources?: RuntimeDisplaySource[];
|
||||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||||
}) {
|
}) {
|
||||||
const isHuman = message.type === "human";
|
const isHuman = message.type === "human";
|
||||||
@@ -101,6 +109,7 @@ export function MessageListItem({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
tokenUsageEnabled={tokenUsageEnabled}
|
tokenUsageEnabled={tokenUsageEnabled}
|
||||||
|
displaySources={displaySources}
|
||||||
extraRehypePlugins={extraRehypePlugins}
|
extraRehypePlugins={extraRehypePlugins}
|
||||||
/>
|
/>
|
||||||
{!isLoading && (
|
{!isLoading && (
|
||||||
@@ -162,6 +171,7 @@ function MessageContent_({
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
threadId,
|
threadId,
|
||||||
tokenUsageEnabled = false,
|
tokenUsageEnabled = false,
|
||||||
|
displaySources = [],
|
||||||
extraRehypePlugins,
|
extraRehypePlugins,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -169,6 +179,7 @@ function MessageContent_({
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
tokenUsageEnabled?: boolean;
|
tokenUsageEnabled?: boolean;
|
||||||
|
displaySources?: RuntimeDisplaySource[];
|
||||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||||
}) {
|
}) {
|
||||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||||
@@ -176,6 +187,7 @@ function MessageContent_({
|
|||||||
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
|
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
|
||||||
[baseRehypePlugins, extraRehypePlugins],
|
[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(
|
||||||
@@ -184,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 (
|
||||||
@@ -201,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);
|
||||||
|
|
||||||
@@ -220,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 ? (
|
||||||
@@ -280,6 +303,7 @@ function MessageContent_({
|
|||||||
{messageResponse}
|
{messageResponse}
|
||||||
</AIElementMessageContent>
|
</AIElementMessageContent>
|
||||||
)}
|
)}
|
||||||
|
<MessageTimeStamp time={messageTime} align="right" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -287,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
|
||||||
*/
|
*/
|
||||||
@@ -470,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";
|
||||||
@@ -63,7 +71,6 @@ export function MessageList({
|
|||||||
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 baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||||
const rehypePlugins = useMemo(
|
const rehypePlugins = useMemo(
|
||||||
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
|
() => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins),
|
||||||
@@ -72,6 +79,31 @@ export function MessageList({
|
|||||||
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).
|
||||||
@@ -190,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}`}
|
||||||
@@ -197,6 +239,7 @@ export function MessageList({
|
|||||||
isLoading={thread.isLoading}
|
isLoading={thread.isLoading}
|
||||||
threadId={threadId}
|
threadId={threadId}
|
||||||
tokenUsageEnabled={tokenUsageEnabled}
|
tokenUsageEnabled={tokenUsageEnabled}
|
||||||
|
displaySources={displaySources}
|
||||||
extraRehypePlugins={extraRehypePlugins}
|
extraRehypePlugins={extraRehypePlugins}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -442,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
@@ -38,6 +39,21 @@ 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")}>
|
||||||
@@ -49,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>
|
||||||
@@ -141,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>
|
||||||
@@ -149,7 +165,7 @@ export function WorkspaceNavChatList({
|
|||||||
)}
|
)}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
{!showCollapseTrigger && <WorkspaceNavStudio />}
|
{/* {!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")}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -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];
|
||||||
|
|||||||
@@ -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 }> {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { getStoredAuth } from "@/core/auth";
|
||||||
|
|
||||||
|
const AUTH_STORAGE_KEY = "deerflow.auth";
|
||||||
|
|
||||||
|
function readUserId(): string | null {
|
||||||
|
return getStoredAuth()?.user_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount once at the app root. Watches `localStorage["deerflow.auth"]` for
|
||||||
|
* cross-tab changes — when another tab logs in as a different user, this tab
|
||||||
|
* still holds the previous user's cached state (chat list, thread ids, etc.)
|
||||||
|
* which then collides with the new token and surfaces as a 404 on "新对话".
|
||||||
|
* Reloading the tab is the simplest way to resync React state with the new
|
||||||
|
* identity.
|
||||||
|
*/
|
||||||
|
export function AuthSyncWatcher() {
|
||||||
|
const lastUserIdRef = useRef<string | null>(readUserId());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleStorage(event: StorageEvent) {
|
||||||
|
if (event.key !== null && event.key !== AUTH_STORAGE_KEY) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextUserId = readUserId();
|
||||||
|
if (nextUserId === lastUserIdRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastUserIdRef.current = nextUserId;
|
||||||
|
if (nextUserId === null) {
|
||||||
|
window.location.replace("#/login");
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("storage", handleStorage);
|
||||||
|
return () => window.removeEventListener("storage", handleStorage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Durable, browser-local record of when each chat question was asked, keyed by
|
||||||
|
* the message id. Captured at send time and reconciled to the server-assigned
|
||||||
|
* message id (see ``useThreadStream``), so the time survives reloads and does
|
||||||
|
* not depend on the backend round-tripping a timestamp on the message.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = "deerflow.chat-ask-times";
|
||||||
|
|
||||||
|
type TimeMap = Record<string, string>;
|
||||||
|
|
||||||
|
function isBrowser(): boolean {
|
||||||
|
return typeof window !== "undefined";
|
||||||
|
}
|
||||||
|
|
||||||
|
let cache: TimeMap | null = null;
|
||||||
|
const EMPTY: TimeMap = {};
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
let storageBound = false;
|
||||||
|
|
||||||
|
function readFromStorage(): TimeMap {
|
||||||
|
if (!isBrowser()) return EMPTY;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return {};
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
return parsed && typeof parsed === "object" ? (parsed as TimeMap) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshot(): TimeMap {
|
||||||
|
if (cache === null) cache = readFromStorage();
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerSnapshot(): TimeMap {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commit(next: TimeMap): void {
|
||||||
|
cache = next;
|
||||||
|
if (isBrowser()) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
// Keep the in-memory cache even if persistence fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const listener of listeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(listener: () => void): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
if (isBrowser() && !storageBound) {
|
||||||
|
storageBound = true;
|
||||||
|
window.addEventListener("storage", (event) => {
|
||||||
|
if (event.key === STORAGE_KEY || event.key === null) {
|
||||||
|
cache = readFromStorage();
|
||||||
|
for (const l of listeners) l();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record the ask time for a message id. First write wins (the true ask time). */
|
||||||
|
export function rememberAskTime(messageId: string, iso: string): void {
|
||||||
|
if (!messageId) return;
|
||||||
|
const current = getSnapshot();
|
||||||
|
if (current[messageId]) return;
|
||||||
|
commit({ ...current, [messageId]: iso });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAskTime(messageId: string): string | undefined {
|
||||||
|
return getSnapshot()[messageId];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record several message times at once (first write wins per id). Used to stamp
|
||||||
|
* a freshly loaded/streamed batch of messages in a single commit so a long
|
||||||
|
* conversation doesn't trigger one storage write + re-render per message.
|
||||||
|
*/
|
||||||
|
export function rememberAskTimes(entries: Record<string, string>): void {
|
||||||
|
const current = getSnapshot();
|
||||||
|
let changed = false;
|
||||||
|
const next: TimeMap = { ...current };
|
||||||
|
for (const [id, iso] of Object.entries(entries)) {
|
||||||
|
if (id && !next[id]) {
|
||||||
|
next[id] = iso;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) commit(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reactive map of messageId → ISO ask time. */
|
||||||
|
export function useChatAskTimes(): TimeMap {
|
||||||
|
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { apiFetch } from "@/core/api";
|
||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
|
||||||
|
/** A user-defined reusable prompt, bound to the account (stored server-side). */
|
||||||
|
export interface CustomPrompt {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
sort_order?: number;
|
||||||
|
created_at?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUERY_KEY = ["user-prompts"] as const;
|
||||||
|
const EMPTY_PROMPTS: CustomPrompt[] = [];
|
||||||
|
const baseUrl = () => `${getBackendBaseURL()}/api/user-prompts`;
|
||||||
|
|
||||||
|
async function listUserPrompts(): Promise<CustomPrompt[]> {
|
||||||
|
const res = await apiFetch(baseUrl());
|
||||||
|
if (!res.ok) throw new Error(`Failed to load prompts: ${res.statusText}`);
|
||||||
|
const data = (await res.json()) as { prompts: CustomPrompt[] };
|
||||||
|
return data.prompts;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUserPrompt(input: {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
}): Promise<CustomPrompt> {
|
||||||
|
const res = await apiFetch(baseUrl(), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||||
|
throw new Error(err.detail ?? `Failed to create prompt: ${res.statusText}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<CustomPrompt>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateUserPrompt(
|
||||||
|
id: string,
|
||||||
|
input: { title?: string; content?: string },
|
||||||
|
): Promise<CustomPrompt> {
|
||||||
|
const res = await apiFetch(`${baseUrl()}/${encodeURIComponent(id)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||||
|
throw new Error(err.detail ?? `Failed to update prompt: ${res.statusText}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<CustomPrompt>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUserPrompt(id: string): Promise<void> {
|
||||||
|
const res = await apiFetch(`${baseUrl()}/${encodeURIComponent(id)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||||
|
throw new Error(err.detail ?? `Failed to delete prompt: ${res.statusText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reactive list of the current user's saved prompts (server-backed). */
|
||||||
|
export function useCustomPrompts(): CustomPrompt[] {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: QUERY_KEY,
|
||||||
|
queryFn: listUserPrompts,
|
||||||
|
});
|
||||||
|
return data ?? EMPTY_PROMPTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateCustomPrompt() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: createUserPrompt,
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateCustomPrompt() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
id,
|
||||||
|
input,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
input: { title?: string; content?: string };
|
||||||
|
}) => updateUserPrompt(id, input),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteCustomPrompt() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: deleteUserPrompt,
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -501,6 +501,20 @@ export const enUS: Translations = {
|
|||||||
detailAdminOnlyHint:
|
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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export { useLocalSettings, useThreadSettings } from "./hooks";
|
export { useLocalSettings, useThreadSettings } from "./hooks";
|
||||||
|
export { isThreadRagModeLocked, lockThreadRagMode } from "./local";
|
||||||
export type { LocalSettings } from "./local";
|
export type { LocalSettings } from "./local";
|
||||||
@@ -6,6 +6,8 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
|||||||
},
|
},
|
||||||
display: {
|
display: {
|
||||||
streamSpeedMeterEnabled: true,
|
streamSpeedMeterEnabled: true,
|
||||||
|
ragModeEnabled: false,
|
||||||
|
citationCleanupWords: [],
|
||||||
},
|
},
|
||||||
context: {
|
context: {
|
||||||
model_name: undefined,
|
model_name: undefined,
|
||||||
@@ -17,6 +19,7 @@ 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,6 +36,16 @@ 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,
|
||||||
@@ -93,6 +106,24 @@ export function saveThreadModelName(
|
|||||||
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,
|
||||||
threadModelName: string | undefined,
|
threadModelName: string | undefined,
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import type { SkillDisplayConfig } from "@/core/skills/type";
|
||||||
|
|
||||||
|
const DEFAULT_CITATION_MAX_ITEMS = 100;
|
||||||
|
const DEFAULT_LIST_MAX_ITEMS = 20;
|
||||||
|
const MAX_DISPLAY_ITEMS = 100;
|
||||||
|
|
||||||
|
export interface DisplaySourceItem {
|
||||||
|
id?: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
snippet: string;
|
||||||
|
source?: string;
|
||||||
|
author?: string;
|
||||||
|
time?: string;
|
||||||
|
url?: string;
|
||||||
|
content?: string;
|
||||||
|
score?: number;
|
||||||
|
raw: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emptyDisplayConfig = (): SkillDisplayConfig => ({
|
||||||
|
enabled: false,
|
||||||
|
mode: "none",
|
||||||
|
resultPath: "results",
|
||||||
|
titleField: null,
|
||||||
|
summaryField: null,
|
||||||
|
contentField: null,
|
||||||
|
sourceField: null,
|
||||||
|
authorField: null,
|
||||||
|
typeField: null,
|
||||||
|
timeField: null,
|
||||||
|
scoreField: null,
|
||||||
|
urlField: null,
|
||||||
|
idField: null,
|
||||||
|
linkStrategy: "auto",
|
||||||
|
linkTemplate: null,
|
||||||
|
citationToken: "doubleBracket",
|
||||||
|
maxItems: DEFAULT_CITATION_MAX_ITEMS,
|
||||||
|
customInstruction: "",
|
||||||
|
tableColumns: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const candidateFields: Record<string, string[]> = {
|
||||||
|
titleField: ["title", "m_title", "name", "subject", "headline"],
|
||||||
|
summaryField: ["summary", "snippet", "content_preview", "description", "abstract"],
|
||||||
|
contentField: ["content", "page_content", "text", "content_preview", "body"],
|
||||||
|
sourceField: ["source", "source_name", "site", "from", "origin"],
|
||||||
|
authorField: ["author", "authors", "creator", "writer", "byline", "publisher", "owner"],
|
||||||
|
typeField: ["type", "category", "source_category", "source1"],
|
||||||
|
timeField: ["publish_time", "m_publish", "updatedAt", "createdAt", "date", "time"],
|
||||||
|
scoreField: ["relevance_score", "rerank_score", "score", "similarity"],
|
||||||
|
urlField: ["url", "link", "href", "source_url"],
|
||||||
|
idField: ["recUuid", "rec_uuid", "uuid", "docId", "taskId", "recordId", "id"],
|
||||||
|
};
|
||||||
|
|
||||||
|
function getByPath(value: unknown, path: string): unknown {
|
||||||
|
if (!path) return value;
|
||||||
|
return path.split(".").reduce<unknown>((acc, key) => {
|
||||||
|
if (acc && typeof acc === "object") {
|
||||||
|
return (acc as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFirstArray(
|
||||||
|
value: unknown,
|
||||||
|
path = "",
|
||||||
|
): { path: string; items: Record<string, unknown>[] } | null {
|
||||||
|
if (Array.isArray(value) && value.every((item) => item && typeof item === "object")) {
|
||||||
|
return { path, items: value as Record<string, unknown>[] };
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== "object") return null;
|
||||||
|
for (const [key, child] of Object.entries(value)) {
|
||||||
|
const found = findFirstArray(child, path ? `${path}.${key}` : key);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickField(sample: Record<string, unknown>, candidates: string[]): string | null {
|
||||||
|
const keys = Object.keys(sample);
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const exact = keys.find((key) => key === candidate);
|
||||||
|
if (exact) return exact;
|
||||||
|
}
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const lowered = candidate.toLowerCase();
|
||||||
|
const fuzzy = keys.find((key) => key.toLowerCase().includes(lowered));
|
||||||
|
if (fuzzy) return fuzzy;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferDisplayConfig(samplePayload: unknown, skillName?: string): SkillDisplayConfig {
|
||||||
|
const found = findFirstArray(samplePayload) ?? { path: "results", items: [] };
|
||||||
|
const sample = found.items[0] ?? {};
|
||||||
|
const inferred = emptyDisplayConfig();
|
||||||
|
inferred.enabled = true;
|
||||||
|
inferred.mode = skillName === "knowledge-search-v2" ? "citation" : "list";
|
||||||
|
inferred.resultPath = found.path || "results";
|
||||||
|
inferred.maxItems = inferMaxItems(found.items.length, inferred.mode);
|
||||||
|
|
||||||
|
for (const [target, candidates] of Object.entries(candidateFields)) {
|
||||||
|
(inferred as unknown as Record<string, string | null>)[target] = pickField(sample, candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skillName === "knowledge-search-v2") {
|
||||||
|
inferred.titleField = inferred.titleField || "title";
|
||||||
|
inferred.summaryField = inferred.summaryField || "content_preview";
|
||||||
|
inferred.contentField = inferred.contentField || "content_preview";
|
||||||
|
inferred.sourceField = inferred.sourceField || "source";
|
||||||
|
inferred.authorField = inferred.authorField || pickField(sample, candidateFields.authorField);
|
||||||
|
inferred.typeField = inferred.typeField || "source_category";
|
||||||
|
inferred.timeField = inferred.timeField || "publish_time";
|
||||||
|
inferred.scoreField = inferred.scoreField || "relevance_score";
|
||||||
|
inferred.idField = pickField(sample, ["recUuid", "rec_uuid", "uuid", "id"]) || inferred.idField;
|
||||||
|
inferred.linkStrategy = inferred.urlField ? "auto" : "id";
|
||||||
|
inferred.linkTemplate = "https://xxx.xxx.xxx?recuuid={recUuid}";
|
||||||
|
}
|
||||||
|
|
||||||
|
inferred.tableColumns = buildDefaultTableColumns(sample, inferred);
|
||||||
|
return inferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDefaultTableColumns(
|
||||||
|
sample: Record<string, unknown>,
|
||||||
|
config: SkillDisplayConfig,
|
||||||
|
): NonNullable<SkillDisplayConfig["tableColumns"]> {
|
||||||
|
const preferred = [
|
||||||
|
config.titleField,
|
||||||
|
config.typeField,
|
||||||
|
config.sourceField,
|
||||||
|
config.authorField,
|
||||||
|
config.timeField,
|
||||||
|
config.scoreField,
|
||||||
|
].filter((field): field is string => !!field);
|
||||||
|
const fallback = Object.keys(sample)
|
||||||
|
.filter((field) => !preferred.includes(field))
|
||||||
|
.slice(0, Math.max(0, 6 - preferred.length));
|
||||||
|
return [...preferred, ...fallback].slice(0, 8).map((field) => ({
|
||||||
|
label: field,
|
||||||
|
field,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringify(value: unknown, fallback = ""): string {
|
||||||
|
if (value === null || value === undefined) return fallback;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const parts = value
|
||||||
|
.map((item) =>
|
||||||
|
typeof item === "string" || typeof item === "number" || typeof item === "boolean"
|
||||||
|
? String(item).trim()
|
||||||
|
: "",
|
||||||
|
)
|
||||||
|
.filter(Boolean);
|
||||||
|
return parts.length > 0 ? parts.join("、") : fallback;
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTemplate(template: string, item: Record<string, unknown>): string {
|
||||||
|
return template.replace(/\{([^}]+)\}/g, (_match, key: string) =>
|
||||||
|
encodeURIComponent(stringify(item[key])),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLink(item: Record<string, unknown>, config: SkillDisplayConfig): string | undefined {
|
||||||
|
const url = config.urlField ? stringify(item[config.urlField]) : "";
|
||||||
|
const id = config.idField ? stringify(item[config.idField]) : "";
|
||||||
|
if (config.linkStrategy === "url") return url || undefined;
|
||||||
|
if (config.linkStrategy === "id") {
|
||||||
|
return id && config.linkTemplate ? renderTemplate(config.linkTemplate, item) : undefined;
|
||||||
|
}
|
||||||
|
return url || (id && config.linkTemplate ? renderTemplate(config.linkTemplate, item) : undefined) || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDisplaySources(payload: unknown, config: SkillDisplayConfig): DisplaySourceItem[] {
|
||||||
|
const rawItems = getByPath(payload, config.resultPath);
|
||||||
|
if (!Array.isArray(rawItems)) return [];
|
||||||
|
const maxItems = resolveMaxItems(config, rawItems.length);
|
||||||
|
return rawItems
|
||||||
|
.filter((item): item is Record<string, unknown> => !!item && typeof item === "object")
|
||||||
|
.slice(0, maxItems)
|
||||||
|
.map((item, index) => ({
|
||||||
|
id: config.idField ? stringify(item[config.idField]) : undefined,
|
||||||
|
title: config.titleField ? stringify(item[config.titleField], `结果 ${index + 1}`) : `结果 ${index + 1}`,
|
||||||
|
type: config.typeField ? stringify(item[config.typeField], "result") : "result",
|
||||||
|
snippet: config.summaryField ? stringify(item[config.summaryField]) : "",
|
||||||
|
content: config.contentField ? stringify(item[config.contentField]) : undefined,
|
||||||
|
source: config.sourceField ? stringify(item[config.sourceField]) : undefined,
|
||||||
|
author: config.authorField ? stringify(item[config.authorField]) : undefined,
|
||||||
|
time: config.timeField ? stringify(item[config.timeField]) : undefined,
|
||||||
|
score:
|
||||||
|
config.scoreField && typeof item[config.scoreField] === "number"
|
||||||
|
? (item[config.scoreField] as number)
|
||||||
|
: undefined,
|
||||||
|
url: resolveLink(item, config),
|
||||||
|
raw: item,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferMaxItems(
|
||||||
|
itemCount: number,
|
||||||
|
mode: SkillDisplayConfig["mode"],
|
||||||
|
): number {
|
||||||
|
const fallback =
|
||||||
|
mode === "citation" ? DEFAULT_CITATION_MAX_ITEMS : DEFAULT_LIST_MAX_ITEMS;
|
||||||
|
return Math.min(
|
||||||
|
MAX_DISPLAY_ITEMS,
|
||||||
|
Math.max(fallback, itemCount || fallback),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMaxItems(config: SkillDisplayConfig, itemCount: number): number {
|
||||||
|
const configured = Number(config.maxItems) || 0;
|
||||||
|
if (config.mode === "citation" && configured <= 8 && itemCount > configured) {
|
||||||
|
return Math.min(itemCount, MAX_DISPLAY_ITEMS);
|
||||||
|
}
|
||||||
|
return Math.min(
|
||||||
|
MAX_DISPLAY_ITEMS,
|
||||||
|
Math.max(1, configured || inferMaxItems(itemCount, config.mode)),
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
|||||||
import { getBackendBaseURL } from "@/core/config";
|
import { 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;
|
||||||
|
|||||||
@@ -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] });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface AgentThreadContext extends Record<string, unknown> {
|
|||||||
writing_mode?: boolean;
|
writing_mode?: boolean;
|
||||||
writing_artifact_path?: string;
|
writing_artifact_path?: string;
|
||||||
memory_injection_enabled?: boolean;
|
memory_injection_enabled?: boolean;
|
||||||
|
rag_mode_enabled?: boolean;
|
||||||
agent_id?: string;
|
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;
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { BotIcon, RefreshCwIcon, SearchIcon } from "lucide-react";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
WorkspaceBody,
|
||||||
|
WorkspaceContainer,
|
||||||
|
WorkspaceHeader,
|
||||||
|
} from "@/components/workspace/workspace-container";
|
||||||
|
import { AgentCard } from "@/components/workspace/agents/agent-card";
|
||||||
|
import { useAdminSyncAgentSkills, useAllAgentsAdmin } from "@/core/agents";
|
||||||
|
import type { AgentSkillsSyncResult } from "@/core/agents/api";
|
||||||
|
import { desensitize } from "@/lib/desensitize";
|
||||||
|
|
||||||
|
export default function AdminAgentsPage() {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const { agents, isLoading } = useAllAgentsAdmin(true);
|
||||||
|
const syncSkills = useAdminSyncAgentSkills();
|
||||||
|
const [syncResult, setSyncResult] = useState<AgentSkillsSyncResult | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [syncDialogOpen, setSyncDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
async function handleSyncSkills() {
|
||||||
|
try {
|
||||||
|
const result = await syncSkills.mutateAsync();
|
||||||
|
setSyncResult(result);
|
||||||
|
setSyncDialogOpen(true);
|
||||||
|
if (result.modified === 0) {
|
||||||
|
toast.success("所有智能体的技能均有效,无需清理");
|
||||||
|
} else {
|
||||||
|
toast.success(
|
||||||
|
`已清理 ${result.modified} 个智能体中的失效技能引用`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredAgents = useMemo(() => {
|
||||||
|
const needle = search.trim().toLowerCase();
|
||||||
|
if (!needle) return agents;
|
||||||
|
return agents.filter((a) =>
|
||||||
|
desensitize(a.name).toLowerCase().includes(needle),
|
||||||
|
);
|
||||||
|
}, [agents, search]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorkspaceContainer>
|
||||||
|
<WorkspaceHeader />
|
||||||
|
<WorkspaceBody>
|
||||||
|
<div className="flex size-full min-h-0 flex-col">
|
||||||
|
<div className="flex items-start justify-between gap-4 border-b px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">智能体管理</h1>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||||
|
管理员视图:查看、编辑、删除所有用户的智能体(包括内置、用户自定义与私密广场条目)。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSyncSkills}
|
||||||
|
disabled={syncSkills.isPending}
|
||||||
|
title="扫描所有智能体的 config.yaml,移除指向已不存在技能的引用"
|
||||||
|
>
|
||||||
|
<RefreshCwIcon
|
||||||
|
className={`mr-1.5 size-4 ${syncSkills.isPending ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
{syncSkills.isPending ? "正在同步…" : "同步技能"}
|
||||||
|
</Button>
|
||||||
|
<div className="relative w-64">
|
||||||
|
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="按名称搜索"
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
||||||
|
加载中…
|
||||||
|
</div>
|
||||||
|
) : filteredAgents.length === 0 ? (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||||
|
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
|
||||||
|
<BotIcon className="text-muted-foreground h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
当前没有可管理的智能体。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-muted-foreground mb-3 text-xs">
|
||||||
|
共 {filteredAgents.length} 个智能体。
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{filteredAgents.map((agent) => (
|
||||||
|
<AgentCard key={agent.id} agent={agent} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</WorkspaceBody>
|
||||||
|
|
||||||
|
<Dialog open={syncDialogOpen} onOpenChange={setSyncDialogOpen}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>同步技能 — 清理结果</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{syncResult
|
||||||
|
? `扫描 ${syncResult.checked} 个智能体配置,清理 ${syncResult.modified} 个。`
|
||||||
|
: ""}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{syncResult && syncResult.removals.length > 0 ? (
|
||||||
|
<div className="max-h-[60vh] overflow-y-auto">
|
||||||
|
<ul className="space-y-3 text-sm">
|
||||||
|
{syncResult.removals.map((entry) => (
|
||||||
|
<li
|
||||||
|
key={entry.agent_id}
|
||||||
|
className="rounded-md border px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="font-medium">
|
||||||
|
{entry.agent_name
|
||||||
|
? desensitize(entry.agent_name)
|
||||||
|
: entry.agent_id}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground mt-0.5 text-xs">
|
||||||
|
id: {entry.agent_id}
|
||||||
|
{entry.user_id ? ` · owner: ${entry.user_id}` : " · built-in"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||||
|
{entry.removed_skills.map((s) => (
|
||||||
|
<span
|
||||||
|
key={s}
|
||||||
|
className="bg-muted rounded px-1.5 py-0.5 text-xs"
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
所有智能体引用的技能均存在,未做任何修改。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setSyncDialogOpen(false)}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</WorkspaceContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
SearchIcon,
|
||||||
|
SparklesIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
WorkspaceBody,
|
||||||
|
WorkspaceContainer,
|
||||||
|
WorkspaceHeader,
|
||||||
|
} from "@/components/workspace/workspace-container";
|
||||||
|
import { SkillRow } from "@/components/workspace/settings/skill-settings-page";
|
||||||
|
import { getStoredAuth } from "@/core/auth";
|
||||||
|
import { useAllSkillsAdmin } from "@/core/skills/hooks";
|
||||||
|
import { desensitize } from "@/lib/desensitize";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
export default function AdminSkillsPage() {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const { skills, isLoading, error } = useAllSkillsAdmin(true);
|
||||||
|
|
||||||
|
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||||
|
const currentUserId = storedAuth?.user_id ?? null;
|
||||||
|
|
||||||
|
// Hide archived/stale entries here — they remain accessible from the regular
|
||||||
|
// skills settings dialog. The admin page is for live skills only.
|
||||||
|
const filteredSkills = useMemo(() => {
|
||||||
|
const needle = search.trim().toLowerCase();
|
||||||
|
return skills
|
||||||
|
.filter((s) => (s.state ?? "active") === "active")
|
||||||
|
.filter((s) => !needle || desensitize(s.name).toLowerCase().includes(needle));
|
||||||
|
}, [skills, search]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filteredSkills.length / PAGE_SIZE));
|
||||||
|
useEffect(() => {
|
||||||
|
if (page > totalPages) setPage(totalPages);
|
||||||
|
}, [page, totalPages]);
|
||||||
|
const pagedSkills = useMemo(
|
||||||
|
() => filteredSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||||
|
[filteredSkills, page],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorkspaceContainer>
|
||||||
|
<WorkspaceHeader />
|
||||||
|
<WorkspaceBody>
|
||||||
|
<div className="flex size-full min-h-0 flex-col">
|
||||||
|
<div className="flex items-start justify-between gap-4 border-b px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">技能管理</h1>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-sm">
|
||||||
|
管理员视图:查看、编辑、删除所有用户的技能(包括内置技能与未发布的自定义技能)。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="relative w-64 shrink-0">
|
||||||
|
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="按名称搜索"
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-6">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
||||||
|
加载中…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="text-destructive text-sm">
|
||||||
|
加载失败:{error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && !error && filteredSkills.length === 0 && (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||||
|
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
|
||||||
|
<SparklesIcon className="text-muted-foreground h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
当前没有可管理的技能。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && !error && filteredSkills.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
共 {filteredSkills.length} 个技能。
|
||||||
|
</p>
|
||||||
|
{pagedSkills.map((skill) => (
|
||||||
|
<SkillRow
|
||||||
|
key={skill.name}
|
||||||
|
skill={skill}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
isAdmin
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!isLoading && !error && totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="size-4" />
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
第 {page} / {totalPages} 页
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
<ChevronRightIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</WorkspaceBody>
|
||||||
|
</WorkspaceContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -231,14 +231,25 @@ export default function ChatPage() {
|
|||||||
</header>
|
</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
|
||||||
|
|||||||
@@ -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())
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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,172 @@ 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";
|
||||||
|
|
||||||
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="task-management" element={<TaskManagement />}>
|
||||||
|
{/* XK任务 */}
|
||||||
|
<Route
|
||||||
|
path="xk-task-jc"
|
||||||
|
element={
|
||||||
|
<TaskXkFrom
|
||||||
|
gopath={'#/pageIndie/pageHome/pageSwrw'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="xk-task-dispatch"
|
||||||
|
element={
|
||||||
|
<TaskXkFrom
|
||||||
|
gopath={'#/pageIndie/pageJczl'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
{/* 轻应用 */}
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -18,12 +18,15 @@ import ChatPage from "./ChatPage";
|
|||||||
import AgentsPage from "./AgentsPage";
|
import AgentsPage from "./AgentsPage";
|
||||||
import AgentChatPage from "./AgentChatPage";
|
import AgentChatPage from "./AgentChatPage";
|
||||||
import NewAgentPage from "./NewAgentPage";
|
import NewAgentPage from "./NewAgentPage";
|
||||||
|
import AdminAgentsPage from "./AdminAgentsPage";
|
||||||
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
||||||
|
import AdminSkillsPage from "./AdminSkillsPage";
|
||||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||||
import LlmMetricsPage from "./LlmMetricsPage";
|
import LlmMetricsPage from "./LlmMetricsPage";
|
||||||
import MemoryPage from "./MemoryPage";
|
import MemoryPage from "./MemoryPage";
|
||||||
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
|
||||||
|
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
|
||||||
import SkillEvolutionPage from "./SkillEvolutionPage";
|
import SkillEvolutionPage from "./SkillEvolutionPage";
|
||||||
import SkillsPage from "./SkillsPage";
|
import SkillsPage from "./SkillsPage";
|
||||||
import UsersPage from "./UsersPage";
|
import UsersPage from "./UsersPage";
|
||||||
@@ -217,7 +220,9 @@ export default function WorkspaceRoutes({
|
|||||||
path="roundtable/planning"
|
path="roundtable/planning"
|
||||||
element={
|
element={
|
||||||
<WorkspaceLayout {...layoutProps}>
|
<WorkspaceLayout {...layoutProps}>
|
||||||
<RoundtablePlanningPage />
|
<ChatRuntime>
|
||||||
|
<RoundtablePlanningPage />
|
||||||
|
</ChatRuntime>
|
||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -279,6 +284,30 @@ export default function WorkspaceRoutes({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="admin/agents"
|
||||||
|
element={
|
||||||
|
getStoredAuth()?.system_role === "admin" ? (
|
||||||
|
<WorkspaceLayout {...layoutProps}>
|
||||||
|
<AdminAgentsPage />
|
||||||
|
</WorkspaceLayout>
|
||||||
|
) : (
|
||||||
|
<Navigate to="/page/workspace/chats/new" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="admin/skills"
|
||||||
|
element={
|
||||||
|
getStoredAuth()?.system_role === "admin" ? (
|
||||||
|
<WorkspaceLayout {...layoutProps}>
|
||||||
|
<AdminSkillsPage />
|
||||||
|
</WorkspaceLayout>
|
||||||
|
) : (
|
||||||
|
<Navigate to="/page/workspace/chats/new" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="admin/tags"
|
path="admin/tags"
|
||||||
element={
|
element={
|
||||||
@@ -315,6 +344,18 @@ export default function WorkspaceRoutes({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="settings/private-square"
|
||||||
|
element={
|
||||||
|
getStoredAuth()?.system_role === "admin" ? (
|
||||||
|
<WorkspaceLayout {...layoutProps}>
|
||||||
|
<PrivateSquareSettingsPage />
|
||||||
|
</WorkspaceLayout>
|
||||||
|
) : (
|
||||||
|
<Navigate to="/page/workspace/chats/new" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="settings/llm-metrics"
|
path="settings/llm-metrics"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -260,6 +260,69 @@ export interface StreamMultiAgentResult {
|
|||||||
clarification: ClarificationPayload | null;
|
clarification: ClarificationPayload | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort partial JSON parser for accumulated `tool_call_chunks.args`.
|
||||||
|
*
|
||||||
|
* LangChain emits tool args as a series of string fragments (one delta per
|
||||||
|
* chunk). To get progressive `args.path` / `args.content` during streaming —
|
||||||
|
* the way `useStream` does in the main chat — we concatenate the deltas and
|
||||||
|
* try to parse. When the JSON is still mid-token (e.g. `{"path":"/mnt`) we
|
||||||
|
* close any open string + brackets and retry, so callers can read partially
|
||||||
|
* available keys even before the full payload arrives.
|
||||||
|
*
|
||||||
|
* Returns null when nothing useful can be extracted yet.
|
||||||
|
*/
|
||||||
|
function parsePartialJson(s: string): Record<string, unknown> | null {
|
||||||
|
if (!s) return null;
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(s);
|
||||||
|
return v && typeof v === "object" ? (v as Record<string, unknown>) : null;
|
||||||
|
} catch {
|
||||||
|
// fall through to best-effort close + retry
|
||||||
|
}
|
||||||
|
let trimmed = s;
|
||||||
|
// A dangling `\` would turn our injected closing `"` into an escape; drop it.
|
||||||
|
if (trimmed.endsWith("\\")) trimmed = trimmed.slice(0, -1);
|
||||||
|
let inString = false;
|
||||||
|
let escaped = false;
|
||||||
|
const stack: string[] = [];
|
||||||
|
for (const ch of trimmed) {
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "\\") {
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inString) {
|
||||||
|
if (ch === '"') inString = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') {
|
||||||
|
inString = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "{") stack.push("}");
|
||||||
|
else if (ch === "[") stack.push("]");
|
||||||
|
else if (ch === "}" || ch === "]") stack.pop();
|
||||||
|
}
|
||||||
|
let closed = trimmed;
|
||||||
|
if (inString) {
|
||||||
|
closed += '"';
|
||||||
|
} else {
|
||||||
|
// Mid-key / dangling separator (`{"path":`) can't be balanced cleanly.
|
||||||
|
closed = closed.replace(/[,:]\s*$/, "");
|
||||||
|
}
|
||||||
|
while (stack.length) closed += stack.pop();
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(closed);
|
||||||
|
return v && typeof v === "object" ? (v as Record<string, unknown>) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Identify the final status frame our backend appends after the LangGraph
|
* Identify the final status frame our backend appends after the LangGraph
|
||||||
* stream. Required keys are `status` (array for leader, string for special)
|
* stream. Required keys are `status` (array for leader, string for special)
|
||||||
@@ -394,6 +457,61 @@ export async function streamMultiAgent(
|
|||||||
req.onPhaseChange?.(next, name, args);
|
req.onPhaseChange?.(next, name, args);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 累积 tool_call_chunks(delta args 字符串) —— 与 LangChain `AIMessageChunk`
|
||||||
|
* 合并的内部行为对齐,补主聊天靠 `useStream` 暗中做的那部分工作。Key 是 chunk.index
|
||||||
|
* (并发 tool 调用通常 0/1/2 区分);chunk.id 变化时视作"同一 index 上的新调用"并重置。
|
||||||
|
* 每帧累积后用 `parsePartialJson` 尽力 parse,成功就把合成的 `tool_calls[]` 注入
|
||||||
|
* 到 messages-tuple 帧的 chunkObj 上,让上层 `onUpstreamFrame` 和 `onPhaseChange`
|
||||||
|
* 都能拿到完整的 `args.path` / `args.content` 来做"流式期间打开沙箱 + 实时预览"。
|
||||||
|
*/
|
||||||
|
const toolCallAcc = new Map<
|
||||||
|
number,
|
||||||
|
{ id?: string; name: string; argsStr: string }
|
||||||
|
>();
|
||||||
|
|
||||||
|
type SyntheticToolCall = {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ingestToolCallChunks = (chunks: unknown): void => {
|
||||||
|
if (!Array.isArray(chunks)) return;
|
||||||
|
for (const tcc of chunks) {
|
||||||
|
if (!tcc || typeof tcc !== "object") continue;
|
||||||
|
const tccObj = tcc as Record<string, unknown>;
|
||||||
|
const idx = typeof tccObj.index === "number" ? tccObj.index : 0;
|
||||||
|
let entry = toolCallAcc.get(idx);
|
||||||
|
if (!entry) {
|
||||||
|
entry = { name: "", argsStr: "" };
|
||||||
|
toolCallAcc.set(idx, entry);
|
||||||
|
}
|
||||||
|
if (typeof tccObj.id === "string" && tccObj.id) {
|
||||||
|
if (entry.id && entry.id !== tccObj.id) {
|
||||||
|
// 同一 index 上换了新 tool_call,重置累积(不太常见,defensive)。
|
||||||
|
entry.name = "";
|
||||||
|
entry.argsStr = "";
|
||||||
|
}
|
||||||
|
entry.id = tccObj.id;
|
||||||
|
}
|
||||||
|
if (typeof tccObj.name === "string" && tccObj.name) entry.name = tccObj.name;
|
||||||
|
if (typeof tccObj.args === "string") entry.argsStr += tccObj.args;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSyntheticToolCalls = (): SyntheticToolCall[] => {
|
||||||
|
const out: SyntheticToolCall[] = [];
|
||||||
|
for (const entry of toolCallAcc.values()) {
|
||||||
|
if (!entry.name) continue;
|
||||||
|
const parsed = parsePartialJson(entry.argsStr);
|
||||||
|
if (parsed) {
|
||||||
|
out.push({ id: entry.id, name: entry.name, args: parsed });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
@@ -441,26 +559,51 @@ export async function streamMultiAgent(
|
|||||||
const toolCalls = chunkObj.tool_calls;
|
const toolCalls = chunkObj.tool_calls;
|
||||||
const toolCallChunks = chunkObj.tool_call_chunks;
|
const toolCallChunks = chunkObj.tool_call_chunks;
|
||||||
const content = chunkObj.content;
|
const content = chunkObj.content;
|
||||||
|
|
||||||
|
// 1) 累积本帧的 tool_call_chunks delta(若有),再合成可能可读的 tool_calls。
|
||||||
|
// 主聊天 useStream 在 SDK 层做了这件事,我们在自定义 SSE 里手动补上。
|
||||||
|
ingestToolCallChunks(toolCallChunks);
|
||||||
|
const hasCompleteToolCalls =
|
||||||
|
Array.isArray(toolCalls) && toolCalls.length > 0;
|
||||||
|
let synthetic: SyntheticToolCall[] = [];
|
||||||
|
if (!hasCompleteToolCalls && toolCallAcc.size > 0) {
|
||||||
|
synthetic = buildSyntheticToolCalls();
|
||||||
|
if (synthetic.length > 0) {
|
||||||
|
// 把累积出的 tool_calls 注入回帧的 chunkObj —— 这样下面 onUpstreamFrame
|
||||||
|
// 那边的 handleUpstreamFrame 只读 tool_calls 字段就能拿到完整 args,
|
||||||
|
// 不必再单独做一份累积。
|
||||||
|
chunkObj.tool_calls = synthetic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveToolCalls: unknown[] | null = hasCompleteToolCalls
|
||||||
|
? (toolCalls as unknown[])
|
||||||
|
: synthetic.length > 0
|
||||||
|
? synthetic
|
||||||
|
: null;
|
||||||
|
|
||||||
const hasToolCall =
|
const hasToolCall =
|
||||||
(Array.isArray(toolCalls) && toolCalls.length > 0) ||
|
(effectiveToolCalls !== null && effectiveToolCalls.length > 0) ||
|
||||||
(Array.isArray(toolCallChunks) && toolCallChunks.length > 0);
|
(Array.isArray(toolCallChunks) && toolCallChunks.length > 0);
|
||||||
|
|
||||||
if (chunkType === "tool") {
|
if (chunkType === "tool") {
|
||||||
setPhase("tool_result");
|
setPhase("tool_result");
|
||||||
} else if (hasToolCall) {
|
} else if (hasToolCall) {
|
||||||
// Pick the most informative name we can find in either field.
|
// Pick the most informative name + args we have so far.
|
||||||
// 优先用 tool_calls(完整对象,含解析好的 args),tool_call_chunks 的 args
|
// 优先用累积出的 / 完整的 tool_calls(含可读 args 对象);若都还不到火候,
|
||||||
// 通常是局部 JSON 字符串(LangGraph 流式期间逐字符发),没法直接用。
|
// 回退到 tool_call_chunks 拿名字(args 还是 string,不透传)。
|
||||||
const firstCall = Array.isArray(toolCalls) && toolCalls.length > 0
|
const firstCall =
|
||||||
? (toolCalls[0] as Record<string, unknown>)
|
effectiveToolCalls && effectiveToolCalls.length > 0
|
||||||
: Array.isArray(toolCallChunks) && toolCallChunks.length > 0
|
? (effectiveToolCalls[0] as Record<string, unknown>)
|
||||||
? (toolCallChunks[0] as Record<string, unknown>)
|
: Array.isArray(toolCallChunks) && toolCallChunks.length > 0
|
||||||
: null;
|
? (toolCallChunks[0] as Record<string, unknown>)
|
||||||
const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : "";
|
: null;
|
||||||
// 仅当来自 tool_calls(完整对象)时透传 args;tool_call_chunks 的 args 是
|
const toolName =
|
||||||
// string 拼接中,不传上去给 UI,等下一个完整 chunk 再带。
|
firstCall && typeof firstCall.name === "string" ? firstCall.name : "";
|
||||||
const fromCompleteToolCalls = Array.isArray(toolCalls) && toolCalls.length > 0;
|
|
||||||
const args =
|
const args =
|
||||||
fromCompleteToolCalls && firstCall && typeof firstCall.args === "object" && firstCall.args !== null
|
firstCall &&
|
||||||
|
typeof firstCall.args === "object" &&
|
||||||
|
firstCall.args !== null
|
||||||
? (firstCall.args as Record<string, unknown>)
|
? (firstCall.args as Record<string, unknown>)
|
||||||
: undefined;
|
: undefined;
|
||||||
setPhase("tool_calling", toolName, args);
|
setPhase("tool_calling", toolName, args);
|
||||||
|
|||||||
@@ -202,6 +202,8 @@ export interface ReasoningBlockProps {
|
|||||||
/** 用于显示「思考用时」的可选时长(秒)。流式期间不显示。 */
|
/** 用于显示「思考用时」的可选时长(秒)。流式期间不显示。 */
|
||||||
durationSec?: number;
|
durationSec?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** 展开内容区额外 class,例如 max-h + overflow-y-auto。 */
|
||||||
|
contentClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const REASONING_AUTO_CLOSE_DELAY_MS = 1000;
|
const REASONING_AUTO_CLOSE_DELAY_MS = 1000;
|
||||||
@@ -211,6 +213,7 @@ export function ReasoningBlock({
|
|||||||
streaming = false,
|
streaming = false,
|
||||||
durationSec,
|
durationSec,
|
||||||
className,
|
className,
|
||||||
|
contentClassName,
|
||||||
}: ReasoningBlockProps) {
|
}: ReasoningBlockProps) {
|
||||||
// open 初始值跟 streaming 同步:流式中默认展开方便观察思考过程,完成后会自动收起一次。
|
// open 初始值跟 streaming 同步:流式中默认展开方便观察思考过程,完成后会自动收起一次。
|
||||||
const [open, setOpen] = useState<boolean>(streaming);
|
const [open, setOpen] = useState<boolean>(streaming);
|
||||||
@@ -260,7 +263,12 @@ export function ReasoningBlock({
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2 text-[11.5px] text-slate-600 leading-relaxed">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2 text-[11.5px] text-slate-600 leading-relaxed",
|
||||||
|
contentClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
<MarkdownContent>{children}</MarkdownContent>
|
<MarkdownContent>{children}</MarkdownContent>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ export interface MessageStepsCardProps {
|
|||||||
/** 是否流式中 —— 决定最后一步显示 `active` 还是 `complete`。 */
|
/** 是否流式中 —— 决定最后一步显示 `active` 还是 `complete`。 */
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** 点击 write_file / present_files 等产物相关步骤时打开沙箱。 */
|
||||||
|
onArtifactStepClick?: (step: BubbleStep) => void;
|
||||||
|
/** 把 agent_orchestration 派活的 agent id 解析成中文席位名(用于 chip 显示)。 */
|
||||||
|
resolveAgentName?: (idOrName: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 工具调用阶段的 step 才进入折叠卡;非工具步骤(connecting/streaming/tool_result)归到「思考」段。 */
|
/** 工具调用阶段的 step 才进入折叠卡;非工具步骤(connecting/streaming/tool_result)归到「思考」段。 */
|
||||||
@@ -51,7 +55,13 @@ function isToolStep(step: BubbleStep): boolean {
|
|||||||
return step.key.startsWith("tool_calling:") && step.key.length > "tool_calling:".length;
|
return step.key.startsWith("tool_calling:") && step.key.length > "tool_calling:".length;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageStepsCard({ steps, streaming, className }: MessageStepsCardProps) {
|
export function MessageStepsCard({
|
||||||
|
steps,
|
||||||
|
streaming,
|
||||||
|
className,
|
||||||
|
onArtifactStepClick,
|
||||||
|
resolveAgentName,
|
||||||
|
}: MessageStepsCardProps) {
|
||||||
// 把步骤拆成「工具调用」与「思考过程」两类 —— 与主聊天 MessageGroup 的 `lastToolCallStep` /
|
// 把步骤拆成「工具调用」与「思考过程」两类 —— 与主聊天 MessageGroup 的 `lastToolCallStep` /
|
||||||
// `lastReasoningStep` 划分对齐。
|
// `lastReasoningStep` 划分对齐。
|
||||||
const toolSteps = steps.filter(isToolStep);
|
const toolSteps = steps.filter(isToolStep);
|
||||||
@@ -119,11 +129,19 @@ export function MessageStepsCard({ steps, streaming, className }: MessageStepsCa
|
|||||||
<ChainOfThoughtContent className="px-3 pb-2">
|
<ChainOfThoughtContent className="px-3 pb-2">
|
||||||
{showAbove &&
|
{showAbove &&
|
||||||
priorToolSteps.map((s) => (
|
priorToolSteps.map((s) => (
|
||||||
<ToolStep key={s.id} step={s} status="complete" />
|
<ToolStep
|
||||||
|
key={s.id}
|
||||||
|
step={s}
|
||||||
|
status="complete"
|
||||||
|
onArtifactStepClick={onArtifactStepClick}
|
||||||
|
resolveAgentName={resolveAgentName}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
<ToolStep
|
<ToolStep
|
||||||
step={lastToolStep}
|
step={lastToolStep}
|
||||||
status={streaming && trailingReasoning.length === 0 ? "active" : "complete"}
|
status={streaming && trailingReasoning.length === 0 ? "active" : "complete"}
|
||||||
|
onArtifactStepClick={onArtifactStepClick}
|
||||||
|
resolveAgentName={resolveAgentName}
|
||||||
/>
|
/>
|
||||||
</ChainOfThoughtContent>
|
</ChainOfThoughtContent>
|
||||||
)}
|
)}
|
||||||
@@ -141,16 +159,35 @@ export function MessageStepsCard({ steps, streaming, className }: MessageStepsCa
|
|||||||
function ToolStep({
|
function ToolStep({
|
||||||
step,
|
step,
|
||||||
status,
|
status,
|
||||||
|
onArtifactStepClick,
|
||||||
|
resolveAgentName,
|
||||||
}: {
|
}: {
|
||||||
step: BubbleStep;
|
step: BubbleStep;
|
||||||
status: "complete" | "active";
|
status: "complete" | "active";
|
||||||
|
onArtifactStepClick?: (step: BubbleStep) => void;
|
||||||
|
resolveAgentName?: (idOrName: string) => string;
|
||||||
}) {
|
}) {
|
||||||
const display = getStepDisplay(step.key, step.label, step.args);
|
const display = getStepDisplay(step.key, step.label, step.args);
|
||||||
const argNode = renderStepArgs(step.key, step.args);
|
const argNode = renderStepArgs(step.key, step.args, resolveAgentName);
|
||||||
|
const [, toolName] = step.key.split(":");
|
||||||
|
// read_file 也可点 —— 验证类「查看文件…」步骤常出现在最后一条,点击后在沙箱里
|
||||||
|
// 查看该文件内容(走虚拟预览或 outputs HTTP)。主聊天只让 write 类可点,这里增强。
|
||||||
|
const isArtifactStep =
|
||||||
|
toolName === "write_file" ||
|
||||||
|
toolName === "str_replace" ||
|
||||||
|
toolName === "present_files" ||
|
||||||
|
toolName === "read_file";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChainOfThoughtStep
|
<ChainOfThoughtStep
|
||||||
icon={display.icon}
|
icon={display.icon}
|
||||||
status={status}
|
status={status}
|
||||||
|
className={isArtifactStep && onArtifactStepClick ? "cursor-pointer" : undefined}
|
||||||
|
onClick={
|
||||||
|
isArtifactStep && onArtifactStepClick
|
||||||
|
? () => onArtifactStepClick(step)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
label={
|
label={
|
||||||
<span className="text-[12px] text-slate-700">
|
<span className="text-[12px] text-slate-700">
|
||||||
<span className="font-medium">{display.label}</span>
|
<span className="font-medium">{display.label}</span>
|
||||||
|
|||||||
@@ -430,8 +430,8 @@ export function RecommendAgentsDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col">
|
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col overflow-hidden gap-3">
|
||||||
<DialogHeader>
|
<DialogHeader className="shrink-0">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<SparklesIcon className="size-5 text-purple-500" />
|
<SparklesIcon className="size-5 text-purple-500" />
|
||||||
选择参会智能体
|
选择参会智能体
|
||||||
@@ -466,7 +466,7 @@ export function RecommendAgentsDialog({
|
|||||||
<CheckIcon className="size-3 text-emerald-500" />
|
<CheckIcon className="size-3 text-emerald-500" />
|
||||||
推荐参会智能体
|
推荐参会智能体
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto pr-1 -mr-1 space-y-4">
|
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar pr-1 -mr-1 space-y-4">
|
||||||
{state.phase === "error" ? (
|
{state.phase === "error" ? (
|
||||||
<div className="text-sm text-destructive p-4 border border-destructive/30 rounded-md bg-destructive/5">
|
<div className="text-sm text-destructive p-4 border border-destructive/30 rounded-md bg-destructive/5">
|
||||||
{state.errorMessage ?? "未知错误"}
|
{state.errorMessage ?? "未知错误"}
|
||||||
@@ -536,7 +536,7 @@ export function RecommendAgentsDialog({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<DialogFooter className="flex-row sm:justify-between items-center gap-2 flex-wrap">
|
<DialogFooter className="shrink-0 flex-row sm:justify-between items-center gap-2 flex-wrap border-t pt-3 mt-1">
|
||||||
<div className="flex items-center gap-3 text-xs">
|
<div className="flex items-center gap-3 text-xs">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -644,14 +644,14 @@ function CollapsibleModelOutput({
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="border rounded-md bg-muted/20 shrink-0">
|
<section className="border rounded-md bg-muted/20 shrink-0 max-h-[38vh] flex flex-col min-h-0 overflow-hidden">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
userTouchedRef.current = true;
|
userTouchedRef.current = true;
|
||||||
setOpen((v) => !v);
|
setOpen((v) => !v);
|
||||||
}}
|
}}
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase tracking-wider hover:bg-muted/40 transition-colors cursor-pointer"
|
className="w-full shrink-0 flex items-center gap-2 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase tracking-wider hover:bg-muted/40 transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Loader2Icon className="size-3 text-purple-500 shrink-0 animate-spin" />
|
<Loader2Icon className="size-3 text-purple-500 shrink-0 animate-spin" />
|
||||||
@@ -676,7 +676,11 @@ function CollapsibleModelOutput({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{open && <div className="px-3 pb-3">{children}</div>}
|
{open && (
|
||||||
|
<div className="px-3 pb-3 min-h-0 flex-1 overflow-y-auto custom-scrollbar">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -745,7 +749,12 @@ function RecommenderOutput({ state }: { state: InternalState }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hasReasoning && (
|
{hasReasoning && (
|
||||||
<ReasoningBlock streaming={isStreaming}>{reasoning!}</ReasoningBlock>
|
<ReasoningBlock
|
||||||
|
streaming={isStreaming}
|
||||||
|
contentClassName="max-h-48 overflow-y-auto custom-scrollbar"
|
||||||
|
>
|
||||||
|
{reasoning!}
|
||||||
|
</ReasoningBlock>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!hasRationale && !hasReasoning && isStreaming && (
|
{!hasRationale && !hasReasoning && isStreaming && (
|
||||||
@@ -753,7 +762,7 @@ function RecommenderOutput({ state }: { state: InternalState }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{hasRationale && (
|
{hasRationale && (
|
||||||
<div className="max-h-72 overflow-y-auto rounded-md border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-700 leading-relaxed">
|
<div className="max-h-40 overflow-y-auto custom-scrollbar rounded-md border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-700 leading-relaxed">
|
||||||
<MarkdownContent>{cleanedRationale}</MarkdownContent>
|
<MarkdownContent>{cleanedRationale}</MarkdownContent>
|
||||||
{isStreaming && <StreamingCursor />}
|
{isStreaming && <StreamingCursor />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -353,7 +353,13 @@ function Step1Message({
|
|||||||
<div className="text-xs text-slate-700 leading-relaxed font-medium break-words space-y-2 roundtable-md">
|
<div className="text-xs text-slate-700 leading-relaxed font-medium break-words space-y-2 roundtable-md">
|
||||||
{introText && <MarkdownContent>{introText}</MarkdownContent>}
|
{introText && <MarkdownContent>{introText}</MarkdownContent>}
|
||||||
{parts.summary && <IntentReadySummaryCard summary={parts.summary} />}
|
{parts.summary && <IntentReadySummaryCard summary={parts.summary} />}
|
||||||
{parts.after && <MarkdownContent>{parts.after}</MarkdownContent>}
|
{/*
|
||||||
|
parts.after 是模型违反 SOUL §0「硬规则 3」在 [INTENT_READY] 块后
|
||||||
|
继续输出的内容(典型:"那么我先帮你列一下大纲……")。后端 intent.py
|
||||||
|
的终态帧已经把 content 截断到 marker 结尾,但流式期间 LangGraph
|
||||||
|
messages-tuple 还是会把那段废话逐 token 推过来——故意在 UI 层
|
||||||
|
把它丢弃,让用户看到的就是"复述 + 摘要卡",干净结束。
|
||||||
|
*/}
|
||||||
{isStreaming && <StreamingCursor />}
|
{isStreaming && <StreamingCursor />}
|
||||||
{isPendingClarification && clarification && (
|
{isPendingClarification && clarification && (
|
||||||
<IntentClarificationCard
|
<IntentClarificationCard
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Step 2 主面板 —— 多智能体圆桌研讨。
|
* Step 2 主面板 —— 多智能体圆桌研讨。
|
||||||
*
|
*
|
||||||
* 包含 4 块:
|
* 包含 4 块:
|
||||||
* 1) 顶部 Headline + 挂起/恢复按钮
|
* 1) 顶部 Headline + 推理模型下拉 + 挂起/恢复按钮
|
||||||
* 2) Persona 抽屉(点击席位头像后展开,见 `PersonaDrawer.tsx`)
|
* 2) Persona 抽屉(点击席位头像后展开,见 `PersonaDrawer.tsx`)
|
||||||
* 3) 对话流(初始化占位 + 气泡列表 + ChainOfThought 折叠卡 + markdown)
|
* 3) 对话流(初始化占位 + 气泡列表 + ChainOfThought 折叠卡 + markdown)
|
||||||
* 4) 底部澄清条 + 用户干预输入 + 「达成共识」入口
|
* 4) 底部澄清条 + 用户干预输入 + 「达成共识」入口
|
||||||
@@ -15,9 +15,10 @@
|
|||||||
*
|
*
|
||||||
* 状态全部来自 `useStep2Orchestration` —— 本组件不调任何后端、不持久化。
|
* 状态全部来自 `useStep2Orchestration` —— 本组件不调任何后端、不持久化。
|
||||||
*/
|
*/
|
||||||
import { useRef } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Cpu,
|
Cpu,
|
||||||
|
FileIcon,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
Pause,
|
Pause,
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
|
|
||||||
import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer";
|
import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer";
|
||||||
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
||||||
|
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
|
||||||
import { getAvatar } from "@/roundtable-planning/components/Avatars";
|
import { getAvatar } from "@/roundtable-planning/components/Avatars";
|
||||||
import {
|
import {
|
||||||
MarkdownContent,
|
MarkdownContent,
|
||||||
@@ -65,11 +67,24 @@ export interface Step2PanelProps {
|
|||||||
agentConfigs: Record<number, AgentConfig>;
|
agentConfigs: Record<number, AgentConfig>;
|
||||||
selectedAgentId: number | null;
|
selectedAgentId: number | null;
|
||||||
roles: RoleEntry[];
|
roles: RoleEntry[];
|
||||||
|
/** agent id(小写)→ 席位元数据,用于把派活 chip 的 id 显示成中文席位名。 */
|
||||||
|
agentNameToRole: Record<
|
||||||
|
string,
|
||||||
|
{ speakerId: number; avatarType: string; displayName: string }
|
||||||
|
>;
|
||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
availableModels: ModelOption[];
|
availableModels: ModelOption[];
|
||||||
modelsLoading: boolean;
|
modelsLoading: boolean;
|
||||||
|
setSelectedModel: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
activeArtifactThreadId: string | null;
|
||||||
|
seatStubMessages: Record<string, unknown[]>;
|
||||||
|
openArtifactFromStep: (
|
||||||
|
dialogue: Step2Dialogue,
|
||||||
|
step: NonNullable<Step2Dialogue["steps"]>[number],
|
||||||
|
) => void;
|
||||||
|
/** 主聊天里 `present_files` 会在气泡下方渲染卡片;Step 2 沿用同一交互。 */
|
||||||
|
openArtifactPreview: (threadId: string, path: string) => void;
|
||||||
// ─── Setters ──────────────────────────────────────────────────
|
// ─── Setters ──────────────────────────────────────────────────
|
||||||
setIsRoundTableRunning: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
setClarificationDraft: React.Dispatch<React.SetStateAction<string>>;
|
setClarificationDraft: React.Dispatch<React.SetStateAction<string>>;
|
||||||
setCustomAgentInput: React.Dispatch<React.SetStateAction<string>>;
|
setCustomAgentInput: React.Dispatch<React.SetStateAction<string>>;
|
||||||
setAgentConfigs: React.Dispatch<
|
setAgentConfigs: React.Dispatch<
|
||||||
@@ -77,8 +92,10 @@ export interface Step2PanelProps {
|
|||||||
>;
|
>;
|
||||||
setSelectedAgentId: React.Dispatch<React.SetStateAction<number | null>>;
|
setSelectedAgentId: React.Dispatch<React.SetStateAction<number | null>>;
|
||||||
// ─── Handlers ─────────────────────────────────────────────────
|
// ─── Handlers ─────────────────────────────────────────────────
|
||||||
|
onPauseRoundtable: () => void;
|
||||||
|
onResumeRoundtable: () => void;
|
||||||
|
onHandleInjectToRoundtable: () => void;
|
||||||
onSubmitClarification: (answer: string) => void;
|
onSubmitClarification: (answer: string) => void;
|
||||||
onInjectToRoundtable: () => void;
|
|
||||||
onSavePersona: (id: number) => void;
|
onSavePersona: (id: number) => void;
|
||||||
onProceedToStep3: () => void;
|
onProceedToStep3: () => void;
|
||||||
onTriggerToast: (msg: string) => void;
|
onTriggerToast: (msg: string) => void;
|
||||||
@@ -97,16 +114,23 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
agentConfigs,
|
agentConfigs,
|
||||||
selectedAgentId,
|
selectedAgentId,
|
||||||
roles,
|
roles,
|
||||||
|
agentNameToRole,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
availableModels,
|
availableModels,
|
||||||
modelsLoading,
|
modelsLoading,
|
||||||
setIsRoundTableRunning,
|
setSelectedModel,
|
||||||
|
activeArtifactThreadId,
|
||||||
|
seatStubMessages,
|
||||||
|
openArtifactFromStep,
|
||||||
|
openArtifactPreview,
|
||||||
setClarificationDraft,
|
setClarificationDraft,
|
||||||
setCustomAgentInput,
|
setCustomAgentInput,
|
||||||
setAgentConfigs,
|
setAgentConfigs,
|
||||||
setSelectedAgentId,
|
setSelectedAgentId,
|
||||||
|
onPauseRoundtable,
|
||||||
|
onResumeRoundtable,
|
||||||
|
onHandleInjectToRoundtable,
|
||||||
onSubmitClarification,
|
onSubmitClarification,
|
||||||
onInjectToRoundtable,
|
|
||||||
onSavePersona,
|
onSavePersona,
|
||||||
onProceedToStep3,
|
onProceedToStep3,
|
||||||
onTriggerToast,
|
onTriggerToast,
|
||||||
@@ -116,8 +140,27 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
|
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
|
||||||
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]);
|
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]);
|
||||||
|
|
||||||
return (
|
const resolveAgentName = useCallback(
|
||||||
<div className="flex-1 flex flex-col min-h-0 bg-slate-50/20">
|
(idOrName: string) =>
|
||||||
|
agentNameToRole[idOrName.toLowerCase()]?.displayName ?? idOrName,
|
||||||
|
[agentNameToRole],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动
|
||||||
|
const [pauseFlash, setPauseFlash] = useState(false);
|
||||||
|
|
||||||
|
const handleInject = useCallback(() => {
|
||||||
|
onHandleInjectToRoundtable();
|
||||||
|
setPauseFlash(true);
|
||||||
|
window.setTimeout(() => setPauseFlash(false), 1600);
|
||||||
|
}, [onHandleInjectToRoundtable]);
|
||||||
|
|
||||||
|
const isSandboxStreaming =
|
||||||
|
isOrchestrating ||
|
||||||
|
step2RoundtableDialogues.some((dialogue) => dialogue.streaming === true);
|
||||||
|
|
||||||
|
const chatBody = (
|
||||||
|
<>
|
||||||
{/* Headline */}
|
{/* Headline */}
|
||||||
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-white shadow-sm">
|
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-white shadow-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -129,30 +172,55 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5 shrink-0">
|
||||||
{isRoundTableRunning && isOrchestrating ? (
|
<select
|
||||||
|
id="step2-global-model-select"
|
||||||
|
value={selectedModel}
|
||||||
|
onChange={(e) => setSelectedModel(e.target.value)}
|
||||||
|
disabled={modelsLoading || availableModels.length === 0}
|
||||||
|
title={
|
||||||
|
modelsLoading
|
||||||
|
? "正在加载模型列表…"
|
||||||
|
: availableModels.length === 0
|
||||||
|
? "未在 config.yaml 中配置任何模型"
|
||||||
|
: "切换全局推理模型"
|
||||||
|
}
|
||||||
|
className="max-w-[220px] bg-slate-50/50 border border-slate-200 rounded-xl px-3 py-1.5 text-xs font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
|
||||||
|
>
|
||||||
|
{availableModels.length === 0 && (
|
||||||
|
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
|
||||||
|
)}
|
||||||
|
{availableModels.map((m) => (
|
||||||
|
<option key={m.name} value={m.name}>
|
||||||
|
{m.display_name && m.display_name !== m.name
|
||||||
|
? `${m.display_name} (${m.name})`
|
||||||
|
: m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{!isRoundTableRunning ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
type="button"
|
||||||
setIsRoundTableRunning(false);
|
onClick={onResumeRoundtable}
|
||||||
onTriggerToast("⏸️ 会商模拟暂挂!智能体挂起...");
|
className={`bg-emerald-100 hover:bg-emerald-200 text-emerald-800 text-[10px] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-emerald-200/40 shadow-sm ${
|
||||||
}}
|
pauseFlash
|
||||||
className="bg-amber-100 hover:bg-amber-200 text-amber-800 text-[10px] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-amber-200/40 shadow-sm"
|
? "ring-2 ring-emerald-400 ring-offset-1 animate-pulse"
|
||||||
>
|
: ""
|
||||||
<Pause className="w-3.5 h-3.5" />
|
}`}
|
||||||
挂起协商
|
|
||||||
</button>
|
|
||||||
) : !isRoundTableRunning ? (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setIsRoundTableRunning(true);
|
|
||||||
onTriggerToast("▶️ 恢复大模型并行博弈与推理决策");
|
|
||||||
}}
|
|
||||||
className="bg-emerald-100 hover:bg-emerald-200 text-emerald-800 text-[10px] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-emerald-200/40 shadow-sm"
|
|
||||||
>
|
>
|
||||||
<Play className="w-3.5 h-3.5 fill-emerald-800" />
|
<Play className="w-3.5 h-3.5 fill-emerald-800" />
|
||||||
恢复会商
|
恢复会商
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPauseRoundtable}
|
||||||
|
className="bg-amber-100 hover:bg-amber-200 text-amber-800 text-[10px] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-amber-200/40 shadow-sm"
|
||||||
|
>
|
||||||
|
<Pause className="w-3.5 h-3.5" />
|
||||||
|
暂停会商
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -186,7 +254,17 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{step2RoundtableDialogues.map((msg) => (
|
{step2RoundtableDialogues.map((msg) => (
|
||||||
<Step2Message key={msg.id} msg={msg} />
|
<Step2Message
|
||||||
|
key={msg.id}
|
||||||
|
msg={msg}
|
||||||
|
resolveAgentName={resolveAgentName}
|
||||||
|
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
|
||||||
|
onFileCardClick={(path) => {
|
||||||
|
if (msg.agentThreadId) {
|
||||||
|
openArtifactPreview(msg.agentThreadId, path);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{!step2AtBottom && <ScrollToBottomButton onClick={scrollStep2ToBottom} />}
|
{!step2AtBottom && <ScrollToBottomButton onClick={scrollStep2ToBottom} />}
|
||||||
@@ -270,17 +348,19 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
value={customAgentInput}
|
value={customAgentInput}
|
||||||
onChange={(e) => setCustomAgentInput(e.target.value)}
|
onChange={(e) => setCustomAgentInput(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") onInjectToRoundtable();
|
if (e.key === "Enter") handleInject();
|
||||||
}}
|
}}
|
||||||
placeholder="作为指挥官,您可以随时插话输入指令,对智能体博弈朝向实施人工干预..."
|
placeholder="作为指挥官,您可以随时插话输入指令,对智能体博弈朝向实施人工干预..."
|
||||||
className="flex-1 bg-white border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 font-medium"
|
className="flex-1 bg-white border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 font-medium"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onInjectToRoundtable}
|
type="button"
|
||||||
|
onClick={handleInject}
|
||||||
|
title="记录干预指令并立即恢复会商执行"
|
||||||
className="bg-primary hover:bg-[var(--brand-900)] text-white text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-primary/10"
|
className="bg-primary hover:bg-[var(--brand-900)] text-white text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-primary/10"
|
||||||
>
|
>
|
||||||
干预研审
|
干预并发送
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="h-6 w-[1px] bg-slate-200 shrink-0" />
|
<div className="h-6 w-[1px] bg-slate-200 shrink-0" />
|
||||||
@@ -301,7 +381,21 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
达成共识生成全案成果 >
|
达成共识生成全案成果 >
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeSeatMessages = activeArtifactThreadId
|
||||||
|
? seatStubMessages[activeArtifactThreadId]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Step2SandboxLayout
|
||||||
|
activeThreadId={activeArtifactThreadId}
|
||||||
|
isStreaming={isSandboxStreaming}
|
||||||
|
seatMessages={activeSeatMessages}
|
||||||
|
>
|
||||||
|
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">{chatBody}</div>
|
||||||
|
</Step2SandboxLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,9 +409,21 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
*/
|
*/
|
||||||
interface Step2MessageProps {
|
interface Step2MessageProps {
|
||||||
msg: Step2Dialogue;
|
msg: Step2Dialogue;
|
||||||
|
/** 把派活 chip 的 agent id 解析成中文席位名。 */
|
||||||
|
resolveAgentName?: (idOrName: string) => string;
|
||||||
|
onArtifactStepClick?: (
|
||||||
|
step: NonNullable<Step2Dialogue["steps"]>[number],
|
||||||
|
) => void;
|
||||||
|
/** 点击气泡下方 `present_files` 文件卡时打开沙箱(走真实 HTTP 路径)。 */
|
||||||
|
onFileCardClick?: (path: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Step2Message({ msg }: Step2MessageProps) {
|
function Step2Message({
|
||||||
|
msg,
|
||||||
|
resolveAgentName,
|
||||||
|
onArtifactStepClick,
|
||||||
|
onFileCardClick,
|
||||||
|
}: Step2MessageProps) {
|
||||||
const isInjected = msg.sender === "用户 (插话干预)";
|
const isInjected = msg.sender === "用户 (插话干预)";
|
||||||
const isStreaming = msg.streaming === true;
|
const isStreaming = msg.streaming === true;
|
||||||
// 抽出 `<think>...</think>` 作 reasoning;剩下的是要渲染的正文。
|
// 抽出 `<think>...</think>` 作 reasoning;剩下的是要渲染的正文。
|
||||||
@@ -326,6 +432,7 @@ function Step2Message({ msg }: Step2MessageProps) {
|
|||||||
const rawContent = msg.content ?? "";
|
const rawContent = msg.content ?? "";
|
||||||
const { content, reasoning } = splitInlineReasoning(rawContent);
|
const { content, reasoning } = splitInlineReasoning(rawContent);
|
||||||
const hasContent = content.length > 0;
|
const hasContent = content.length > 0;
|
||||||
|
const presentedFiles = collectPresentedFiles(msg.steps);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
@@ -340,7 +447,12 @@ function Step2Message({ msg }: Step2MessageProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{Array.isArray(msg.steps) && msg.steps.length > 0 && (
|
{Array.isArray(msg.steps) && msg.steps.length > 0 && (
|
||||||
<MessageStepsCard steps={msg.steps} streaming={isStreaming} />
|
<MessageStepsCard
|
||||||
|
steps={msg.steps}
|
||||||
|
streaming={isStreaming}
|
||||||
|
onArtifactStepClick={onArtifactStepClick}
|
||||||
|
resolveAgentName={resolveAgentName}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{reasoning && (
|
{reasoning && (
|
||||||
<ReasoningBlock streaming={isStreaming}>{reasoning}</ReasoningBlock>
|
<ReasoningBlock streaming={isStreaming}>{reasoning}</ReasoningBlock>
|
||||||
@@ -351,6 +463,135 @@ function Step2Message({ msg }: Step2MessageProps) {
|
|||||||
<MarkdownContent>{content}</MarkdownContent>
|
<MarkdownContent>{content}</MarkdownContent>
|
||||||
{isStreaming && <StreamingCursor />}
|
{isStreaming && <StreamingCursor />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{presentedFiles.length > 0 && (
|
||||||
|
<PresentFilesRow
|
||||||
|
files={presentedFiles}
|
||||||
|
onClick={onFileCardClick}
|
||||||
|
disabled={!msg.agentThreadId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</MessageBubble>
|
</MessageBubble>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收集气泡里所有"写了文件"的产出路径,按出现顺序去重,聚合到一行文件卡片。
|
||||||
|
*
|
||||||
|
* 三类来源都算:
|
||||||
|
* - `present_files`:总控呈现文件,args.filepaths 是数组(可一次多个);
|
||||||
|
* - `write_file` / `str_replace`:子智能体被后端 excluded_tools 禁用了 present_files,
|
||||||
|
* 只能 write_file 写文件,单文件 path 在 args.path —— 也聚合进卡片,保证"凡是写了
|
||||||
|
* 文件就出可点的卡片",而不是只有 present_files 才出。
|
||||||
|
*/
|
||||||
|
function collectPresentedFiles(
|
||||||
|
steps: Step2Dialogue["steps"],
|
||||||
|
): string[] {
|
||||||
|
if (!Array.isArray(steps) || steps.length === 0) return [];
|
||||||
|
const out: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const add = (fp: unknown) => {
|
||||||
|
if (typeof fp !== "string" || !fp || seen.has(fp)) return;
|
||||||
|
seen.add(fp);
|
||||||
|
out.push(fp);
|
||||||
|
};
|
||||||
|
for (const step of steps) {
|
||||||
|
if (step.key === "tool_calling:present_files") {
|
||||||
|
const fps = step.args?.filepaths;
|
||||||
|
if (Array.isArray(fps)) fps.forEach(add);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
step.key === "tool_calling:write_file" ||
|
||||||
|
step.key === "tool_calling:str_replace"
|
||||||
|
) {
|
||||||
|
add(step.args?.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PresentFilesRow({
|
||||||
|
files,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
files: string[];
|
||||||
|
onClick?: (path: string) => void;
|
||||||
|
disabled: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{files.map((path) => (
|
||||||
|
<PresentFileCard
|
||||||
|
key={path}
|
||||||
|
path={path}
|
||||||
|
disabled={disabled || !onClick}
|
||||||
|
onClick={() => onClick?.(path)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE_EXT_LABELS: Record<string, string> = {
|
||||||
|
md: "Markdown",
|
||||||
|
json: "JSON",
|
||||||
|
csv: "CSV",
|
||||||
|
txt: "TXT",
|
||||||
|
py: "Python",
|
||||||
|
js: "JS",
|
||||||
|
ts: "TS",
|
||||||
|
tsx: "TSX",
|
||||||
|
jsx: "JSX",
|
||||||
|
html: "HTML",
|
||||||
|
css: "CSS",
|
||||||
|
xml: "XML",
|
||||||
|
yaml: "YAML",
|
||||||
|
yml: "YAML",
|
||||||
|
pdf: "PDF",
|
||||||
|
png: "PNG",
|
||||||
|
jpg: "JPG",
|
||||||
|
jpeg: "JPEG",
|
||||||
|
svg: "SVG",
|
||||||
|
zip: "ZIP",
|
||||||
|
};
|
||||||
|
|
||||||
|
function PresentFileCard({
|
||||||
|
path,
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
path: string;
|
||||||
|
disabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const filename = path.split("/").pop() ?? path;
|
||||||
|
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
||||||
|
const typeLabel = FILE_EXT_LABELS[ext] ?? (ext.toUpperCase() || "FILE");
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
title={disabled ? "席位线程未就绪,暂无法预览" : path}
|
||||||
|
className="flex max-w-50 min-w-30 flex-col gap-1 rounded-lg border border-slate-200 bg-white p-3 text-left shadow-sm transition hover:border-primary/50 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<FileIcon className="mt-0.5 size-4 shrink-0 text-slate-400" />
|
||||||
|
<span
|
||||||
|
className="truncate text-[12px] font-medium text-slate-700"
|
||||||
|
title={filename}
|
||||||
|
>
|
||||||
|
{filename}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-mono font-semibold text-slate-500">
|
||||||
|
{typeLabel}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-400">点击预览</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* Step 2 沙箱分栏 —— 对齐主聊天 `ChatBox` 的 60/40 对话 | 产物预览布局。
|
||||||
|
*
|
||||||
|
* 圆桌 Step 2 走自定义 SSE,没有 LangGraph SDK thread;这里用最小 ThreadContext stub
|
||||||
|
* 供 `ArtifactFileDetail` / `useArtifactContent` 工作(真实 path 走 artifacts API 轮询)。
|
||||||
|
*/
|
||||||
|
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||||
|
import { FilesIcon, XIcon } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||||
|
|
||||||
|
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
import {
|
||||||
|
ArtifactFileDetail,
|
||||||
|
ArtifactFileList,
|
||||||
|
useArtifacts,
|
||||||
|
} from "@/components/workspace/artifacts";
|
||||||
|
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||||
|
import type { AgentThreadState } from "@/core/threads";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||||
|
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
||||||
|
|
||||||
|
interface Step2SandboxLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** 当前预览文件所属的席位 thread id。 */
|
||||||
|
activeThreadId: string | null;
|
||||||
|
/** 是否有流式 run 进行中 —— 开启 artifacts API 轮询。 */
|
||||||
|
isStreaming?: boolean;
|
||||||
|
/**
|
||||||
|
* 当前 active 席位的 stub ai messages —— 让 useArtifactContent 通过虚拟 URL
|
||||||
|
* `write-file:...?message_id=...&tool_call_id=...` 直接读 tool_call.args.content,
|
||||||
|
* 对齐主聊天 chats/:threadId 的实时预览体验。
|
||||||
|
*/
|
||||||
|
seatMessages?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Step2SandboxLayout({
|
||||||
|
children,
|
||||||
|
activeThreadId,
|
||||||
|
isStreaming = false,
|
||||||
|
seatMessages,
|
||||||
|
}: Step2SandboxLayoutProps) {
|
||||||
|
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||||
|
const {
|
||||||
|
artifacts: rawArtifacts,
|
||||||
|
open: artifactsOpen,
|
||||||
|
setOpen: setArtifactsOpen,
|
||||||
|
selectedArtifact,
|
||||||
|
} = useArtifacts();
|
||||||
|
const artifacts = rawArtifacts ?? [];
|
||||||
|
|
||||||
|
const artifactPanelOpen = artifactsOpen;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (layoutRef.current) {
|
||||||
|
layoutRef.current.setLayout(
|
||||||
|
artifactPanelOpen ? OPEN_MODE : CLOSE_MODE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [artifactPanelOpen]);
|
||||||
|
|
||||||
|
const threadStub = useMemo(() => {
|
||||||
|
const messages = seatMessages ?? [];
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
isLoading: isStreaming,
|
||||||
|
isThreadLoading: false,
|
||||||
|
values: {
|
||||||
|
title: "",
|
||||||
|
messages,
|
||||||
|
artifacts,
|
||||||
|
},
|
||||||
|
} as unknown as BaseStream<AgentThreadState>;
|
||||||
|
}, [artifacts, isStreaming, seatMessages]);
|
||||||
|
|
||||||
|
const previewThreadId = activeThreadId ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThreadContext.Provider value={{ thread: threadStub, isMock: false }}>
|
||||||
|
<ResizablePanelGroup
|
||||||
|
id="roundtable-step2-panels"
|
||||||
|
orientation="horizontal"
|
||||||
|
defaultLayout={{ chat: 100, artifacts: 0 }}
|
||||||
|
groupRef={layoutRef}
|
||||||
|
className="h-full min-h-0 flex-1"
|
||||||
|
>
|
||||||
|
<ResizablePanel className="relative" defaultSize={100} id="chat">
|
||||||
|
{children}
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle
|
||||||
|
id="roundtable-step2-separator"
|
||||||
|
className={cn(
|
||||||
|
"opacity-33 hover:opacity-100",
|
||||||
|
!artifactPanelOpen && "pointer-events-none opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<ResizablePanel
|
||||||
|
className={cn(
|
||||||
|
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
|
||||||
|
!artifactsOpen && "opacity-0",
|
||||||
|
)}
|
||||||
|
id="artifacts"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
|
||||||
|
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedArtifact && previewThreadId ? (
|
||||||
|
<ArtifactFileDetail
|
||||||
|
className="size-full"
|
||||||
|
filepath={selectedArtifact}
|
||||||
|
threadId={previewThreadId}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
{artifacts.length === 0 ? (
|
||||||
|
<ConversationEmptyState
|
||||||
|
icon={<FilesIcon />}
|
||||||
|
title="未选择文件"
|
||||||
|
description="智能体写入文件后将在此预览"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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">产出文件</h2>
|
||||||
|
</header>
|
||||||
|
<main className="min-h-0 grow">
|
||||||
|
{previewThreadId ? (
|
||||||
|
<ArtifactFileList
|
||||||
|
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||||
|
files={artifacts}
|
||||||
|
threadId={previewThreadId}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</ThreadContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||||
import {
|
import {
|
||||||
initMultiAgent,
|
initMultiAgent,
|
||||||
streamMultiAgent,
|
streamMultiAgent,
|
||||||
@@ -44,6 +45,8 @@ export type Step2Dialogue = {
|
|||||||
time: string;
|
time: string;
|
||||||
confidence: string;
|
confidence: string;
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
|
/** special 席位气泡对应的 LangGraph thread,用于沙箱预览。 */
|
||||||
|
agentThreadId?: string;
|
||||||
steps?: Array<{
|
steps?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
key: string;
|
key: string;
|
||||||
@@ -53,6 +56,24 @@ export type Step2Dialogue = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stub AI message —— 用于把 special seat SSE 拿到的工具调用注入到 ThreadContext
|
||||||
|
* 的 `thread.messages` 里,让 `useArtifactContent` 在沙箱里通过虚拟 URL
|
||||||
|
* `write-file:<path>?message_id=...&tool_call_id=...` 直接读 `args.content`,
|
||||||
|
* 完全对齐主聊天 `chats/:threadId` 的 write_file 实时预览体验。
|
||||||
|
*/
|
||||||
|
export interface StubToolCall {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
export interface StubAiMessage {
|
||||||
|
id: string;
|
||||||
|
type: "ai";
|
||||||
|
content: string;
|
||||||
|
tool_calls: StubToolCall[];
|
||||||
|
}
|
||||||
|
|
||||||
/** Persona 抽屉的 per-seat 覆盖(prompt / temp 仅前端记录,model 真实生效)。 */
|
/** Persona 抽屉的 per-seat 覆盖(prompt / temp 仅前端记录,model 真实生效)。 */
|
||||||
export type AgentConfig = {
|
export type AgentConfig = {
|
||||||
model: string;
|
model: string;
|
||||||
@@ -70,6 +91,12 @@ export type Step2Snapshot = {
|
|||||||
hasConsensus: boolean;
|
hasConsensus: boolean;
|
||||||
consensusPercentage: number;
|
consensusPercentage: number;
|
||||||
budgetLimit: number;
|
budgetLimit: number;
|
||||||
|
/**
|
||||||
|
* 每个 seat thread 的 stub ai messages —— 加载草稿后点击历史 write_file 步骤,
|
||||||
|
* 沙箱要能复读 args.content。不存这部分 stub 的话,虚拟 URL 解析会拿不到内容。
|
||||||
|
* 老草稿可能没有这字段,反序列化时按空对象兜底。
|
||||||
|
*/
|
||||||
|
seatStubMessages: Record<string, StubAiMessage[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,9 +171,15 @@ export interface UseStep2OrchestrationReturn {
|
|||||||
// ─── Handlers ───────────────────────────────────────────────────────
|
// ─── Handlers ───────────────────────────────────────────────────────
|
||||||
/** 中断当前编排循环 + 所有 in-flight SSE。 */
|
/** 中断当前编排循环 + 所有 in-flight SSE。 */
|
||||||
stopOrchestration: () => void;
|
stopOrchestration: () => void;
|
||||||
|
/** 与 Step2Panel「暂停会商」按钮相同 —— 挂起编排并中止 in-flight SSE。 */
|
||||||
|
pauseRoundtable: () => void;
|
||||||
|
/** 与 Step2Panel「恢复会商」按钮相同 —— 恢复自动研讨(含待处理的干预指令)。 */
|
||||||
|
resumeRoundtable: () => void;
|
||||||
|
/** 记录干预指令(不暂停不恢复);通常由 Step2Panel 在 pauseRoundtable 之后调用。 */
|
||||||
|
recordInjectMessage: () => void;
|
||||||
/** 中断所有 in-flight SSE(给新建任务 / 加载草稿用)。 */
|
/** 中断所有 in-flight SSE(给新建任务 / 加载草稿用)。 */
|
||||||
abortAllInFlight: () => void;
|
abortAllInFlight: () => void;
|
||||||
/** 用户「干预研审」按钮 —— 追加一条人工指令气泡并触发一轮编排。 */
|
/** 一键干预:记录指令 + 自动恢复会商 + 触发新一轮编排(替代 pause + record + resume 组合)。 */
|
||||||
handleInjectToRoundtable: () => void;
|
handleInjectToRoundtable: () => void;
|
||||||
/** 总控澄清回复 —— 把回答拼到 prompt 里再起一轮编排。 */
|
/** 总控澄清回复 —— 把回答拼到 prompt 里再起一轮编排。 */
|
||||||
submitClarification: (rawAnswer: string) => void;
|
submitClarification: (rawAnswer: string) => void;
|
||||||
@@ -158,6 +191,24 @@ export interface UseStep2OrchestrationReturn {
|
|||||||
hydrateFromDraft: (snapshot: Step2Snapshot) => void;
|
hydrateFromDraft: (snapshot: Step2Snapshot) => void;
|
||||||
/** 「新建任务」清空 Step 2 数据(threadIds / dialogues / 共识等)。 */
|
/** 「新建任务」清空 Step 2 数据(threadIds / dialogues / 共识等)。 */
|
||||||
resetStep2State: () => void;
|
resetStep2State: () => void;
|
||||||
|
/** 当前沙箱预览绑定的席位 thread id。 */
|
||||||
|
activeArtifactThreadId: string | null;
|
||||||
|
/**
|
||||||
|
* 各 special seat 的 stub messages —— 注入到沙箱 ThreadContext.thread.messages,
|
||||||
|
* 让 `useArtifactContent` 通过虚拟 URL 直接读 `args.content`(对齐主聊天)。
|
||||||
|
*/
|
||||||
|
seatStubMessages: Record<string, StubAiMessage[]>;
|
||||||
|
/** 从工具步骤或流式 write_file 打开沙箱预览。 */
|
||||||
|
openArtifactPreview: (
|
||||||
|
threadId: string,
|
||||||
|
path: string,
|
||||||
|
options?: { auto?: boolean },
|
||||||
|
) => void;
|
||||||
|
/** 点击气泡内工具步骤时打开沙箱。 */
|
||||||
|
openArtifactFromStep: (
|
||||||
|
dialogue: Step2Dialogue,
|
||||||
|
step: NonNullable<Step2Dialogue["steps"]>[number],
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useStep2Orchestration(
|
export function useStep2Orchestration(
|
||||||
@@ -172,7 +223,36 @@ export function useStep2Orchestration(
|
|||||||
loadedFromDraftRef,
|
loadedFromDraftRef,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
|
const {
|
||||||
|
artifacts,
|
||||||
|
setArtifacts,
|
||||||
|
select: selectArtifact,
|
||||||
|
setOpen: setArtifactsOpen,
|
||||||
|
deselect: deselectArtifact,
|
||||||
|
autoOpen,
|
||||||
|
autoSelect,
|
||||||
|
} = useArtifacts();
|
||||||
|
const artifactPathsRef = useRef<string[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
artifactPathsRef.current = artifacts ?? [];
|
||||||
|
}, [artifacts]);
|
||||||
|
// 用户手动关掉沙箱后,autoOpen 会被 ArtifactsProvider 置 false —— 把它们打到 ref
|
||||||
|
// 上,让 openSeatVirtualArtifact 不用进 useCallback deps,避免每次 toggle 重建函数。
|
||||||
|
const autoOpenFlagsRef = useRef({ autoOpen, autoSelect });
|
||||||
|
useEffect(() => {
|
||||||
|
autoOpenFlagsRef.current = { autoOpen, autoSelect };
|
||||||
|
}, [autoOpen, autoSelect]);
|
||||||
|
/** 已自动打开过的 virtualUrl 集合 —— 流式期间一秒会回调几十次,防止重复 schedule 开沙箱。 */
|
||||||
|
const hasAutoOpenedRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
// ─── State ─────────────────────────────────────────────────────────
|
// ─── State ─────────────────────────────────────────────────────────
|
||||||
|
const [activeArtifactThreadId, setActiveArtifactThreadId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [seatStubMessages, setSeatStubMessages] = useState<
|
||||||
|
Record<string, StubAiMessage[]>
|
||||||
|
>({});
|
||||||
|
const seatStubMessagesRef = useRef<Record<string, StubAiMessage[]>>({});
|
||||||
const [selectedAgents, setSelectedAgents] = useState<SelectedAgent[]>(
|
const [selectedAgents, setSelectedAgents] = useState<SelectedAgent[]>(
|
||||||
DEFAULT_SELECTED_AGENTS,
|
DEFAULT_SELECTED_AGENTS,
|
||||||
);
|
);
|
||||||
@@ -222,6 +302,8 @@ export function useStep2Orchestration(
|
|||||||
const orchestrationCancelRef = useRef<boolean>(false);
|
const orchestrationCancelRef = useRef<boolean>(false);
|
||||||
const step2InitRef = useRef<boolean>(false);
|
const step2InitRef = useRef<boolean>(false);
|
||||||
const activeAbortersRef = useRef<Set<AbortController>>(new Set());
|
const activeAbortersRef = useRef<Set<AbortController>>(new Set());
|
||||||
|
/** 干预研审:恢复会商后待执行的 leader prompt。 */
|
||||||
|
const pendingInjectPromptRef = useRef<string | null>(null);
|
||||||
// selectedModel / agentConfigs / agentNameToRole / threadIds / coordinatorName
|
// selectedModel / agentConfigs / agentNameToRole / threadIds / coordinatorName
|
||||||
// 等等被 runOrchestration 在循环里反复读取。把它们打到 ref 上,可以让
|
// 等等被 runOrchestration 在循环里反复读取。把它们打到 ref 上,可以让
|
||||||
// runOrchestration 的 useCallback deps 大幅收窄,避免每次模型切换、agentConfigs
|
// runOrchestration 的 useCallback deps 大幅收窄,避免每次模型切换、agentConfigs
|
||||||
@@ -251,6 +333,334 @@ export function useStep2Orchestration(
|
|||||||
isOrchestratingRef.current = isOrchestrating;
|
isOrchestratingRef.current = isOrchestrating;
|
||||||
}, [isOrchestrating]);
|
}, [isOrchestrating]);
|
||||||
|
|
||||||
|
const resolveSeatThreadId = useCallback((ids: ThreadIdMap, agentName: string) => {
|
||||||
|
return ids[agentName] ?? ids[agentName.toLowerCase()];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把工具调用幂等地写进 seat 的 stub ai message —— **同 (toolName, path)** 视为同一次
|
||||||
|
* 调用,后续帧只更新 args(LangGraph 流式期间会多次发送完整 tool_calls 覆盖 args)。
|
||||||
|
* 返回的 `messageId`/`toolCallId` 用于拼出 `write-file:` 虚拟 URL,沙箱 useArtifactContent
|
||||||
|
* 据此从 thread.messages 取出 args.content,完全绕过 HTTP。
|
||||||
|
*/
|
||||||
|
const upsertSeatToolCall = useCallback(
|
||||||
|
(
|
||||||
|
seatThreadId: string,
|
||||||
|
toolName: string,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
): { messageId: string; toolCallId: string } => {
|
||||||
|
const path = typeof args.path === "string" ? args.path : "";
|
||||||
|
const current = seatStubMessagesRef.current[seatThreadId] ?? [];
|
||||||
|
const existing = current[0];
|
||||||
|
const baseMsg: StubAiMessage = existing ?? {
|
||||||
|
id: `roundtable-stub-${seatThreadId}`,
|
||||||
|
type: "ai",
|
||||||
|
content: "",
|
||||||
|
tool_calls: [],
|
||||||
|
};
|
||||||
|
const idx = baseMsg.tool_calls.findIndex(
|
||||||
|
(tc) =>
|
||||||
|
tc.name === toolName &&
|
||||||
|
String((tc.args as { path?: unknown })?.path ?? "") === path,
|
||||||
|
);
|
||||||
|
const toolCallId =
|
||||||
|
idx >= 0
|
||||||
|
? baseMsg.tool_calls[idx].id
|
||||||
|
: `tc-${seatThreadId}-${baseMsg.tool_calls.length}`;
|
||||||
|
const nextToolCall: StubToolCall = {
|
||||||
|
id: toolCallId,
|
||||||
|
name: toolName,
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
const nextToolCalls =
|
||||||
|
idx >= 0
|
||||||
|
? baseMsg.tool_calls.map((tc, i) =>
|
||||||
|
i === idx ? nextToolCall : tc,
|
||||||
|
)
|
||||||
|
: [...baseMsg.tool_calls, nextToolCall];
|
||||||
|
const nextAiMsg: StubAiMessage = {
|
||||||
|
...baseMsg,
|
||||||
|
tool_calls: nextToolCalls,
|
||||||
|
};
|
||||||
|
const nextMap = {
|
||||||
|
...seatStubMessagesRef.current,
|
||||||
|
[seatThreadId]: [nextAiMsg],
|
||||||
|
};
|
||||||
|
seatStubMessagesRef.current = nextMap;
|
||||||
|
setSeatStubMessages(nextMap);
|
||||||
|
return { messageId: nextAiMsg.id, toolCallId };
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用「虚拟 URL」(write-file: scheme)打开沙箱 —— 内容来自 stub messages 的 args.content,
|
||||||
|
* 不污染 `artifacts[]` 真实路径列表(列表后续可由 present_files / 显式 setArtifacts 填充)。
|
||||||
|
*
|
||||||
|
* auto=true 流式期间:
|
||||||
|
* - 已自动打开过该 virtualUrl 就直接跳过(防止 onPhaseChange / onUpstreamFrame 每帧重复 schedule);
|
||||||
|
* - 用户手动关闭过沙箱(`autoOpen` 已被 ArtifactsProvider 置 false)也尊重决定,不再自动开。
|
||||||
|
* auto=false(用户点击步骤 / 文件卡)始终强制打开。
|
||||||
|
*/
|
||||||
|
const openSeatVirtualArtifact = useCallback(
|
||||||
|
(seatThreadId: string, virtualUrl: string, auto: boolean) => {
|
||||||
|
if (auto) {
|
||||||
|
if (hasAutoOpenedRef.current.has(virtualUrl)) return;
|
||||||
|
const flags = autoOpenFlagsRef.current;
|
||||||
|
if (!flags.autoOpen || !flags.autoSelect) return;
|
||||||
|
hasAutoOpenedRef.current.add(virtualUrl);
|
||||||
|
}
|
||||||
|
setActiveArtifactThreadId(seatThreadId);
|
||||||
|
const open = () => {
|
||||||
|
selectArtifact(virtualUrl, auto);
|
||||||
|
setArtifactsOpen(true);
|
||||||
|
};
|
||||||
|
if (auto) {
|
||||||
|
window.setTimeout(open, 100);
|
||||||
|
} else {
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectArtifact, setArtifactsOpen],
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildWriteFileVirtualUrl = useCallback(
|
||||||
|
(path: string, messageId: string, toolCallId: string) =>
|
||||||
|
new URL(
|
||||||
|
`write-file:${path}?message_id=${encodeURIComponent(
|
||||||
|
messageId,
|
||||||
|
)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
|
||||||
|
).toString(),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 反查某个产出文件是由哪个席位 thread 写入的。
|
||||||
|
*
|
||||||
|
* 总控(coordinator)常用 `present_files` 呈现「子智能体写的文件」,但文件物理上落在
|
||||||
|
* 子席位 thread 的 outputs 里,coordinator thread 并没有 —— 直接拿总控气泡的
|
||||||
|
* `agentThreadId`(=coordinator thread)走 HTTP 拉会 404(`Thread ... not found`)。
|
||||||
|
* 流式期间子席位 write_file 时,内容已缓存进 seatStubMessages(key=真实写入 thread)。
|
||||||
|
* 这里按 path 反查那条 stub,让关闭沙箱后再点文件卡也能复用虚拟预览(内存内容,
|
||||||
|
* 与流式期间体验一致,且不依赖后端 thread 是否仍可访问)。
|
||||||
|
*/
|
||||||
|
const findSeatArtifactSource = useCallback(
|
||||||
|
(
|
||||||
|
rawPath: string,
|
||||||
|
): { seatThreadId: string; messageId: string; toolCallId: string } | null => {
|
||||||
|
const target = rawPath.trim();
|
||||||
|
if (!target) return null;
|
||||||
|
const targetBase = target.split("/").pop() ?? target;
|
||||||
|
let basenameMatch:
|
||||||
|
| { seatThreadId: string; messageId: string; toolCallId: string }
|
||||||
|
| null = null;
|
||||||
|
for (const [seatThreadId, msgs] of Object.entries(
|
||||||
|
seatStubMessagesRef.current,
|
||||||
|
)) {
|
||||||
|
const msg = msgs?.[0];
|
||||||
|
if (!msg) continue;
|
||||||
|
for (const tc of msg.tool_calls) {
|
||||||
|
if (tc.name !== "write_file" && tc.name !== "str_replace") continue;
|
||||||
|
const p =
|
||||||
|
typeof (tc.args as { path?: unknown })?.path === "string"
|
||||||
|
? ((tc.args as { path?: string }).path as string)
|
||||||
|
: "";
|
||||||
|
if (!p) continue;
|
||||||
|
if (p === target) {
|
||||||
|
return { seatThreadId, messageId: msg.id, toolCallId: tc.id };
|
||||||
|
}
|
||||||
|
if (!basenameMatch && (p.split("/").pop() ?? p) === targetBase) {
|
||||||
|
basenameMatch = { seatThreadId, messageId: msg.id, toolCallId: tc.id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return basenameMatch;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `openArtifactPreview` —— 走真实路径(present_files 等已落盘场景),走 HTTP API 加载。
|
||||||
|
* 沙箱 write_file 实时预览请用 `openSeatVirtualArtifact`。
|
||||||
|
*/
|
||||||
|
const openArtifactPreview = useCallback(
|
||||||
|
(threadId: string, path: string, options?: { auto?: boolean }) => {
|
||||||
|
const normalized = path.trim();
|
||||||
|
if (!normalized) return;
|
||||||
|
|
||||||
|
// present_files 呈现的文件多半是子智能体 write_file 写入的:真实内容/thread 在 stub 里。
|
||||||
|
// 先反查 stub 走虚拟预览(内存内容)—— 关闭沙箱后再点文件卡也能显示,且不受
|
||||||
|
// 总控 coordinator thread 在后端 artifacts 路由下 not found 的影响。
|
||||||
|
const seatSource = findSeatArtifactSource(normalized);
|
||||||
|
if (seatSource) {
|
||||||
|
const virtualUrl = buildWriteFileVirtualUrl(
|
||||||
|
normalized,
|
||||||
|
seatSource.messageId,
|
||||||
|
seatSource.toolCallId,
|
||||||
|
);
|
||||||
|
openSeatVirtualArtifact(
|
||||||
|
seatSource.seatThreadId,
|
||||||
|
virtualUrl,
|
||||||
|
options?.auto ?? false,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!threadId) return;
|
||||||
|
|
||||||
|
setActiveArtifactThreadId(threadId);
|
||||||
|
const prev = artifactPathsRef.current;
|
||||||
|
if (!prev.includes(normalized)) {
|
||||||
|
const next = [...prev, normalized];
|
||||||
|
artifactPathsRef.current = next;
|
||||||
|
setArtifacts(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const openPanel = () => {
|
||||||
|
selectArtifact(normalized, options?.auto ?? false);
|
||||||
|
setArtifactsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options?.auto) {
|
||||||
|
window.setTimeout(openPanel, 100);
|
||||||
|
} else {
|
||||||
|
openPanel();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
findSeatArtifactSource,
|
||||||
|
buildWriteFileVirtualUrl,
|
||||||
|
openSeatVirtualArtifact,
|
||||||
|
selectArtifact,
|
||||||
|
setArtifacts,
|
||||||
|
setArtifactsOpen,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openArtifactFromStep = useCallback(
|
||||||
|
(
|
||||||
|
dialogue: Step2Dialogue,
|
||||||
|
step: NonNullable<Step2Dialogue["steps"]>[number],
|
||||||
|
) => {
|
||||||
|
const seatThreadId = dialogue.agentThreadId;
|
||||||
|
if (!seatThreadId) return;
|
||||||
|
const [, toolName] = step.key.split(":");
|
||||||
|
if (toolName === "write_file" || toolName === "str_replace") {
|
||||||
|
const path = step.args?.path;
|
||||||
|
if (typeof path !== "string" || !step.args) return;
|
||||||
|
const { messageId, toolCallId } = upsertSeatToolCall(
|
||||||
|
seatThreadId,
|
||||||
|
toolName,
|
||||||
|
step.args,
|
||||||
|
);
|
||||||
|
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
|
||||||
|
openSeatVirtualArtifact(seatThreadId, virtualUrl, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (toolName === "read_file") {
|
||||||
|
// 验证类「查看文件…」步骤:用 args.path 在沙箱预览该文件。
|
||||||
|
// openArtifactPreview 内部 findSeatArtifactSource 优先按 path 命中流式期间
|
||||||
|
// write_file 缓存的内容(虚拟预览);未命中再走 seat thread 的 outputs HTTP。
|
||||||
|
const path = step.args?.path;
|
||||||
|
if (typeof path === "string" && path) {
|
||||||
|
openArtifactPreview(seatThreadId, path);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (toolName === "present_files") {
|
||||||
|
const filepaths = step.args?.filepaths;
|
||||||
|
if (Array.isArray(filepaths)) {
|
||||||
|
const first = filepaths.find(
|
||||||
|
(p): p is string => typeof p === "string" && p.length > 0,
|
||||||
|
);
|
||||||
|
if (first) openArtifactPreview(seatThreadId, first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
buildWriteFileVirtualUrl,
|
||||||
|
openArtifactPreview,
|
||||||
|
openSeatVirtualArtifact,
|
||||||
|
upsertSeatToolCall,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleArtifactToolPhase = useCallback(
|
||||||
|
(
|
||||||
|
phase: StreamPhase,
|
||||||
|
toolName: string | undefined,
|
||||||
|
args: Record<string, unknown> | undefined,
|
||||||
|
seatThreadId: string | undefined,
|
||||||
|
) => {
|
||||||
|
if (phase !== "tool_calling") return;
|
||||||
|
if (!seatThreadId) return;
|
||||||
|
const isArtifactTool =
|
||||||
|
toolName === "write_file" || toolName === "str_replace";
|
||||||
|
if (!isArtifactTool) return;
|
||||||
|
const path = args?.path;
|
||||||
|
if (typeof path !== "string" || !path || !args) {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
console.debug(
|
||||||
|
"[roundtable-sandbox] tool_calling without path yet",
|
||||||
|
{ toolName, args },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { messageId, toolCallId } = upsertSeatToolCall(
|
||||||
|
seatThreadId,
|
||||||
|
toolName as string,
|
||||||
|
args,
|
||||||
|
);
|
||||||
|
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
console.debug(
|
||||||
|
"[roundtable-sandbox] auto-open from phase",
|
||||||
|
{ toolName, path, seatThreadId, virtualUrl },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
openSeatVirtualArtifact(seatThreadId, virtualUrl, true);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
buildWriteFileVirtualUrl,
|
||||||
|
openSeatVirtualArtifact,
|
||||||
|
upsertSeatToolCall,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兜底:从 LangGraph messages-tuple 帧里扫描完整 tool_calls,把 args 持续灌进 stub。
|
||||||
|
* 与 `handleArtifactToolPhase` 相比:
|
||||||
|
* - 后者依赖 `onPhaseChange` 在 phase==="tool_calling" 时回调,首次出现 path 即触发自动打开;
|
||||||
|
* - 前者覆盖「phase 已切到 tool_result / streaming 后,后续帧又带回完整 tool_calls」的边界,
|
||||||
|
* 保证沙箱 MD 始终拿到 args.content 的最新版本(LangGraph 在收口阶段仍可能再发一次)。
|
||||||
|
* 不做自动打开 —— 那是 handleArtifactToolPhase 的职责;这里只做 args 同步。
|
||||||
|
*/
|
||||||
|
const handleUpstreamFrame = useCallback(
|
||||||
|
(frame: unknown, seatThreadId: string | undefined) => {
|
||||||
|
if (!seatThreadId) return;
|
||||||
|
if (!Array.isArray(frame) || frame.length === 0) return;
|
||||||
|
const chunk = frame[0];
|
||||||
|
if (!chunk || typeof chunk !== "object") return;
|
||||||
|
const obj = chunk as Record<string, unknown>;
|
||||||
|
const toolCalls = obj.tool_calls;
|
||||||
|
if (!Array.isArray(toolCalls) || toolCalls.length === 0) return;
|
||||||
|
for (const tc of toolCalls) {
|
||||||
|
if (!tc || typeof tc !== "object") continue;
|
||||||
|
const tcObj = tc as Record<string, unknown>;
|
||||||
|
const name = typeof tcObj.name === "string" ? tcObj.name : "";
|
||||||
|
if (name !== "write_file" && name !== "str_replace") continue;
|
||||||
|
const tcArgs = tcObj.args;
|
||||||
|
if (!tcArgs || typeof tcArgs !== "object") continue;
|
||||||
|
const argsObj = tcArgs as Record<string, unknown>;
|
||||||
|
const path = argsObj.path;
|
||||||
|
if (typeof path !== "string" || !path) continue;
|
||||||
|
upsertSeatToolCall(seatThreadId, name, argsObj);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[upsertSeatToolCall],
|
||||||
|
);
|
||||||
|
|
||||||
// ─── Dialogue / Speaker helpers ────────────────────────────────────
|
// ─── Dialogue / Speaker helpers ────────────────────────────────────
|
||||||
const appendDialogue = useCallback(
|
const appendDialogue = useCallback(
|
||||||
(card: {
|
(card: {
|
||||||
@@ -323,7 +733,9 @@ export function useStep2Orchestration(
|
|||||||
const steps: Step2Dialogue["steps"] = d.steps ?? [];
|
const steps: Step2Dialogue["steps"] = d.steps ?? [];
|
||||||
const last = steps[steps.length - 1];
|
const last = steps[steps.length - 1];
|
||||||
if (last && last.key === key) {
|
if (last && last.key === key) {
|
||||||
if (args && !last.args) {
|
// 同 phase:tool 重复回调 —— 流式累积期 args 会持续长大(present_files
|
||||||
|
// 多文件分批、write_file 内容增长),拿最新 args 覆盖,不要早早定死。
|
||||||
|
if (args && args !== last.args) {
|
||||||
const next = [...steps];
|
const next = [...steps];
|
||||||
next[next.length - 1] = { ...last, args };
|
next[next.length - 1] = { ...last, args };
|
||||||
return { ...d, steps: next };
|
return { ...d, steps: next };
|
||||||
@@ -349,6 +761,7 @@ export function useStep2Orchestration(
|
|||||||
avatarType: string;
|
avatarType: string;
|
||||||
initialContent?: string;
|
initialContent?: string;
|
||||||
confidence?: string;
|
confidence?: string;
|
||||||
|
agentThreadId?: string;
|
||||||
}): number => {
|
}): number => {
|
||||||
const id = Date.now() + Math.random();
|
const id = Date.now() + Math.random();
|
||||||
setStep2RoundtableDialogues((prev) => [
|
setStep2RoundtableDialogues((prev) => [
|
||||||
@@ -362,6 +775,7 @@ export function useStep2Orchestration(
|
|||||||
confidence: card.confidence ?? "正在输出…",
|
confidence: card.confidence ?? "正在输出…",
|
||||||
streaming: true,
|
streaming: true,
|
||||||
steps: [],
|
steps: [],
|
||||||
|
agentThreadId: card.agentThreadId,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
return id;
|
return id;
|
||||||
@@ -500,11 +914,15 @@ export function useStep2Orchestration(
|
|||||||
safetyCounter += 1;
|
safetyCounter += 1;
|
||||||
highlightSpeaker(2);
|
highlightSpeaker(2);
|
||||||
|
|
||||||
|
// 总控也可能直接 write_file —— 之前只接了 special 流,导致总控写文件时沙箱不自动打开。
|
||||||
|
// 这里把 leader thread id 绑到气泡 + artifact handler 上,与 special 同样的行为。
|
||||||
|
const leaderThreadId = resolveSeatThreadId(ids, coord);
|
||||||
const leaderBubbleId = appendStreamingDialogue({
|
const leaderBubbleId = appendStreamingDialogue({
|
||||||
sender: "总控协调",
|
sender: "总控协调",
|
||||||
avatarType: "blue",
|
avatarType: "blue",
|
||||||
initialContent: "",
|
initialContent: "",
|
||||||
confidence: "调度中",
|
confidence: "调度中",
|
||||||
|
agentThreadId: leaderThreadId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const leaderAborter = new AbortController();
|
const leaderAborter = new AbortController();
|
||||||
@@ -528,7 +946,10 @@ export function useStep2Orchestration(
|
|||||||
`${phase}:${toolName ?? ""}`,
|
`${phase}:${toolName ?? ""}`,
|
||||||
args,
|
args,
|
||||||
);
|
);
|
||||||
|
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
|
||||||
},
|
},
|
||||||
|
onUpstreamFrame: (frame) =>
|
||||||
|
handleUpstreamFrame(frame, leaderThreadId),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
activeAbortersRef.current.delete(leaderAborter);
|
activeAbortersRef.current.delete(leaderAborter);
|
||||||
@@ -587,11 +1008,13 @@ export function useStep2Orchestration(
|
|||||||
? agentConfigsRef.current[meta.speakerId]?.model
|
? agentConfigsRef.current[meta.speakerId]?.model
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const seatThreadId = resolveSeatThreadId(ids, subName);
|
||||||
const subBubbleId = appendStreamingDialogue({
|
const subBubbleId = appendStreamingDialogue({
|
||||||
sender: meta?.displayName ?? subName,
|
sender: meta?.displayName ?? subName,
|
||||||
avatarType: meta?.avatarType ?? "blue",
|
avatarType: meta?.avatarType ?? "blue",
|
||||||
initialContent: "",
|
initialContent: "",
|
||||||
confidence: "调度中",
|
confidence: "调度中",
|
||||||
|
agentThreadId: seatThreadId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const subAborter = new AbortController();
|
const subAborter = new AbortController();
|
||||||
@@ -616,7 +1039,15 @@ export function useStep2Orchestration(
|
|||||||
`${phase}:${toolName ?? ""}`,
|
`${phase}:${toolName ?? ""}`,
|
||||||
args,
|
args,
|
||||||
);
|
);
|
||||||
|
handleArtifactToolPhase(
|
||||||
|
phase,
|
||||||
|
toolName,
|
||||||
|
args,
|
||||||
|
seatThreadId,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
onUpstreamFrame: (frame) =>
|
||||||
|
handleUpstreamFrame(frame, seatThreadId),
|
||||||
});
|
});
|
||||||
if (orchestrationCancelRef.current) {
|
if (orchestrationCancelRef.current) {
|
||||||
finalizeDialogue(
|
finalizeDialogue(
|
||||||
@@ -684,6 +1115,9 @@ export function useStep2Orchestration(
|
|||||||
markSpeakerIdle,
|
markSpeakerIdle,
|
||||||
clearAllStreamingSpeakers,
|
clearAllStreamingSpeakers,
|
||||||
triggerToast,
|
triggerToast,
|
||||||
|
handleArtifactToolPhase,
|
||||||
|
handleUpstreamFrame,
|
||||||
|
resolveSeatThreadId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -765,9 +1199,7 @@ export function useStep2Orchestration(
|
|||||||
|
|
||||||
// 挂起 / 恢复:与上面的 init effect 独立。`isRoundTableRunning` toggle 时:
|
// 挂起 / 恢复:与上面的 init effect 独立。`isRoundTableRunning` toggle 时:
|
||||||
// - false → stopOrchestration
|
// - false → stopOrchestration
|
||||||
// - true 且 idle → 触发一次「请继续」的 leader cycle
|
// - true 且 idle → 触发 leader cycle(若有干预指令则优先执行)
|
||||||
// 注意 deps 故意只有 isRoundTableRunning,不再额外加 currentStep —— 回到 Step 2
|
|
||||||
// 检视讨论时不应该自动 fire 新的 leader 调用。
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentStep !== 2) return;
|
if (currentStep !== 2) return;
|
||||||
if (!isRoundTableRunning) {
|
if (!isRoundTableRunning) {
|
||||||
@@ -781,6 +1213,18 @@ export function useStep2Orchestration(
|
|||||||
!coordinatorNameRef.current
|
!coordinatorNameRef.current
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
const pending = pendingInjectPromptRef.current;
|
||||||
|
if (pending) {
|
||||||
|
pendingInjectPromptRef.current = null;
|
||||||
|
void runOrchestration(
|
||||||
|
pending,
|
||||||
|
threadIdsRef.current,
|
||||||
|
coordinatorNameRef.current,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void runOrchestration(
|
void runOrchestration(
|
||||||
"用户已恢复研讨,请继续上一轮的协调工作。",
|
"用户已恢复研讨,请继续上一轮的协调工作。",
|
||||||
threadIdsRef.current,
|
threadIdsRef.current,
|
||||||
@@ -789,35 +1233,69 @@ export function useStep2Orchestration(
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isRoundTableRunning]);
|
}, [isRoundTableRunning]);
|
||||||
|
|
||||||
// ─── Public handlers ─────────────────────────────────────────────
|
const pauseRoundtable = useCallback(() => {
|
||||||
const handleInjectToRoundtable = useCallback(() => {
|
setIsRoundTableRunning(false);
|
||||||
if (!customAgentInput.trim()) return;
|
orchestrationCancelRef.current = true;
|
||||||
if (!threadIdsRef.current || !coordinatorNameRef.current) {
|
stopOrchestration();
|
||||||
triggerToast("⚠️ 圆桌尚未就绪,请稍候");
|
triggerToast("⏸️ 会商已暂停");
|
||||||
return;
|
}, [stopOrchestration, triggerToast]);
|
||||||
}
|
|
||||||
if (isOrchestratingRef.current) {
|
|
||||||
triggerToast("⏳ 当前轮次仍在进行中,请等待或暂停后再发送");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const resumeRoundtable = useCallback(() => {
|
||||||
|
setIsRoundTableRunning(true);
|
||||||
|
triggerToast("▶️ 恢复大模型并行博弈与推理决策");
|
||||||
|
}, [triggerToast]);
|
||||||
|
|
||||||
|
const recordInjectMessage = useCallback(() => {
|
||||||
const userText = customAgentInput.trim();
|
const userText = customAgentInput.trim();
|
||||||
setCustomAgentInput("");
|
if (!userText) return;
|
||||||
|
if (!threadIdsRef.current || !coordinatorNameRef.current) {
|
||||||
|
triggerToast("⚠️ 圆桌尚未就绪,无法记录干预指令");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCustomAgentInput("");
|
||||||
appendDialogue({
|
appendDialogue({
|
||||||
sender: "用户 (插话干预)",
|
sender: "用户 (插话干预)",
|
||||||
avatarType: "human",
|
avatarType: "human",
|
||||||
content: `💡 (人工指令介入) ${userText}`,
|
content: `💡 (人工指令介入) ${userText}`,
|
||||||
confidence: "最高指令",
|
confidence: "最高指令",
|
||||||
});
|
});
|
||||||
triggerToast("您的人工纠偏指令已同步至圆桌节点!");
|
pendingInjectPromptRef.current = `用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`;
|
||||||
|
triggerToast("干预指令已记录,会商已自动恢复执行");
|
||||||
|
}, [appendDialogue, customAgentInput, triggerToast]);
|
||||||
|
|
||||||
void runOrchestration(
|
const handleInjectToRoundtable = useCallback(() => {
|
||||||
`用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`,
|
const userText = customAgentInput.trim();
|
||||||
threadIdsRef.current,
|
if (!userText) return;
|
||||||
coordinatorNameRef.current,
|
if (!threadIdsRef.current || !coordinatorNameRef.current) {
|
||||||
);
|
triggerToast("⚠️ 圆桌尚未就绪,无法记录干预指令");
|
||||||
}, [appendDialogue, customAgentInput, runOrchestration, triggerToast]);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中止当前编排(如有)
|
||||||
|
orchestrationCancelRef.current = true;
|
||||||
|
abortAllInFlight();
|
||||||
|
// 强制标记非编排中,使后续 runOrchestration 可立即启动
|
||||||
|
isOrchestratingRef.current = false;
|
||||||
|
setIsOrchestrating(false);
|
||||||
|
|
||||||
|
setCustomAgentInput("");
|
||||||
|
appendDialogue({
|
||||||
|
sender: "用户 (插话干预)",
|
||||||
|
avatarType: "human",
|
||||||
|
content: `💡 (人工指令介入) ${userText}`,
|
||||||
|
confidence: "最高指令",
|
||||||
|
});
|
||||||
|
|
||||||
|
const prompt = `用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`;
|
||||||
|
|
||||||
|
// 确保处于运行状态(同步更新按钮 UI)
|
||||||
|
setIsRoundTableRunning(true);
|
||||||
|
triggerToast("干预指令已发送,会商继续执行");
|
||||||
|
|
||||||
|
// 直接启动新一轮编排
|
||||||
|
void runOrchestration(prompt, threadIdsRef.current, coordinatorNameRef.current);
|
||||||
|
}, [customAgentInput, appendDialogue, abortAllInFlight, runOrchestration, triggerToast]);
|
||||||
|
|
||||||
const submitClarification = useCallback(
|
const submitClarification = useCallback(
|
||||||
(rawAnswer: string) => {
|
(rawAnswer: string) => {
|
||||||
@@ -939,6 +1417,11 @@ export function useStep2Orchestration(
|
|||||||
setHasConsensus(snapshot.hasConsensus);
|
setHasConsensus(snapshot.hasConsensus);
|
||||||
setConsensusPercentage(snapshot.consensusPercentage);
|
setConsensusPercentage(snapshot.consensusPercentage);
|
||||||
setBudgetLimit(snapshot.budgetLimit);
|
setBudgetLimit(snapshot.budgetLimit);
|
||||||
|
const restoredStubs = snapshot.seatStubMessages ?? {};
|
||||||
|
seatStubMessagesRef.current = restoredStubs;
|
||||||
|
setSeatStubMessages(restoredStubs);
|
||||||
|
// 草稿里历史 write_file 的虚拟 URL 与当前会话无关 —— 清空守卫,允许新 run 触发自动打开。
|
||||||
|
hasAutoOpenedRef.current = new Set();
|
||||||
// 调用方根据 furthestStep 决定是否置 init 闸门为已 init;这里默认置上,
|
// 调用方根据 furthestStep 决定是否置 init 闸门为已 init;这里默认置上,
|
||||||
// 调用方需要的话再 resetInitGate()。
|
// 调用方需要的话再 resetInitGate()。
|
||||||
step2InitRef.current = true;
|
step2InitRef.current = true;
|
||||||
@@ -960,8 +1443,16 @@ export function useStep2Orchestration(
|
|||||||
setLastLeaderContent("");
|
setLastLeaderContent("");
|
||||||
setHasConsensus(false);
|
setHasConsensus(false);
|
||||||
setConsensusPercentage(0);
|
setConsensusPercentage(0);
|
||||||
|
setActiveArtifactThreadId(null);
|
||||||
|
artifactPathsRef.current = [];
|
||||||
|
setArtifacts([]);
|
||||||
|
seatStubMessagesRef.current = {};
|
||||||
|
setSeatStubMessages({});
|
||||||
|
hasAutoOpenedRef.current = new Set();
|
||||||
|
pendingInjectPromptRef.current = null;
|
||||||
|
deselectArtifact();
|
||||||
step2InitRef.current = false;
|
step2InitRef.current = false;
|
||||||
}, [abortAllInFlight]);
|
}, [abortAllInFlight, deselectArtifact, setArtifacts]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selectedAgents,
|
selectedAgents,
|
||||||
@@ -996,6 +1487,9 @@ export function useStep2Orchestration(
|
|||||||
setAgentConfigs,
|
setAgentConfigs,
|
||||||
resetInitGate,
|
resetInitGate,
|
||||||
stopOrchestration,
|
stopOrchestration,
|
||||||
|
pauseRoundtable,
|
||||||
|
resumeRoundtable,
|
||||||
|
recordInjectMessage,
|
||||||
abortAllInFlight,
|
abortAllInFlight,
|
||||||
handleInjectToRoundtable,
|
handleInjectToRoundtable,
|
||||||
submitClarification,
|
submitClarification,
|
||||||
@@ -1003,5 +1497,9 @@ export function useStep2Orchestration(
|
|||||||
handleSaveAgentAndRecalculate,
|
handleSaveAgentAndRecalculate,
|
||||||
hydrateFromDraft,
|
hydrateFromDraft,
|
||||||
resetStep2State,
|
resetStep2State,
|
||||||
|
activeArtifactThreadId,
|
||||||
|
seatStubMessages,
|
||||||
|
openArtifactPreview,
|
||||||
|
openArtifactFromStep,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -135,14 +135,187 @@ export function newCoordinatorName(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 后端 intent agent 输出的 `[INTENT_READY]\n\`\`\`json {...}\`\`\`` 块。
|
* 后端 intent agent 输出的**标准** `[INTENT_READY]\n\`\`\`json {...}\`\`\`` 块形状。
|
||||||
* 前端用同样形状的正则把摘要从对话气泡里抽出来,渲染成绿色摘要卡片。
|
* 仅作格式参考 / 快速匹配用;实际从气泡正文里抽摘要走 `splitIntentReadyBlock` 的
|
||||||
|
* 容错提取(marker 定位 + 括号配对 + 多级降级 parse),不再依赖这条严格正则。
|
||||||
*/
|
*/
|
||||||
export const INTENT_READY_RE = /\[INTENT_READY\]\s*```json\s*(\{[\s\S]*?\})\s*```/;
|
export const INTENT_READY_RE = /\[INTENT_READY\]\s*```json\s*(\{[\s\S]*?\})\s*```/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 JSON 文本里**字符串值内部**的字面控制字符(换行 / 回车 / 制表符)转义成合法的
|
||||||
|
* `\n` / `\r` / `\t`。模型偶尔会在 summary 的字符串值里直接敲换行,产出的 JSON 不合法,
|
||||||
|
* `JSON.parse` 会抛 `Bad control character in string literal`。这里只在字符串内替换,
|
||||||
|
* 不动结构性空白,避免破坏本来就合法的 JSON。
|
||||||
|
*/
|
||||||
|
function sanitizeJsonControlChars(raw: string): string {
|
||||||
|
let out = "";
|
||||||
|
let inString = false;
|
||||||
|
let escaped = false;
|
||||||
|
for (const ch of raw) {
|
||||||
|
if (escaped) {
|
||||||
|
out += ch;
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "\\") {
|
||||||
|
out += ch;
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') {
|
||||||
|
inString = !inString;
|
||||||
|
out += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inString) {
|
||||||
|
if (ch === "\n") {
|
||||||
|
out += "\\n";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "\r") {
|
||||||
|
out += "\\r";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "\t") {
|
||||||
|
out += "\\t";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out += ch;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 `fromIndex` 起找第一个 `{`,按花括号配对(跳过字符串内部的括号)找到匹配的 `}`,
|
||||||
|
* 返回 JSON 子串及其在原文里的起止下标。
|
||||||
|
*
|
||||||
|
* 比 `\{[\s\S]*?\}` 这种正则更抗模型跑偏:容忍 JSON 内嵌套对象、字符串里的裸换行 /
|
||||||
|
* 花括号,以及模型漏写 ```json 代码围栏(直接 `[INTENT_READY] {...}`)的情况。
|
||||||
|
*/
|
||||||
|
function extractBalancedJson(
|
||||||
|
text: string,
|
||||||
|
fromIndex: number,
|
||||||
|
): { json: string; end: number } | null {
|
||||||
|
const start = text.indexOf("{", fromIndex);
|
||||||
|
if (start < 0) return null;
|
||||||
|
let depth = 0;
|
||||||
|
let inString = false;
|
||||||
|
let escaped = false;
|
||||||
|
for (let i = start; i < text.length; i += 1) {
|
||||||
|
const ch = text[i];
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "\\") {
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') {
|
||||||
|
inString = !inString;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inString) continue;
|
||||||
|
if (ch === "{") depth += 1;
|
||||||
|
else if (ch === "}") {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) return { json: text.slice(start, i + 1), end: i + 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null; // 括号未闭合(流式中段 / 模型截断)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 去掉对象 / 数组里 `}` `]` 之前的尾随逗号(只在字符串外处理)。 */
|
||||||
|
function stripTrailingCommas(raw: string): string {
|
||||||
|
let out = "";
|
||||||
|
let inString = false;
|
||||||
|
let escaped = false;
|
||||||
|
for (let i = 0; i < raw.length; i += 1) {
|
||||||
|
const ch = raw[i];
|
||||||
|
if (escaped) {
|
||||||
|
out += ch;
|
||||||
|
escaped = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === "\\") {
|
||||||
|
out += ch;
|
||||||
|
escaped = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') {
|
||||||
|
inString = !inString;
|
||||||
|
out += ch;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!inString && ch === ",") {
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < raw.length && /\s/.test(raw[j])) j += 1;
|
||||||
|
if (raw[j] === "}" || raw[j] === "]") continue; // 丢弃尾随逗号
|
||||||
|
}
|
||||||
|
out += ch;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把任意值归一成 string[]:数组逐项转字符串、单个字符串包成单元素、其它视为空。 */
|
||||||
|
function toStringArray(value: unknown): string[] {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.map((x) => (typeof x === "string" ? x : x == null ? "" : String(x)))
|
||||||
|
.filter((x) => x.trim().length > 0);
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value.trim().length > 0) return [value];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把解析出的对象归一成 IntentSummary。只要 `objective` 是非空字符串就认。
|
||||||
|
* constraints / assumptions 容忍模型写成字符串 / null / 缺失,统一归到 string[]。
|
||||||
|
*/
|
||||||
|
function normalizeIntentSummary(parsed: unknown): IntentSummary | null {
|
||||||
|
if (!parsed || typeof parsed !== "object") return null;
|
||||||
|
const obj = parsed as Record<string, unknown>;
|
||||||
|
const objective =
|
||||||
|
typeof obj.objective === "string" ? obj.objective.trim() : "";
|
||||||
|
if (!objective) return null;
|
||||||
|
return {
|
||||||
|
objective,
|
||||||
|
constraints: toStringArray(obj.constraints),
|
||||||
|
assumptions: toStringArray(obj.assumptions),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 INTENT_READY 的 JSON 块。内网模型输出不可控,这里逐级降级重试:
|
||||||
|
* 1) 原文直接 parse
|
||||||
|
* 2) 转义字符串内的裸控制字符(换行 / 制表符)后再 parse
|
||||||
|
* 3) 再去掉尾随逗号后 parse
|
||||||
|
* 三次都失败才放弃。任一成功都过 `normalizeIntentSummary` 兜字段类型。
|
||||||
|
*/
|
||||||
|
function parseIntentSummary(raw: string): IntentSummary | null {
|
||||||
|
const sanitized = sanitizeJsonControlChars(raw);
|
||||||
|
const candidates = [raw, sanitized, stripTrailingCommas(sanitized)];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
const summary = normalizeIntentSummary(JSON.parse(candidate));
|
||||||
|
if (summary) return summary;
|
||||||
|
} catch {
|
||||||
|
// 下一个候选
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INTENT_READY_MARKER_RE = /\[INTENT_READY\]/i;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把含 [INTENT_READY] 标记的气泡正文拆成前 / 摘要 / 后三段。
|
* 把含 [INTENT_READY] 标记的气泡正文拆成前 / 摘要 / 后三段。
|
||||||
* 摘要 JSON 格式异常时整体保留在 `before`,用户仍能看到原文。
|
* 摘要 JSON 格式异常时整体保留在 `before`,用户仍能看到原文。
|
||||||
|
*
|
||||||
|
* 提取不再依赖严格的 ```json 围栏正则:先定位 [INTENT_READY](大小写不敏感),
|
||||||
|
* 再用括号配对抓出后面的第一个完整 JSON 对象,最后多级降级 parse。这样模型漏写
|
||||||
|
* 代码围栏、JSON 里有裸换行 / 尾随逗号、字段类型写偏等情况都能尽量恢复。
|
||||||
*/
|
*/
|
||||||
export function splitIntentReadyBlock(text: string): {
|
export function splitIntentReadyBlock(text: string): {
|
||||||
before: string;
|
before: string;
|
||||||
@@ -150,20 +323,16 @@ export function splitIntentReadyBlock(text: string): {
|
|||||||
after: string;
|
after: string;
|
||||||
} {
|
} {
|
||||||
if (!text) return { before: text, summary: null, after: "" };
|
if (!text) return { before: text, summary: null, after: "" };
|
||||||
const m = INTENT_READY_RE.exec(text);
|
const marker = INTENT_READY_MARKER_RE.exec(text);
|
||||||
if (!m) return { before: text, summary: null, after: "" };
|
if (!marker) return { before: text, summary: null, after: "" };
|
||||||
let summary: IntentSummary | null = null;
|
const block = extractBalancedJson(text, marker.index + marker[0].length);
|
||||||
try {
|
if (!block) return { before: text, summary: null, after: "" };
|
||||||
const parsed = JSON.parse(m[1]);
|
const summary = parseIntentSummary(block.json);
|
||||||
if (parsed && typeof parsed === "object" && typeof parsed.objective === "string") {
|
if (!summary) return { before: text, summary: null, after: "" };
|
||||||
summary = parsed as IntentSummary;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return { before: text, summary: null, after: "" };
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
before: text.slice(0, m.index).trimEnd(),
|
before: text.slice(0, marker.index).trimEnd(),
|
||||||
summary,
|
summary,
|
||||||
after: text.slice(m.index + m[0].length).trimStart(),
|
// 丢掉 JSON 块之后残留的代码围栏 ``` 与模型多说的话(SOUL 要求 READY 后即停)。
|
||||||
|
after: text.slice(block.end).replace(/^\s*```+/, "").trimStart(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { ChainOfThoughtSearchResult } from "@/components/ai-elements/chain-of-thought";
|
import { ChainOfThoughtSearchResult } from "@/components/ai-elements/chain-of-thought";
|
||||||
|
import { CodeBlock } from "@/components/ai-elements/code-block";
|
||||||
|
import { MarkdownContent } from "@/roundtable-planning/components/MessageBubble";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据工具名 + args 渲染 ChainOfThoughtStep 的子节点(路径 chip / 命令代码块等)。
|
* 根据工具名 + args 渲染 ChainOfThoughtStep 的子节点(路径 chip / 命令代码块等)。
|
||||||
@@ -34,6 +36,7 @@ import { ChainOfThoughtSearchResult } from "@/components/ai-elements/chain-of-th
|
|||||||
export function renderStepArgs(
|
export function renderStepArgs(
|
||||||
key: string,
|
key: string,
|
||||||
args?: Record<string, unknown>,
|
args?: Record<string, unknown>,
|
||||||
|
resolveAgentName?: (idOrName: string) => string,
|
||||||
): React.ReactNode {
|
): React.ReactNode {
|
||||||
if (!args) return null;
|
if (!args) return null;
|
||||||
const [, toolName] = key.split(":");
|
const [, toolName] = key.split(":");
|
||||||
@@ -43,15 +46,18 @@ export function renderStepArgs(
|
|||||||
const agentName = typeof args.agent_name === "string" ? args.agent_name : "";
|
const agentName = typeof args.agent_name === "string" ? args.agent_name : "";
|
||||||
const task = typeof args.task === "string" ? args.task : "";
|
const task = typeof args.task === "string" ? args.task : "";
|
||||||
if (!agentName && !task) return null;
|
if (!agentName && !task) return null;
|
||||||
|
// 派活 chip 显示中文席位名而非 agent id(客户自建 agent 的 id 常是一串 hex)。
|
||||||
|
const displayName =
|
||||||
|
agentName && resolveAgentName ? resolveAgentName(agentName) : agentName;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{agentName && (
|
{displayName && (
|
||||||
<ChainOfThoughtSearchResult>{agentName}</ChainOfThoughtSearchResult>
|
<ChainOfThoughtSearchResult>{displayName}</ChainOfThoughtSearchResult>
|
||||||
)}
|
)}
|
||||||
{task && (
|
{task && (
|
||||||
<span className="block text-[11px] text-slate-600 line-clamp-3 mt-0.5">
|
<div className="mt-1 max-h-64 overflow-y-auto rounded-md border border-slate-200 bg-white px-2.5 py-1 text-[11px] text-slate-600 leading-relaxed">
|
||||||
{task}
|
<MarkdownContent>{task}</MarkdownContent>
|
||||||
</span>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -75,9 +81,14 @@ export function renderStepArgs(
|
|||||||
case "bash": {
|
case "bash": {
|
||||||
const command = typeof args.command === "string" ? args.command : "";
|
const command = typeof args.command === "string" ? args.command : "";
|
||||||
return command ? (
|
return command ? (
|
||||||
<code className="block bg-slate-100 rounded px-2 py-1 text-[11px] font-mono text-slate-800 mt-0.5 line-clamp-2 break-all">
|
<div className="mt-1 max-h-60 overflow-auto rounded-md border border-slate-200 [&_pre]:px-2.5! [&_pre]:py-1.5!">
|
||||||
{command}
|
<CodeBlock
|
||||||
</code>
|
className="rounded-none border-none"
|
||||||
|
showLineNumbers={false}
|
||||||
|
language="bash"
|
||||||
|
code={command}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
case "write_todos": {
|
case "write_todos": {
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
*
|
*
|
||||||
* 重构后的主文件只负责"组合 + 编排":
|
* 重构后的主文件只负责"组合 + 编排":
|
||||||
* - 顶栏(步骤切换 + 草稿菜单 + 新建任务 + 设置)
|
* - 顶栏(步骤切换 + 草稿菜单 + 新建任务 + 设置)
|
||||||
* - 左侧栏(任务概览 + 角色面板 + 人工参与)
|
* - 左侧栏:Step1/2 为任务概览 + 角色 + 人工参与;Step2 侧栏可整体收起为图标条,含可收起的意图理解
|
||||||
* - 中间视区:Step1Panel / Step2Panel / Step3Panel(`currentStep` 切换)
|
* - 中间视区:Step1Panel / Step2Panel / Step3Panel(`currentStep` 切换);Step2 顶栏含推理模型下拉
|
||||||
* - 右侧栏(推理模型 / 当前意图摘要 / 澄清记录)
|
* - 右侧栏:仅 Step1(推理模型 / 当前意图摘要 / 澄清记录);Step2/3 隐藏以让中间区自适应
|
||||||
* - 推荐弹窗 + Toast
|
* - 推荐弹窗 + Toast
|
||||||
*
|
*
|
||||||
* 业务状态全部下沉到 hook:
|
* 业务状态全部下沉到 hook:
|
||||||
@@ -27,6 +27,10 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
PanelLeftClose,
|
||||||
|
PanelLeftOpen,
|
||||||
Clipboard,
|
Clipboard,
|
||||||
Clock,
|
Clock,
|
||||||
Cpu,
|
Cpu,
|
||||||
@@ -42,6 +46,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useModels } from "@/core/models/hooks";
|
import { useModels } from "@/core/models/hooks";
|
||||||
|
|
||||||
@@ -77,6 +82,7 @@ export default function RoundtablePlanningPage() {
|
|||||||
const [showToast, setShowToast] = useState<string | null>(null);
|
const [showToast, setShowToast] = useState<string | null>(null);
|
||||||
const [isRecommendDialogOpen, setIsRecommendDialogOpen] = useState<boolean>(false);
|
const [isRecommendDialogOpen, setIsRecommendDialogOpen] = useState<boolean>(false);
|
||||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||||
|
const [step2SidebarExpanded, setStep2SidebarExpanded] = useState(true);
|
||||||
|
|
||||||
// 角色面板(LeftSidebar)。原页面把它当成两步共用的展示数据,这里继续放在
|
// 角色面板(LeftSidebar)。原页面把它当成两步共用的展示数据,这里继续放在
|
||||||
// 主页 —— Step 1 / Step 2 / Step 3 都会读 / 写它,任何 hook 单独持有都不合适。
|
// 主页 —— Step 1 / Step 2 / Step 3 都会读 / 写它,任何 hook 单独持有都不合适。
|
||||||
@@ -188,6 +194,7 @@ export default function RoundtablePlanningPage() {
|
|||||||
hasConsensus: step2.hasConsensus,
|
hasConsensus: step2.hasConsensus,
|
||||||
consensusPercentage: step2.consensusPercentage,
|
consensusPercentage: step2.consensusPercentage,
|
||||||
budgetLimit: step2.budgetLimit,
|
budgetLimit: step2.budgetLimit,
|
||||||
|
seatStubMessages: step2.seatStubMessages,
|
||||||
}),
|
}),
|
||||||
hydrateStep1: step1.hydrateFromDraft,
|
hydrateStep1: step1.hydrateFromDraft,
|
||||||
hydrateStep2: step2.hydrateFromDraft,
|
hydrateStep2: step2.hydrateFromDraft,
|
||||||
@@ -345,37 +352,41 @@ export default function RoundtablePlanningPage() {
|
|||||||
新建任务
|
新建任务
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
{currentStep !== 2 && (
|
||||||
id="start-clear-btn"
|
<>
|
||||||
onClick={() => {
|
<button
|
||||||
setCurrentStep(1);
|
id="start-clear-btn"
|
||||||
triggerToast("已切换至第一阶段:人机问答意图澄清");
|
onClick={() => {
|
||||||
}}
|
setCurrentStep(1);
|
||||||
className="bg-primary text-white hover:bg-[var(--brand-900)] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition-all shadow-sm shadow-primary/10 active:scale-95"
|
triggerToast("已切换至第一阶段:人机问答意图澄清");
|
||||||
>
|
}}
|
||||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
|
className="bg-primary text-white hover:bg-[var(--brand-900)] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition-all shadow-sm shadow-primary/10 active:scale-95"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
>
|
||||||
</svg>
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
|
||||||
开始澄清
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
</button>
|
</svg>
|
||||||
|
开始澄清
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="human-intervention-btn"
|
id="human-intervention-btn"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setRoles((prev) =>
|
setRoles((prev) =>
|
||||||
prev.map((r) =>
|
prev.map((r) =>
|
||||||
r.id === 1
|
r.id === 1
|
||||||
? { ...r, status: r.status === "在线" ? "等待激活" : "在线" }
|
? { ...r, status: r.status === "在线" ? "等待激活" : "在线" }
|
||||||
: r,
|
: r,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
triggerToast("指挥专家状态变更!人工介入切换");
|
triggerToast("指挥专家状态变更!人工介入切换");
|
||||||
}}
|
}}
|
||||||
className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition-all dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
|
className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition-all dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
|
||||||
>
|
>
|
||||||
<Users className="w-3.5 h-3.5 text-slate-500 dark:text-muted-foreground" />
|
<Users className="w-3.5 h-3.5 text-slate-500 dark:text-muted-foreground" />
|
||||||
人工介入
|
人工介入
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="save-draft-header-btn"
|
id="save-draft-header-btn"
|
||||||
@@ -415,6 +426,11 @@ export default function RoundtablePlanningPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* MAIN */}
|
{/* MAIN */}
|
||||||
|
<Step2SandboxSidebarSync
|
||||||
|
currentStep={currentStep}
|
||||||
|
sidebarExpanded={step2SidebarExpanded}
|
||||||
|
setSidebarExpanded={setStep2SidebarExpanded}
|
||||||
|
/>
|
||||||
<main className="flex-1 overflow-hidden p-4 flex gap-4 min-h-0">
|
<main className="flex-1 overflow-hidden p-4 flex gap-4 min-h-0">
|
||||||
{/* LEFT SIDEBAR */}
|
{/* LEFT SIDEBAR */}
|
||||||
<LeftSidebar
|
<LeftSidebar
|
||||||
@@ -423,6 +439,9 @@ export default function RoundtablePlanningPage() {
|
|||||||
agentNameToRole={step2.agentNameToRole}
|
agentNameToRole={step2.agentNameToRole}
|
||||||
selectedAgents={step2.selectedAgents}
|
selectedAgents={step2.selectedAgents}
|
||||||
budgetLimit={step2.budgetLimit}
|
budgetLimit={step2.budgetLimit}
|
||||||
|
intentReady={step1.intentReady}
|
||||||
|
sidebarExpanded={step2SidebarExpanded}
|
||||||
|
setSidebarExpanded={setStep2SidebarExpanded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* MIDDLE VIEWPORT */}
|
{/* MIDDLE VIEWPORT */}
|
||||||
@@ -469,16 +488,23 @@ export default function RoundtablePlanningPage() {
|
|||||||
agentConfigs={step2.agentConfigs}
|
agentConfigs={step2.agentConfigs}
|
||||||
selectedAgentId={step2.selectedAgentId}
|
selectedAgentId={step2.selectedAgentId}
|
||||||
roles={roles}
|
roles={roles}
|
||||||
|
agentNameToRole={step2.agentNameToRole}
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
availableModels={availableModels}
|
availableModels={availableModels}
|
||||||
modelsLoading={modelsLoading}
|
modelsLoading={modelsLoading}
|
||||||
setIsRoundTableRunning={step2.setIsRoundTableRunning}
|
setSelectedModel={setSelectedModel}
|
||||||
|
activeArtifactThreadId={step2.activeArtifactThreadId}
|
||||||
|
seatStubMessages={step2.seatStubMessages}
|
||||||
|
openArtifactFromStep={step2.openArtifactFromStep}
|
||||||
|
openArtifactPreview={step2.openArtifactPreview}
|
||||||
|
onPauseRoundtable={step2.pauseRoundtable}
|
||||||
|
onResumeRoundtable={step2.resumeRoundtable}
|
||||||
|
onHandleInjectToRoundtable={step2.handleInjectToRoundtable}
|
||||||
setClarificationDraft={step2.setClarificationDraft}
|
setClarificationDraft={step2.setClarificationDraft}
|
||||||
setCustomAgentInput={step2.setCustomAgentInput}
|
setCustomAgentInput={step2.setCustomAgentInput}
|
||||||
setAgentConfigs={step2.setAgentConfigs}
|
setAgentConfigs={step2.setAgentConfigs}
|
||||||
setSelectedAgentId={step2.setSelectedAgentId}
|
setSelectedAgentId={step2.setSelectedAgentId}
|
||||||
onSubmitClarification={step2.submitClarification}
|
onSubmitClarification={step2.submitClarification}
|
||||||
onInjectToRoundtable={step2.handleInjectToRoundtable}
|
|
||||||
onSavePersona={step2.handleSaveAgentAndRecalculate}
|
onSavePersona={step2.handleSaveAgentAndRecalculate}
|
||||||
onProceedToStep3={() => {
|
onProceedToStep3={() => {
|
||||||
if (!step2.hasConsensus) {
|
if (!step2.hasConsensus) {
|
||||||
@@ -787,6 +813,35 @@ interface LeftSidebarProps {
|
|||||||
>;
|
>;
|
||||||
selectedAgents: SelectedAgent[];
|
selectedAgents: SelectedAgent[];
|
||||||
budgetLimit: number;
|
budgetLimit: number;
|
||||||
|
intentReady: ReturnType<typeof useStep1Intent>["intentReady"];
|
||||||
|
sidebarExpanded: boolean;
|
||||||
|
setSidebarExpanded: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step2SandboxSidebarSync({
|
||||||
|
currentStep,
|
||||||
|
sidebarExpanded,
|
||||||
|
setSidebarExpanded,
|
||||||
|
}: {
|
||||||
|
currentStep: number;
|
||||||
|
sidebarExpanded: boolean;
|
||||||
|
setSidebarExpanded: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}) {
|
||||||
|
const { open } = useArtifacts();
|
||||||
|
const prevOpenRef = useRef(open);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentStep !== 2) {
|
||||||
|
prevOpenRef.current = open;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (open && !prevOpenRef.current && sidebarExpanded) {
|
||||||
|
setSidebarExpanded(false);
|
||||||
|
}
|
||||||
|
prevOpenRef.current = open;
|
||||||
|
}, [currentStep, open, setSidebarExpanded, sidebarExpanded]);
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LeftSidebar({
|
function LeftSidebar({
|
||||||
@@ -795,12 +850,32 @@ function LeftSidebar({
|
|||||||
agentNameToRole,
|
agentNameToRole,
|
||||||
selectedAgents,
|
selectedAgents,
|
||||||
budgetLimit: _budgetLimit,
|
budgetLimit: _budgetLimit,
|
||||||
|
intentReady,
|
||||||
|
sidebarExpanded,
|
||||||
|
setSidebarExpanded,
|
||||||
}: LeftSidebarProps) {
|
}: LeftSidebarProps) {
|
||||||
|
const isStep2 = currentStep === 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
id="left-sidebar"
|
id="left-sidebar"
|
||||||
className="w-[280px] shrink-0 flex flex-col gap-4 overflow-y-auto pr-1"
|
className={cn(
|
||||||
|
"shrink-0 flex flex-col gap-3 overflow-y-auto pr-1 transition-[width] duration-200 ease-out",
|
||||||
|
isStep2 && !sidebarExpanded ? "w-[56px]" : "w-[280px]",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
|
{isStep2 && !sidebarExpanded && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSidebarExpanded(true)}
|
||||||
|
title="展开侧栏"
|
||||||
|
aria-expanded={false}
|
||||||
|
className="w-full shrink-0 flex h-10 items-center justify-center rounded-2xl border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
|
||||||
|
>
|
||||||
|
<PanelLeftOpen className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{currentStep === 3 ? (
|
{currentStep === 3 ? (
|
||||||
<>
|
<>
|
||||||
{/* Step 3 时只保留「参与与结论贡献」面板;原文件里其它两个 hidden 块未启用,
|
{/* Step 3 时只保留「参与与结论贡献」面板;原文件里其它两个 hidden 块未启用,
|
||||||
@@ -849,41 +924,67 @@ function LeftSidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : currentStep === 2 ? (
|
||||||
<>
|
sidebarExpanded ? (
|
||||||
{/* 任务概览 */}
|
<>
|
||||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
<IntentSummaryCard
|
||||||
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
|
intentReady={intentReady}
|
||||||
<Clipboard className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
title="意图理解"
|
||||||
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">任务概览</h2>
|
collapsible
|
||||||
</div>
|
sidebarToggle={{
|
||||||
<div className="space-y-3">
|
expanded: sidebarExpanded,
|
||||||
<div>
|
onToggle: () => setSidebarExpanded((v) => !v),
|
||||||
<span className="text-[10px] uppercase tracking-wider text-slate-400 font-bold block mb-1">
|
}}
|
||||||
当前节点
|
/>
|
||||||
</span>
|
|
||||||
<div className="flex items-center justify-between gap-2.5">
|
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[240px]">
|
||||||
<span className="text-sm font-bold text-slate-800">初始任务</span>
|
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
|
||||||
<span
|
<Users className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
||||||
className={`text-[10px] font-bold px-2 py-0.5 rounded-md border shrink-0 ${
|
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">参与角色</h2>
|
||||||
currentStep === 1
|
</div>
|
||||||
? "text-[#ff9500] bg-orange-50 border-orange-200/50"
|
<div className="space-y-2.5 overflow-y-auto flex-1 pr-1 custom-scrollbar">
|
||||||
: currentStep === 2
|
{roles.map((role) => (
|
||||||
? "text-[var(--brand-900)] bg-[var(--brand-200)] border-red-200/50"
|
<RoleRow key={role.id} role={role} agentNameToRole={agentNameToRole} />
|
||||||
: "text-emerald-600 bg-emerald-50 border-emerald-200/50"
|
))}
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{currentStep === 1 ? "待澄清" : currentStep === 2 ? "研讨中" : "已规划交付"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-500 mt-2.5 leading-relaxed bg-slate-50 border border-slate-100 p-2 rounded-lg">
|
|
||||||
围绕某复杂任务制定规划方案
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 参与角色 */}
|
<HumanParticipationCard />
|
||||||
|
<TaskOverviewCard currentStep={currentStep} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CollapsedSidebarSection title="意图理解">
|
||||||
|
<IntentSectionIcon className="h-5 w-5" />
|
||||||
|
</CollapsedSidebarSection>
|
||||||
|
|
||||||
|
<CollapsedSidebarSection title="参与角色">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
{roles.map((role) => (
|
||||||
|
<RoleAvatarBadge
|
||||||
|
key={role.id}
|
||||||
|
role={role}
|
||||||
|
agentNameToRole={agentNameToRole}
|
||||||
|
size="h-8 w-8"
|
||||||
|
showSpeakingAnimation
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CollapsedSidebarSection>
|
||||||
|
|
||||||
|
<CollapsedSidebarSection title="人工参与">
|
||||||
|
<Man1Avatar size="h-8 w-8" />
|
||||||
|
</CollapsedSidebarSection>
|
||||||
|
|
||||||
|
<CollapsedSidebarSection title="任务概览">
|
||||||
|
<Clipboard className="h-5 w-5 text-primary" strokeWidth={2.5} />
|
||||||
|
</CollapsedSidebarSection>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TaskOverviewCard currentStep={currentStep} />
|
||||||
|
|
||||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[300px]">
|
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[300px]">
|
||||||
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
|
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
|
||||||
<Users className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
<Users className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
||||||
@@ -896,32 +997,317 @@ function LeftSidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 人工参与 */}
|
<HumanParticipationCard />
|
||||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
|
||||||
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
|
|
||||||
<Activity className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
|
||||||
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">人工参与</h2>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between p-2 rounded-xl bg-slate-50 border border-slate-100">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<Man1Avatar size="h-7 w-7" />
|
|
||||||
<span className="text-xs font-bold text-slate-800">用户</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="flex h-1.5 w-1.5 relative">
|
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
|
||||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500"></span>
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] font-bold text-emerald-600">在线</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TaskOverviewCard({ currentStep }: { currentStep: number }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
||||||
|
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
|
||||||
|
<Clipboard className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
||||||
|
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">任务概览</h2>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-slate-400 font-bold block mb-1">
|
||||||
|
当前节点
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center justify-between gap-2.5">
|
||||||
|
<span className="text-sm font-bold text-slate-800">初始任务</span>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-bold px-2 py-0.5 rounded-md border shrink-0 ${
|
||||||
|
currentStep === 1
|
||||||
|
? "text-[#ff9500] bg-orange-50 border-orange-200/50"
|
||||||
|
: currentStep === 2
|
||||||
|
? "text-[var(--brand-900)] bg-[var(--brand-200)] border-red-200/50"
|
||||||
|
: "text-emerald-600 bg-emerald-50 border-emerald-200/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{currentStep === 1 ? "待澄清" : currentStep === 2 ? "研讨中" : "已规划交付"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-2.5 leading-relaxed bg-slate-50 border border-slate-100 p-2 rounded-lg">
|
||||||
|
围绕某复杂任务制定规划方案
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HumanParticipationCard() {
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
||||||
|
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
|
||||||
|
<Activity className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
||||||
|
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">人工参与</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-2 rounded-xl bg-slate-50 border border-slate-100">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<Man1Avatar size="h-7 w-7" />
|
||||||
|
<span className="text-xs font-bold text-slate-800">用户</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="flex h-1.5 w-1.5 relative">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500"></span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-bold text-emerald-600">在线</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelSelectorCardProps {
|
||||||
|
selectedModel: string;
|
||||||
|
setSelectedModel: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
availableModels: ReadonlyArray<{ name: string; display_name?: string }>;
|
||||||
|
modelsLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelSelectorCard({
|
||||||
|
selectedModel,
|
||||||
|
setSelectedModel,
|
||||||
|
availableModels,
|
||||||
|
modelsLoading,
|
||||||
|
}: ModelSelectorCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
||||||
|
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
|
||||||
|
<Cpu className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
||||||
|
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">推理模型</h2>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
id="global-model-select"
|
||||||
|
value={selectedModel}
|
||||||
|
onChange={(e) => setSelectedModel(e.target.value)}
|
||||||
|
disabled={modelsLoading || availableModels.length === 0}
|
||||||
|
title={
|
||||||
|
modelsLoading
|
||||||
|
? "正在加载模型列表…"
|
||||||
|
: availableModels.length === 0
|
||||||
|
? "未在 config.yaml 中配置任何模型"
|
||||||
|
: "切换全局推理模型;不影响 Persona 抽屉里已设置的 per-seat 覆盖"
|
||||||
|
}
|
||||||
|
className="w-full bg-slate-50/50 border border-slate-100 rounded-xl px-3 py-2 text-xs font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
|
||||||
|
>
|
||||||
|
{availableModels.length === 0 && (
|
||||||
|
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
|
||||||
|
)}
|
||||||
|
{availableModels.map((m) => (
|
||||||
|
<option key={m.name} value={m.name}>
|
||||||
|
{m.display_name && m.display_name !== m.name
|
||||||
|
? `${m.display_name} (${m.name})`
|
||||||
|
: m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IntentSummaryCardProps {
|
||||||
|
intentReady: ReturnType<typeof useStep1Intent>["intentReady"];
|
||||||
|
title?: string;
|
||||||
|
/** Step 2 侧栏: 收起时仅展示目标,展开后显示约束与假设。 */
|
||||||
|
collapsible?: boolean;
|
||||||
|
/** 侧栏整体收起按钮,放在标题栏右侧。 */
|
||||||
|
sidebarToggle?: {
|
||||||
|
expanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function IntentSummaryCard({
|
||||||
|
intentReady,
|
||||||
|
title = "当前意图摘要",
|
||||||
|
collapsible = false,
|
||||||
|
sidebarToggle,
|
||||||
|
}: IntentSummaryCardProps) {
|
||||||
|
const [expanded, setExpanded] = useState(!collapsible);
|
||||||
|
const showFullDetail = !collapsible || expanded;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
||||||
|
<div className="flex items-center justify-between gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<IntentSectionIcon className="w-4 h-4" />
|
||||||
|
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest truncate">{title}</h2>
|
||||||
|
</div>
|
||||||
|
{sidebarToggle && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={sidebarToggle.onToggle}
|
||||||
|
title={sidebarToggle.expanded ? "收起侧栏" : "展开侧栏"}
|
||||||
|
aria-expanded={sidebarToggle.expanded}
|
||||||
|
className="shrink-0 flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 hover:text-primary hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
{sidebarToggle.expanded ? (
|
||||||
|
<PanelLeftClose className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<PanelLeftOpen className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 bg-slate-50/50 border border-slate-100 rounded-xl space-y-3 mr-0.5">
|
||||||
|
{intentReady ? (
|
||||||
|
<>
|
||||||
|
<ul className="text-xs text-slate-700 space-y-2.5 font-normal">
|
||||||
|
<li className="flex items-baseline gap-2 leading-relaxed">
|
||||||
|
<span className="text-primary text-base leading-none">•</span>
|
||||||
|
<span>
|
||||||
|
<b className="text-slate-900 font-bold">目标</b>: {intentReady.objective}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{collapsible && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
className="flex items-center gap-0.5 text-[10px] font-bold text-slate-500 hover:text-primary transition-colors"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<>
|
||||||
|
收起
|
||||||
|
<ChevronUp className="w-3.5 h-3.5" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
展开
|
||||||
|
<ChevronDown className="w-3.5 h-3.5" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showFullDetail && (
|
||||||
|
<ul className="text-xs text-slate-700 space-y-2.5 font-normal">
|
||||||
|
{intentReady.constraints.map((c, i) => (
|
||||||
|
<li key={`c-${i}`} className="flex items-baseline gap-2 leading-relaxed">
|
||||||
|
<span className="text-primary text-base leading-none">•</span>
|
||||||
|
<span>
|
||||||
|
<b className="text-slate-900 font-bold">约束</b>: {c}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{(intentReady.assumptions ?? []).map((a, i) => (
|
||||||
|
<li key={`a-${i}`} className="flex items-baseline gap-2 leading-relaxed">
|
||||||
|
<span className="text-slate-400 text-base leading-none">•</span>
|
||||||
|
<span className="text-slate-500">
|
||||||
|
<b className="text-slate-700 font-bold">假设</b>: {a}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-slate-400 italic leading-relaxed">
|
||||||
|
等待任务理解智能体澄清完成...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IntentSectionIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={cn("text-primary shrink-0", className)}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<circle cx="12" cy="12" r="6" />
|
||||||
|
<circle cx="12" cy="12" r="2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsedSidebarSection({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
title={title}
|
||||||
|
className={cn(
|
||||||
|
"w-full shrink-0 bg-white border border-slate-200/50 rounded-2xl p-2 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex flex-col items-center justify-center",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRoleAvatar(
|
||||||
|
role: RoleEntry,
|
||||||
|
agentNameToRole: Record<
|
||||||
|
string,
|
||||||
|
{ speakerId: number; avatarType: string; displayName: string }
|
||||||
|
>,
|
||||||
|
size: string,
|
||||||
|
) {
|
||||||
|
if (role.type === "human") {
|
||||||
|
return <Man1Avatar size={size} />;
|
||||||
|
}
|
||||||
|
return getAvatar(
|
||||||
|
role.agentId ? agentNameToRole[role.agentId]?.avatarType ?? "blue" : "blue",
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleAvatarBadge({
|
||||||
|
role,
|
||||||
|
agentNameToRole,
|
||||||
|
size = "h-7 w-7",
|
||||||
|
showSpeakingAnimation = false,
|
||||||
|
}: {
|
||||||
|
role: RoleEntry;
|
||||||
|
agentNameToRole: Record<
|
||||||
|
string,
|
||||||
|
{ speakerId: number; avatarType: string; displayName: string }
|
||||||
|
>;
|
||||||
|
size?: string;
|
||||||
|
showSpeakingAnimation?: boolean;
|
||||||
|
}) {
|
||||||
|
const isOngoing = role.status === "研讨中";
|
||||||
|
const animate = showSpeakingAnimation && isOngoing;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
title={`${role.name} · ${role.status}`}
|
||||||
|
className={cn(
|
||||||
|
"relative shrink-0 rounded-full",
|
||||||
|
animate && "ring-2 ring-primary/50 ring-offset-2 ring-offset-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{animate && (
|
||||||
|
<span className="pointer-events-none absolute inset-0 rounded-full border-2 border-primary/35 animate-ping" />
|
||||||
|
)}
|
||||||
|
<div className={cn("relative rounded-full", animate && "animate-pulse")}>
|
||||||
|
{renderRoleAvatar(role, agentNameToRole, size)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function RoleRow({
|
function RoleRow({
|
||||||
role,
|
role,
|
||||||
agentNameToRole,
|
agentNameToRole,
|
||||||
@@ -949,14 +1335,7 @@ function RoleRow({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
{role.type === "human" ? (
|
<RoleAvatarBadge role={role} agentNameToRole={agentNameToRole} />
|
||||||
<Man1Avatar size="h-7 w-7" />
|
|
||||||
) : (
|
|
||||||
getAvatar(
|
|
||||||
role.agentId ? agentNameToRole[role.agentId]?.avatarType ?? "blue" : "blue",
|
|
||||||
"h-7 w-7",
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-xs font-bold text-slate-800 tracking-tight">{role.name}</span>
|
<span className="text-xs font-bold text-slate-800 tracking-tight">{role.name}</span>
|
||||||
{role.type === "agent" && (
|
{role.type === "agent" && (
|
||||||
@@ -1024,89 +1403,21 @@ function RightSidebar({
|
|||||||
timelineEntries,
|
timelineEntries,
|
||||||
triggerToast,
|
triggerToast,
|
||||||
}: RightSidebarProps) {
|
}: RightSidebarProps) {
|
||||||
if (currentStep === 3) return null;
|
// Step 2 侧栏内容已迁至左侧;Step 3 沿用原逻辑不展示右侧栏。
|
||||||
|
if (currentStep !== 1) return null;
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
id="right-sidebar"
|
id="right-sidebar"
|
||||||
className="w-[300px] shrink-0 flex flex-col gap-4 overflow-y-auto pl-1"
|
className="w-[300px] shrink-0 flex flex-col gap-4 overflow-y-auto pl-1"
|
||||||
>
|
>
|
||||||
{/* 推理模型选择器 —— 全局,与 Persona 抽屉的 per-seat 覆盖叠加(后者优先)。 */}
|
<ModelSelectorCard
|
||||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
selectedModel={selectedModel}
|
||||||
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
|
setSelectedModel={setSelectedModel}
|
||||||
<Cpu className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
availableModels={availableModels}
|
||||||
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">推理模型</h2>
|
modelsLoading={modelsLoading}
|
||||||
</div>
|
/>
|
||||||
<select
|
|
||||||
id="global-model-select"
|
|
||||||
value={selectedModel}
|
|
||||||
onChange={(e) => setSelectedModel(e.target.value)}
|
|
||||||
disabled={modelsLoading || availableModels.length === 0}
|
|
||||||
title={
|
|
||||||
modelsLoading
|
|
||||||
? "正在加载模型列表…"
|
|
||||||
: availableModels.length === 0
|
|
||||||
? "未在 config.yaml 中配置任何模型"
|
|
||||||
: "切换全局推理模型;不影响 Persona 抽屉里已设置的 per-seat 覆盖"
|
|
||||||
}
|
|
||||||
className="w-full bg-slate-50/50 border border-slate-100 rounded-xl px-3 py-2 text-xs font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
|
|
||||||
>
|
|
||||||
{availableModels.length === 0 && (
|
|
||||||
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
|
|
||||||
)}
|
|
||||||
{availableModels.map((m) => (
|
|
||||||
<option key={m.name} value={m.name}>
|
|
||||||
{m.display_name && m.display_name !== m.name
|
|
||||||
? `${m.display_name} (${m.name})`
|
|
||||||
: m.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 当前意图摘要 —— 由 intent agent 输出的结构化 summary 驱动。 */}
|
<IntentSummaryCard intentReady={intentReady} />
|
||||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
|
||||||
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
|
|
||||||
<svg className="w-4 h-4 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<circle cx="12" cy="12" r="6" />
|
|
||||||
<circle cx="12" cy="12" r="2" />
|
|
||||||
</svg>
|
|
||||||
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">当前意图摘要</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3 bg-slate-50/50 border border-slate-100 rounded-xl space-y-3 mr-0.5">
|
|
||||||
{intentReady ? (
|
|
||||||
<ul className="text-xs text-slate-700 space-y-2.5 font-normal">
|
|
||||||
<li className="flex items-baseline gap-2 leading-relaxed">
|
|
||||||
<span className="text-primary text-base leading-none">•</span>
|
|
||||||
<span>
|
|
||||||
<b className="text-slate-900 font-bold">目标</b>: {intentReady.objective}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
{intentReady.constraints.map((c, i) => (
|
|
||||||
<li key={`c-${i}`} className="flex items-baseline gap-2 leading-relaxed">
|
|
||||||
<span className="text-primary text-base leading-none">•</span>
|
|
||||||
<span>
|
|
||||||
<b className="text-slate-900 font-bold">约束</b>: {c}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{(intentReady.assumptions ?? []).map((a, i) => (
|
|
||||||
<li key={`a-${i}`} className="flex items-baseline gap-2 leading-relaxed">
|
|
||||||
<span className="text-slate-400 text-base leading-none">•</span>
|
|
||||||
<span className="text-slate-500">
|
|
||||||
<b className="text-slate-700 font-bold">假设</b>: {a}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-slate-400 italic leading-relaxed">
|
|
||||||
等待任务理解智能体澄清完成...
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 澄清记录 Timeline */}
|
{/* 澄清记录 Timeline */}
|
||||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[300px]">
|
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[300px]">
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ export interface DraftStep2Snapshot {
|
|||||||
hasConsensus: boolean;
|
hasConsensus: boolean;
|
||||||
consensusPercentage: number;
|
consensusPercentage: number;
|
||||||
budgetLimit: number;
|
budgetLimit: number;
|
||||||
|
/**
|
||||||
|
* Per-seat stub AI messages used by the sandbox `write-file:` virtual URL
|
||||||
|
* loader. Older drafts won't have this — callers should default to `{}`.
|
||||||
|
*/
|
||||||
|
seatStubMessages?: Record<string, unknown[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DraftRecord {
|
export interface DraftRecord {
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
export interface SceneInfo {
|
||||||
|
applicableTask: string;
|
||||||
|
createTime: string;
|
||||||
|
deleted: number;
|
||||||
|
id: number;
|
||||||
|
relatedAgent: string;
|
||||||
|
sceneCode: string;
|
||||||
|
sceneIntro: string;
|
||||||
|
sceneName: string;
|
||||||
|
sceneType: string;
|
||||||
|
status: number;
|
||||||
|
updateTime: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskListItem {
|
||||||
|
createBy: number;
|
||||||
|
createTime: string;
|
||||||
|
deptId: null;
|
||||||
|
id: number;
|
||||||
|
isTop: number;
|
||||||
|
issuingDept: string;
|
||||||
|
keywords: null;
|
||||||
|
overview: string;
|
||||||
|
planTaskTop: number;
|
||||||
|
platform: null;
|
||||||
|
ptaskId: null;
|
||||||
|
recommendedBasis: null;
|
||||||
|
scene: string; // 或 SceneInfo,如果你已经解析
|
||||||
|
sourceType: number;
|
||||||
|
step: null;
|
||||||
|
taskContent: string;
|
||||||
|
taskDirection: string;
|
||||||
|
taskEndDate: string;
|
||||||
|
taskLevel: number;
|
||||||
|
taskName: string;
|
||||||
|
taskNo: string;
|
||||||
|
taskStartDate: string;
|
||||||
|
taskStatus: number;
|
||||||
|
taskSuperiorFlag: number;
|
||||||
|
taskTimeLimit: null;
|
||||||
|
taskType: number;
|
||||||
|
column: string;
|
||||||
|
updateBy: number;
|
||||||
|
updateTime: string;
|
||||||
|
endCount: number;
|
||||||
|
sumAll: number;
|
||||||
|
progress: number;
|
||||||
|
taskStatusData: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionListItem {
|
||||||
|
actionDay: number;
|
||||||
|
actionEndDate: string;
|
||||||
|
actionManager: number;
|
||||||
|
actionName: string;
|
||||||
|
actionNo: any;
|
||||||
|
actionStartDate: string;
|
||||||
|
actionStatus: number;
|
||||||
|
actionTerm: number;
|
||||||
|
actionTitle: any;
|
||||||
|
actorName: any;
|
||||||
|
audienceGroup: any
|
||||||
|
audienceGroupId: any;
|
||||||
|
audienceRecReason: any
|
||||||
|
behavior: any
|
||||||
|
createBy: number
|
||||||
|
createTime: string;
|
||||||
|
deleteFlag: number;
|
||||||
|
deptId: any;
|
||||||
|
deptName: string;
|
||||||
|
drivingFactor: any;
|
||||||
|
expectedBehaviorId: any;
|
||||||
|
id: number
|
||||||
|
keywords: string
|
||||||
|
level: number
|
||||||
|
scene: string
|
||||||
|
sourceType: number
|
||||||
|
step: null
|
||||||
|
taskId: number
|
||||||
|
updateBy: number
|
||||||
|
updateTime: number
|
||||||
|
useActionId: null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface actionColumnItem {
|
||||||
|
icon: string;
|
||||||
|
columnName: string;
|
||||||
|
actionList: ActionListItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const taskStatusList = {
|
||||||
|
'0': {
|
||||||
|
label: '待签收',
|
||||||
|
color: '#0062d9',
|
||||||
|
icon: '待签收.svg'
|
||||||
|
},
|
||||||
|
'10': {
|
||||||
|
label: '待分析',
|
||||||
|
color: '#2f9acc',
|
||||||
|
icon: '待CEH.svg'
|
||||||
|
},
|
||||||
|
'20': {
|
||||||
|
label: '分析中',
|
||||||
|
color: '#d97b00',
|
||||||
|
icon: '进行中.svg'
|
||||||
|
},
|
||||||
|
'25': {
|
||||||
|
label: '分析完成已提交',
|
||||||
|
color: '#1eb07f',
|
||||||
|
icon: '完成.svg'
|
||||||
|
},
|
||||||
|
'30': {
|
||||||
|
label: '已分析待审核',
|
||||||
|
color: '#1eb07f',
|
||||||
|
icon: '完成.svg'
|
||||||
|
},
|
||||||
|
'40': {
|
||||||
|
label: '分析审核通过',
|
||||||
|
color: '#1eb07f',
|
||||||
|
icon: '完成.svg'
|
||||||
|
},
|
||||||
|
'50': {
|
||||||
|
label: '分析审核未通过',
|
||||||
|
color: '#cf1322',
|
||||||
|
icon: '未通过.svg'
|
||||||
|
},
|
||||||
|
'60': {
|
||||||
|
label: '行动已下发待签收',
|
||||||
|
color: '#1eb07f',
|
||||||
|
icon: '完成.svg'
|
||||||
|
},
|
||||||
|
'70': {
|
||||||
|
label: '行动已签收待策划',
|
||||||
|
color: '#1eb07f',
|
||||||
|
icon: '完成.svg'
|
||||||
|
},
|
||||||
|
'80': {
|
||||||
|
label: '行动策划已提交',
|
||||||
|
color: '#1eb07f',
|
||||||
|
icon: '完成.svg'
|
||||||
|
},
|
||||||
|
'100': {
|
||||||
|
label: '作战方案待审核',
|
||||||
|
color: '#1eb07f',
|
||||||
|
icon: '完成.svg'
|
||||||
|
},
|
||||||
|
'110': {
|
||||||
|
label: '作战方案审核通过',
|
||||||
|
color: '#1eb07f',
|
||||||
|
icon: '完成.svg'
|
||||||
|
},
|
||||||
|
'120': {
|
||||||
|
label: '作战方案未通过',
|
||||||
|
color: '#cf1322',
|
||||||
|
icon: '未通过.svg'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export default function TaskManagement() {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Outlet, useNavigate } from 'react-router-dom';
|
||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
const userInfo = localStorage.getItem('userInfo');
|
||||||
|
const xkLinkUrl = import.meta.env.VITE_AIPI_XKLINKTO_URL;
|
||||||
|
interface TaskPgFromProps {
|
||||||
|
gopath: string
|
||||||
|
}
|
||||||
|
const TaskXkFrom: React.FC<TaskPgFromProps> = ({gopath}) => {
|
||||||
|
const [xkUrl, setXkUrl] = useState(xkLinkUrl);
|
||||||
|
useEffect(() => {
|
||||||
|
if(userInfo) {
|
||||||
|
const userData: any = JSON.parse(userInfo);
|
||||||
|
setXkUrl(`${xkLinkUrl}${gopath}?accessToken=${userData.accessToken}`);
|
||||||
|
}
|
||||||
|
}, [gopath]);
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<iframe className="h-[calc(100vh-92px)]" src={xkUrl}></iframe>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export default TaskXkFrom
|
||||||
@@ -567,6 +567,7 @@
|
|||||||
transform: translateY(-50%) scale(1);
|
transform: translateY(-50%) scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Tiptap writing editor ─────────────────────────────────────────────── */
|
/* ─── Tiptap writing editor ─────────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
# host shell; values in this file are what the container will receive.
|
# host shell; values in this file are what the container will receive.
|
||||||
|
|
||||||
# AI provider keys. Replace placeholders and rotate any test key before release.
|
# AI provider keys. Replace placeholders and rotate any test key before release.
|
||||||
DEEPSEEK_API_KEY=sk-cea9133f82e24e9f939136746b05d5f8
|
DEEPSEEK_API_KEY=sk-9808fb2375534112ac275541aec14b9b
|
||||||
OPENAI_API_KEY=your-openai-api-key
|
OPENAI_API_KEY=your-openai-api-key
|
||||||
ANTHROPIC_API_KEY=your-anthropic-api-key
|
ANTHROPIC_API_KEY=your-anthropic-api-key
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ from app.gateway.routers import (
|
|||||||
thread_shares,
|
thread_shares,
|
||||||
threads,
|
threads,
|
||||||
uploads,
|
uploads,
|
||||||
|
user_prompts,
|
||||||
writing,
|
writing,
|
||||||
)
|
)
|
||||||
from deerflow.config import app_config as deerflow_app_config
|
from deerflow.config import app_config as deerflow_app_config
|
||||||
@@ -502,6 +503,24 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Skill ownership migration failed (non-fatal)")
|
logger.exception("Skill ownership migration failed (non-fatal)")
|
||||||
|
|
||||||
|
# 一次性把 .deer-flow/agents/{id}/ 目录里历史遗留的 agent 导入 agents 表。
|
||||||
|
# 路由层原本每次请求都跑一遍这个 sync(扫文件系统 + N 个 agent 各一次 DB
|
||||||
|
# SELECT/UPDATE),把它挪到启动期 + 路由层只保留 TTL 兜底,显著降低
|
||||||
|
# /api/agents 列表延迟。
|
||||||
|
try:
|
||||||
|
from app.gateway.routers.agents import (
|
||||||
|
_mark_legacy_sync_fresh,
|
||||||
|
_sync_legacy_agents,
|
||||||
|
)
|
||||||
|
|
||||||
|
agent_store = getattr(app.state, "agent_store", None)
|
||||||
|
if agent_store is not None:
|
||||||
|
await _sync_legacy_agents(agent_store)
|
||||||
|
_mark_legacy_sync_fresh()
|
||||||
|
logger.info("Legacy folder-backed agents synced at startup")
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Legacy agent sync failed at startup (non-fatal)")
|
||||||
|
|
||||||
# 启动 skill curator 调度器:常驻后台任务,每小时检查一次需不需要进化
|
# 启动 skill curator 调度器:常驻后台任务,每小时检查一次需不需要进化
|
||||||
# (per-user interval_hours 仍是真正决定要不要跑进化的闸)。
|
# (per-user interval_hours 仍是真正决定要不要跑进化的闸)。
|
||||||
try:
|
try:
|
||||||
@@ -806,6 +825,8 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
|||||||
# Recommended Questions API is mounted at /api/recommended-questions
|
# Recommended Questions API is mounted at /api/recommended-questions
|
||||||
app.include_router(recommended_questions.router)
|
app.include_router(recommended_questions.router)
|
||||||
|
|
||||||
|
app.include_router(user_prompts.router)
|
||||||
|
|
||||||
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions
|
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions
|
||||||
app.include_router(suggestions.router)
|
app.include_router(suggestions.router)
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
app.state.memory_config_store = make_user_memory_config_store(sf)
|
app.state.memory_config_store = make_user_memory_config_store(sf)
|
||||||
|
|
||||||
|
from deerflow.persistence.user_prompts import make_user_prompt_store
|
||||||
|
|
||||||
|
app.state.user_prompt_store = make_user_prompt_store(sf)
|
||||||
|
|
||||||
# Run event store (has its own factory with config-driven backend selection)
|
# Run event store (has its own factory with config-driven backend selection)
|
||||||
run_events_config = getattr(config, "run_events", None)
|
run_events_config = getattr(config, "run_events", None)
|
||||||
app.state.run_event_store = make_run_event_store(run_events_config)
|
app.state.run_event_store = make_run_event_store(run_events_config)
|
||||||
|
|||||||
@@ -2,23 +2,31 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from fastapi import APIRouter, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.gateway.deps import get_agent_store, get_tag_store
|
from app.gateway.auth.password import verify_password_async
|
||||||
|
from app.gateway.deps import get_agent_store, get_config, get_tag_store
|
||||||
|
from deerflow.agents.lead_agent.prompt import refresh_skills_system_prompt_cache_async
|
||||||
from deerflow.config.agents_api_config import get_agents_api_config
|
from deerflow.config.agents_api_config import get_agents_api_config
|
||||||
from deerflow.config.agents_config import list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
|
from deerflow.config.agents_config import list_custom_agents, load_agent_config, load_agent_soul, validate_agent_id
|
||||||
|
from deerflow.config.app_config import AppConfig
|
||||||
from deerflow.config.paths import get_paths
|
from deerflow.config.paths import get_paths
|
||||||
|
from deerflow.config.system_settings import load_system_settings
|
||||||
from deerflow.persistence.agents import AgentStore
|
from deerflow.persistence.agents import AgentStore
|
||||||
from deerflow.persistence.tags import TagStore
|
from deerflow.persistence.tags import TagStore
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
from deerflow.skills.cascade import sync_agent_skills_to_available
|
||||||
|
from deerflow.skills.storage import get_or_new_skill_storage
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api", tags=["agents"])
|
router = APIRouter(prefix="/api", tags=["agents"])
|
||||||
@@ -32,6 +40,7 @@ class AgentResponse(BaseModel):
|
|||||||
description: str = Field(default="", description="Agent description")
|
description: str = Field(default="", description="Agent description")
|
||||||
user_id: str | None = Field(default=None, description="Owner user id. Null means built-in.")
|
user_id: str | None = Field(default=None, description="Owner user id. Null means built-in.")
|
||||||
published: bool = Field(default=False, description="Whether a user-created agent is public")
|
published: bool = Field(default=False, description="Whether a user-created agent is public")
|
||||||
|
is_private: bool = Field(default=False, description="Whether the agent lives in the password-gated private square instead of the public one")
|
||||||
builtin: bool = Field(default=False, description="True when user_id is null")
|
builtin: bool = Field(default=False, description="True when user_id is null")
|
||||||
created_at: datetime | str | None = None
|
created_at: datetime | str | None = None
|
||||||
updated_at: datetime | str | None = None
|
updated_at: datetime | str | None = None
|
||||||
@@ -50,6 +59,11 @@ class AgentResponse(BaseModel):
|
|||||||
description="Admin-curated homepage rank. Null = not featured. Lower sorts first.",
|
description="Admin-curated homepage rank. Null = not featured. Lower sorts first.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is_favorite: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Whether the requesting user has favorited (收藏) this agent.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AgentsListResponse(BaseModel):
|
class AgentsListResponse(BaseModel):
|
||||||
"""Response model for listing all visible agents."""
|
"""Response model for listing all visible agents."""
|
||||||
@@ -68,6 +82,7 @@ class AgentCreateRequest(BaseModel):
|
|||||||
description: str = Field(default="", description="Agent description")
|
description: str = Field(default="", description="Agent description")
|
||||||
soul: str = Field(default="", description="SOUL.md content - agent personality and behavioral guardrails")
|
soul: str = Field(default="", description="SOUL.md content - agent personality and behavioral guardrails")
|
||||||
published: bool = Field(default=False, description="Whether the new agent should be public")
|
published: bool = Field(default=False, description="Whether the new agent should be public")
|
||||||
|
is_private: bool = Field(default=False, description="When True, the new agent goes into the password-gated private square instead of the public one")
|
||||||
skills: list[str] | None = Field(default=None, description="Skill whitelist persisted to the agent's config.yaml")
|
skills: list[str] | None = Field(default=None, description="Skill whitelist persisted to the agent's config.yaml")
|
||||||
model: str | None = Field(default=None, description="Optional model override persisted to config.yaml")
|
model: str | None = Field(default=None, description="Optional model override persisted to config.yaml")
|
||||||
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml")
|
tool_groups: list[str] | None = Field(default=None, description="Optional tool group whitelist persisted to config.yaml")
|
||||||
@@ -80,6 +95,7 @@ class AgentUpdateRequest(BaseModel):
|
|||||||
description: str | None = Field(default=None, description="Updated description")
|
description: str | None = Field(default=None, description="Updated description")
|
||||||
soul: str | None = Field(default=None, description="Updated SOUL.md content")
|
soul: str | None = Field(default=None, description="Updated SOUL.md content")
|
||||||
published: bool | None = Field(default=None, description="Updated publication state")
|
published: bool | None = Field(default=None, description="Updated publication state")
|
||||||
|
is_private: bool | None = Field(default=None, description="Move the agent into / out of the private square (true / false)")
|
||||||
skills: list[str] | None = Field(default=None, description="Updated skill whitelist (config.yaml)")
|
skills: list[str] | None = Field(default=None, description="Updated skill whitelist (config.yaml)")
|
||||||
model: str | None = Field(default=None, description="Updated model override (config.yaml)")
|
model: str | None = Field(default=None, description="Updated model override (config.yaml)")
|
||||||
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
|
tool_groups: list[str] | None = Field(default=None, description="Updated tool group whitelist (config.yaml)")
|
||||||
@@ -192,6 +208,11 @@ async def _sync_legacy_agents(store: AgentStore) -> None:
|
|||||||
|
|
||||||
This preserves old deployments where agents already existed under
|
This preserves old deployments where agents already existed under
|
||||||
``.deer-flow/agents/{name}`` before the agents table was introduced.
|
``.deer-flow/agents/{name}`` before the agents table was introduced.
|
||||||
|
|
||||||
|
Heavy: scans the agents directory + parses each ``config.yaml`` + issues a
|
||||||
|
SELECT-then-INSERT/UPDATE per agent. Call once at startup via the lifespan,
|
||||||
|
and rely on :func:`_maybe_sync_legacy_agents` (TTL-gated) inside request
|
||||||
|
handlers as a safety net for environments that bypassed the lifespan hook.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
for agent_cfg in list_custom_agents():
|
for agent_cfg in list_custom_agents():
|
||||||
@@ -210,6 +231,44 @@ async def _sync_legacy_agents(store: AgentStore) -> None:
|
|||||||
logger.exception("Failed to sync legacy folder-backed agents")
|
logger.exception("Failed to sync legacy folder-backed agents")
|
||||||
|
|
||||||
|
|
||||||
|
# Throttle the per-request legacy-sync fallback. The real sync runs once at
|
||||||
|
# startup (lifespan); request handlers used to call ``_sync_legacy_agents`` on
|
||||||
|
# every hit, which scanned the filesystem + did N DB upserts per agent each
|
||||||
|
# time. After the first successful sync we skip the work for ``_LEGACY_SYNC_TTL_SECONDS``
|
||||||
|
# — long enough to be effectively "startup-only" in steady state, short enough
|
||||||
|
# that a freshly dropped agent directory gets picked up on the next bucket.
|
||||||
|
_LEGACY_SYNC_TTL_SECONDS = 3600.0
|
||||||
|
_legacy_sync_state: dict[str, Any] = {"last_at": 0.0, "lock": None}
|
||||||
|
|
||||||
|
|
||||||
|
async def _maybe_sync_legacy_agents(store: AgentStore) -> None:
|
||||||
|
"""TTL-gated wrapper around :func:`_sync_legacy_agents`.
|
||||||
|
|
||||||
|
Becomes a no-op in steady state — the heavy lifting happens in the lifespan
|
||||||
|
hook. Inside the TTL window we don't even take the lock; outside it we
|
||||||
|
serialize callers so a thundering herd doesn't fan out into N filesystem
|
||||||
|
scans + N*M DB queries.
|
||||||
|
"""
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - float(_legacy_sync_state["last_at"]) < _LEGACY_SYNC_TTL_SECONDS:
|
||||||
|
return
|
||||||
|
lock = _legacy_sync_state["lock"]
|
||||||
|
if lock is None:
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
_legacy_sync_state["lock"] = lock
|
||||||
|
async with lock:
|
||||||
|
# Re-check under the lock — a sibling coroutine may have just refreshed.
|
||||||
|
if time.monotonic() - float(_legacy_sync_state["last_at"]) < _LEGACY_SYNC_TTL_SECONDS:
|
||||||
|
return
|
||||||
|
await _sync_legacy_agents(store)
|
||||||
|
_legacy_sync_state["last_at"] = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_legacy_sync_fresh() -> None:
|
||||||
|
"""Called by the lifespan hook after running :func:`_sync_legacy_agents`."""
|
||||||
|
_legacy_sync_state["last_at"] = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
def _ensure_agent_dir_from_record(record: dict[str, Any]) -> None:
|
def _ensure_agent_dir_from_record(record: dict[str, Any]) -> None:
|
||||||
"""Lazily restore an agent's on-disk artifacts from its DB record.
|
"""Lazily restore an agent's on-disk artifacts from its DB record.
|
||||||
|
|
||||||
@@ -247,20 +306,36 @@ def _record_to_response(
|
|||||||
record: dict[str, Any],
|
record: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
include_soul: bool = False,
|
include_soul: bool = False,
|
||||||
|
include_extras: bool = True,
|
||||||
) -> AgentResponse:
|
) -> AgentResponse:
|
||||||
_ensure_agent_dir_from_record(record)
|
"""Convert a DB row to an API response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
include_soul: Read SOUL.md from disk and embed in ``soul``. Set False
|
||||||
|
in list endpoints — the frontend (gallery / recommend / selector)
|
||||||
|
never reads ``soul``, only the edit dialog does. Skipping this
|
||||||
|
avoids one synchronous file read per agent.
|
||||||
|
include_extras: Self-heal a missing agent dir and read deprecated
|
||||||
|
``model`` / ``tool_groups`` / ``skills`` from ``config.yaml``. Set
|
||||||
|
False in list endpoints to skip a stat + yaml parse per agent —
|
||||||
|
those fields are unused by current frontends and the dir restore
|
||||||
|
can happen lazily when the agent is actually opened.
|
||||||
|
"""
|
||||||
soul: str | None = None
|
soul: str | None = None
|
||||||
|
extras: dict[str, Any] = {"skills": None, "model": None, "tool_groups": None}
|
||||||
|
if include_extras:
|
||||||
|
_ensure_agent_dir_from_record(record)
|
||||||
|
extras = _load_config_extras(record["id"])
|
||||||
if include_soul:
|
if include_soul:
|
||||||
soul = load_agent_soul(record["id"]) or ""
|
soul = load_agent_soul(record["id"]) or ""
|
||||||
|
|
||||||
extras = _load_config_extras(record["id"])
|
|
||||||
|
|
||||||
return AgentResponse(
|
return AgentResponse(
|
||||||
id=record["id"],
|
id=record["id"],
|
||||||
name=record["name"],
|
name=record["name"],
|
||||||
description=record.get("description") or "",
|
description=record.get("description") or "",
|
||||||
user_id=record.get("user_id"),
|
user_id=record.get("user_id"),
|
||||||
published=bool(record.get("published", False)),
|
published=bool(record.get("published", False)),
|
||||||
|
is_private=bool(record.get("is_private", False)),
|
||||||
builtin=record.get("user_id") is None,
|
builtin=record.get("user_id") is None,
|
||||||
created_at=record.get("created_at"),
|
created_at=record.get("created_at"),
|
||||||
updated_at=record.get("updated_at"),
|
updated_at=record.get("updated_at"),
|
||||||
@@ -272,6 +347,25 @@ def _record_to_response(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _record_to_response_async(
|
||||||
|
record: dict[str, Any],
|
||||||
|
*,
|
||||||
|
include_soul: bool = False,
|
||||||
|
include_extras: bool = True,
|
||||||
|
) -> AgentResponse:
|
||||||
|
"""Async wrapper: when we actually touch disk, hop to a worker thread so
|
||||||
|
we don't block the asyncio loop. List endpoints (no soul, no extras) skip
|
||||||
|
this overhead and stay on the calling task."""
|
||||||
|
if not include_soul and not include_extras:
|
||||||
|
return _record_to_response(record, include_soul=False, include_extras=False)
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
_record_to_response,
|
||||||
|
record,
|
||||||
|
include_soul=include_soul,
|
||||||
|
include_extras=include_extras,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _get_owned_or_raise(store: AgentStore, agent_id: str, user_id: str) -> dict[str, Any]:
|
async def _get_owned_or_raise(store: AgentStore, agent_id: str, user_id: str) -> dict[str, Any]:
|
||||||
owned = await store.get_owned(agent_id, user_id)
|
owned = await store.get_owned(agent_id, user_id)
|
||||||
if owned is not None:
|
if owned is not None:
|
||||||
@@ -291,17 +385,21 @@ async def _get_editable_or_raise(
|
|||||||
) -> tuple[dict[str, Any], str]:
|
) -> tuple[dict[str, Any], str]:
|
||||||
"""Return (record, edit_mode) where edit_mode is 'owner' or 'admin'.
|
"""Return (record, edit_mode) where edit_mode is 'owner' or 'admin'.
|
||||||
|
|
||||||
'admin' is granted only when the agent is built-in (user_id IS NULL) and the
|
Admins may edit any agent — built-in (``user_id IS NULL``) or owned by
|
||||||
caller is a system admin. Custom agents owned by other users still 403.
|
another user — so the admin console can manage the full agent catalog.
|
||||||
|
Non-admin callers are still restricted to agents they own.
|
||||||
"""
|
"""
|
||||||
owned = await store.get_owned(agent_id, user_id)
|
owned = await store.get_owned(agent_id, user_id)
|
||||||
if owned is not None:
|
if owned is not None:
|
||||||
return owned, "owner"
|
return owned, "owner"
|
||||||
|
if is_admin:
|
||||||
|
record = await store.get_any(agent_id)
|
||||||
|
if record is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||||
|
return record, "admin"
|
||||||
visible = await store.get_visible(agent_id, user_id)
|
visible = await store.get_visible(agent_id, user_id)
|
||||||
if visible is None:
|
if visible is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||||
if is_admin and visible.get("user_id") is None:
|
|
||||||
return visible, "admin"
|
|
||||||
raise HTTPException(status_code=403, detail="You can only modify agents that you created")
|
raise HTTPException(status_code=403, detail="You can only modify agents that you created")
|
||||||
|
|
||||||
|
|
||||||
@@ -315,20 +413,42 @@ async def list_agents(
|
|||||||
request: Request,
|
request: Request,
|
||||||
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on agent name"),
|
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on agent name"),
|
||||||
tag_ids: list[str] | None = Query(default=None, description="Keep agents carrying any of these tags (OR)"),
|
tag_ids: list[str] | None = Query(default=None, description="Keep agents carrying any of these tags (OR)"),
|
||||||
|
admin: bool = Query(
|
||||||
|
default=False,
|
||||||
|
description=(
|
||||||
|
"Admin-only override. When ``true`` and the caller is a system admin, "
|
||||||
|
"every agent is returned (built-in + every user's agents + private-square "
|
||||||
|
"entries) regardless of the usual ownership/visibility filter."
|
||||||
|
),
|
||||||
|
),
|
||||||
) -> AgentsListResponse:
|
) -> AgentsListResponse:
|
||||||
_require_agents_api_enabled()
|
_require_agents_api_enabled()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
store = get_agent_store(request)
|
store = get_agent_store(request)
|
||||||
tag_store: TagStore = get_tag_store(request)
|
tag_store: TagStore = get_tag_store(request)
|
||||||
await _sync_legacy_agents(store)
|
await _maybe_sync_legacy_agents(store)
|
||||||
user_id = _current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
agents = await store.list_visible(user_id)
|
if admin:
|
||||||
responses = [_record_to_response(a, include_soul=True) for a in agents]
|
if not _is_admin_user(request):
|
||||||
|
raise HTTPException(status_code=403, detail="Only system admins can list all agents")
|
||||||
|
agents = await store.list_all()
|
||||||
|
else:
|
||||||
|
agents = await store.list_visible(user_id)
|
||||||
|
# List endpoint stays lean: skip per-agent SOUL.md read, config.yaml
|
||||||
|
# parse, and dir self-heal — none of those feed UI fields the gallery /
|
||||||
|
# recommend dialog / selector actually render. Detail endpoints (single
|
||||||
|
# agent + create/update responses) still load them.
|
||||||
|
responses = [
|
||||||
|
_record_to_response(a, include_soul=False, include_extras=False)
|
||||||
|
for a in agents
|
||||||
|
]
|
||||||
|
|
||||||
assignments = await tag_store.list_assignments("agent", [r.id for r in responses])
|
assignments = await tag_store.list_assignments("agent", [r.id for r in responses])
|
||||||
|
favorite_ids = await store.list_favorite_ids(user_id)
|
||||||
for resp in responses:
|
for resp in responses:
|
||||||
resp.tags = assignments.get(resp.id, [])
|
resp.tags = assignments.get(resp.id, [])
|
||||||
|
resp.is_favorite = resp.id in favorite_ids
|
||||||
if search:
|
if search:
|
||||||
needle = search.strip().lower()
|
needle = search.strip().lower()
|
||||||
responses = [r for r in responses if needle in r.name.lower()]
|
responses = [r for r in responses if needle in r.name.lower()]
|
||||||
@@ -368,11 +488,18 @@ async def get_agent_selector(request: Request) -> AgentSelectorResponse:
|
|||||||
_require_agents_api_enabled()
|
_require_agents_api_enabled()
|
||||||
try:
|
try:
|
||||||
store = get_agent_store(request)
|
store = get_agent_store(request)
|
||||||
await _sync_legacy_agents(store)
|
await _maybe_sync_legacy_agents(store)
|
||||||
|
|
||||||
user_id = _current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
records = await store.list_featured(user_id)
|
records = await store.list_featured(user_id)
|
||||||
return AgentSelectorResponse(agents=[_record_to_response(r) for r in records])
|
# Selector dropdown only renders name/description/featured_order — same
|
||||||
|
# rationale as list_agents: skip soul + extras + self-heal.
|
||||||
|
return AgentSelectorResponse(
|
||||||
|
agents=[
|
||||||
|
_record_to_response(r, include_soul=False, include_extras=False)
|
||||||
|
for r in records
|
||||||
|
]
|
||||||
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -392,12 +519,18 @@ async def get_agent(agent_id: str, request: Request) -> AgentResponse:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
store = get_agent_store(request)
|
store = get_agent_store(request)
|
||||||
await _sync_legacy_agents(store)
|
await _maybe_sync_legacy_agents(store)
|
||||||
user_id = _current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
record = await store.get_visible(agent_id, user_id)
|
record = await store.get_visible(agent_id, user_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||||
return _record_to_response(record, include_soul=True)
|
# Detail view legitimately reads SOUL.md + config.yaml — hop to a
|
||||||
|
# worker thread so the asyncio loop stays responsive under concurrent
|
||||||
|
# detail fetches.
|
||||||
|
response = await _record_to_response_async(record, include_soul=True, include_extras=True)
|
||||||
|
favorite_ids = await store.list_favorite_ids(user_id)
|
||||||
|
response.is_favorite = agent_id in favorite_ids
|
||||||
|
return response
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -446,10 +579,11 @@ async def create_agent_endpoint(request: AgentCreateRequest, http_request: Reque
|
|||||||
"description": request.description or "",
|
"description": request.description or "",
|
||||||
"user_id": _current_user_id(http_request),
|
"user_id": _current_user_id(http_request),
|
||||||
"published": request.published,
|
"published": request.published,
|
||||||
|
"is_private": request.is_private,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
logger.info("Created agent '%s' (%s) at %s", name, agent_id, agent_dir)
|
logger.info("Created agent '%s' (%s) at %s", name, agent_id, agent_dir)
|
||||||
return _record_to_response(record, include_soul=True)
|
return await _record_to_response_async(record, include_soul=True, include_extras=True)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -486,6 +620,15 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
|
|||||||
update_data["description"] = request.description or ""
|
update_data["description"] = request.description or ""
|
||||||
if "published" in fields_set:
|
if "published" in fields_set:
|
||||||
update_data["published"] = bool(request.published)
|
update_data["published"] = bool(request.published)
|
||||||
|
if "is_private" in fields_set:
|
||||||
|
update_data["is_private"] = bool(request.is_private)
|
||||||
|
# Mutual exclusion: an agent moved into the private square cannot
|
||||||
|
# also be publicly published. Force ``published=false`` whenever the
|
||||||
|
# same request sets ``is_private=true``, even if the client forgot
|
||||||
|
# to clear it. (When the request only sets one of the two, the
|
||||||
|
# other side is left untouched.)
|
||||||
|
if update_data.get("is_private") is True:
|
||||||
|
update_data["published"] = False
|
||||||
|
|
||||||
next_name = update_data.get("name", current["name"])
|
next_name = update_data.get("name", current["name"])
|
||||||
next_description = update_data.get("description", current.get("description") or "")
|
next_description = update_data.get("description", current.get("description") or "")
|
||||||
@@ -511,14 +654,17 @@ async def update_agent(agent_id: str, request: AgentUpdateRequest, http_request:
|
|||||||
if not update_data:
|
if not update_data:
|
||||||
record = current
|
record = current
|
||||||
elif edit_mode == "admin":
|
elif edit_mode == "admin":
|
||||||
record = await store.update_builtin(agent_id, update_data)
|
# Admins may edit built-in (``user_id IS NULL``) and any user-owned
|
||||||
|
# agent — ``update_any`` skips the ownership check that ``update``
|
||||||
|
# and ``update_builtin`` enforce.
|
||||||
|
record = await store.update_any(agent_id, update_data)
|
||||||
else:
|
else:
|
||||||
record = await store.update(agent_id, user_id, update_data)
|
record = await store.update(agent_id, user_id, update_data)
|
||||||
if record is None:
|
if record is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||||
|
|
||||||
logger.info("Updated agent '%s' (%s)", record["name"], agent_id)
|
logger.info("Updated agent '%s' (%s)", record["name"], agent_id)
|
||||||
return _record_to_response(record, include_soul=True)
|
return await _record_to_response_async(record, include_soul=True, include_extras=True)
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -598,7 +744,7 @@ async def delete_agent(agent_id: str, request: Request) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if edit_mode == "admin":
|
if edit_mode == "admin":
|
||||||
deleted = await store.delete_builtin(agent_id)
|
deleted = await store.delete_any(agent_id)
|
||||||
else:
|
else:
|
||||||
deleted = await store.delete(agent_id, user_id)
|
deleted = await store.delete(agent_id, user_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
@@ -611,6 +757,10 @@ async def delete_agent(agent_id: str, request: Request) -> None:
|
|||||||
await get_tag_store(request).unassign_all("agent", agent_id)
|
await get_tag_store(request).unassign_all("agent", agent_id)
|
||||||
except Exception: # noqa: BLE001 — tag cleanup must not block deletion
|
except Exception: # noqa: BLE001 — tag cleanup must not block deletion
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
await store.delete_favorites_for_agent(agent_id)
|
||||||
|
except Exception: # noqa: BLE001 — favorite cleanup must not block deletion
|
||||||
|
pass
|
||||||
logger.info("Deleted agent '%s' from %s", agent_id, agent_dir)
|
logger.info("Deleted agent '%s' from %s", agent_id, agent_dir)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -619,6 +769,53 @@ async def delete_agent(agent_id: str, request: Request) -> None:
|
|||||||
raise HTTPException(status_code=500, detail=f"Failed to delete agent: {str(e)}") from e
|
raise HTTPException(status_code=500, detail=f"Failed to delete agent: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/agents/{agent_id}/favorite",
|
||||||
|
status_code=204,
|
||||||
|
summary="Favorite Agent",
|
||||||
|
description="Bookmark a visible agent into the current user's 我的 tab. Idempotent.",
|
||||||
|
)
|
||||||
|
async def favorite_agent(agent_id: str, request: Request) -> None:
|
||||||
|
_require_agents_api_enabled()
|
||||||
|
agent_id = _validate_storage_id(agent_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
store = get_agent_store(request)
|
||||||
|
user_id = _current_user_id(request)
|
||||||
|
record = await store.get_visible(agent_id, user_id)
|
||||||
|
if record is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||||
|
await store.add_favorite(user_id, agent_id)
|
||||||
|
logger.info("User '%s' favorited agent '%s'", user_id, agent_id)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to favorite agent '%s': %s", agent_id, e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to favorite agent: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/agents/{agent_id}/favorite",
|
||||||
|
status_code=204,
|
||||||
|
summary="Unfavorite Agent",
|
||||||
|
description="Remove an agent from the current user's favorites. No-op when not favorited.",
|
||||||
|
)
|
||||||
|
async def unfavorite_agent(agent_id: str, request: Request) -> None:
|
||||||
|
_require_agents_api_enabled()
|
||||||
|
agent_id = _validate_storage_id(agent_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
store = get_agent_store(request)
|
||||||
|
user_id = _current_user_id(request)
|
||||||
|
await store.remove_favorite(user_id, agent_id)
|
||||||
|
logger.info("User '%s' unfavorited agent '%s'", user_id, agent_id)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to unfavorite agent '%s': %s", agent_id, e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to unfavorite agent: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
@router.put(
|
@router.put(
|
||||||
"/agents/{agent_id}/featured",
|
"/agents/{agent_id}/featured",
|
||||||
response_model=AgentResponse,
|
response_model=AgentResponse,
|
||||||
@@ -642,7 +839,7 @@ async def set_agent_featured(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
store = get_agent_store(request)
|
store = get_agent_store(request)
|
||||||
await _sync_legacy_agents(store)
|
await _maybe_sync_legacy_agents(store)
|
||||||
record = await store.get_any(agent_id)
|
record = await store.get_any(agent_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||||
@@ -654,7 +851,7 @@ async def set_agent_featured(
|
|||||||
if updated is None:
|
if updated is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||||
logger.info("Set agent '%s' featured_order=%s by admin", agent_id, body.order)
|
logger.info("Set agent '%s' featured_order=%s by admin", agent_id, body.order)
|
||||||
return _record_to_response(updated, include_soul=True)
|
return await _record_to_response_async(updated, include_soul=True, include_extras=True)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -662,6 +859,107 @@ async def set_agent_featured(
|
|||||||
raise HTTPException(status_code=500, detail=f"Failed to set featured: {str(e)}") from e
|
raise HTTPException(status_code=500, detail=f"Failed to set featured: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
|
class PrivateSquareListRequest(BaseModel):
|
||||||
|
"""Request body for listing all private-square agents."""
|
||||||
|
|
||||||
|
password: str = Field(..., description="Plain-text private-square password. Verified against the admin-configured hash before the listing is returned.")
|
||||||
|
|
||||||
|
|
||||||
|
class AgentPrivateUpdateRequest(BaseModel):
|
||||||
|
"""Admin-only request body for moving an agent into / out of the private square."""
|
||||||
|
|
||||||
|
is_private: bool = Field(..., description="Set true to move the agent into the private square, false to move it back into the public square.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/agents/private-square/list",
|
||||||
|
response_model=AgentsListResponse,
|
||||||
|
summary="List Private-Square Agents",
|
||||||
|
description=(
|
||||||
|
"Verify the admin-configured private-square password and return every "
|
||||||
|
"agent flagged ``is_private=true``. Returns 404 when no password is set, "
|
||||||
|
"403 when the supplied password does not match, and the full agent list "
|
||||||
|
"otherwise — regardless of ownership, since the password is the gate."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def list_private_square(body: PrivateSquareListRequest, request: Request) -> AgentsListResponse:
|
||||||
|
_require_agents_api_enabled()
|
||||||
|
|
||||||
|
settings = load_system_settings().private_square
|
||||||
|
if not settings.password_hash:
|
||||||
|
raise HTTPException(status_code=404, detail="Private square password is not configured")
|
||||||
|
if not body.password or not await verify_password_async(body.password, settings.password_hash):
|
||||||
|
raise HTTPException(status_code=403, detail="Incorrect private square password")
|
||||||
|
|
||||||
|
try:
|
||||||
|
store = get_agent_store(request)
|
||||||
|
tag_store: TagStore = get_tag_store(request)
|
||||||
|
await _sync_legacy_agents(store)
|
||||||
|
records = await store.list_private()
|
||||||
|
responses = [_record_to_response(r, include_soul=True) for r in records]
|
||||||
|
|
||||||
|
assignments = await tag_store.list_assignments("agent", [r.id for r in responses])
|
||||||
|
for resp in responses:
|
||||||
|
resp.tags = assignments.get(resp.id, [])
|
||||||
|
return AgentsListResponse(agents=responses)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to list private agents: %s", e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to list private agents: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/agents/{agent_id}/private",
|
||||||
|
response_model=AgentResponse,
|
||||||
|
summary="Move Agent Between Squares (admin)",
|
||||||
|
description=(
|
||||||
|
"Admin-only. Move any agent (built-in or user-owned) into or out of the "
|
||||||
|
"password-gated private square. Owners can already toggle the flag on "
|
||||||
|
"their own agents via the regular ``PUT /agents/{id}`` endpoint; this "
|
||||||
|
"endpoint exists so admins can re-categorize other users' agents too."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def set_agent_private(
|
||||||
|
agent_id: str,
|
||||||
|
body: AgentPrivateUpdateRequest,
|
||||||
|
request: Request,
|
||||||
|
) -> AgentResponse:
|
||||||
|
_require_agents_api_enabled()
|
||||||
|
agent_id = _validate_storage_id(agent_id)
|
||||||
|
|
||||||
|
if not _is_admin_user(request):
|
||||||
|
raise HTTPException(status_code=403, detail="Only system admins can re-categorize other users' agents")
|
||||||
|
|
||||||
|
try:
|
||||||
|
store = get_agent_store(request)
|
||||||
|
await _sync_legacy_agents(store)
|
||||||
|
record = await store.get_any(agent_id)
|
||||||
|
if record is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||||
|
|
||||||
|
update: dict[str, Any] = {"is_private": bool(body.is_private)}
|
||||||
|
# Mutual exclusion — see ``update_agent`` above. Moving an agent into
|
||||||
|
# the private square also drops the public ``published`` flag so we
|
||||||
|
# never end up with an agent that's "in both squares".
|
||||||
|
if update["is_private"]:
|
||||||
|
update["published"] = False
|
||||||
|
if record.get("user_id") is None:
|
||||||
|
updated = await store.update_builtin(agent_id, update)
|
||||||
|
else:
|
||||||
|
updated = await store.update(agent_id, str(record["user_id"]), update)
|
||||||
|
if updated is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Agent '{agent_id}' not found")
|
||||||
|
|
||||||
|
logger.info("Admin set agent '%s' is_private=%s", agent_id, body.is_private)
|
||||||
|
return _record_to_response(updated, include_soul=True)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to set is_private for agent '%s': %s", agent_id, e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to update agent: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
class AgentFeaturedReorderRequest(BaseModel):
|
class AgentFeaturedReorderRequest(BaseModel):
|
||||||
"""Request body for admin to bulk-reorder the homepage featured list."""
|
"""Request body for admin to bulk-reorder the homepage featured list."""
|
||||||
|
|
||||||
@@ -705,3 +1003,78 @@ async def reorder_featured_agents(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to reorder featured agents: %s", e, exc_info=True)
|
logger.error("Failed to reorder featured agents: %s", e, exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to reorder featured agents: {str(e)}") from e
|
raise HTTPException(status_code=500, detail=f"Failed to reorder featured agents: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
|
class AgentSkillRemovalEntry(BaseModel):
|
||||||
|
agent_id: str = Field(..., description="Agent storage id whose config.yaml was rewritten")
|
||||||
|
agent_name: str | None = Field(default=None, description="Display name from the DB, if any")
|
||||||
|
user_id: str | None = Field(default=None, description="Owner user id (null = built-in)")
|
||||||
|
removed_skills: list[str] = Field(..., description="Skill names that no longer exist on disk and were stripped")
|
||||||
|
|
||||||
|
|
||||||
|
class AgentSkillsSyncResponse(BaseModel):
|
||||||
|
checked: int = Field(..., description="Number of agent config.yaml files scanned")
|
||||||
|
modified: int = Field(..., description="Number of agents whose skills list was rewritten")
|
||||||
|
removals: list[AgentSkillRemovalEntry] = Field(default_factory=list, description="Per-agent removal details")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/agents/admin/sync-skills",
|
||||||
|
response_model=AgentSkillsSyncResponse,
|
||||||
|
summary="Sync Agent Skills (admin)",
|
||||||
|
description=(
|
||||||
|
"Admin-only. Scan every agent's ``config.yaml`` and strip skill names that "
|
||||||
|
"no longer exist on disk. Repairs historical drift where a skill was "
|
||||||
|
"removed without the delete/unpublish cascade firing (e.g. deleted from "
|
||||||
|
"the filesystem manually, or removed in an older version of the code)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def admin_sync_agent_skills(
|
||||||
|
request: Request,
|
||||||
|
config: AppConfig = Depends(get_config),
|
||||||
|
) -> AgentSkillsSyncResponse:
|
||||||
|
_require_agents_api_enabled()
|
||||||
|
if not _is_admin_user(request):
|
||||||
|
raise HTTPException(status_code=403, detail="Only system admins can run agent-skills sync")
|
||||||
|
|
||||||
|
try:
|
||||||
|
storage = get_or_new_skill_storage(app_config=config)
|
||||||
|
all_skills = storage.load_skills(enabled_only=False)
|
||||||
|
available = {skill.name for skill in all_skills}
|
||||||
|
|
||||||
|
result = await asyncio.to_thread(sync_agent_skills_to_available, available)
|
||||||
|
|
||||||
|
# Enrich removals with display name + owner from the DB so the admin
|
||||||
|
# UI can show "我的智能体 X 移除了技能 Y" instead of just the id.
|
||||||
|
store = get_agent_store(request)
|
||||||
|
enriched: list[AgentSkillRemovalEntry] = []
|
||||||
|
for entry in result.get("removals", []):
|
||||||
|
agent_id = entry["agent_id"]
|
||||||
|
record = await store.get_any(agent_id)
|
||||||
|
enriched.append(
|
||||||
|
AgentSkillRemovalEntry(
|
||||||
|
agent_id=agent_id,
|
||||||
|
agent_name=(record.get("name") if record else None),
|
||||||
|
user_id=(record.get("user_id") if record else None),
|
||||||
|
removed_skills=entry["removed_skills"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.get("modified"):
|
||||||
|
await refresh_skills_system_prompt_cache_async()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Admin agent-skills sync: checked=%d modified=%d",
|
||||||
|
result["checked"],
|
||||||
|
result["modified"],
|
||||||
|
)
|
||||||
|
return AgentSkillsSyncResponse(
|
||||||
|
checked=result["checked"],
|
||||||
|
modified=result["modified"],
|
||||||
|
removals=enriched,
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to sync agent skills: %s", e, exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to sync agent skills: {str(e)}") from e
|
||||||
@@ -65,6 +65,7 @@ from fastapi.responses import StreamingResponse
|
|||||||
# identical in connection handling, payload shape, and error mapping.
|
# identical in connection handling, payload shape, and error mapping.
|
||||||
from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents
|
from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents
|
||||||
from app.gateway.routers.multi_agent import (
|
from app.gateway.routers.multi_agent import (
|
||||||
|
_ROUNDTABLE_THREAD_METADATA,
|
||||||
_auth_headers,
|
_auth_headers,
|
||||||
_create_thread,
|
_create_thread,
|
||||||
_diagnose_exception,
|
_diagnose_exception,
|
||||||
@@ -133,6 +134,70 @@ def _normalize_options(raw: object) -> tuple[str, ...]:
|
|||||||
return tuple(sorted(o for o in out if o))
|
return tuple(sorted(o for o in out if o))
|
||||||
|
|
||||||
|
|
||||||
|
def _count_past_clarifications(messages: list[dict[str, Any]]) -> int:
|
||||||
|
"""统计当前 thread 内已经发出过的 ``ask_clarification`` 次数。
|
||||||
|
|
||||||
|
用来给 ``stream_intent`` 做"3 轮硬上限"兜底:
|
||||||
|
- 0-1 次:正常流程,模型自行决定继续问还是收敛
|
||||||
|
- 2 次(即将进入第 3 轮):prompt 追加系统提示,要求本轮必须 INTENT_READY
|
||||||
|
- ≥3 次:工具白名单移除 ``ask_clarification``,模型物理上无法再问
|
||||||
|
|
||||||
|
只数"已经成型"的 tool_call,不区分用户是否回答过——LangGraph 把 ask
|
||||||
|
工具的调用永久记录在 AIMessage.tool_calls 里,数它即可。
|
||||||
|
"""
|
||||||
|
if not isinstance(messages, list):
|
||||||
|
return 0
|
||||||
|
count = 0
|
||||||
|
for msg in messages:
|
||||||
|
if not isinstance(msg, dict):
|
||||||
|
continue
|
||||||
|
if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
|
||||||
|
continue
|
||||||
|
for tc in msg.get("tool_calls") or []:
|
||||||
|
if (tc.get("name") or "").lower() == "ask_clarification":
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_thread_messages(
|
||||||
|
client: httpx.AsyncClient,
|
||||||
|
base: str,
|
||||||
|
headers: dict[str, str],
|
||||||
|
thread_id: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""读取当前 thread checkpoint 的 messages 列表(best-effort)。
|
||||||
|
|
||||||
|
用 ASGITransport loopback 调 ``GET /api/threads/{tid}/state``,失败时返回
|
||||||
|
空列表——计数兜底是个"刹车",失败时退化成原有的纯 SOUL 自律行为,不阻断主流程。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
resp = await client.get(f"{base}/api/threads/{thread_id}/state", headers=headers)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("intent: get thread state failed (%s) — falling back to soul-only flow", exc)
|
||||||
|
return []
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.warning(
|
||||||
|
"intent: get thread state %s returned %s — falling back to soul-only flow",
|
||||||
|
thread_id,
|
||||||
|
resp.status_code,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
payload = resp.json()
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
values = payload.get("values") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(values, dict):
|
||||||
|
return []
|
||||||
|
messages = values.get("messages")
|
||||||
|
return messages if isinstance(messages, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
# 硬上限:超过这个轮数,无论模型怎么想,都强制移除 ask_clarification 工具。
|
||||||
|
# 与 SOUL.md §0 「硬规则 2」对齐——"绝不存在第 4 轮"。
|
||||||
|
_MAX_CLARIFICATION_ROUNDS = 3
|
||||||
|
|
||||||
|
|
||||||
def _is_repeated_clarification(
|
def _is_repeated_clarification(
|
||||||
messages: list[dict[str, Any]],
|
messages: list[dict[str, Any]],
|
||||||
*,
|
*,
|
||||||
@@ -222,7 +287,7 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
|
|||||||
# 看不到逐 token 增量。
|
# 看不到逐 token 增量。
|
||||||
try:
|
try:
|
||||||
async with _make_loopback_client(request) as client:
|
async with _make_loopback_client(request) as client:
|
||||||
thread_id = await _create_thread(client, base, headers)
|
thread_id = await _create_thread(client, base, headers, metadata=_ROUNDTABLE_THREAD_METADATA)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
# 上游已用结构化方式失败,原样传给前端不需要二次包装。
|
# 上游已用结构化方式失败,原样传给前端不需要二次包装。
|
||||||
raise
|
raise
|
||||||
@@ -279,33 +344,70 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
|||||||
# 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。
|
# 因此既无须 trust_env=False 防代理,也不存在 loopback 40s 超时这种坑。
|
||||||
async with _make_loopback_client(request) as client:
|
async with _make_loopback_client(request) as client:
|
||||||
try:
|
try:
|
||||||
|
# —— 3 轮硬上限兜底 ——
|
||||||
|
# 在发起 run 之前先读一次 thread state,数当前 ask_clarification 已发出次数:
|
||||||
|
# past=0/1 → 正常,不动 message,不动工具白名单
|
||||||
|
# past=2(本轮即将变成第 3 轮)→ 给消息加前缀,强提示"必须 INTENT_READY"
|
||||||
|
# past≥3 → 直接把 ask_clarification 也加进 excluded_tools,工具物理消失
|
||||||
|
# 这一步是个"刹车":SOUL 让模型自律,这里让系统兜底。
|
||||||
|
past_clarifications = _count_past_clarifications(
|
||||||
|
await _fetch_thread_messages(client, base, headers, thread_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. ask_clarification 的工具白名单——超过硬上限就移除。
|
||||||
|
base_excluded_tools = [
|
||||||
|
"web_search",
|
||||||
|
"present_files",
|
||||||
|
"view_image",
|
||||||
|
"agent_orchestration",
|
||||||
|
"bash",
|
||||||
|
"ls",
|
||||||
|
"read_file",
|
||||||
|
"write_file",
|
||||||
|
"str_replace",
|
||||||
|
"memory",
|
||||||
|
"hindsight_recall",
|
||||||
|
"hindsight_reflect",
|
||||||
|
"hindsight_retain",
|
||||||
|
"task",
|
||||||
|
"write_todos",
|
||||||
|
]
|
||||||
|
if past_clarifications >= _MAX_CLARIFICATION_ROUNDS:
|
||||||
|
base_excluded_tools.append("ask_clarification")
|
||||||
|
logger.info(
|
||||||
|
"intent stream: past_clarifications=%d >= %d, force-disabling "
|
||||||
|
"ask_clarification to break the loop",
|
||||||
|
past_clarifications,
|
||||||
|
_MAX_CLARIFICATION_ROUNDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. message 前缀——给模型一个明确的"本轮必须收敛"信号。
|
||||||
|
effective_message = message
|
||||||
|
if past_clarifications >= _MAX_CLARIFICATION_ROUNDS:
|
||||||
|
effective_message = (
|
||||||
|
f"[系统强制] 已澄清 {past_clarifications} 轮,触发硬上限。"
|
||||||
|
f"本轮请直接输出 [INTENT_READY],缺的字段用 assumptions 兜底,"
|
||||||
|
f"绝对不要再调任何工具,也不要再询问任何问题。\n\n"
|
||||||
|
f"用户最新输入:{message}"
|
||||||
|
)
|
||||||
|
elif past_clarifications >= _MAX_CLARIFICATION_ROUNDS - 1:
|
||||||
|
effective_message = (
|
||||||
|
f"[系统提示] 已澄清 {past_clarifications} 轮,这是最后一轮。"
|
||||||
|
f"本轮请直接输出 [INTENT_READY],未明确的字段用 assumptions 兜底。\n\n"
|
||||||
|
f"用户最新输入:{message}"
|
||||||
|
)
|
||||||
|
|
||||||
body = _run_payload(
|
body = _run_payload(
|
||||||
agent_name=INTENT_AGENT_ID,
|
agent_name=INTENT_AGENT_ID,
|
||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
new_message=message,
|
new_message=effective_message,
|
||||||
# Intent agent only ever needs ask_clarification. Block
|
# Intent agent only ever needs ask_clarification (and even that
|
||||||
|
# is yanked once past_clarifications hits the cap above). Block
|
||||||
# everything else so the model can't drift into solutioning
|
# everything else so the model can't drift into solutioning
|
||||||
# (e.g. saving a design doc with write_file) — the model
|
# (e.g. saving a design doc with write_file) — SOUL prose is
|
||||||
# tends to ignore SOUL prose, hard exclusion is reliable.
|
# not reliable on its own, hard exclusion is.
|
||||||
# 仅保留 ask_clarification;其余工具见模块 docstring「工具白名单」。
|
excluded_tools=base_excluded_tools,
|
||||||
excluded_tools=[
|
|
||||||
"web_search",
|
|
||||||
"present_files",
|
|
||||||
"view_image",
|
|
||||||
"agent_orchestration",
|
|
||||||
"bash",
|
|
||||||
"ls",
|
|
||||||
"read_file",
|
|
||||||
"write_file",
|
|
||||||
"str_replace",
|
|
||||||
"memory",
|
|
||||||
"hindsight_recall",
|
|
||||||
"hindsight_reflect",
|
|
||||||
"hindsight_retain",
|
|
||||||
"task",
|
|
||||||
"write_todos",
|
|
||||||
],
|
|
||||||
skill_stop_names=[],
|
skill_stop_names=[],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -338,7 +440,13 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
|||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
logger.warning("intent summary JSON parse failed: %s", exc)
|
logger.warning("intent summary JSON parse failed: %s", exc)
|
||||||
summary = None
|
summary = None
|
||||||
ready_text = msg_text
|
# SOUL §0 硬规则 3 要求 ```json``` 反引号闭合后不得再输出任何字符,
|
||||||
|
# 但模型仍偶尔会在后面接 "那么我帮你列一下大纲……" 之类的尾巴。
|
||||||
|
# 终态帧只回到 marker 块结尾 + 一个换行,把废话从 content 里裁掉——
|
||||||
|
# 草稿持久化 / 跨设备重放 / 前端 splitIntentReadyBlock 的 after
|
||||||
|
# 段都因此干净。流式期间用户仍会逐 token 看到那段废话,前端
|
||||||
|
# Step1Panel 已经把 `parts.after` 隐藏掉,体验闭环。
|
||||||
|
ready_text = msg_text[: m.end()].rstrip() + "\n"
|
||||||
break
|
break
|
||||||
if summary and ready_text:
|
if summary and ready_text:
|
||||||
final_frame = {
|
final_frame = {
|
||||||
|
|||||||
@@ -183,8 +183,11 @@ async def update_mcp_configuration(request: McpConfigUpdateRequest, http_request
|
|||||||
|
|
||||||
# Convert request to dict format for JSON serialization
|
# Convert request to dict format for JSON serialization
|
||||||
config_data = {
|
config_data = {
|
||||||
"mcpServers": {name: server.model_dump() for name, server in request.mcp_servers.items()},
|
"mcpServers": {name: server.model_dump(by_alias=True) for name, server in request.mcp_servers.items()},
|
||||||
"skills": {name: {"enabled": skill.enabled} for name, skill in current_config.skills.items()},
|
"skills": {
|
||||||
|
name: skill.model_dump(by_alias=True, exclude_none=True)
|
||||||
|
for name, skill in current_config.skills.items()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Write the configuration to file
|
# Write the configuration to file
|
||||||
|
|||||||
@@ -53,18 +53,28 @@ import os
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
|
import uuid
|
||||||
from typing import Any, AsyncIterator
|
from typing import Any, AsyncIterator
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from deerflow.config import get_app_config
|
from deerflow.config import get_app_config
|
||||||
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
|
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
|
||||||
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
from deerflow.utils.time import now_iso
|
||||||
from fastapi import APIRouter, Body, HTTPException, Request
|
from fastapi import APIRouter, Body, HTTPException, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
from langgraph.checkpoint.base import empty_checkpoint
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/multi-agent", tags=["multi-agent"])
|
router = APIRouter(prefix="/api/multi-agent", tags=["multi-agent"])
|
||||||
|
|
||||||
|
|
||||||
|
# 圆桌(intent / recommend / 席位 / 协调)创建的 thread 都是功能型内部线程,
|
||||||
|
# 不是用户的普通对话。打上这个 metadata,前端「最近的对话」列表(recent-chat-list.tsx)
|
||||||
|
# 据 `metadata.thread_type` 过滤掉,与 scheduler / bootstrap 系统会话同一套机制。
|
||||||
|
_ROUNDTABLE_THREAD_METADATA: dict[str, Any] = {"thread_type": "roundtable", "system": True}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 共享:异常诊断器(intent/recommend/multi-agent 共用)
|
# 共享:异常诊断器(intent/recommend/multi-agent 共用)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -285,9 +295,22 @@ def _auth_headers(request: Request) -> dict[str, str]:
|
|||||||
return {"Authorization": token, "Content-Type": "application/json"}
|
return {"Authorization": token, "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
async def _create_thread(client: httpx.AsyncClient, base: str, headers: dict[str, str]) -> str:
|
async def _create_thread(
|
||||||
"""``POST /api/threads`` → 新 thread_id(intent/recommend/init 席位均用)。"""
|
client: httpx.AsyncClient,
|
||||||
resp = await client.post(f"{base}/api/threads", json={}, headers=headers)
|
base: str,
|
||||||
|
headers: dict[str, str],
|
||||||
|
*,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""``POST /api/threads`` → 新 thread_id(intent/recommend/init 席位均用)。
|
||||||
|
|
||||||
|
``metadata`` 写入 thread 元数据,圆桌调用方传 ``_ROUNDTABLE_THREAD_METADATA``
|
||||||
|
把这些功能型线程标记成可被前端「最近的对话」过滤掉的系统会话。
|
||||||
|
"""
|
||||||
|
body: dict[str, Any] = {}
|
||||||
|
if metadata:
|
||||||
|
body["metadata"] = metadata
|
||||||
|
resp = await client.post(f"{base}/api/threads", json=body, headers=headers)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()["thread_id"]
|
return resp.json()["thread_id"]
|
||||||
|
|
||||||
@@ -323,6 +346,181 @@ async def _create_agent(
|
|||||||
return False, resp.text
|
return False, resp.text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 同进程直调 helpers(仅供 init_multi_agent 用,不要扩散到其它地方)
|
||||||
|
#
|
||||||
|
# init 阶段串了 N+1 个 thread create + N 个 GET /api/agents + 1 个 POST /api/agents,
|
||||||
|
# 用 HTTP loopback 串走每次都得 TCP + 鉴权中间件 + Pydantic 反序列化一遍。每个
|
||||||
|
# 调用 10-30ms,加起来对前端就是「点进 Step 2 后席位灯一颗一颗亮」的卡顿来源。
|
||||||
|
#
|
||||||
|
# 这里把这三个调用改成直接吃 app.state 里的 store/checkpointer —— 跑在同一个事
|
||||||
|
# 件循环里,省掉 TCP + 序列化 + 中间件链。鉴权 / 校验 / 文件副作用按需在直调
|
||||||
|
# helper 里显式做掉,不要"绕过"任何一项。
|
||||||
|
#
|
||||||
|
# 设计边界:这里只覆盖 init 阶段。SSE 长连接(/runs/stream / state)留在 HTTP
|
||||||
|
# loopback,因为它们本就是别的进程模型(LangGraph 子树),抽出来风险大收益小。
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_user_id(request: Request) -> str:
|
||||||
|
"""与 routers/agents._current_user_id 完全等价,直调路径里用。"""
|
||||||
|
user = getattr(request.state, "user", None)
|
||||||
|
if user is not None:
|
||||||
|
return str(user.id)
|
||||||
|
return get_effective_user_id()
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_thread_direct(request: Request, *, metadata: dict[str, Any] | None = None) -> str:
|
||||||
|
"""同进程版的 ``POST /api/threads``。
|
||||||
|
|
||||||
|
完全镜像 ``routers/threads.create_thread`` 的写盘动作(thread_meta +
|
||||||
|
空 checkpoint),但跳过 TCP + Pydantic 反序列化。仅供 init 阶段批量建席位
|
||||||
|
线程用,失败时抛 HTTPException 由 SSE 帧上报。
|
||||||
|
|
||||||
|
``metadata`` 写入 thread 元数据;init 传 ``_ROUNDTABLE_THREAD_METADATA`` 把
|
||||||
|
席位 / 协调线程标记成系统会话,从前端「最近的对话」里隐藏。
|
||||||
|
"""
|
||||||
|
from app.gateway.deps import get_checkpointer, get_thread_store
|
||||||
|
|
||||||
|
checkpointer = get_checkpointer(request)
|
||||||
|
thread_store = get_thread_store(request)
|
||||||
|
thread_id = str(uuid.uuid4())
|
||||||
|
now = now_iso()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await thread_store.create(thread_id, assistant_id=None, metadata=metadata or {})
|
||||||
|
except Exception:
|
||||||
|
logger.exception("[init-direct] thread_meta create failed for %s", thread_id)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create thread")
|
||||||
|
|
||||||
|
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||||||
|
ckpt_metadata = {
|
||||||
|
"step": -1,
|
||||||
|
"source": "input",
|
||||||
|
"writes": None,
|
||||||
|
"parents": {},
|
||||||
|
"created_at": now,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
await checkpointer.aput(config, empty_checkpoint(), ckpt_metadata, {})
|
||||||
|
except Exception:
|
||||||
|
logger.exception("[init-direct] checkpoint create failed for %s", thread_id)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create thread")
|
||||||
|
return thread_id
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_agents_batch_direct(
|
||||||
|
request: Request,
|
||||||
|
agent_ids: list[str],
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
"""同进程版的 N 个并行 ``GET /api/agents/{id}`` 合并为 1 次 DB 查询。
|
||||||
|
|
||||||
|
返回 ``{agent_id: {id, name, description}}``。**严格模式**:任一席位对当前
|
||||||
|
用户不可见就抛 404 —— 与原 HTTP loopback 的 ``_get_agent`` 行为一致(原路径
|
||||||
|
单查 404 会 raise,在外层被 except 捕获并 yield error 帧),不要悄悄回退。
|
||||||
|
"""
|
||||||
|
from app.gateway.deps import get_agent_store
|
||||||
|
|
||||||
|
store = get_agent_store(request)
|
||||||
|
user_id = _resolve_user_id(request)
|
||||||
|
records = await store.list_by_ids(agent_ids, user_id)
|
||||||
|
out: dict[str, dict[str, Any]] = {}
|
||||||
|
for r in records:
|
||||||
|
rid = str(r.get("id") or "").lower()
|
||||||
|
if not rid:
|
||||||
|
continue
|
||||||
|
out[rid] = {
|
||||||
|
"id": rid,
|
||||||
|
"name": str(r.get("name") or rid),
|
||||||
|
"description": str(r.get("description") or ""),
|
||||||
|
}
|
||||||
|
missing = [sid for sid in agent_ids if sid.lower() not in out]
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Agent(s) not visible to current user: {', '.join(missing)}",
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_coordinator_direct(
|
||||||
|
request: Request,
|
||||||
|
*,
|
||||||
|
agent_id: str,
|
||||||
|
name: str,
|
||||||
|
description: str,
|
||||||
|
soul: str,
|
||||||
|
model: str | None,
|
||||||
|
skills: list[str] | None,
|
||||||
|
) -> tuple[bool, dict[str, Any] | str]:
|
||||||
|
"""同进程版的协调 agent 创建。
|
||||||
|
|
||||||
|
完整复刻 ``routers/agents.create_agent_endpoint`` 的关键副作用:
|
||||||
|
- ``agents_api.enabled`` master switch
|
||||||
|
- 显示名校验(空白则 422 → 这里转 (False, msg))
|
||||||
|
- 重复 id 检查(409 → (False, msg))
|
||||||
|
- 写 ``config.yaml`` + ``SOUL.md``(用 ``asyncio.to_thread`` 防同步 IO 阻塞)
|
||||||
|
- ``agent_store.create`` 入表
|
||||||
|
|
||||||
|
返回 ``(True, record)`` 或 ``(False, error_text)``;调用方决定要不要发 SSE error 帧。
|
||||||
|
"""
|
||||||
|
from app.gateway.deps import get_agent_store
|
||||||
|
from app.gateway.routers.agents import (
|
||||||
|
_require_agents_api_enabled,
|
||||||
|
_write_agent_config,
|
||||||
|
_write_agent_soul,
|
||||||
|
)
|
||||||
|
from deerflow.config.paths import get_paths
|
||||||
|
|
||||||
|
_require_agents_api_enabled()
|
||||||
|
normalized_name = (name or "").strip()
|
||||||
|
if not normalized_name:
|
||||||
|
return False, "Agent name cannot be empty"
|
||||||
|
|
||||||
|
store = get_agent_store(request)
|
||||||
|
if await store.get_any(agent_id) is not None:
|
||||||
|
return False, f"Agent id '{agent_id}' already exists"
|
||||||
|
|
||||||
|
agent_dir = get_paths().agent_dir(agent_id)
|
||||||
|
if agent_dir.exists():
|
||||||
|
return False, f"Agent id '{agent_id}' already exists"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# config.yaml / SOUL.md 是同步 IO —— 全部丢线程池,不阻塞 init SSE 的发帧节奏。
|
||||||
|
await asyncio.to_thread(
|
||||||
|
_write_agent_config,
|
||||||
|
agent_id,
|
||||||
|
name=normalized_name,
|
||||||
|
description=description or "",
|
||||||
|
skills=skills,
|
||||||
|
model=model,
|
||||||
|
tool_groups=None,
|
||||||
|
)
|
||||||
|
await asyncio.to_thread(_write_agent_soul, agent_id, soul)
|
||||||
|
record = await store.create(
|
||||||
|
{
|
||||||
|
"id": agent_id,
|
||||||
|
"name": normalized_name,
|
||||||
|
"description": description or "",
|
||||||
|
"user_id": _resolve_user_id(request),
|
||||||
|
"published": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return True, record
|
||||||
|
except Exception as exc:
|
||||||
|
# 与 routers/agents 失败回滚一致 —— 文件已写出但 DB 失败时清掉 dir,避免下次
|
||||||
|
# `agent_dir.exists()` 误判为"已存在"导致后续 init 永远 409。
|
||||||
|
try:
|
||||||
|
if agent_dir.exists():
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
await asyncio.to_thread(shutil.rmtree, agent_dir)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("[init-direct] cleanup after coordinator create failure")
|
||||||
|
logger.exception("[init-direct] coordinator create failed")
|
||||||
|
return False, str(exc)
|
||||||
|
|
||||||
|
|
||||||
async def _append_thread_message(
|
async def _append_thread_message(
|
||||||
client: httpx.AsyncClient,
|
client: httpx.AsyncClient,
|
||||||
base: str,
|
base: str,
|
||||||
@@ -456,6 +654,8 @@ def _run_payload(
|
|||||||
new_message: str,
|
new_message: str,
|
||||||
excluded_tools: list[str],
|
excluded_tools: list[str],
|
||||||
skill_stop_names: list[str],
|
skill_stop_names: list[str],
|
||||||
|
thinking_enabled: bool = True,
|
||||||
|
reasoning_effort: str = "medium",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""构造 ``POST /api/threads/{thread_id}/runs/stream`` 的请求体。
|
"""构造 ``POST /api/threads/{thread_id}/runs/stream`` 的请求体。
|
||||||
|
|
||||||
@@ -463,6 +663,10 @@ def _run_payload(
|
|||||||
- ``excluded_tools``:按路由裁剪工具面(intent 仅澄清、special 禁派活等)。
|
- ``excluded_tools``:按路由裁剪工具面(intent 仅澄清、special 禁派活等)。
|
||||||
- ``skill_stop_names``:leader 侧配合 SkillStopMiddleware 在 ``agent_orchestration``
|
- ``skill_stop_names``:leader 侧配合 SkillStopMiddleware 在 ``agent_orchestration``
|
||||||
调用后截断图,以便网关从 AIMessage.tool_calls 解析派活列表。
|
调用后截断图,以便网关从 AIMessage.tool_calls 解析派活列表。
|
||||||
|
- ``thinking_enabled`` / ``reasoning_effort``:默认开 CoT + medium,适合 Step 1 澄清
|
||||||
|
与子智能体交付这类需要推理的场景。**推荐弹窗与总控派活**是短决策任务,调用方
|
||||||
|
应传 ``thinking_enabled=False`` + ``reasoning_effort="low"``,省掉模型先吐
|
||||||
|
reasoning tokens 才开口的时间,显著降低前端"调度中"首字节延迟。
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
"input": {
|
"input": {
|
||||||
@@ -479,11 +683,17 @@ def _run_payload(
|
|||||||
"agent_name": agent_name,
|
"agent_name": agent_name,
|
||||||
"model_name": model_name,
|
"model_name": model_name,
|
||||||
"mode": "pro",
|
"mode": "pro",
|
||||||
"reasoning_effort": "medium",
|
"reasoning_effort": reasoning_effort,
|
||||||
"thinking_enabled": True,
|
"thinking_enabled": thinking_enabled,
|
||||||
"is_plan_mode": False,
|
"is_plan_mode": False,
|
||||||
"subagent_enabled": False,
|
"subagent_enabled": False,
|
||||||
"thread_id": thread_id,
|
"thread_id": thread_id,
|
||||||
|
# 圆桌四个 router(intent / recommend / leader / special)都是临时任务,
|
||||||
|
# 用户个人记忆与本次研讨无关。MemoryMiddleware 的 Hindsight recall 是
|
||||||
|
# 同步阻塞 HTTP 调用(单次 timeout 15s),如果记忆服务慢/不通会让前端
|
||||||
|
# "调度中"卡很久;这里默认跳过,builtin USER.md/MEMORY.md 仍通过系统
|
||||||
|
# 提示注入不受影响。
|
||||||
|
"memory_recall_disabled": True,
|
||||||
},
|
},
|
||||||
"stream_mode": ["messages-tuple", "values"],
|
"stream_mode": ["messages-tuple", "values"],
|
||||||
"stream_subgraphs": True,
|
"stream_subgraphs": True,
|
||||||
@@ -534,111 +744,132 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea
|
|||||||
_validate_agent_id(name, "agent_names entry")
|
_validate_agent_id(name, "agent_names entry")
|
||||||
_validate_agent_id(main_agent_name, "main_agent_name")
|
_validate_agent_id(main_agent_name, "main_agent_name")
|
||||||
|
|
||||||
base = _loopback_base(request)
|
|
||||||
headers = _auth_headers(request)
|
|
||||||
|
|
||||||
def _frame(payload: dict[str, Any]) -> str:
|
def _frame(payload: dict[str, Any]) -> str:
|
||||||
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
async def stream_generator() -> AsyncIterator[str]:
|
async def stream_generator() -> AsyncIterator[str]:
|
||||||
# TCP loopback 到 127.0.0.1:<gateway port>。client 配置见 _make_loopback_client:
|
# init 阶段 fast path:全部走同进程直调(_create_thread_direct /
|
||||||
# trust_env=False 防 HTTP_PROXY 拦截,timeout=None 容纳 SSE 长连接。
|
# _get_agents_batch_direct / _create_coordinator_direct),不再走 HTTP
|
||||||
async with _make_loopback_client(request) as client:
|
# loopback —— TCP + 鉴权中间件 + Pydantic 反序列化每次 10-30ms,N+1 个席位
|
||||||
try:
|
# 累计就是"点进 Step 2 后席位一颗颗亮"的卡顿来源。
|
||||||
# Per-seat prepare task — same body as before, but we no longer
|
try:
|
||||||
# await them with asyncio.gather. Instead, asyncio.as_completed
|
t_init_start = time.perf_counter()
|
||||||
# surfaces them in REAL completion order so we can emit a
|
# 1) 一次 DB 查询拿到所有席位的 name/description,替代之前的 N 个并行
|
||||||
# seat_ready frame the moment each finishes.
|
# GET /api/agents/{id}。SOUL 拼接需要描述,这里一次性拿齐。
|
||||||
async def _prepare_sub_agent(sub_id: str) -> tuple[str, str, str, str]:
|
agent_meta = await _get_agents_batch_direct(request, agent_names)
|
||||||
info = await _get_agent(client, base, headers, sub_id)
|
t_meta_done = time.perf_counter()
|
||||||
display_name = str(info.get("name") or sub_id)
|
|
||||||
description = info.get("description", "") or ""
|
|
||||||
thread_id = await _create_thread(client, base, headers)
|
|
||||||
return sub_id.lower(), thread_id, display_name, description
|
|
||||||
|
|
||||||
tasks = [asyncio.create_task(_prepare_sub_agent(sid)) for sid in agent_names]
|
# 2) 每个席位 thread 并行创建(直调 thread_store + checkpointer),
|
||||||
prepared: list[tuple[str, str, str, str]] = []
|
# 用 asyncio.as_completed 保持"先完先发 seat_ready"的体验。
|
||||||
try:
|
async def _prepare_sub_agent(sub_id: str) -> tuple[str, str, str, str]:
|
||||||
for fut in asyncio.as_completed(tasks):
|
thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
|
||||||
sid, thread_id, display_name, description = await fut
|
meta = agent_meta.get(sub_id.lower(), {})
|
||||||
prepared.append((sid, thread_id, display_name, description))
|
return (
|
||||||
yield _frame({
|
sub_id.lower(),
|
||||||
"event": "seat_ready",
|
thread_id,
|
||||||
"agent_name": sid,
|
str(meta.get("name") or sub_id),
|
||||||
"thread_id": thread_id,
|
str(meta.get("description") or ""),
|
||||||
"display_name": display_name,
|
|
||||||
"description": description,
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
# Cancel any still-pending sibling so we don't leak tasks
|
|
||||||
# past the AsyncClient close.
|
|
||||||
for t in tasks:
|
|
||||||
if not t.done():
|
|
||||||
t.cancel()
|
|
||||||
raise
|
|
||||||
|
|
||||||
agent_threads: dict[str, str] = {sid: thread_id for sid, thread_id, _, _ in prepared}
|
|
||||||
descriptions: list[str] = [
|
|
||||||
f"- agent_name: {sid}({display_name}):{description}"
|
|
||||||
for sid, _, display_name, description in prepared
|
|
||||||
]
|
|
||||||
|
|
||||||
# 动态 SOUL:把本次 init 的席位列表写进总控 prompt,供 agent_orchestration 派活。
|
|
||||||
soul = (
|
|
||||||
"#核心定位:\n"
|
|
||||||
"用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总,"
|
|
||||||
"当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果\n"
|
|
||||||
"#调度方法:\n"
|
|
||||||
"通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活。"
|
|
||||||
"注意:agent_name 必须严格使用下方列出的英文 id(例如 roundtable-intelligence,"
|
|
||||||
"或形如 02714cbe6e64406a8a4659f0e3d9c0b8 这样的 UUID),"
|
|
||||||
"不要把括号里的中文显示名拼进 agent_name 里。\n"
|
|
||||||
"#关键约束(必读):\n"
|
|
||||||
"1. **不要对同一子智能体重复派同样的活**:每次派活前回顾对话历史,"
|
|
||||||
"如果某个子智能体已经针对你正要派的任务完成过交付("
|
|
||||||
"看到形如\"子智能体[名称]完成XX工作,交付内容为:...\"的 human 消息),"
|
|
||||||
"就直接基于已有交付推进,**不要重复派活**,除非你确实有**具体的、不一样的**补充问题。\n"
|
|
||||||
"2. **看到交付要会用**:子智能体的交付内容会作为 human 消息追加到你的对话历史里,"
|
|
||||||
"你能完整看见。当各方信息已足够形成结论时,**直接综合输出最终方案**(此时不再调用任何工具),"
|
|
||||||
"让讨论收敛。\n"
|
|
||||||
"3. **避免无意义循环**:如果连续两轮都没有新信息流入,立刻停止派活并基于现有素材给出最终结论。\n"
|
|
||||||
"#可调度智能体:\n"
|
|
||||||
+ "\n".join(descriptions)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
coord_thread = await _create_thread(client, base, headers)
|
tasks = [asyncio.create_task(_prepare_sub_agent(sid)) for sid in agent_names]
|
||||||
agent_threads[main_agent_name] = coord_thread
|
prepared: list[tuple[str, str, str, str]] = []
|
||||||
ok, result = await _create_agent(
|
try:
|
||||||
client,
|
for fut in asyncio.as_completed(tasks):
|
||||||
base,
|
sid, thread_id, display_name, description = await fut
|
||||||
headers,
|
prepared.append((sid, thread_id, display_name, description))
|
||||||
|
yield _frame({
|
||||||
|
"event": "seat_ready",
|
||||||
|
"agent_name": sid,
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"display_name": display_name,
|
||||||
|
"description": description,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
# Cancel any still-pending sibling so partially-prepared seats
|
||||||
|
# don't keep running past the stream close.
|
||||||
|
for t in tasks:
|
||||||
|
if not t.done():
|
||||||
|
t.cancel()
|
||||||
|
raise
|
||||||
|
t_seats_done = time.perf_counter()
|
||||||
|
|
||||||
|
agent_threads: dict[str, str] = {sid: thread_id for sid, thread_id, _, _ in prepared}
|
||||||
|
descriptions: list[str] = [
|
||||||
|
f"- agent_name: {sid}({display_name}):{description}"
|
||||||
|
for sid, _, display_name, description in prepared
|
||||||
|
]
|
||||||
|
|
||||||
|
# 动态 SOUL:把本次 init 的席位列表写进总控 prompt,供 agent_orchestration 派活。
|
||||||
|
soul = (
|
||||||
|
"#核心定位:\n"
|
||||||
|
"用途:根据用户的任务目标,调用其他智能体干活,只负责协调汇总,"
|
||||||
|
"当你觉得已有信息支持回答用户问题时候,停止调度,并输出最后的结果\n"
|
||||||
|
"#调度方法:\n"
|
||||||
|
"通过使用agent_orchestration技能调用其他智能体干活,使用技能时传入agent_name和需要做的活。"
|
||||||
|
"注意:agent_name 必须严格使用下方列出的英文 id(例如 roundtable-intelligence,"
|
||||||
|
"或形如 02714cbe6e64406a8a4659f0e3d9c0b8 这样的 UUID),"
|
||||||
|
"不要把括号里的中文显示名拼进 agent_name 里。\n"
|
||||||
|
"#关键约束(必读):\n"
|
||||||
|
"1. **不要对同一子智能体重复派同样的活**:每次派活前回顾对话历史,"
|
||||||
|
"如果某个子智能体已经针对你正要派的任务完成过交付("
|
||||||
|
"看到形如\"子智能体[名称]完成XX工作,交付内容为:...\"的 human 消息),"
|
||||||
|
"就直接基于已有交付推进,**不要重复派活**,除非你确实有**具体的、不一样的**补充问题。\n"
|
||||||
|
"2. **看到交付要会用**:子智能体的交付内容会作为 human 消息追加到你的对话历史里,"
|
||||||
|
"你能完整看见。当各方信息已足够形成结论时,**直接综合输出最终方案**(此时不再调用任何工具),"
|
||||||
|
"让讨论收敛。\n"
|
||||||
|
"3. **避免无意义循环**:如果连续两轮都没有新信息流入,立刻停止派活并基于现有素材给出最终结论。\n"
|
||||||
|
"#可调度智能体:\n"
|
||||||
|
+ "\n".join(descriptions)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3) coordinator thread + agent 并行创建。thread 与 agent 之间无依赖,
|
||||||
|
# asyncio.gather 比串行省一个直调往返的开销。
|
||||||
|
coord_thread_task = asyncio.create_task(
|
||||||
|
_create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
|
||||||
|
)
|
||||||
|
coord_agent_task = asyncio.create_task(
|
||||||
|
_create_coordinator_direct(
|
||||||
|
request,
|
||||||
|
agent_id=main_agent_name,
|
||||||
name=main_agent_name,
|
name=main_agent_name,
|
||||||
description="你是总控控制协调智能体,负责协调其他智能体干活",
|
description="你是总控控制协调智能体,负责协调其他智能体干活",
|
||||||
|
soul=soul,
|
||||||
model=model,
|
model=model,
|
||||||
skills=["agent_orchestration"],
|
skills=["agent_orchestration"],
|
||||||
soul=soul,
|
|
||||||
agent_id=main_agent_name,
|
|
||||||
)
|
)
|
||||||
if not ok and isinstance(result, str) and "exists" not in result:
|
)
|
||||||
yield _frame({"event": "error", "detail": f"create coordinator agent failed: {result}"})
|
coord_thread = await coord_thread_task
|
||||||
return
|
agent_threads[main_agent_name] = coord_thread
|
||||||
|
ok, result = await coord_agent_task
|
||||||
|
if not ok and isinstance(result, str) and "exists" not in result:
|
||||||
|
yield _frame({"event": "error", "detail": f"create coordinator agent failed: {result}"})
|
||||||
|
return
|
||||||
|
t_coord_done = time.perf_counter()
|
||||||
|
logger.info(
|
||||||
|
"[init-direct] meta=%.3fs seats=%.3fs coord=%.3fs total=%.3fs seats_count=%d",
|
||||||
|
t_meta_done - t_init_start,
|
||||||
|
t_seats_done - t_meta_done,
|
||||||
|
t_coord_done - t_seats_done,
|
||||||
|
t_coord_done - t_init_start,
|
||||||
|
len(prepared),
|
||||||
|
)
|
||||||
|
|
||||||
yield _frame({
|
yield _frame({
|
||||||
"event": "coordinator_ready",
|
"event": "coordinator_ready",
|
||||||
"main_agent_name": main_agent_name,
|
"main_agent_name": main_agent_name,
|
||||||
"thread_id": coord_thread,
|
"thread_id": coord_thread,
|
||||||
})
|
})
|
||||||
yield _frame({
|
yield _frame({
|
||||||
"event": "init_done",
|
"event": "init_done",
|
||||||
"main_agent_name": main_agent_name,
|
"main_agent_name": main_agent_name,
|
||||||
"thread_ids": agent_threads,
|
"thread_ids": agent_threads,
|
||||||
})
|
})
|
||||||
except HTTPException as exc:
|
except HTTPException as exc:
|
||||||
logger.warning("multi-agent init HTTPException: %s %s", exc.status_code, exc.detail)
|
logger.warning("multi-agent init HTTPException: %s %s", exc.status_code, exc.detail)
|
||||||
yield _frame({"event": "error", "detail": f"[{exc.status_code}] {exc.detail}"})
|
yield _frame({"event": "error", "detail": f"[{exc.status_code}] {exc.detail}"})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("multi-agent init unexpected failure")
|
logger.exception("multi-agent init unexpected failure")
|
||||||
yield _frame({"event": "error", "detail": f"unexpected: {exc!r}"})
|
yield _frame({"event": "error", "detail": f"unexpected: {exc!r}"})
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
stream_generator(),
|
stream_generator(),
|
||||||
@@ -764,25 +995,30 @@ def _find_trailing_ai_text(messages: list[dict[str, Any]], *, exclude_text: str
|
|||||||
|
|
||||||
|
|
||||||
def _find_dispatch_message(messages: list[dict[str, Any]]) -> dict[str, Any]:
|
def _find_dispatch_message(messages: list[dict[str, Any]]) -> dict[str, Any]:
|
||||||
"""Locate the latest AIMessage that carries dispatch tool calls.
|
"""Locate the latest AIMessage from THIS turn.
|
||||||
|
|
||||||
The very last message can be a skill-stop ToolMessage injected by
|
Trailing ToolMessages (skill_stop / clarification ack injected by
|
||||||
``SkillStopMiddleware`` when it intercepts the dispatch, or a clarification
|
``SkillStopMiddleware`` or ``ClarificationMiddleware``) are skipped because
|
||||||
ToolMessage. Either way, the actionable tool_calls live on the AIMessage
|
the actionable tool_calls live on the AIMessage that emitted them. We stop
|
||||||
*before* it, so we walk backwards until we hit one with ``tool_calls``.
|
at the **first AIMessage encountered** — its ``tool_calls`` (or lack
|
||||||
Returns the latest AIMessage when none of them carry tool_calls so the
|
thereof) is the authoritative signal for this turn.
|
||||||
caller can still extract its text content.
|
|
||||||
|
历史踩坑:之前实现是"一直往前翻直到撞到带 tool_calls 的 AI",看似优雅,
|
||||||
|
实际上让 leader 永远收敛不了:本轮 LLM 不论怎么共识,只要历史上派过活,
|
||||||
|
上一轮的派活 AI 就会被翻出来再用一次,前端会把同样的 task 又发给子智能体,
|
||||||
|
陷入死循环。现在停在最新一条 AI 上,把判定交给调用方:`tool_calls=[]` →
|
||||||
|
共识分支,有 tool_calls → 派活分支。
|
||||||
"""
|
"""
|
||||||
last_ai: dict[str, Any] = {}
|
|
||||||
for msg in reversed(messages):
|
for msg in reversed(messages):
|
||||||
if not isinstance(msg, dict):
|
if not isinstance(msg, dict):
|
||||||
continue
|
continue
|
||||||
if (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
|
msg_type = (msg.get("type") or "").lower()
|
||||||
if not last_ai:
|
if msg_type in ("tool", "toolmessage"):
|
||||||
last_ai = msg
|
continue # 跳过末尾 skill_stop / clarification ack
|
||||||
if msg.get("tool_calls"):
|
if msg_type in ("ai", "aimessage", "aimessagechunk"):
|
||||||
return msg
|
return msg
|
||||||
return last_ai
|
break # 撞到 Human/其他类型,本轮没 AI(异常路径,留空让上游报错)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
async def _leader_run(
|
async def _leader_run(
|
||||||
@@ -820,6 +1056,11 @@ async def _leader_run(
|
|||||||
new_message=new_message,
|
new_message=new_message,
|
||||||
excluded_tools=["web_search"],
|
excluded_tools=["web_search"],
|
||||||
skill_stop_names=effective_stop_names,
|
skill_stop_names=effective_stop_names,
|
||||||
|
# 总控派活是"下一步派谁、什么任务"的短决策,不需要 CoT。
|
||||||
|
# 关掉 thinking + low reasoning 让"调度中"几乎瞬间转入"正在派活",
|
||||||
|
# 子智能体仍保留 thinking(在 _special_run 里默认 True),保证交付质量。
|
||||||
|
thinking_enabled=False,
|
||||||
|
reasoning_effort="low",
|
||||||
)
|
)
|
||||||
t_payload_built = time.perf_counter()
|
t_payload_built = time.perf_counter()
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ from fastapi.responses import StreamingResponse
|
|||||||
|
|
||||||
from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents
|
from app.gateway.routers._roundtable_seed import ensure_roundtable_functional_agents
|
||||||
from app.gateway.routers.multi_agent import (
|
from app.gateway.routers.multi_agent import (
|
||||||
|
_ROUNDTABLE_THREAD_METADATA,
|
||||||
_auth_headers,
|
_auth_headers,
|
||||||
_create_thread,
|
_create_thread,
|
||||||
_flatten_content,
|
_flatten_content,
|
||||||
@@ -237,7 +238,7 @@ async def stream_recommend(request: Request, payload: dict = Body(...)) -> Strea
|
|||||||
async with _make_loopback_client(request) as client:
|
async with _make_loopback_client(request) as client:
|
||||||
try:
|
try:
|
||||||
# 每次推荐独立 thread;prompt 已包含完整 intent + 候选列表,无需多轮。
|
# 每次推荐独立 thread;prompt 已包含完整 intent + 候选列表,无需多轮。
|
||||||
thread_id = await _create_thread(client, base, headers)
|
thread_id = await _create_thread(client, base, headers, metadata=_ROUNDTABLE_THREAD_METADATA)
|
||||||
|
|
||||||
body = _run_payload(
|
body = _run_payload(
|
||||||
agent_name=RECOMMENDER_AGENT_ID,
|
agent_name=RECOMMENDER_AGENT_ID,
|
||||||
@@ -253,6 +254,12 @@ async def stream_recommend(request: Request, payload: dict = Body(...)) -> Strea
|
|||||||
"ask_clarification",
|
"ask_clarification",
|
||||||
],
|
],
|
||||||
skill_stop_names=[],
|
skill_stop_names=[],
|
||||||
|
# 推荐是"从候选池挑 2-6 个"的短分类任务,不需要 CoT。
|
||||||
|
# 关掉 thinking + 用 low reasoning,避免模型先吐数千 reasoning
|
||||||
|
# tokens 才开始输出 [RECOMMEND_READY],前端"等待推荐结果..."
|
||||||
|
# 从 10-30s 降到 2-5s。
|
||||||
|
thinking_enabled=False,
|
||||||
|
reasoning_effort="low",
|
||||||
)
|
)
|
||||||
|
|
||||||
captured: list[str] = []
|
captured: list[str] = []
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ class ScheduledTaskCreateRequest(BaseModel):
|
|||||||
timezone: str | None = None
|
timezone: str | None = None
|
||||||
execution_agent_name: str | None = None
|
execution_agent_name: str | None = None
|
||||||
execution_context: dict[str, Any] | None = None
|
execution_context: dict[str, Any] | None = None
|
||||||
|
require_markdown: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Require the result to be Markdown and exceed the minimum length, else mark the run failed.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ScheduledTaskUpdateRequest(BaseModel):
|
class ScheduledTaskUpdateRequest(BaseModel):
|
||||||
@@ -48,6 +52,7 @@ class ScheduledTaskUpdateRequest(BaseModel):
|
|||||||
enabled: bool | None = None
|
enabled: bool | None = None
|
||||||
execution_agent_name: str | None = None
|
execution_agent_name: str | None = None
|
||||||
execution_context: dict[str, Any] | None = None
|
execution_context: dict[str, Any] | None = None
|
||||||
|
require_markdown: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class ScheduledTaskDeliveryProfileUpdateRequest(BaseModel):
|
class ScheduledTaskDeliveryProfileUpdateRequest(BaseModel):
|
||||||
@@ -555,6 +560,7 @@ async def create_scheduled_task(body: ScheduledTaskCreateRequest, request: Reque
|
|||||||
timezone=body.timezone,
|
timezone=body.timezone,
|
||||||
execution_agent_name=body.execution_agent_name,
|
execution_agent_name=body.execution_agent_name,
|
||||||
execution_context=body.execution_context,
|
execution_context=body.execution_context,
|
||||||
|
require_markdown=body.require_markdown,
|
||||||
)
|
)
|
||||||
except DuplicateTaskNameError:
|
except DuplicateTaskNameError:
|
||||||
raise HTTPException(status_code=409, detail="已存在同名定时任务,请换一个名称")
|
raise HTTPException(status_code=409, detail="已存在同名定时任务,请换一个名称")
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -3,10 +3,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.gateway.auth.password import hash_password_async
|
||||||
from app.gateway.deps import get_optional_user_from_request
|
from app.gateway.deps import get_optional_user_from_request
|
||||||
from deerflow.config.system_settings import (
|
from deerflow.config.system_settings import (
|
||||||
PromptPrefixSettings,
|
PromptPrefixSettings,
|
||||||
@@ -31,6 +33,30 @@ class PromptPrefixUpdateRequest(BaseModel):
|
|||||||
prompt_prefix: str | None = None
|
prompt_prefix: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PrivateSquareStatusResponse(BaseModel):
|
||||||
|
"""Status of the private-square feature, readable by any logged-in user.
|
||||||
|
|
||||||
|
The password hash itself is **never** returned — the response only signals
|
||||||
|
whether the feature is enabled and whether a password has been set.
|
||||||
|
"""
|
||||||
|
|
||||||
|
enabled: bool = Field(..., description="Whether the private square is exposed to users")
|
||||||
|
password_set: bool = Field(..., description="Whether a password is currently configured")
|
||||||
|
|
||||||
|
|
||||||
|
class PrivateSquareUpdateRequest(BaseModel):
|
||||||
|
enabled: bool | None = Field(default=None, description="Toggle the private-square feature")
|
||||||
|
password: str | None = Field(default=None, description="New password in plain text. Pass an empty string to clear.")
|
||||||
|
|
||||||
|
|
||||||
|
class CitationDisplayResponse(BaseModel):
|
||||||
|
cleanup_words: list[str] = Field(default_factory=list, description="Global citation cleanup words")
|
||||||
|
|
||||||
|
|
||||||
|
class CitationDisplayUpdateRequest(BaseModel):
|
||||||
|
cleanup_words: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
def _to_response(settings: PromptPrefixSettings) -> PromptPrefixResponse:
|
def _to_response(settings: PromptPrefixSettings) -> PromptPrefixResponse:
|
||||||
return PromptPrefixResponse(
|
return PromptPrefixResponse(
|
||||||
enabled=settings.enabled,
|
enabled=settings.enabled,
|
||||||
@@ -61,5 +87,116 @@ async def update_prompt_prefix(body: PromptPrefixUpdateRequest, request: Request
|
|||||||
current = load_system_settings()
|
current = load_system_settings()
|
||||||
fields = body.model_dump(exclude_unset=True)
|
fields = body.model_dump(exclude_unset=True)
|
||||||
updated_prefix = current.prompt_prefix.model_copy(update=fields)
|
updated_prefix = current.prompt_prefix.model_copy(update=fields)
|
||||||
saved = save_system_settings(SystemSettings(prompt_prefix=updated_prefix))
|
saved = save_system_settings(current.model_copy(update={"prompt_prefix": updated_prefix}))
|
||||||
return _to_response(saved.prompt_prefix)
|
return _to_response(saved.prompt_prefix)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/citation-display", response_model=CitationDisplayResponse)
|
||||||
|
async def get_citation_display() -> CitationDisplayResponse:
|
||||||
|
"""Return global citation display cleanup settings."""
|
||||||
|
settings = load_system_settings().citation_display
|
||||||
|
return CitationDisplayResponse(cleanup_words=settings.cleanup_words)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/citation-display", response_model=CitationDisplayResponse)
|
||||||
|
async def update_citation_display(
|
||||||
|
body: CitationDisplayUpdateRequest,
|
||||||
|
request: Request,
|
||||||
|
) -> CitationDisplayResponse:
|
||||||
|
"""Update global citation display cleanup settings. Admin only."""
|
||||||
|
await _require_admin(request)
|
||||||
|
|
||||||
|
current = load_system_settings()
|
||||||
|
words = [
|
||||||
|
str(item).strip()
|
||||||
|
for item in (body.cleanup_words or [])
|
||||||
|
if str(item).strip()
|
||||||
|
]
|
||||||
|
updated = current.citation_display.model_copy(update={"cleanup_words": words})
|
||||||
|
saved = save_system_settings(
|
||||||
|
current.model_copy(update={"citation_display": updated})
|
||||||
|
)
|
||||||
|
return CitationDisplayResponse(cleanup_words=saved.citation_display.cleanup_words)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/private-square", response_model=PrivateSquareStatusResponse)
|
||||||
|
async def get_private_square_status() -> PrivateSquareStatusResponse:
|
||||||
|
"""Return whether the private square is enabled and a password is set."""
|
||||||
|
settings = load_system_settings().private_square
|
||||||
|
return PrivateSquareStatusResponse(
|
||||||
|
enabled=settings.enabled,
|
||||||
|
password_set=bool(settings.password_hash),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/private-square", response_model=PrivateSquareStatusResponse)
|
||||||
|
async def update_private_square(body: PrivateSquareUpdateRequest, request: Request) -> PrivateSquareStatusResponse:
|
||||||
|
"""Update the private-square configuration. Admin only.
|
||||||
|
|
||||||
|
Setting ``password`` to a non-empty string rehashes and stores it.
|
||||||
|
Passing an empty string clears the password (effectively locking the
|
||||||
|
private square so no one can read it).
|
||||||
|
"""
|
||||||
|
await _require_admin(request)
|
||||||
|
|
||||||
|
current = load_system_settings()
|
||||||
|
updated = current.private_square
|
||||||
|
fields = body.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
update: dict[str, object] = {}
|
||||||
|
if "enabled" in fields and fields["enabled"] is not None:
|
||||||
|
update["enabled"] = bool(fields["enabled"])
|
||||||
|
if "password" in fields and fields["password"] is not None:
|
||||||
|
plain = str(fields["password"])
|
||||||
|
if plain == "":
|
||||||
|
update["password_hash"] = ""
|
||||||
|
else:
|
||||||
|
update["password_hash"] = await hash_password_async(plain)
|
||||||
|
if update:
|
||||||
|
updated = updated.model_copy(update=update)
|
||||||
|
saved = save_system_settings(current.model_copy(update={"private_square": updated}))
|
||||||
|
else:
|
||||||
|
saved = current
|
||||||
|
return PrivateSquareStatusResponse(
|
||||||
|
enabled=saved.private_square.enabled,
|
||||||
|
password_set=bool(saved.private_square.password_hash),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AppearanceResponse(BaseModel):
|
||||||
|
"""Effective system-wide appearance defaults."""
|
||||||
|
|
||||||
|
default_theme: Literal["light", "dark"] = Field(..., description="System default theme — light or dark")
|
||||||
|
|
||||||
|
|
||||||
|
class AppearanceUpdateRequest(BaseModel):
|
||||||
|
default_theme: Literal["light", "dark"] | None = Field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/appearance", response_model=AppearanceResponse)
|
||||||
|
async def get_appearance_settings() -> AppearanceResponse:
|
||||||
|
"""Return the global default theme. Any logged-in user may read this."""
|
||||||
|
settings = load_system_settings().appearance
|
||||||
|
return AppearanceResponse(default_theme=settings.default_theme)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/appearance", response_model=AppearanceResponse)
|
||||||
|
async def update_appearance_settings(
|
||||||
|
body: AppearanceUpdateRequest, request: Request
|
||||||
|
) -> AppearanceResponse:
|
||||||
|
"""Update the global appearance defaults. Admin only."""
|
||||||
|
await _require_admin(request)
|
||||||
|
|
||||||
|
current = load_system_settings()
|
||||||
|
fields = body.model_dump(exclude_unset=True)
|
||||||
|
update: dict[str, object] = {}
|
||||||
|
if "default_theme" in fields and fields["default_theme"] is not None:
|
||||||
|
update["default_theme"] = fields["default_theme"]
|
||||||
|
if update:
|
||||||
|
updated_appearance = current.appearance.model_copy(update=update)
|
||||||
|
saved = save_system_settings(
|
||||||
|
current.model_copy(update={"appearance": updated_appearance})
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
saved = current
|
||||||
|
return AppearanceResponse(default_theme=saved.appearance.default_theme)
|
||||||
@@ -13,23 +13,37 @@ matching the LangGraph Platform wire format expected by the
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from langgraph.checkpoint.base import empty_checkpoint
|
from langgraph.checkpoint.base import empty_checkpoint
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.gateway.authz import require_permission
|
from app.gateway.authz import require_permission
|
||||||
from app.gateway.deps import get_checkpointer
|
from app.gateway.deps import get_checkpointer
|
||||||
from app.gateway.utils import sanitize_log_param
|
from app.gateway.utils import sanitize_log_param
|
||||||
|
from deerflow.config.extensions_config import SkillDisplayConfig
|
||||||
from deerflow.config.paths import Paths, get_paths
|
from deerflow.config.paths import Paths, get_paths
|
||||||
|
from deerflow.persistence.engine import get_engine, get_session_factory
|
||||||
|
from deerflow.persistence.skills.model import SkillDisplayAdapterRow
|
||||||
from deerflow.runtime import serialize_channel_values
|
from deerflow.runtime import serialize_channel_values
|
||||||
|
from deerflow.runtime.references import (
|
||||||
|
adapt_reference_batches_with_displays,
|
||||||
|
build_reference_batches as build_runtime_reference_batches,
|
||||||
|
build_reference_debug_payload,
|
||||||
|
build_reference_debug_summary,
|
||||||
|
)
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
from deerflow.utils.time import coerce_iso, now_iso
|
from deerflow.utils.time import coerce_iso, now_iso
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/api/threads", tags=["threads"])
|
router = APIRouter(prefix="/api/threads", tags=["threads"])
|
||||||
|
_RAG_DEBUG_LOGGER_NAME = "rag_citation_debug"
|
||||||
|
|
||||||
|
|
||||||
# Metadata keys that the server controls; clients are not allowed to set
|
# Metadata keys that the server controls; clients are not allowed to set
|
||||||
@@ -48,6 +62,76 @@ def _strip_reserved_metadata(metadata: dict[str, Any] | None) -> dict[str, Any]:
|
|||||||
return {k: v for k, v in metadata.items() if k not in _SERVER_RESERVED_METADATA_KEYS}
|
return {k: v for k, v in metadata.items() if k not in _SERVER_RESERVED_METADATA_KEYS}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_rag_debug_logger() -> logging.Logger:
|
||||||
|
debug_logger = logging.getLogger(_RAG_DEBUG_LOGGER_NAME)
|
||||||
|
if getattr(debug_logger, "_rag_file_configured", False):
|
||||||
|
return debug_logger
|
||||||
|
log_path = Path(os.environ.get("RAG_CITATION_DEBUG_LOG", "logs/rag_citation_debug.log"))
|
||||||
|
if not log_path.is_absolute():
|
||||||
|
log_path = Path.cwd() / log_path
|
||||||
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
handler = logging.FileHandler(log_path, encoding="utf-8")
|
||||||
|
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||||||
|
debug_logger.addHandler(handler)
|
||||||
|
debug_logger.setLevel(logging.INFO)
|
||||||
|
debug_logger.propagate = False
|
||||||
|
setattr(debug_logger, "_rag_file_configured", True)
|
||||||
|
setattr(debug_logger, "_rag_file_path", str(log_path))
|
||||||
|
return debug_logger
|
||||||
|
|
||||||
|
|
||||||
|
def _rag_debug_file_path() -> str:
|
||||||
|
return str(getattr(_get_rag_debug_logger(), "_rag_file_path", "logs/rag_citation_debug.log"))
|
||||||
|
|
||||||
|
|
||||||
|
def _log_rag_debug(message: str, *args: Any) -> None:
|
||||||
|
logger.info(message, *args)
|
||||||
|
_get_rag_debug_logger().info(message, *args)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_text_loads(value: str | None) -> dict[str, Any] | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value)
|
||||||
|
return parsed if isinstance(parsed, dict) else None
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to parse persisted skill display JSON", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_persisted_skill_displays() -> dict[str, SkillDisplayConfig]:
|
||||||
|
engine = get_engine()
|
||||||
|
sf = get_session_factory()
|
||||||
|
if engine is None or sf is None:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(SkillDisplayAdapterRow.__table__.create, checkfirst=True)
|
||||||
|
async with sf() as session:
|
||||||
|
result = await session.execute(select(SkillDisplayAdapterRow))
|
||||||
|
displays: dict[str, SkillDisplayConfig] = {}
|
||||||
|
for row in result.scalars():
|
||||||
|
payload = _json_text_loads(row.display_config)
|
||||||
|
if payload is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
display = SkillDisplayConfig.model_validate(payload)
|
||||||
|
except Exception:
|
||||||
|
logger.debug(
|
||||||
|
"Failed to validate persisted skill display for %s",
|
||||||
|
row.skill_name,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if display.enabled and display.mode != "none":
|
||||||
|
displays[row.skill_name] = display
|
||||||
|
return displays
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to load persisted skill display adapters", exc_info=True)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Response / request models
|
# Response / request models
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -139,6 +223,40 @@ class ThreadHistoryRequest(BaseModel):
|
|||||||
before: str | None = Field(default=None, description="Cursor for pagination")
|
before: str | None = Field(default=None, description="Cursor for pagination")
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceSourceResponse(BaseModel):
|
||||||
|
index: int
|
||||||
|
id: str | None = None
|
||||||
|
skillName: str = "backend-reference"
|
||||||
|
mode: str = "citation"
|
||||||
|
title: str
|
||||||
|
type: str = "result"
|
||||||
|
snippet: str = ""
|
||||||
|
source: str | None = None
|
||||||
|
author: str | None = None
|
||||||
|
time: str | None = None
|
||||||
|
url: str | None = None
|
||||||
|
content: str | None = None
|
||||||
|
score: float | None = None
|
||||||
|
tableColumns: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
raw: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceBatchResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
assistant_message_id: str
|
||||||
|
sources: list[ReferenceSourceResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceBatchesResponse(BaseModel):
|
||||||
|
batches: list[ReferenceBatchResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceDebugLogRequest(BaseModel):
|
||||||
|
event: str
|
||||||
|
thread_id: str | None = None
|
||||||
|
payload: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -182,6 +300,250 @@ def _derive_thread_status(checkpoint_tuple) -> str:
|
|||||||
return "idle"
|
return "idle"
|
||||||
|
|
||||||
|
|
||||||
|
def _message_type(message: Any) -> str:
|
||||||
|
if isinstance(message, dict):
|
||||||
|
return str(message.get("type") or message.get("role") or "")
|
||||||
|
return str(getattr(message, "type", "") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _message_id(message: Any) -> str | None:
|
||||||
|
if isinstance(message, dict):
|
||||||
|
value = message.get("id")
|
||||||
|
else:
|
||||||
|
value = getattr(message, "id", None)
|
||||||
|
return str(value) if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _message_content(message: Any) -> Any:
|
||||||
|
if isinstance(message, dict):
|
||||||
|
return message.get("content")
|
||||||
|
return getattr(message, "content", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _message_name(message: Any) -> str:
|
||||||
|
if isinstance(message, dict):
|
||||||
|
return str(message.get("name") or (message.get("additional_kwargs") or {}).get("name") or "tool")
|
||||||
|
return str(getattr(message, "name", None) or "tool")
|
||||||
|
|
||||||
|
|
||||||
|
def _has_tool_calls(message: Any) -> bool:
|
||||||
|
if isinstance(message, dict):
|
||||||
|
direct = message.get("tool_calls")
|
||||||
|
chunks = message.get("tool_call_chunks")
|
||||||
|
extra = (message.get("additional_kwargs") or {}).get("tool_calls")
|
||||||
|
else:
|
||||||
|
direct = getattr(message, "tool_calls", None)
|
||||||
|
chunks = getattr(message, "tool_call_chunks", None)
|
||||||
|
extra = (getattr(message, "additional_kwargs", None) or {}).get("tool_calls")
|
||||||
|
return bool(direct or chunks or extra)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_answer_content(message: Any) -> bool:
|
||||||
|
content = _message_content(message)
|
||||||
|
if isinstance(content, str):
|
||||||
|
return bool(content.strip())
|
||||||
|
if isinstance(content, list):
|
||||||
|
for part in content:
|
||||||
|
if isinstance(part, dict) and part.get("type") == "text" and str(part.get("text") or "").strip():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_tool_payload(message: Any) -> Any:
|
||||||
|
content = _message_content(message)
|
||||||
|
if isinstance(content, dict):
|
||||||
|
return _unwrap_tool_payload(content)
|
||||||
|
if isinstance(content, list):
|
||||||
|
texts: list[str] = []
|
||||||
|
for part in content:
|
||||||
|
if not isinstance(part, dict):
|
||||||
|
continue
|
||||||
|
if isinstance(part.get("json"), dict):
|
||||||
|
return _unwrap_tool_payload(part["json"])
|
||||||
|
if part.get("type") == "json" and isinstance(part.get("json"), dict):
|
||||||
|
return _unwrap_tool_payload(part["json"])
|
||||||
|
if part.get("type") == "text" and isinstance(part.get("text"), str):
|
||||||
|
texts.append(part["text"])
|
||||||
|
unwrapped = _unwrap_tool_payload(part)
|
||||||
|
if unwrapped is not None:
|
||||||
|
return unwrapped
|
||||||
|
content = "\n".join(texts)
|
||||||
|
if isinstance(content, str):
|
||||||
|
return _parse_json_like(content)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _unwrap_tool_payload(value: dict[str, Any]) -> Any:
|
||||||
|
if any(isinstance(value.get(key), list) for key in ("results", "data", "items", "hits", "documents", "records", "sources")):
|
||||||
|
return value
|
||||||
|
for key in ("result", "output", "stdout", "content", "text", "payload", "response"):
|
||||||
|
nested = value.get(key)
|
||||||
|
if isinstance(nested, str):
|
||||||
|
parsed = _parse_json_like(nested)
|
||||||
|
if parsed is not None:
|
||||||
|
return parsed
|
||||||
|
if isinstance(nested, dict):
|
||||||
|
unwrapped = _unwrap_tool_payload(nested)
|
||||||
|
if unwrapped is not None:
|
||||||
|
return unwrapped
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json_like(text: str) -> Any:
|
||||||
|
trimmed = text.strip()
|
||||||
|
if not trimmed:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(trimmed)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
first_obj = trimmed.find("{")
|
||||||
|
last_obj = trimmed.rfind("}")
|
||||||
|
if 0 <= first_obj < last_obj:
|
||||||
|
try:
|
||||||
|
return json.loads(trimmed[first_obj : last_obj + 1])
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _first_list(payload: Any) -> list[Any]:
|
||||||
|
if isinstance(payload, list):
|
||||||
|
return payload
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return []
|
||||||
|
for key in ("results", "data", "items", "hits", "documents", "records", "entries", "matches", "chunks", "sources"):
|
||||||
|
value = payload.get(key)
|
||||||
|
if isinstance(value, list):
|
||||||
|
return value
|
||||||
|
for key in ("result", "payload", "response", "output", "data"):
|
||||||
|
nested = payload.get(key)
|
||||||
|
found = _first_list(nested)
|
||||||
|
if found:
|
||||||
|
return found
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _nested_records(raw: Any) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return []
|
||||||
|
layers = [raw]
|
||||||
|
for key in ("metadata", "meta", "attrs", "attributes", "properties", "extra"):
|
||||||
|
nested = raw.get(key)
|
||||||
|
if isinstance(nested, dict):
|
||||||
|
layers.append(nested)
|
||||||
|
return layers
|
||||||
|
|
||||||
|
|
||||||
|
def _pick(layers: list[dict[str, Any]], keys: tuple[str, ...]) -> str | None:
|
||||||
|
for layer in layers:
|
||||||
|
for key in keys:
|
||||||
|
value = layer.get(key)
|
||||||
|
if isinstance(value, (str, int, float)) and str(value).strip():
|
||||||
|
return str(value).strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_reference_item(raw: Any, fallback_index: int) -> dict[str, Any] | None:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
layers = _nested_records(raw)
|
||||||
|
title = _pick(layers, ("title", "m_title", "name", "question", "keyword", "query", "text"))
|
||||||
|
content = _pick(layers, ("content", "page_content", "summary", "snippet", "description", "content_preview", "abstract", "body"))
|
||||||
|
url = _pick(layers, ("url", "link", "href", "source_url", "web_url"))
|
||||||
|
source = _pick(layers, ("source", "source1", "site", "provider", "from"))
|
||||||
|
time = _pick(layers, ("time", "date", "published_date", "publishedDate", "m_publish", "publish_time", "created_at"))
|
||||||
|
item_id = _pick(layers, ("recUuid", "rec_uuid", "uuid", "docId", "doc_id", "id", "recordId", "taskId"))
|
||||||
|
score_raw = _pick(layers, ("score", "relevance", "similarity"))
|
||||||
|
score = None
|
||||||
|
if score_raw is not None:
|
||||||
|
try:
|
||||||
|
score = float(score_raw)
|
||||||
|
except Exception:
|
||||||
|
score = None
|
||||||
|
if not title:
|
||||||
|
title = source or f"结果 {fallback_index}"
|
||||||
|
if not content:
|
||||||
|
content = ""
|
||||||
|
if not title and not content and not url:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": item_id or url,
|
||||||
|
"title": title,
|
||||||
|
"type": "result",
|
||||||
|
"snippet": content,
|
||||||
|
"content": content,
|
||||||
|
"source": source,
|
||||||
|
"time": time,
|
||||||
|
"url": url,
|
||||||
|
"score": score,
|
||||||
|
"raw": raw,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _reference_key(source: dict[str, Any]) -> str:
|
||||||
|
raw = source.get("raw") if isinstance(source.get("raw"), dict) else {}
|
||||||
|
item_id = (
|
||||||
|
source.get("id")
|
||||||
|
or source.get("url")
|
||||||
|
or raw.get("recUuid")
|
||||||
|
or raw.get("uuid")
|
||||||
|
or raw.get("docId")
|
||||||
|
or raw.get("id")
|
||||||
|
)
|
||||||
|
if item_id:
|
||||||
|
return str(item_id).casefold().strip()
|
||||||
|
return "|".join(
|
||||||
|
str(source.get(key) or "").casefold().strip()
|
||||||
|
for key in ("title", "source", "time", "content", "snippet")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_reference_batches(messages: list[Any]) -> list[ReferenceBatchResponse]:
|
||||||
|
batches: list[ReferenceBatchResponse] = []
|
||||||
|
for index, message in enumerate(messages):
|
||||||
|
if _message_type(message) != "human":
|
||||||
|
continue
|
||||||
|
end = len(messages)
|
||||||
|
for cursor in range(index + 1, len(messages)):
|
||||||
|
if _message_type(messages[cursor]) == "human":
|
||||||
|
end = cursor
|
||||||
|
break
|
||||||
|
assistant_message: Any | None = None
|
||||||
|
for cursor in range(end - 1, index, -1):
|
||||||
|
candidate = messages[cursor]
|
||||||
|
if (
|
||||||
|
_message_type(candidate) == "ai"
|
||||||
|
and _message_id(candidate)
|
||||||
|
and _has_answer_content(candidate)
|
||||||
|
and not _has_tool_calls(candidate)
|
||||||
|
):
|
||||||
|
assistant_message = candidate
|
||||||
|
break
|
||||||
|
if assistant_message is None:
|
||||||
|
continue
|
||||||
|
seen: set[str] = set()
|
||||||
|
sources: list[ReferenceSourceResponse] = []
|
||||||
|
for tool_message in messages[index + 1 : end]:
|
||||||
|
if _message_type(tool_message) != "tool":
|
||||||
|
continue
|
||||||
|
payload = _extract_tool_payload(tool_message)
|
||||||
|
for raw_item in _first_list(payload):
|
||||||
|
normalized = _normalize_reference_item(raw_item, len(sources) + 1)
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
key = _reference_key(normalized)
|
||||||
|
if key and key in seen:
|
||||||
|
continue
|
||||||
|
if key:
|
||||||
|
seen.add(key)
|
||||||
|
sources.append(ReferenceSourceResponse(index=len(sources) + 1, **normalized))
|
||||||
|
if sources:
|
||||||
|
mid = _message_id(assistant_message) or str(index)
|
||||||
|
batches.append(ReferenceBatchResponse(id=mid, assistant_message_id=mid, sources=sources))
|
||||||
|
return batches
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Endpoints
|
# Endpoints
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -458,6 +820,103 @@ async def get_thread_state(thread_id: str, request: Request) -> ThreadStateRespo
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{thread_id}/reference-batches", response_model=ReferenceBatchesResponse)
|
||||||
|
@require_permission("threads", "read", owner_check=True)
|
||||||
|
async def get_thread_reference_batches(thread_id: str, request: Request) -> ReferenceBatchesResponse:
|
||||||
|
"""Return backend-built citation batches for completed assistant turns."""
|
||||||
|
checkpointer = get_checkpointer(request)
|
||||||
|
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||||||
|
try:
|
||||||
|
checkpoint_tuple = await checkpointer.aget_tuple(config)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to get reference batches for thread %s", sanitize_log_param(thread_id))
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to get reference batches")
|
||||||
|
|
||||||
|
if checkpoint_tuple is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
|
||||||
|
|
||||||
|
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
|
||||||
|
values = serialize_channel_values(checkpoint.get("channel_values", {}) or {})
|
||||||
|
messages = values.get("messages")
|
||||||
|
if not isinstance(messages, list):
|
||||||
|
_log_rag_debug(
|
||||||
|
"backend reference-batches thread=%s messages=missing batches=0 log_file=%s",
|
||||||
|
sanitize_log_param(thread_id),
|
||||||
|
_rag_debug_file_path(),
|
||||||
|
)
|
||||||
|
return ReferenceBatchesResponse(batches=[])
|
||||||
|
batches_before_display_adapt = build_runtime_reference_batches(messages)
|
||||||
|
raw_batches = batches_before_display_adapt
|
||||||
|
persisted_displays = await _load_persisted_skill_displays()
|
||||||
|
if persisted_displays:
|
||||||
|
raw_batches = adapt_reference_batches_with_displays(raw_batches, persisted_displays)
|
||||||
|
debug_summary = build_reference_debug_summary(messages, raw_batches)
|
||||||
|
_log_rag_debug(
|
||||||
|
"backend reference-batches thread=%s messages=%s turns=%s tool_messages=%s raw_items=%s "
|
||||||
|
"normalized_items=%s kept_items=%s batches=%s batch_sources=%s batch_ids=%s "
|
||||||
|
"display_adapters=%s turns_detail=%s log_file=%s",
|
||||||
|
sanitize_log_param(thread_id),
|
||||||
|
debug_summary["messages"],
|
||||||
|
debug_summary["turns"],
|
||||||
|
debug_summary["tool_messages"],
|
||||||
|
debug_summary["raw_items"],
|
||||||
|
debug_summary["normalized_items"],
|
||||||
|
debug_summary["kept_items"],
|
||||||
|
len(raw_batches),
|
||||||
|
",".join(str(batch.get("source_count", 0)) for batch in debug_summary["batches"]),
|
||||||
|
",".join(str(batch.get("id") or "") for batch in debug_summary["batches"]),
|
||||||
|
len(persisted_displays),
|
||||||
|
json.dumps(debug_summary["turns_detail"], ensure_ascii=False),
|
||||||
|
_rag_debug_file_path(),
|
||||||
|
)
|
||||||
|
_log_rag_debug(
|
||||||
|
"backend reference-batches-payload thread=%s %s",
|
||||||
|
sanitize_log_param(thread_id),
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"display_adapter_names": sorted(persisted_displays.keys()),
|
||||||
|
"before_display_adapt": build_reference_debug_payload(
|
||||||
|
messages=messages,
|
||||||
|
batches=batches_before_display_adapt,
|
||||||
|
),
|
||||||
|
"after_display_adapt": build_reference_debug_payload(
|
||||||
|
messages=messages,
|
||||||
|
batches=raw_batches,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
default=str,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return ReferenceBatchesResponse(
|
||||||
|
batches=[
|
||||||
|
ReferenceBatchResponse(
|
||||||
|
id=str(batch.get("id") or ""),
|
||||||
|
assistant_message_id=str(batch.get("assistant_message_id") or batch.get("id") or ""),
|
||||||
|
sources=[
|
||||||
|
ReferenceSourceResponse(**source)
|
||||||
|
for source in batch.get("sources", [])
|
||||||
|
if isinstance(source, dict)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
for batch in raw_batches
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reference-debug-log")
|
||||||
|
async def write_reference_debug_log(body: ReferenceDebugLogRequest) -> dict[str, str | bool]:
|
||||||
|
"""Append frontend citation diagnostics to the backend RAG debug log."""
|
||||||
|
_get_rag_debug_logger().info(
|
||||||
|
"frontend %s thread=%s payload=%s log_file=%s",
|
||||||
|
body.event,
|
||||||
|
sanitize_log_param(body.thread_id or ""),
|
||||||
|
json.dumps(body.payload, ensure_ascii=False, default=str),
|
||||||
|
_rag_debug_file_path(),
|
||||||
|
)
|
||||||
|
return {"ok": True, "log_file": _rag_debug_file_path()}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{thread_id}/state", response_model=ThreadStateResponse)
|
@router.post("/{thread_id}/state", response_model=ThreadStateResponse)
|
||||||
@require_permission("threads", "write", owner_check=True, require_existing=True)
|
@require_permission("threads", "write", owner_check=True, require_existing=True)
|
||||||
async def update_thread_state(thread_id: str, body: ThreadStateUpdateRequest, request: Request) -> ThreadStateResponse:
|
async def update_thread_state(thread_id: str, body: ThreadStateUpdateRequest, request: Request) -> ThreadStateResponse:
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""CRUD API for a user's personal custom prompts (常用提示词).
|
||||||
|
|
||||||
|
Prompts are bound to the requesting user (not stored in the browser), so they
|
||||||
|
follow the account across devices/sessions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/user-prompts", tags=["user-prompts"])
|
||||||
|
|
||||||
|
|
||||||
|
class UserPromptResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
title: str = ""
|
||||||
|
content: str
|
||||||
|
sort_order: int = 0
|
||||||
|
created_at: datetime | str | None = None
|
||||||
|
updated_at: datetime | str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserPromptsListResponse(BaseModel):
|
||||||
|
prompts: list[UserPromptResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class UserPromptCreateRequest(BaseModel):
|
||||||
|
title: str = Field(default="", max_length=255)
|
||||||
|
content: str = Field(..., min_length=1)
|
||||||
|
sort_order: int = Field(default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class UserPromptUpdateRequest(BaseModel):
|
||||||
|
title: str | None = Field(default=None, max_length=255)
|
||||||
|
content: str | None = Field(default=None, min_length=1)
|
||||||
|
sort_order: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _current_user_id(request: Request) -> str:
|
||||||
|
user = getattr(request.state, "user", None)
|
||||||
|
if user is not None:
|
||||||
|
return str(user.id)
|
||||||
|
return get_effective_user_id()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_store(request: Request):
|
||||||
|
store = getattr(request.app.state, "user_prompt_store", None)
|
||||||
|
if store is None:
|
||||||
|
raise HTTPException(status_code=503, detail="User prompt store not available")
|
||||||
|
return store
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=UserPromptsListResponse)
|
||||||
|
async def list_user_prompts(request: Request) -> UserPromptsListResponse:
|
||||||
|
store = _get_store(request)
|
||||||
|
user_id = _current_user_id(request)
|
||||||
|
rows = await store.list_for_user(user_id)
|
||||||
|
return UserPromptsListResponse(prompts=[UserPromptResponse(**r) for r in rows])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=UserPromptResponse, status_code=201)
|
||||||
|
async def create_user_prompt(request: Request, body: UserPromptCreateRequest) -> UserPromptResponse:
|
||||||
|
store = _get_store(request)
|
||||||
|
user_id = _current_user_id(request)
|
||||||
|
row = await store.create(
|
||||||
|
user_id,
|
||||||
|
{
|
||||||
|
"id": uuid4().hex,
|
||||||
|
"title": (body.title or "").strip(),
|
||||||
|
"content": body.content,
|
||||||
|
"sort_order": body.sort_order,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return UserPromptResponse(**row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{prompt_id}", response_model=UserPromptResponse)
|
||||||
|
async def update_user_prompt(request: Request, prompt_id: str, body: UserPromptUpdateRequest) -> UserPromptResponse:
|
||||||
|
store = _get_store(request)
|
||||||
|
user_id = _current_user_id(request)
|
||||||
|
data: dict = {}
|
||||||
|
if body.title is not None:
|
||||||
|
data["title"] = body.title.strip()
|
||||||
|
if body.content is not None:
|
||||||
|
data["content"] = body.content
|
||||||
|
if body.sort_order is not None:
|
||||||
|
data["sort_order"] = body.sort_order
|
||||||
|
row = await store.update(prompt_id, user_id, data)
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Prompt not found")
|
||||||
|
return UserPromptResponse(**row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{prompt_id}", status_code=204)
|
||||||
|
async def delete_user_prompt(request: Request, prompt_id: str) -> None:
|
||||||
|
store = _get_store(request)
|
||||||
|
user_id = _current_user_id(request)
|
||||||
|
deleted = await store.delete(prompt_id, user_id)
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Prompt not found")
|
||||||
@@ -102,6 +102,13 @@ _DEFAULT_ASSISTANT_ID = "lead_agent"
|
|||||||
# These are NOT stored in the agents DB and must bypass the custom-agent access check.
|
# These are NOT stored in the agents DB and must bypass the custom-agent access check.
|
||||||
_BUILTIN_GRAPH_IDS: frozenset[str] = frozenset({"lead_agent", "canvas_agent", "ai_writing"})
|
_BUILTIN_GRAPH_IDS: frozenset[str] = frozenset({"lead_agent", "canvas_agent", "ai_writing"})
|
||||||
|
|
||||||
|
_RAG_CITATION_PROMPT = """当前对话已开启参考文献模式。
|
||||||
|
如果本轮调用了搜索、知识库、检索类技能或工具,请基于本轮所有检索结果整体回答。
|
||||||
|
你必须把同一轮内多次检索得到的资料视为同一批参考文献,按出现顺序从 [1] 开始连续编号,不要让每个检索词各自从 [1] 重新编号。
|
||||||
|
回答正文中的关键结论必须标注对应编号,例如 [1]、[2],多个来源可写为 [1][3][5]。
|
||||||
|
不要引用不存在的编号,不要使用技能内部自带的编号,不要把参考文献完整列表堆在正文末尾。
|
||||||
|
如果没有任何可用检索结果,请直接说明未检索到可靠参考资料。"""
|
||||||
|
|
||||||
|
|
||||||
def _prepend_prompt_prefix(graph_input: dict[str, Any], prefix: str) -> dict[str, Any]:
|
def _prepend_prompt_prefix(graph_input: dict[str, Any], prefix: str) -> dict[str, Any]:
|
||||||
"""Return ``graph_input`` with the prefix *tagged* onto its first HumanMessage.
|
"""Return ``graph_input`` with the prefix *tagged* onto its first HumanMessage.
|
||||||
@@ -140,6 +147,43 @@ def _prompt_prefix_opted_in(body: Any) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _rag_mode_requested(body: Any) -> bool:
|
||||||
|
context = getattr(body, "context", None)
|
||||||
|
if isinstance(context, Mapping) and context.get("rag_mode_enabled"):
|
||||||
|
return True
|
||||||
|
metadata = getattr(body, "metadata", None)
|
||||||
|
if isinstance(metadata, Mapping) and (
|
||||||
|
metadata.get("rag_mode_enabled") or metadata.get("rag_mode")
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _thread_rag_mode_enabled(record: Any) -> bool:
|
||||||
|
if not isinstance(record, Mapping):
|
||||||
|
return False
|
||||||
|
metadata = record.get("metadata")
|
||||||
|
return isinstance(metadata, Mapping) and bool(
|
||||||
|
metadata.get("rag_mode_enabled") or metadata.get("rag_mode")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_invisible_prompt(graph_input: dict[str, Any], prompt: str) -> dict[str, Any]:
|
||||||
|
messages = graph_input.get("messages")
|
||||||
|
if not isinstance(messages, list) or not messages:
|
||||||
|
return graph_input
|
||||||
|
new_messages = list(messages)
|
||||||
|
for idx, msg in enumerate(new_messages):
|
||||||
|
if not isinstance(msg, HumanMessage):
|
||||||
|
continue
|
||||||
|
extra = dict(getattr(msg, "additional_kwargs", None) or {})
|
||||||
|
existing = str(extra.get("prompt_prefix") or "").strip()
|
||||||
|
extra["prompt_prefix"] = f"{existing}\n\n{prompt}".strip() if existing else prompt
|
||||||
|
new_messages[idx] = msg.model_copy(update={"additional_kwargs": extra})
|
||||||
|
break
|
||||||
|
return {**graph_input, "messages": new_messages}
|
||||||
|
|
||||||
|
|
||||||
# Whitelist of run-context keys that the langgraph-compat layer forwards from
|
# Whitelist of run-context keys that the langgraph-compat layer forwards from
|
||||||
# ``body.context`` into the run config. ``config["context"]`` exists in
|
# ``body.context`` into the run config. ``config["context"]`` exists in
|
||||||
# LangGraph >=0.6, but these values must be written to both ``configurable``
|
# LangGraph >=0.6, but these values must be written to both ``configurable``
|
||||||
@@ -153,6 +197,7 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
|
|||||||
"mode",
|
"mode",
|
||||||
"thinking_enabled",
|
"thinking_enabled",
|
||||||
"reasoning_effort",
|
"reasoning_effort",
|
||||||
|
"rag_mode_enabled",
|
||||||
"writing_mode",
|
"writing_mode",
|
||||||
"writing_artifact_path",
|
"writing_artifact_path",
|
||||||
"canvas_artifact_title",
|
"canvas_artifact_title",
|
||||||
@@ -398,16 +443,29 @@ async def start_run(
|
|||||||
# Upsert thread metadata so the thread appears in /threads/search,
|
# Upsert thread metadata so the thread appears in /threads/search,
|
||||||
# even for threads that were never explicitly created via POST /threads
|
# even for threads that were never explicitly created via POST /threads
|
||||||
# (e.g. stateless runs).
|
# (e.g. stateless runs).
|
||||||
|
requested_rag_mode = _rag_mode_requested(body)
|
||||||
|
effective_rag_mode = requested_rag_mode
|
||||||
try:
|
try:
|
||||||
existing = await run_ctx.thread_store.get(thread_id)
|
existing = await run_ctx.thread_store.get(thread_id)
|
||||||
|
effective_rag_mode = requested_rag_mode or _thread_rag_mode_enabled(existing)
|
||||||
if existing is None:
|
if existing is None:
|
||||||
|
metadata = dict(body.metadata or {})
|
||||||
|
if effective_rag_mode:
|
||||||
|
metadata["rag_mode_enabled"] = True
|
||||||
|
metadata["rag_mode"] = True
|
||||||
await run_ctx.thread_store.create(
|
await run_ctx.thread_store.create(
|
||||||
thread_id,
|
thread_id,
|
||||||
assistant_id=body.assistant_id,
|
assistant_id=body.assistant_id,
|
||||||
metadata=body.metadata,
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await run_ctx.thread_store.update_status(thread_id, "running")
|
await run_ctx.thread_store.update_status(thread_id, "running")
|
||||||
|
if requested_rag_mode and not _thread_rag_mode_enabled(existing):
|
||||||
|
await run_ctx.thread_store.update_metadata(
|
||||||
|
thread_id,
|
||||||
|
{"rag_mode_enabled": True, "rag_mode": True},
|
||||||
|
)
|
||||||
|
effective_rag_mode = True
|
||||||
# SDK 的 ``client.threads.create()`` 不传 assistant_id —— 先建出来空的 thread,
|
# SDK 的 ``client.threads.create()`` 不传 assistant_id —— 先建出来空的 thread,
|
||||||
# 真正提交 run 时才知道用哪个 assistant。这里回填一次,避免历史列表 / 按
|
# 真正提交 run 时才知道用哪个 assistant。这里回填一次,避免历史列表 / 按
|
||||||
# assistant 筛选时拿不到关联。
|
# assistant 筛选时拿不到关联。
|
||||||
@@ -431,6 +489,8 @@ async def start_run(
|
|||||||
prefix_settings = load_system_settings().prompt_prefix
|
prefix_settings = load_system_settings().prompt_prefix
|
||||||
if prefix_settings.enabled and prefix_settings.prompt_prefix.strip():
|
if prefix_settings.enabled and prefix_settings.prompt_prefix.strip():
|
||||||
graph_input = _prepend_prompt_prefix(graph_input, prefix_settings.prompt_prefix.strip())
|
graph_input = _prepend_prompt_prefix(graph_input, prefix_settings.prompt_prefix.strip())
|
||||||
|
if effective_rag_mode:
|
||||||
|
graph_input = _append_invisible_prompt(graph_input, _RAG_CITATION_PROMPT)
|
||||||
config = build_run_config(thread_id, body.config, body.metadata, assistant_id=body.assistant_id)
|
config = build_run_config(thread_id, body.config, body.metadata, assistant_id=body.assistant_id)
|
||||||
|
|
||||||
# Merge DeerFlow-specific context overrides into both ``configurable`` and ``context``.
|
# Merge DeerFlow-specific context overrides into both ``configurable`` and ``context``.
|
||||||
@@ -438,6 +498,8 @@ async def start_run(
|
|||||||
# that carries agent configuration (model_name, thinking_enabled, etc.).
|
# that carries agent configuration (model_name, thinking_enabled, etc.).
|
||||||
# Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored.
|
# Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored.
|
||||||
body_context = dict(getattr(body, "context", None) or {})
|
body_context = dict(getattr(body, "context", None) or {})
|
||||||
|
if effective_rag_mode:
|
||||||
|
body_context["rag_mode_enabled"] = True
|
||||||
# Promote whitelisted top-level body fields into the context map so
|
# Promote whitelisted top-level body fields into the context map so
|
||||||
# callers can pass them either nested under ``context`` or as flat
|
# callers can pass them either nested under ``context`` or as flat
|
||||||
# top-level fields (``excluded_tools``, ``skill_stop_names``, …).
|
# top-level fields (``excluded_tools``, ``skill_stop_names``, …).
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ gateway:
|
|||||||
port: 8001
|
port: 8001
|
||||||
|
|
||||||
models:
|
models:
|
||||||
- name: deepseek-chat
|
- name: zai-org/GLM-5-FP8
|
||||||
display_name: DeepSeek Chat
|
display_name: zai-org/GLM-5-FP8
|
||||||
use: langchain_openai:ChatOpenAI
|
use: langchain_openai:ChatOpenAI
|
||||||
model: deepseek-chat
|
model: zai-org/GLM-5-FP8
|
||||||
api_key: sk-e1c22b7931dc446f8b67e57878edd105
|
api_key: Bearer modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA
|
||||||
base_url: https://api.deepseek.com
|
base_url: https://api.us-west-2.modal.direct/v1
|
||||||
request_timeout: 600.0
|
request_timeout: 600.0
|
||||||
max_retries: 2
|
max_retries: 2
|
||||||
max_tokens: 4096
|
max_tokens: 4096
|
||||||
@@ -121,7 +121,7 @@ sandbox:
|
|||||||
allow_host_bash: true
|
allow_host_bash: true
|
||||||
|
|
||||||
skills:
|
skills:
|
||||||
path: D:\project\bj\deerflow-server\skills
|
path: D:\cursor-work\offline-backend-20260512\backend\skills
|
||||||
security_scan:
|
security_scan:
|
||||||
enabled: false
|
enabled: false
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -9,6 +9,7 @@ from deerflow.agents.middlewares.clarification_middleware import ClarificationMi
|
|||||||
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
|
from deerflow.agents.middlewares.loop_detection_middleware import LoopDetectionMiddleware
|
||||||
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
|
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
|
||||||
from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware
|
from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware
|
||||||
|
from deerflow.agents.middlewares.rag_citation_middleware import RagCitationMiddleware
|
||||||
from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware
|
from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware
|
||||||
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
|
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
|
||||||
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
|
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
|
||||||
@@ -254,11 +255,14 @@ def _build_middlewares(
|
|||||||
List of middleware instances.
|
List of middleware instances.
|
||||||
"""
|
"""
|
||||||
resolved_app_config = app_config or get_app_config()
|
resolved_app_config = app_config or get_app_config()
|
||||||
|
cfg = _get_runtime_config(config)
|
||||||
middlewares = build_lead_runtime_middlewares(app_config=resolved_app_config, lazy_init=True)
|
middlewares = build_lead_runtime_middlewares(app_config=resolved_app_config, lazy_init=True)
|
||||||
|
|
||||||
# Inject the admin prompt prefix into the model request (kept out of the
|
# Inject the admin prompt prefix into the model request (kept out of the
|
||||||
# persisted/displayed message so the user never sees it).
|
# persisted/displayed message so the user never sees it).
|
||||||
middlewares.append(PromptPrefixMiddleware())
|
middlewares.append(PromptPrefixMiddleware())
|
||||||
|
if cfg.get("rag_mode_enabled", False):
|
||||||
|
middlewares.append(RagCitationMiddleware())
|
||||||
|
|
||||||
# Add summarization middleware if enabled
|
# Add summarization middleware if enabled
|
||||||
summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config)
|
summarization_middleware = _create_summarization_middleware(app_config=resolved_app_config)
|
||||||
@@ -266,7 +270,6 @@ def _build_middlewares(
|
|||||||
middlewares.append(summarization_middleware)
|
middlewares.append(summarization_middleware)
|
||||||
|
|
||||||
# Add TodoList middleware if plan mode is enabled
|
# Add TodoList middleware if plan mode is enabled
|
||||||
cfg = _get_runtime_config(config)
|
|
||||||
is_plan_mode = cfg.get("is_plan_mode", False)
|
is_plan_mode = cfg.get("is_plan_mode", False)
|
||||||
is_bootstrap = cfg.get("is_bootstrap", False)
|
is_bootstrap = cfg.get("is_bootstrap", False)
|
||||||
todo_list_middleware = _create_todo_list_middleware(is_plan_mode)
|
todo_list_middleware = _create_todo_list_middleware(is_plan_mode)
|
||||||
@@ -371,6 +374,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
|||||||
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
|
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
|
||||||
is_bootstrap = cfg.get("is_bootstrap", False)
|
is_bootstrap = cfg.get("is_bootstrap", False)
|
||||||
memory_injection_enabled = bool(cfg.get("memory_injection_enabled", True))
|
memory_injection_enabled = bool(cfg.get("memory_injection_enabled", True))
|
||||||
|
rag_mode_enabled = bool(cfg.get("rag_mode_enabled", False))
|
||||||
writing_mode = bool(cfg.get("writing_mode", False))
|
writing_mode = bool(cfg.get("writing_mode", False))
|
||||||
writing_artifact_path = cfg.get("writing_artifact_path") if writing_mode else None
|
writing_artifact_path = cfg.get("writing_artifact_path") if writing_mode else None
|
||||||
if not isinstance(writing_artifact_path, str) or not writing_artifact_path.strip():
|
if not isinstance(writing_artifact_path, str) or not writing_artifact_path.strip():
|
||||||
@@ -461,6 +465,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
|||||||
is_bootstrap=True,
|
is_bootstrap=True,
|
||||||
app_config=resolved_app_config,
|
app_config=resolved_app_config,
|
||||||
memory_injection_enabled=memory_injection_enabled,
|
memory_injection_enabled=memory_injection_enabled,
|
||||||
|
rag_mode_enabled=rag_mode_enabled,
|
||||||
),
|
),
|
||||||
state_schema=ThreadState,
|
state_schema=ThreadState,
|
||||||
)
|
)
|
||||||
@@ -495,6 +500,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
|
|||||||
available_skills=(set(agent_config.skills or []) if agent_config else None),
|
available_skills=(set(agent_config.skills or []) if agent_config else None),
|
||||||
app_config=resolved_app_config,
|
app_config=resolved_app_config,
|
||||||
memory_injection_enabled=memory_injection_enabled,
|
memory_injection_enabled=memory_injection_enabled,
|
||||||
|
rag_mode_enabled=rag_mode_enabled,
|
||||||
is_notebook_mode=is_notebook_mode,
|
is_notebook_mode=is_notebook_mode,
|
||||||
notebook_source_ids=notebook_source_ids,
|
notebook_source_ids=notebook_source_ids,
|
||||||
),
|
),
|
||||||
|
|||||||
Loaded 100 of 298 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user