Compare commits
12
Commits
gzy_iframedf
..
liuq
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9afcacc223 | ||
|
|
57289251a0 | ||
|
|
0c7d15fe59 | ||
|
|
f9baa2f8b9 | ||
|
|
9e4195db36 | ||
|
|
17257b79a7 | ||
|
|
43e73df066 | ||
|
|
843a9a3a7b | ||
|
|
9ffe28f049 | ||
|
|
b90c71d0ce | ||
|
|
9f8b9f7ae5 | ||
|
|
f5a47201ba |
No files matched your search
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "md-viewer-frontend",
|
||||
"runtimeExecutable": "npx",
|
||||
"runtimeArgs": ["ng", "serve", "--port", "4200"],
|
||||
"port": 4200,
|
||||
"cwd": "F:\\angular\\md-viewer\\frontend"
|
||||
}
|
||||
]
|
||||
}
|
||||
-15
@@ -84,20 +84,5 @@ offline-backend-20260512/backend/tests/*
|
||||
deploy
|
||||
.history
|
||||
|
||||
# Repo-root offline dependency pack (local only)
|
||||
/dist/
|
||||
|
||||
# Frontend offline pack working directories
|
||||
frontend-web/.offline-pack-build/
|
||||
frontend-web/.offline-extract-test/
|
||||
|
||||
.codegraph
|
||||
codegraph.mdc
|
||||
|
||||
docs/aidev-docs
|
||||
docs/backups
|
||||
# Frontend skills components must be tracked (excluded from the broad skills/ ignore above)
|
||||
!frontend-web/src/components/workspace/skills/
|
||||
|
||||
# uv lockfile (not tracked per project preference)
|
||||
offline-backend-20260512/backend/uv.lock
|
||||
@@ -1,139 +0,0 @@
|
||||
# Iframe Chat Page Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a standalone `/page/workspace/chats_iframe/*` chat page that reuses the existing thread system and history, while showing only `你好` and no input box before the first user message.
|
||||
|
||||
**Architecture:** Keep the existing workspace shell and chat runtime intact, then add a new route pair and a copied page component dedicated to the iframe path. The new page keeps the original chat behavior once a thread already has messages, but it owns its empty-state rendering and URL transition to `/page/workspace/chats_iframe/:thread_id`.
|
||||
|
||||
**Tech Stack:** React, TypeScript, React Router, Next navigation shim, existing LangGraph thread hooks
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add the iframe chat routes
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend-web/src/pages/WorkspaceRoutes.tsx`
|
||||
|
||||
- [ ] **Step 1: Add `chats_iframe/new` and `chats_iframe/:thread_id` routes that render the new page inside the existing workspace layout and chat runtime**
|
||||
|
||||
```tsx
|
||||
<Route
|
||||
path="chats_iframe/new"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<IframeChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="chats_iframe/:thread_id"
|
||||
element={
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<ChatRuntime>
|
||||
<IframeChatPage />
|
||||
</ChatRuntime>
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Ensure the new route imports compile cleanly**
|
||||
|
||||
Run: `rg -n "IframeChatPage|chats_iframe" frontend-web/src/pages/WorkspaceRoutes.tsx`
|
||||
Expected: `WorkspaceRoutes.tsx` contains one import for `IframeChatPage` and both `chats_iframe` route entries.
|
||||
|
||||
### Task 2: Add a standalone iframe chat page
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend-web/src/pages/IframeChatPage.tsx`
|
||||
- Reference: `frontend-web/src/pages/ChatPage.tsx`
|
||||
|
||||
- [ ] **Step 1: Copy the current `ChatPage` implementation into `IframeChatPage` and preserve the existing chat runtime integration**
|
||||
|
||||
```tsx
|
||||
export default function IframeChatPage() {
|
||||
const routeBase = useRouteBase();
|
||||
const router = useRouter();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
|
||||
useThreadChat();
|
||||
// keep the rest of the existing chat hooks and handlers aligned with ChatPage
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Change the thread creation URL replacement to stay on the iframe route family**
|
||||
|
||||
```tsx
|
||||
onStart: (createdThreadId) => {
|
||||
setThreadId(createdThreadId);
|
||||
setIsNewThread(false);
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}#${routeBase}/chats_iframe/${createdThreadId}`,
|
||||
);
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace the default landing state with a simple `你好` view and do not render the input box while the iframe page is still a brand-new untouched chat**
|
||||
|
||||
```tsx
|
||||
{isLandingState && mounted ? (
|
||||
<div className="flex min-h-[240px] items-center justify-center">
|
||||
<div className="text-2xl font-medium">你好</div>
|
||||
</div>
|
||||
) : null}
|
||||
{!isLandingState &&
|
||||
(mounted ? (
|
||||
<div className="w-full -translate-y-4">
|
||||
<InputBox
|
||||
className="w-full"
|
||||
isNewThread={false}
|
||||
threadId={threadId}
|
||||
autoFocus={false}
|
||||
status={thread.error ? "error" : thread.isLoading ? "streaming" : "ready"}
|
||||
context={settings.context}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" || isUploading}
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
promptPrefix={promptPrefixProp}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div aria-hidden="true" className="bg-background/5 h-32 w-full -translate-y-4 rounded-3xl border" />
|
||||
))}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Keep message rendering, thread title, export, token usage, todos, artifact triggers, and all post-first-message behavior identical to the original page**
|
||||
|
||||
Run: `rg -n "MessageList|ThreadTitle|ArtifactTrigger|TokenUsageIndicator|TodoList" frontend-web/src/pages/IframeChatPage.tsx`
|
||||
Expected: The new page still renders the same core chat primitives used by `ChatPage`.
|
||||
|
||||
### Task 3: Verify the new page compiles
|
||||
|
||||
**Files:**
|
||||
- Test: `frontend-web/src/pages/IframeChatPage.tsx`
|
||||
- Test: `frontend-web/src/pages/WorkspaceRoutes.tsx`
|
||||
|
||||
- [ ] **Step 1: Run the frontend build**
|
||||
|
||||
Run: `cmd /c npm run build`
|
||||
Expected: Build succeeds without TypeScript or bundling errors related to `IframeChatPage` or `chats_iframe`.
|
||||
|
||||
- [ ] **Step 2: Spot-check the changed routes in the built source**
|
||||
|
||||
Run: `rg -n "chats_iframe" frontend-web/src/pages`
|
||||
Expected: Matches appear in `WorkspaceRoutes.tsx` and `IframeChatPage.tsx`.
|
||||
|
||||
- [ ] **Step 3: Commit after verification**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/plans/2026-06-01-iframe-chat-page.md frontend-web/src/pages/WorkspaceRoutes.tsx frontend-web/src/pages/IframeChatPage.tsx
|
||||
git commit -m "feat: add iframe chat page routes"
|
||||
```
|
||||
@@ -24,20 +24,11 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
|
||||
VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
|
||||
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
|
||||
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
VITE_NOTEBOOK_API_BASE_URL=http://47.88.25.99:8000
|
||||
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
|
||||
VITE_STUDIO_ENABLED=true
|
||||
VITE_AIPI_FRONTEND_URL=http://47.88.25.99:3000
|
||||
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
# 我的笔记前端
|
||||
VITE_AIPI_FRONTEND_URL= xxx
|
||||
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
||||
|
||||
# MaxKey 鉴权(getTokenByOnlineTicket)
|
||||
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
||||
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
|
||||
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
|
||||
# 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
|
||||
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
||||
|
||||
@@ -15,15 +15,9 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
|
||||
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
|
||||
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
||||
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
||||
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
|
||||
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
|
||||
VITE_STUDIO_ENABLED=true
|
||||
@@ -5,14 +5,13 @@
|
||||
# ├─────────────────┼──────────────────────────────────────────────────┤
|
||||
# │ pnpm dev │ .env.development + .env.development.local │
|
||||
# │ pnpm build │ .env.production + .env.production.local │
|
||||
# │ pnpm build:network │ .env.network + .env.network.local(内网地址) │
|
||||
# │ scripts/start-* │ 脚本注入的环境变量会覆盖上述文件(见下方说明) │
|
||||
# └─────────────────┴──────────────────────────────────────────────────┘
|
||||
#
|
||||
# 推荐:
|
||||
# - 日常本地开发:改 .env.development(已提交,团队默认 UAT 地址)
|
||||
# - 本机连 127.0.0.1:8001:复制 .env.development.local.example → .env.development.local
|
||||
# - 内网打包部署:改 .env.network 后执行 pnpm build:network(或改 .env.production 后 pnpm build)
|
||||
# - 内网打包部署:改 .env.production 后再执行 pnpm build
|
||||
#
|
||||
# 若浏览器直连后端报 CORS,请在 Gateway 配置 CORS_ORIGINS,包含前端源站,例如:
|
||||
# CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:5174
|
||||
@@ -21,17 +20,10 @@
|
||||
# VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
|
||||
# VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
|
||||
# VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
|
||||
# VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
# VITE_STUDIO_ENABLED=true # 仅控制“工作室”入口显示,列表内容仍依赖 notebook 接口数据
|
||||
|
||||
# --- 生产打包(见 .env.production)---
|
||||
# 部署前修改 .env.production 中的地址,再执行 pnpm build
|
||||
|
||||
# MaxKey 鉴权根地址(无界 onlineTicket 换 token)
|
||||
# VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
||||
# VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
||||
|
||||
# VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
|
||||
# VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
|
||||
# VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
# 本地开发(pnpm dev)— 与原先 .env 保持一致
|
||||
# 个人覆盖请新建 .env.development.local(已 gitignore,优先级更高)
|
||||
|
||||
# Gateway / DeerFlow API
|
||||
VITE_BACKEND_BASE_URL=http://47.88.25.99:7001
|
||||
VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api
|
||||
|
||||
# 轻应用「新建笔记」系列接口
|
||||
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
|
||||
# CMZS后端接口
|
||||
VITE_API_BASE_URL=https://ch1.b.uat.4.cn/zncm
|
||||
# 佳来模型对话接口
|
||||
VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
|
||||
# 多轮检索接口
|
||||
VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat
|
||||
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat
|
||||
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary
|
||||
VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web
|
||||
VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
|
||||
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
|
||||
VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
|
||||
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
|
||||
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
VITE_NOTEBOOK_API_BASE_URL=http://47.88.25.99:8000
|
||||
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
|
||||
VITE_STUDIO_ENABLED=true
|
||||
VITE_AIPI_FRONTEND_URL=http://47.88.25.99:3000
|
||||
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
||||
|
||||
# MaxKey 鉴权(getTokenByOnlineTicket)
|
||||
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
||||
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
|
||||
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
|
||||
# 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
|
||||
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
||||
|
||||
@@ -19,17 +19,10 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
|
||||
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
||||
# MaxKey 鉴权(getTokenByOnlineTicket)
|
||||
VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn
|
||||
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
|
||||
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
|
||||
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
|
||||
VITE_AIPI_FRONTEND_URL=https://knowledgetoolweb.b.uat.4.cn
|
||||
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
|
||||
VITE_STUDIO_ENABLED=true
|
||||
|
||||
# 可选:Canvas 独立 LangGraph 地址
|
||||
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
||||
@@ -3,6 +3,3 @@
|
||||
# VITE_BACKEND_BASE_URL=https://your-intranet-host/deerflow
|
||||
# VITE_LANGGRAPH_BASE_URL=https://your-intranet-host/deerflow/api
|
||||
# VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
|
||||
# VITE_AIPI_BACKENP_URL=https://your-notebook-backend-host
|
||||
# VITE_AIPI_FRONTEND_URL=https://your-notebook-frontend-host
|
||||
# VITE_STUDIO_ENABLED=true
|
||||
@@ -1,35 +0,0 @@
|
||||
# SIT 环境打包(pnpm build:sit / npm run build:sit)— 接口地址为 UAT 的 sit 版本
|
||||
# 打包后变量会写入 dist,运行时不可再改
|
||||
|
||||
# Gateway / DeerFlow API
|
||||
VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow
|
||||
VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api
|
||||
|
||||
# 轻应用「新建笔记」系列接口
|
||||
VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
|
||||
# 佳来模型对话接口
|
||||
VITE_AIPI_CHAT_URL=https://ch1.b.sit.4.cn/gpt
|
||||
# 多轮检索接口
|
||||
VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.sit.4.cn/jialai_b1_chat
|
||||
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.sit.4.cn/qq_b1_chat
|
||||
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.sit.4.cn/knowlibrary
|
||||
VITE_AIPI_B1COH_URL=https://ch1.b.sit.4.cn/web
|
||||
VITE_AIPI_B2ACTOR_URL=https://ty.b.sit.4.cn
|
||||
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.sit.4.cn/knowes/api/iframeKnowledgeUrl
|
||||
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.sit.4.cn/likun_b1_mope
|
||||
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
|
||||
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.sit.4.cn
|
||||
VITE_AIPI_CONSUMER_URL=https://ch1.b.sit.4.cn/consumer
|
||||
# MaxKey 鉴权(getTokenByOnlineTicket)
|
||||
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
||||
VITE_AIPI_PGLINKTO_URL=https://pg.b.sit.4.cn
|
||||
VITE_AIPI_XKLINKTO_URL=https://kz.b.sit.4.cn
|
||||
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
|
||||
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.sit.4.cn
|
||||
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
|
||||
VITE_STUDIO_ENABLED=true
|
||||
|
||||
# 可选:Canvas 独立 LangGraph 地址
|
||||
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
||||
@@ -1,228 +0,0 @@
|
||||
# AI 写作 · 右侧文本编辑器(滑词 / 段落菜单)
|
||||
|
||||
> 说明 AI 写作页、开放画布中文稿右侧编辑器的实现方式:**编辑器本体基于 Tiptap v3**;选中文本浮层(「滑词」)与段落悬停按钮为**自研 React 组件**,未使用 `@tiptap/extension-bubble-menu` 官方 BubbleMenu。
|
||||
|
||||
---
|
||||
|
||||
## 一、功能概览
|
||||
|
||||
| 交互 | 用户感知 | 组件 | 触发方式 |
|
||||
|------|----------|------|----------|
|
||||
| **滑词** | 拖选一段文字后,选区上方出现工具条(AI 改写、加粗、斜体、下划线、H1、复制) | `WritingBubbleMenu` | Tiptap `selectionUpdate` + 浏览器 `window.getSelection()` |
|
||||
| **段落菜单** | 鼠标移入某段/标题/引用块时,段落左侧出现 ✨ 按钮,点击可 AI 改写整段 | `WritingParagraphMenu` | 编辑器 DOM `mousemove` + ProseMirror `posAtCoords` |
|
||||
| **AI 改写面板** | 点击「AI 改写」后弹出润色/扩写等操作与预览 | `WritingAiMenu` / `WritingRewritePreview` | 由 `TextArtifactRenderer` + `useWritingRewrite` 串联 |
|
||||
|
||||
画布线程页(`/page/canvas/:thread_id`)与 AI 写作页(`/page/workspace/ai-writing`)右侧草稿**共用同一套** `TextArtifactRenderer` 与菜单组件。
|
||||
|
||||
---
|
||||
|
||||
## 二、在 AI 写作中的挂载路径
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
AIWritingPage --> CanvasResizableLayout
|
||||
CanvasResizableLayout --> AIWritingDraftPanel
|
||||
AIWritingDraftPanel --> DraftStreamingPreview
|
||||
AIWritingDraftPanel --> TextArtifactRenderer
|
||||
DraftStreamingPreview["DraftStreamingPreview<br/>(流式只读 Markdown)"]
|
||||
TextArtifactRenderer --> WritingBubbleMenu
|
||||
TextArtifactRenderer --> WritingParagraphMenu
|
||||
TextArtifactRenderer --> TiptapEditor
|
||||
TextArtifactRenderer --> WritingAiMenu
|
||||
```
|
||||
|
||||
| 阶段 | 右侧展示 | 滑词 / 段落菜单 |
|
||||
|------|----------|-----------------|
|
||||
| 草稿流式生成中(`writing_draft` / `revising` 且有 section 流) | `DraftStreamingPreview`(ReactMarkdown 只读) | **不可用** |
|
||||
| 成稿可编辑(`isDraftEditable === true`) | `TextArtifactRenderer` + Tiptap | **可用** |
|
||||
| 流式结束但尚未 editable | `TextArtifactRenderer` 且 `isStreaming={true}` | **不可用**(`editable: false`) |
|
||||
|
||||
相关状态由 [CanvasUIContext.tsx](../src/open-canvas/contexts/CanvasUIContext.tsx) 的 `isDraftStreaming`、`isDraftEditable` 控制;注释中「禁用编辑/润色/滑词」即指生成完成前不进入可编辑渲染器。
|
||||
|
||||
入口文件:
|
||||
|
||||
- [AIWritingDraftPanel.tsx](../src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx) — 决定流式预览 vs `TextArtifactRenderer`
|
||||
- [TextArtifactRenderer.tsx](../src/open-canvas/components/artifact/TextArtifactRenderer.tsx) — 挂载编辑器与两套浮层菜单
|
||||
|
||||
---
|
||||
|
||||
## 三、技术栈:编辑器 vs 浮层
|
||||
|
||||
### 3.1 Tiptap 编辑器(有使用)
|
||||
|
||||
| 包 | 用途 |
|
||||
|----|------|
|
||||
| `@tiptap/react` | `useEditor`、`EditorContent` |
|
||||
| `@tiptap/starter-kit` | 段落、标题、加粗、斜体、下划线等 |
|
||||
| `@tiptap/extension-placeholder` | 空文档占位符 |
|
||||
| `tiptap-markdown` | Markdown ↔ 文档互转 |
|
||||
| 自研 `AiHighlightExtension` | AI 改写前高亮选区(ProseMirror Decoration) |
|
||||
|
||||
扩展注册见 [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts);实例创建见 [useWritingEditor.ts](../src/open-canvas/hooks/useWritingEditor.ts)。
|
||||
|
||||
### 3.2 浮层菜单(自研,非 Tiptap 官方插件)
|
||||
|
||||
| 包 | 是否用于滑词/段落菜单 |
|
||||
|----|----------------------|
|
||||
| `@tiptap/extension-bubble-menu` | **未使用**(`package.json` 中有依赖,代码未 import) |
|
||||
| `@tiptap/react` 的 `<BubbleMenu>` | **未使用** |
|
||||
|
||||
两套菜单均采用:
|
||||
|
||||
1. 监听 Tiptap / DOM 事件更新位置与选区;
|
||||
2. `createPortal(..., document.body)` 挂到 `document.body`,避免被右侧滚动容器 `overflow` 裁切;
|
||||
3. `position: fixed` + `getBoundingClientRect()` 定位。
|
||||
|
||||
命名上的 `WritingBubbleMenu` 仅表示「气泡式选区工具条」,实现上与 Tiptap 官方 Bubble Menu 扩展无关。
|
||||
|
||||
---
|
||||
|
||||
## 四、核心文件索引
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| [TiptapEditor.tsx](../src/open-canvas/editor/TiptapEditor.tsx) | `EditorContent` 渲染容器 |
|
||||
| [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts) | StarterKit、Placeholder、Markdown、AiHighlight |
|
||||
| [AiHighlightExtension.ts](../src/open-canvas/editor/AiHighlightExtension.ts) | `setAiHighlight` / `clearAiHighlight` 命令 |
|
||||
| [useWritingEditor.ts](../src/open-canvas/hooks/useWritingEditor.ts) | 创建 editor、同步外部 markdown、editable |
|
||||
| [WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) | **滑词** 选区浮层 |
|
||||
| [WritingParagraphMenu.tsx](../src/open-canvas/components/artifact/WritingParagraphMenu.tsx) | **段落** 左侧 ✨ 按钮 |
|
||||
| [WritingAiMenu.tsx](../src/open-canvas/components/artifact/WritingAiMenu.tsx) | AI 改写操作菜单 |
|
||||
| [WritingRewritePreview.tsx](../src/open-canvas/components/artifact/WritingRewritePreview.tsx) | 改写结果预览 |
|
||||
| [useWritingRewrite.ts](../src/open-canvas/hooks/useWritingRewrite.ts) | 调用润色 API、写回编辑器 |
|
||||
| [apply-rewrite.ts](../src/open-canvas/utils/apply-rewrite.ts) | 将改写结果应用到 ProseMirror 选区 |
|
||||
| [CanvasUIContext.tsx](../src/open-canvas/contexts/CanvasUIContext.tsx) | `writingSelection` 全局状态 |
|
||||
| [selection.ts](../src/open-canvas/types/selection.ts) | `WritingSelection` 类型定义 |
|
||||
|
||||
---
|
||||
|
||||
## 五、滑词:`WritingBubbleMenu`
|
||||
|
||||
### 5.1 行为
|
||||
|
||||
- 用户在编辑器内拖选非空文本时显示浮层;选区为空或位于 `codeBlock` 内时隐藏。
|
||||
- 浮层按钮:`AI 改写`、`B` / `I` / `U`、`H1`、复制。
|
||||
- 按钮 `onMouseDown` 使用 `e.preventDefault()`,避免点击时编辑器失焦导致选区丢失。
|
||||
|
||||
### 5.2 实现要点
|
||||
|
||||
```text
|
||||
editor.on("selectionUpdate")
|
||||
→ 读取 editor.state.selection (from, to, empty)
|
||||
→ window.getSelection() + Range.getBoundingClientRect() 算屏幕坐标
|
||||
→ setWritingSelection({ from, to, text }) 写入 CanvasUIContext
|
||||
→ createPortal 渲染工具条,useLayoutEffect 做贴边 / 上方空间不足时翻到选区下方
|
||||
```
|
||||
|
||||
关键代码位置:[WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) 中 `useEffect` 监听 `selectionUpdate` 与 `blur`。
|
||||
|
||||
点击「AI 改写」时:
|
||||
|
||||
1. `editor.commands.setAiHighlight(from, to)` 高亮选区;
|
||||
2. 调用父组件传入的 `onAiRewrite()`,由 `TextArtifactRenderer` 打开 `WritingAiMenu`。
|
||||
|
||||
---
|
||||
|
||||
## 六、段落菜单:`WritingParagraphMenu`
|
||||
|
||||
### 6.1 行为
|
||||
|
||||
- 鼠标在编辑器内移动时,检测光标下的**块级节点**;
|
||||
- 仅对 `paragraph`、`heading`、`blockquote` 且文本非空时,在块左侧显示 ✨ 按钮;
|
||||
- 鼠标离开编辑器或移出可改写块后,延迟约 **380ms** 隐藏(`HIDE_DELAY`),并在按钮与正文之间留有 hover 桥接区,避免移向按钮时闪烁消失。
|
||||
|
||||
### 6.2 实现要点
|
||||
|
||||
```text
|
||||
editor.view.dom mousemove
|
||||
→ editor.view.posAtCoords({ left, top })
|
||||
→ doc.resolve(pos).before(depth) 定位块起点
|
||||
→ editor.view.nodeDOM(nodeStart) 取 DOM 矩形
|
||||
→ createPortal 固定定位按钮在块左侧 (BTN_SIZE=24, BTN_GAP=6)
|
||||
```
|
||||
|
||||
点击 ✨ 时:
|
||||
|
||||
1. `setTextSelection` 选中整段 ProseMirror 范围(`nodeFrom` ~ `nodeTo`);
|
||||
2. `setAiHighlight` + `setWritingSelection`;
|
||||
3. 触发与滑词相同的 `onAiRewrite()` 流程。
|
||||
|
||||
关键代码位置:[WritingParagraphMenu.tsx](../src/open-canvas/components/artifact/WritingParagraphMenu.tsx)。
|
||||
|
||||
---
|
||||
|
||||
## 七、AI 改写数据流
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Menu as BubbleMenu / ParagraphMenu
|
||||
participant Ctx as CanvasUIContext
|
||||
participant Renderer as TextArtifactRenderer
|
||||
participant API as rewrite API
|
||||
|
||||
User->>Menu: 选中文本或点段落 ✨
|
||||
Menu->>Ctx: setWritingSelection
|
||||
Menu->>Renderer: onAiRewrite()
|
||||
Renderer->>Renderer: phase = menu, WritingAiMenu
|
||||
User->>Renderer: 选择润色/扩写等
|
||||
Renderer->>API: useWritingRewrite.rewrite
|
||||
API-->>Renderer: 流式结果
|
||||
Renderer->>Renderer: WritingRewritePreview
|
||||
User->>Renderer: 接受改写
|
||||
Renderer->>Renderer: applyReplace / applyInsert
|
||||
```
|
||||
|
||||
- 选区信息类型:`WritingSelection`(`from`、`to`、`text`),见 [selection.ts](../src/open-canvas/types/selection.ts)。
|
||||
- 与画布聊天侧选区 `CanvasTextSelection` 分离;后者用于快捷操作栏等,不用于 Tiptap 改写。
|
||||
|
||||
---
|
||||
|
||||
## 八、与顶部工具栏、目录栏的关系
|
||||
|
||||
同一 `TextArtifactRenderer` 编辑模式下还包含:
|
||||
|
||||
| 组件 | 作用 |
|
||||
|------|------|
|
||||
| [WritingToolbar.tsx](../src/open-canvas/components/artifact/WritingToolbar.tsx) | 顶部固定格式栏、导出等 |
|
||||
| [DraftOutlineSidebar.tsx](../src/open-canvas/components/artifact/DraftOutlineSidebar.tsx) | 左侧标题目录,点击滚动到对应 heading |
|
||||
|
||||
滑词 / 段落菜单与工具栏互不替代:工具栏面向全局格式;浮层面向**当前选区或当前段落**的快捷操作与 AI 改写入口。
|
||||
|
||||
---
|
||||
|
||||
## 九、开发与改造建议
|
||||
|
||||
### 9.1 若要改用官方 Bubble Menu
|
||||
|
||||
1. 在 [TiptapExtensions.ts](../src/open-canvas/editor/TiptapExtensions.ts) 或组件层引入 `@tiptap/react` 的 `BubbleMenu`(或注册 `@tiptap/extension-bubble-menu`);
|
||||
2. 将 [WritingBubbleMenu.tsx](../src/open-canvas/components/artifact/WritingBubbleMenu.tsx) 内手写 `selectionUpdate` + Portal 逻辑替换为官方组件的 `shouldShow` / `tippyOptions`(v3 API 以官方文档为准);
|
||||
3. **段落左侧按钮**仍建议保留自研 `WritingParagraphMenu`(官方 Bubble Menu 只覆盖选区,不覆盖 hover 段落场景)。
|
||||
|
||||
### 9.2 新增浮层按钮
|
||||
|
||||
- **滑词**:在 `WritingBubbleMenu` 的 `BubbleButton` 列表中增加,使用 `editor.chain().focus()....run()`。
|
||||
- **段落**:在 `WritingParagraphMenu` 的 `handleClick` 或拆出多按钮布局;注意 `REWRITABLE_TYPES` 与 hover 延迟逻辑。
|
||||
|
||||
### 9.3 流式阶段禁止交互
|
||||
|
||||
不要在 `DraftStreamingPreview` 中挂载 Tiptap 菜单:流式阶段 token 高频更新,挂载 `useWritingEditor` 易引发更新风暴(见 [DraftStreamingPreview.tsx](../src/open-canvas/components/ai-writing/DraftStreamingPreview.tsx) 文件头注释)。应等 `isDraftEditable` 后再走 `TextArtifactRenderer`。
|
||||
|
||||
---
|
||||
|
||||
## 十、相关文档
|
||||
|
||||
- [前端开发.md](./前端开发.md) §25.4 — Canvas / AI 写作路由与组件总表
|
||||
- [ai-writing-用户自带大纲-测试用例.md](./ai-writing-用户自带大纲-测试用例.md) — AI 写作业务流程测试(含右侧草稿层级校验)
|
||||
|
||||
---
|
||||
|
||||
## 十一、依赖版本(摘录)
|
||||
|
||||
以 [package.json](../package.json) 为准,编辑器相关主要为:
|
||||
|
||||
- `@tiptap/core`、`@tiptap/react`、`@tiptap/starter-kit`:`^3.23.4`
|
||||
- `@tiptap/extension-bubble-menu`:已安装但未接入滑词实现
|
||||
- `tiptap-markdown`:Markdown 扩展
|
||||
|
||||
升级 Tiptap 大版本前,请对照官方迁移指南,并重点回归:`WritingBubbleMenu` 选区坐标、`WritingParagraphMenu` 的 `posAtCoords`、以及 `AiHighlightExtension` 与 StarterKit 扩展名冲突(勿重复注册 `underline`)。
|
||||
@@ -1,274 +0,0 @@
|
||||
# AI 写作 ·「用户自带大纲」手动测试用例
|
||||
|
||||
> 功能:用户在 AI 写作表单粘贴已有大纲后,系统**完全按用户大纲生成正文**,兼容一/二/三级目录。
|
||||
> 入口页面:`http://localhost:5173/#/page/workspace/ai-writing`(或 `2026` 端口经 nginx)
|
||||
|
||||
---
|
||||
|
||||
## 一、测试目标
|
||||
|
||||
验证三件事:
|
||||
1. **识别**——用户粘贴的各种格式大纲,能被正确解析成 章节(##) → 小节(###) → 子要点(####) 三级结构。
|
||||
2. **保真**——生成的大纲标题、层级**与用户粘贴的完全一致**,不增、不删、不改写。
|
||||
3. **兼容**——只有一级 / 一+二级 / 一+二+三级 三种深度都能正确生成对应层级的正文。
|
||||
|
||||
---
|
||||
|
||||
## 二、前置条件
|
||||
|
||||
| 项 | 要求 |
|
||||
|----|------|
|
||||
| 服务 | 后端(Gateway 8001)+ 前端(Vite 5174/5173)均已启动,可正常发起一次普通 AI 写作 |
|
||||
| 模型 | 写作模型已在表单可选、能正常输出(先用不带大纲的普通流程跑通一次作为基线) |
|
||||
| 账号 | 已登录(或无鉴权模式) |
|
||||
| 入口 | 进入 `/page/workspace/ai-writing`,能看到「写作配置选项」表单 |
|
||||
|
||||
---
|
||||
|
||||
## 三、通用操作步骤
|
||||
|
||||
除非用例另有说明,每条用例都按以下步骤执行:
|
||||
|
||||
1. 进入 AI 写作页面,点开**「已有大纲(可选)」** 旁的「我已有大纲,直接粘贴 ▾」。
|
||||
2. 在「写作意图」填一句话(如:`写一篇关于新能源汽车出口的分析文章`)。
|
||||
3. 把用例里的**「输入大纲」**整段粘贴进「已有大纲」输入框。
|
||||
4. 文章类型 / 目标字数 / 素材来源保持默认即可。
|
||||
5. 点击「开始 AI 写作」。
|
||||
6. 等待流程走到 **PAUSE-2「大纲确认」**,对照**「预期大纲结构」**核对左侧/卡片里的大纲。
|
||||
7. 点「确认」继续,等正文生成完,对照**「预期正文层级」**核对右侧草稿的标题层级。
|
||||
|
||||
> 关键校验点都在第 6 步(大纲确认卡)。正文层级(第 7 步)作为二次确认。
|
||||
|
||||
---
|
||||
|
||||
## 四、测试用例
|
||||
|
||||
### TC-01 Markdown 三级 + 文章标题
|
||||
|
||||
**输入大纲:**
|
||||
```
|
||||
# 新能源汽车出口为何高速增长
|
||||
## 引言
|
||||
### 出口数据创新高
|
||||
#### 同比增幅
|
||||
#### 全球排名变化
|
||||
### 市场背景
|
||||
## 增长动因
|
||||
### 成本优势
|
||||
```
|
||||
|
||||
**预期大纲结构:**
|
||||
- 文章标题 = `新能源汽车出口为何高速增长`(首个孤立 `#` 被识别为标题,不作为章节)
|
||||
- 章节1 `引言`
|
||||
- 小节 `出口数据创新高` → 子要点:`同比增幅`、`全球排名变化`
|
||||
- 小节 `市场背景`(无子要点)
|
||||
- 章节2 `增长动因`
|
||||
- 小节 `成本优势`
|
||||
|
||||
**预期正文层级:** `#` 文章标题 → `##` 引言/增长动因 → `###` 小节 → `####` 同比增幅/全球排名变化,各级都有正文。
|
||||
|
||||
---
|
||||
|
||||
### TC-02 中文编号三级(一、/(一)/ 1.)
|
||||
|
||||
**输入大纲:**
|
||||
```
|
||||
一、引言
|
||||
(一)背景
|
||||
(二)意义
|
||||
二、现状分析
|
||||
(一)市场数据
|
||||
1. 国内市场
|
||||
2. 海外市场
|
||||
```
|
||||
|
||||
**预期大纲结构:**
|
||||
- 章节1 `引言` → 小节 `背景`、`意义`
|
||||
- 章节2 `现状分析` → 小节 `市场数据` → 子要点 `国内市场`、`海外市场`
|
||||
- 标题留空 → 用「写作意图」兜底(卡片标题约等于意图前 30 字)
|
||||
|
||||
**校验重点:** `一、` > `(一)` > `1.` 的层级顺序正确(符合中文公文惯例)。
|
||||
|
||||
---
|
||||
|
||||
### TC-03 阿拉伯小数点编号(1. / 1.1 / 1.1.1)
|
||||
|
||||
**输入大纲:**
|
||||
```
|
||||
1. 引言
|
||||
1.1 研究背景
|
||||
1.2 研究意义
|
||||
2. 现状
|
||||
2.1 行业概况
|
||||
```
|
||||
|
||||
**预期大纲结构:**
|
||||
- 章节1 `引言` → 小节 `研究背景`、`研究意义`
|
||||
- 章节2 `现状` → 小节 `行业概况`
|
||||
|
||||
---
|
||||
|
||||
### TC-04 缩进 bullet 表示层级
|
||||
|
||||
**输入大纲:**
|
||||
```
|
||||
- 引言
|
||||
- 背景
|
||||
- 意义
|
||||
- 现状
|
||||
- 数据
|
||||
- 细节展开
|
||||
```
|
||||
|
||||
**预期大纲结构:**
|
||||
- 章节1 `引言` → 小节 `背景`、`意义`
|
||||
- 章节2 `现状` → 小节 `数据` → 子要点 `细节展开`
|
||||
|
||||
**校验重点:** 缩进深度正确映射成层级。
|
||||
|
||||
---
|
||||
|
||||
### TC-05 只有一级目录(平铺 Markdown)
|
||||
|
||||
**输入大纲:**
|
||||
```
|
||||
## 引言
|
||||
## 现状
|
||||
## 对策
|
||||
## 结论
|
||||
```
|
||||
|
||||
**预期大纲结构:** 4 个章节 `引言/现状/对策/结论`,**均无小节**。
|
||||
**预期正文层级:** 每个章节 `##` 标题下是一整段连贯正文(不出现 `###`)。
|
||||
|
||||
**校验重点:** 一级目录场景不报错、不被强行拆出多余小节。
|
||||
|
||||
---
|
||||
|
||||
### TC-06 只有一级目录(中文编号)
|
||||
|
||||
**输入大纲:**
|
||||
```
|
||||
一、引言
|
||||
二、现状
|
||||
三、结论
|
||||
```
|
||||
|
||||
**预期大纲结构:** 3 个章节 `引言/现状/结论`,无小节。与 TC-05 等价,验证另一种一级写法。
|
||||
|
||||
---
|
||||
|
||||
### TC-07 纯文本平铺(无任何标记)
|
||||
|
||||
**输入大纲:**
|
||||
```
|
||||
引言
|
||||
现状
|
||||
对策
|
||||
总结
|
||||
```
|
||||
|
||||
**预期大纲结构:** 4 个章节 `引言/现状/对策/总结`(每行一个短标题 → 一级章节)。
|
||||
|
||||
**校验重点:** 无编号无缩进的短标题列表也能识别为一级大纲。
|
||||
|
||||
---
|
||||
|
||||
### TC-08 格式混乱 / 散文化(走 LLM 兜底)
|
||||
|
||||
**输入大纲:**
|
||||
```
|
||||
我想写一篇分析今年AI大模型发展趋势的文章,重点关注国内企业的布局。
|
||||
希望先讲背景,再讲现状,最后给出展望。
|
||||
```
|
||||
|
||||
**预期行为:**
|
||||
- 确定性解析判定为「散文」→ 自动走 **LLM 归一兜底**,整理成一份结构化大纲。
|
||||
- 大纲确认卡能正常显示一份大纲(不报错、不卡死)。
|
||||
- 大纲内容应贴近原意(背景 / 现状 / 展望之类),**但不要求逐字一致**(这一条是兜底鲁棒性,不是保真用例)。
|
||||
|
||||
**校验重点:** 流程不崩、能产出可用大纲。
|
||||
|
||||
---
|
||||
|
||||
### TC-09 保真校验(标题原样保留)
|
||||
|
||||
**输入大纲:**
|
||||
```
|
||||
一、A股市场2024年回顾与展望
|
||||
(一)宏观经济(GDP/CPI)影响
|
||||
(二)流动性与利率
|
||||
二、行业轮动主线
|
||||
```
|
||||
|
||||
**预期大纲结构:** 标题文本**逐字保留**,仅编号被剥离:
|
||||
- 章节1 `A股市场2024年回顾与展望`
|
||||
- 小节 `宏观经济(GDP/CPI)影响`、`流动性与利率`
|
||||
- 章节2 `行业轮动主线`
|
||||
|
||||
**校验重点:** 标题里的中文括号、英文缩写、年份等**不被改写**。
|
||||
|
||||
---
|
||||
|
||||
### TC-10 大纲确认卡里再编辑 / 重新规划
|
||||
|
||||
**前置:** 先用 TC-02 的大纲跑到「大纲确认」卡。
|
||||
|
||||
**操作:**
|
||||
1. 在确认卡里**手动改一个小节标题**(如把 `背景` 改成 `行业背景`)→ 点「确认」。
|
||||
2. 或点「重新规划」并填修改要求(如 `给"现状分析"再加一个小节`)。
|
||||
|
||||
**预期行为:**
|
||||
- 手动改后确认:正文按改后的大纲生成。
|
||||
- 重新规划:基于**用户这份大纲**做编辑式调整(在原结构上加/改),而不是从零另起一份无关大纲。
|
||||
|
||||
**校验重点:** 自带大纲 → 确认 → 编辑 这条回路正常,不会丢掉用户原始结构。
|
||||
|
||||
---
|
||||
|
||||
### TC-11 回归:不填大纲(默认流程不受影响)
|
||||
|
||||
**操作:** 「已有大纲」**留空**,只填写作意图,正常发起。
|
||||
|
||||
**预期行为:** 与功能上线前完全一致——系统自动检索素材、**自动规划大纲**(大纲由 AI 生成,非用户提供)。
|
||||
|
||||
**校验重点:** 增量功能不污染老流程。
|
||||
|
||||
---
|
||||
|
||||
### TC-12 素材关联(仍走检索)
|
||||
|
||||
**前置:** 素材来源选「知识库」,用 TC-02 的大纲发起,等检索出素材并确认。
|
||||
|
||||
**预期行为:**
|
||||
- 走到大纲时,用户的章节会被**关联上检索到的素材**(正文里能体现素材中的事实/数据)。
|
||||
- 关联**只填素材、不改大纲标题与结构**。
|
||||
|
||||
**校验重点:** 「自带大纲」与「检索素材」能共存——大纲是用户的,正文有素材支撑。
|
||||
|
||||
---
|
||||
|
||||
## 五、缺陷记录模板
|
||||
|
||||
| 用例 | 输入大纲(摘要) | 预期 | 实际 | 是否通过 | 备注 |
|
||||
|------|------------------|------|------|----------|------|
|
||||
| TC-01 | Markdown 三级 | 标题+2章节+子要点 | | ☐ | |
|
||||
| TC-02 | 一、/(一)/1. | 中文三级 | | ☐ | |
|
||||
| TC-03 | 1./1.1/1.1.1 | 阿拉伯三级 | | ☐ | |
|
||||
| TC-04 | 缩进 bullet | 缩进三级 | | ☐ | |
|
||||
| TC-05 | 平铺 ## | 4 章节无小节 | | ☐ | |
|
||||
| TC-06 | 平铺 一、 | 3 章节无小节 | | ☐ | |
|
||||
| TC-07 | 纯文本平铺 | 4 章节 | | ☐ | |
|
||||
| TC-08 | 散文 | LLM 兜底产出大纲 | | ☐ | |
|
||||
| TC-09 | 带括号/缩写标题 | 标题逐字保留 | | ☐ | |
|
||||
| TC-10 | 确认卡编辑/重规划 | 在原大纲上调整 | | ☐ | |
|
||||
| TC-11 | 留空 | 走自动规划 | | ☐ | |
|
||||
| TC-12 | 检索+自带大纲 | 素材关联不改结构 | | ☐ | |
|
||||
|
||||
---
|
||||
|
||||
## 六、备注
|
||||
|
||||
- TC-01~07、09 的解析逻辑已有**自动化单测**覆盖(`backend/tests/test_ai_writing_outline_parser.py`,19 个用例),本文档侧重**端到端真机表现**(流式展示、确认卡渲染、正文层级、素材关联)。
|
||||
- 若某条解析结果与预期不符,先看大纲确认卡里的**结构**是否正确(定位是解析问题还是正文生成问题)。
|
||||
- 相关后端实现:`packages/harness/deerflow/agents/ai_writing/outline_parser.py`(解析)、`nodes/writer_outline.py`(自带大纲分支 + 素材关联)。
|
||||
@@ -1,436 +0,0 @@
|
||||
# 工作台问答 · iframe 嵌入开发说明
|
||||
|
||||
本文档说明如何将 DeerFlow **通用工作台问答**(与 `/page/workspace/chats/*` 同一套 LangGraph 流式能力)以**普通 iframe** 形式嵌入第三方页面,并在**父页面无法读取子 frame 内 `thread_id`** 的前提下,轮询「本次问答是否已结束」。
|
||||
|
||||
后端公开 API 实现见:`offline-backend-20260512/backend/app/gateway/routers/public_embed.py`
|
||||
数据表:`embed_sessions`(迁移 `20260602_01`)
|
||||
|
||||
---
|
||||
|
||||
## 0. 速读
|
||||
|
||||
| 角色 | 要做的事 |
|
||||
|------|----------|
|
||||
| **父页面** | 自己生成 `sessionId`(UUID),拼进 iframe `src`;用 `sessionId` 调公开 API 轮询 `finished` |
|
||||
| **iframe(DeerFlow)** | 打开 `/embed/chats/...`,自动登录、自动发问、登记 `sessionId → thread_id` |
|
||||
| **不需要** | 无界(wujie)、父页面拿 `thread_id`、DeerFlow 登录态 Cookie |
|
||||
|
||||
**完成条件**:`GET .../sessions/{sessionId}/status` 返回 `finished === true`(且无进行中的 run,非澄清中断)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与约束
|
||||
|
||||
### 1.1 业务目标
|
||||
|
||||
- 第三方系统用 **iframe** 嵌 DeerFlow 问答 UI。
|
||||
- 传入:**问题文案**、**用户名**(走现有 `login/username`)、**主题色**。
|
||||
- 展示:**消息列表** + **沙箱/产物侧栏**(与主聊天相同的 `ChatBox`);**隐藏**页面左侧栏、Workspace 侧栏、底部输入框。
|
||||
- 进入后 **自动发送** 首条用户消息并走流式问答。
|
||||
|
||||
### 1.2 为何需要 `sessionId`
|
||||
|
||||
iframe 与父页面通常 **跨域**,父页面 **不能** 读取子应用 URL 里的 `thread_id`(hash 路由在子 frame 内)。
|
||||
因此约定:
|
||||
|
||||
1. **父页面**在打开 iframe 前生成全局唯一的 `sessionId`;
|
||||
2. **子页面**在创建/确定 `thread_id` 后,调用后端 **登记接口** 写入 `session_id → thread_id`;
|
||||
3. **父页面**只凭 `sessionId` 查询状态,必要时响应里会带上 `thread_id`(可选使用)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 端到端流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Parent as 父页面
|
||||
participant Iframe as DeerFlow iframe
|
||||
participant API as Gateway /api/public/embed
|
||||
|
||||
Parent->>Parent: sessionId = crypto.randomUUID()
|
||||
Parent->>Iframe: iframe src 含 sessionId、message、username、theme
|
||||
Iframe->>Iframe: loginByUsername + 自动 sendMessage
|
||||
Iframe->>API: POST /sessions { session_id, thread_id }
|
||||
loop 每 1–3s
|
||||
Parent->>API: GET /sessions/{sessionId}/status
|
||||
API-->>Parent: registered, finished, thread_id
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 前端路由与页面
|
||||
|
||||
### 3.1 路由(独立于 `/page/*`)
|
||||
|
||||
在 `App.tsx` 注册,**不经过** `PageRoutes` / `PageSidebar`,因此无外层导航与 Workspace 侧栏。
|
||||
|
||||
| 路由 | 组件 | 说明 |
|
||||
|------|------|------|
|
||||
| `/embed/chats/:thread_id` | `EmbedChatPage`(外包 `ChatRuntime`) | `thread_id` 可为 `new` 或已有 UUID |
|
||||
|
||||
示例(HashRouter,注意 `#`):
|
||||
|
||||
```
|
||||
http://localhost:5173/#/embed/chats/new?sessionId=...&message=...&username=guest&theme=red
|
||||
```
|
||||
|
||||
开发环境端口以本地 Vite 为准(如 5173 / 5174)。
|
||||
|
||||
### 3.2 URL 查询参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `sessionId` 或 `session_id` | **是** | 父页面生成的关联 id,8–128 位,仅字母数字 `_` `-` |
|
||||
| `message` / `question` / `text` | 建议 | 首条用户问题;有则进入后自动发送 |
|
||||
| `username` / `userName` | 否 | 默认 `guest`,对应 `POST /api/v1/auth/login/username` |
|
||||
| `theme` | 否 | `dark-blue` → 深色;`red` 等 → 浅色(与 `LoginPage` 一致) |
|
||||
|
||||
缺少 `sessionId` 时,嵌入页会提示错误,不会发起登记。
|
||||
|
||||
### 3.3 相关源码
|
||||
|
||||
| 路径 | 职责 |
|
||||
|------|------|
|
||||
| [src/pages/EmbedChatPage.tsx](../src/pages/EmbedChatPage.tsx) | 嵌入页:登录、自动发问、登记 session、仅消息列表 + ChatBox |
|
||||
| [src/core/embed/params.ts](../src/core/embed/params.ts) | 从 URL 解析嵌入参数 |
|
||||
| [src/core/embed/status-api.ts](../src/core/embed/status-api.ts) | 登记 / 轮询 API 封装 |
|
||||
| [src/App.tsx](../src/App.tsx) | 路由注册 |
|
||||
|
||||
### 3.4 UI 行为说明
|
||||
|
||||
- **隐藏**:`PageSidebar`、`WorkspaceSidebar`、`InputBox`、顶栏标题栏等(相对完整 `ChatPage`)。
|
||||
- **保留**:`MessageList`、流式指示、澄清卡片(若 agent 触发 `ask_clarification`)、右侧 **产物/沙箱**(`ChatBox` 内 60/40 分栏逻辑与主聊天一致)。
|
||||
- **线程 id**:新建会话时客户端先分配 UUID,`onStart` 后 `history.replaceState` 更新 hash 为 `/embed/chats/{真实id}`(与主聊天 `ChatPage` 相同手法,避免整页重载)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 公开 HTTP API(无需鉴权)
|
||||
|
||||
前缀:`/api/public/embed/`(走 Gateway,与 `VITE_BACKEND_BASE_URL` 一致,默认 `http://47.88.25.99:7001/`)。
|
||||
|
||||
CSRF / Auth 中间件对 `/api/public/` 放行;请求 **不需要** Cookie。
|
||||
|
||||
### 4.1 登记映射(iframe 内自动调用)
|
||||
|
||||
```http
|
||||
POST /api/public/embed/sessions
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"session_id": "parent-generated-uuid",
|
||||
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b"
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "parent-generated-uuid",
|
||||
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b",
|
||||
"registered": true
|
||||
}
|
||||
```
|
||||
|
||||
同一 `session_id` 再次 POST 会 **更新** `thread_id`(例如 `onStart` 后 id 变化)。
|
||||
|
||||
### 4.2 轮询状态(父页面使用)
|
||||
|
||||
```http
|
||||
GET /api/public/embed/sessions/{session_id}/status
|
||||
```
|
||||
|
||||
**尚未登记**(iframe 仍在加载或未 POST):
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "parent-generated-uuid",
|
||||
"registered": false,
|
||||
"thread_id": null,
|
||||
"finished": false,
|
||||
"thread_status": "pending",
|
||||
"has_active_run": false,
|
||||
"latest_run_status": null
|
||||
}
|
||||
```
|
||||
|
||||
**已登记且问答进行中:**
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "parent-generated-uuid",
|
||||
"registered": true,
|
||||
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b",
|
||||
"finished": false,
|
||||
"thread_status": "idle",
|
||||
"has_active_run": true,
|
||||
"latest_run_status": "running"
|
||||
}
|
||||
```
|
||||
|
||||
**已登记且已结束:**
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "parent-generated-uuid",
|
||||
"registered": true,
|
||||
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b",
|
||||
"finished": true,
|
||||
"thread_status": "idle",
|
||||
"has_active_run": false,
|
||||
"latest_run_status": "success"
|
||||
}
|
||||
```
|
||||
|
||||
#### 字段说明
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `registered` | 是否已收到 iframe 的 POST 登记 |
|
||||
| `finished` | **父页面应以此为准**:无 inflight run,且线程非 running/busy,且非 `interrupted`(澄清等待用户) |
|
||||
| `thread_status` | 从 checkpoint 推导:`idle` / `interrupted` / `error` 等 |
|
||||
| `has_active_run` | 是否存在 pending/running 的 LangGraph run |
|
||||
| `latest_run_status` | 该线程最近一次 run 的状态:`success` / `error` / … |
|
||||
| `thread_id` | 解析出的 DeerFlow 线程 id(父页面可选用,非必须) |
|
||||
|
||||
### 4.3 获取生成结果(父页面使用)
|
||||
|
||||
问答结束后(建议先确认 `finished === true`),拉取本次模型输出:
|
||||
|
||||
```http
|
||||
GET /api/public/embed/sessions/{session_id}/result
|
||||
```
|
||||
|
||||
**尚未登记:**
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "parent-generated-uuid",
|
||||
"registered": false,
|
||||
"thread_id": null,
|
||||
"finished": false,
|
||||
"thread_status": "pending",
|
||||
"has_active_run": false,
|
||||
"latest_run_status": null,
|
||||
"messages": [],
|
||||
"generated_text": "",
|
||||
"generated_files": []
|
||||
}
|
||||
```
|
||||
|
||||
**已登记且已有内容:**
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "parent-generated-uuid",
|
||||
"registered": true,
|
||||
"thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b",
|
||||
"finished": true,
|
||||
"thread_status": "idle",
|
||||
"has_active_run": false,
|
||||
"latest_run_status": "success",
|
||||
"messages": [
|
||||
{ "type": "human", "content": "请总结要点", "id": "..." },
|
||||
{ "type": "ai", "content": "根据分析,要点如下:...", "id": "..." }
|
||||
],
|
||||
"generated_text": "根据分析,要点如下:...",
|
||||
"generated_files": [
|
||||
"/mnt/user-data/outputs/summary.md",
|
||||
"/mnt/user-data/outputs/chart.png"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 结果字段说明
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `messages` | 完整消息列表(与 checkpoint 中序列化结构一致,含 human / ai / tool 等) |
|
||||
| `generated_text` | 所有 `ai` / `assistant` 消息正文按顺序拼接(段落间空一行) |
|
||||
| `generated_files` | 本次会话产物的虚拟路径列表(与 UI 产物栏 `artifacts` 一致) |
|
||||
|
||||
未结束时也可调用,会返回当前已有内容;`finished` 仍为 false 表示可能还会继续生成。
|
||||
|
||||
### 4.4 按 thread_id 查询(可选)
|
||||
|
||||
调试或父页面已持有 thread_id 时:
|
||||
|
||||
```http
|
||||
GET /api/public/embed/threads/{thread_id}/status
|
||||
GET /api/public/embed/threads/{thread_id}/result
|
||||
```
|
||||
|
||||
状态接口响应不含 `messages` / `generated_text` / `generated_files`;结果接口字段与 §4.3 相同(无 `session_id` / `registered`)。
|
||||
|
||||
### 4.5 前端封装
|
||||
|
||||
```ts
|
||||
import {
|
||||
getEmbedSessionResult,
|
||||
getEmbedSessionStatus,
|
||||
registerEmbedSession,
|
||||
} from "@/core/embed/status-api";
|
||||
|
||||
// 父页面(或你们自己的脚本)
|
||||
const status = await getEmbedSessionStatus(sessionId);
|
||||
if (status.registered && status.finished) {
|
||||
const result = await getEmbedSessionResult(sessionId);
|
||||
console.log(result.generated_text, result.generated_files);
|
||||
}
|
||||
|
||||
// 仅 iframe 内需要
|
||||
await registerEmbedSession(sessionId, threadId);
|
||||
```
|
||||
|
||||
`fetch` 使用 `credentials: "omit"`,不携带 DeerFlow 会话。
|
||||
|
||||
---
|
||||
|
||||
## 5. 父页面集成示例
|
||||
|
||||
### 5.1 拼接 iframe
|
||||
|
||||
```html
|
||||
<iframe
|
||||
id="deerflow-embed"
|
||||
title="DeerFlow 问答"
|
||||
style="width:100%;height:600px;border:0"
|
||||
></iframe>
|
||||
```
|
||||
|
||||
```javascript
|
||||
const sessionId = crypto.randomUUID();
|
||||
const base = "http://localhost:5173"; // 换成你们部署的前端 origin
|
||||
const params = new URLSearchParams({
|
||||
sessionId,
|
||||
message: "请根据附件总结要点",
|
||||
username: "guest",
|
||||
theme: "red",
|
||||
});
|
||||
document.getElementById("deerflow-embed").src =
|
||||
`${base}/#/embed/chats/new?${params.toString()}`;
|
||||
```
|
||||
|
||||
### 5.2 轮询直到结束
|
||||
|
||||
```javascript
|
||||
const API = "http://47.88.25.99:7001/"; // VITE_BACKEND_BASE_URL
|
||||
|
||||
async function pollUntilFinished(sessionId) {
|
||||
const res = await fetch(
|
||||
`${API}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/status`,
|
||||
{ credentials: "omit" },
|
||||
);
|
||||
if (!res.ok) throw new Error(`status HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (!data.registered) return { done: false, reason: "pending" };
|
||||
if (data.finished) return { done: true, data };
|
||||
return { done: false, reason: "running" };
|
||||
}
|
||||
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const poll = await pollUntilFinished(sessionId);
|
||||
if (poll.done) {
|
||||
clearInterval(timer);
|
||||
const output = await fetch(
|
||||
`${API}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/result`,
|
||||
{ credentials: "omit" },
|
||||
).then((r) => r.json());
|
||||
console.log("问答已结束", output.generated_text, output.generated_files);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, 2000);
|
||||
```
|
||||
|
||||
### 5.3 跨域注意
|
||||
|
||||
父页面从浏览器 `fetch` Gateway(如 `http://47.88.25.99:7001/`)时,**Origin 必须是后端 CORS 白名单里的完整地址**(含协议、主机、端口),与 API 地址是否写 `localhost` / `127.0.0.1` 无关。
|
||||
|
||||
**本地开发**(前端默认 `5174`)请在 Gateway 环境变量中配置:
|
||||
|
||||
```bash
|
||||
# offline-backend-20260512/.env 或启动脚本注入
|
||||
GATEWAY_CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174,http://localhost:5173,http://127.0.0.1:5173
|
||||
```
|
||||
|
||||
修改后**重启 Gateway**(`make dev` / `make gateway`)。若父页面部署在其它域名,把该域名的 origin 一并加入逗号分隔列表。
|
||||
|
||||
临时调试(勿用于公网生产):
|
||||
|
||||
```bash
|
||||
GATEWAY_CORS_ALLOW_ALL=1
|
||||
```
|
||||
|
||||
常见报错:`No 'Access-Control-Allow-Origin' header` → 当前页面的 origin 未出现在 `GATEWAY_CORS_ORIGINS` 中。
|
||||
|
||||
### 5.4 其它部署注意
|
||||
|
||||
---
|
||||
|
||||
## 6. 部署与数据库
|
||||
|
||||
1. **跑迁移**(SQL 持久化后端):
|
||||
|
||||
```bash
|
||||
cd offline-backend-20260512/backend
|
||||
# 按项目惯例执行 alembic upgrade head
|
||||
```
|
||||
|
||||
表:`embed_sessions(session_id PK, thread_id, created_at, updated_at)`。
|
||||
|
||||
2. **重启 Gateway**,确认路由已挂载:`public_embed.router`(`app/gateway/app.py`)。
|
||||
|
||||
3. **前端构建** 后,将嵌入地址配给第三方;`VITE_BACKEND_BASE_URL` 指向可达的 Gateway。
|
||||
|
||||
无 SQL 时开发环境会退化为 **内存** `MemoryEmbedSessionStore`(进程重启后映射丢失);生产请使用持久化库。
|
||||
|
||||
---
|
||||
|
||||
## 7. 安全说明
|
||||
|
||||
- `sessionId` 与 `thread_id` 均视为 **不透明能力令牌**:知晓即可查询状态(公开 API **无登录**)。
|
||||
- 建议:父系统自行生成不可猜测的 UUID;敏感场景在贵司 API 网关再加鉴权或 IP 限制。
|
||||
- 登记接口为 **POST upsert**,不校验「session 是否属于某租户」——若需多租户隔离,请在业务层约定 `sessionId` 命名空间或增加网关校验。
|
||||
|
||||
---
|
||||
|
||||
## 8. 与主聊天 `/page/workspace/chats` 的差异
|
||||
|
||||
| 项目 | 主聊天 | iframe 嵌入 |
|
||||
|------|--------|-------------|
|
||||
| 路由 | `/page/workspace/chats/:id` | `/embed/chats/:id` |
|
||||
| 布局 | 完整 Workspace + 输入框 | 仅消息区 + 产物栏 |
|
||||
| 登录 | 常规登录流 | URL `username` 自动 `login/username` |
|
||||
| 首条消息 | 用户输入 | URL `message` 自动发送 |
|
||||
| 完成态查询 | 无公开 API | `sessionId` + `/api/public/embed/sessions/.../status` |
|
||||
|
||||
能力上仍走同一 `useThreadStream` + `lead_agent` 流式链路,沙箱与产物行为与主聊天一致。
|
||||
|
||||
---
|
||||
|
||||
## 9. 常见问题
|
||||
|
||||
**Q:`registered` 一直为 false?**
|
||||
A:检查 iframe URL 是否带 `sessionId`;子页是否登录成功;Network 里是否有 `POST /api/public/embed/sessions` 且 200。
|
||||
|
||||
**Q:`finished` 一直 false?**
|
||||
A:可能仍在流式生成(`has_active_run: true`),或 agent 进入澄清(`thread_status: interrupted`)。嵌入模式无底部输入框,澄清需产品侧另行处理或避免触发澄清类工具。
|
||||
|
||||
**Q:父页面能否不用轮询?**
|
||||
A:当前标准方案为公开 GET 轮询。若同源可考虑 `postMessage` 扩展(未默认实现)。
|
||||
|
||||
**Q:能否继续用已有 thread_id 嵌入?**
|
||||
A:可以:`#/embed/chats/{thread_id}?sessionId=...`,可选再带 `message` 追加一轮;登记会在已知 id 上 upsert。
|
||||
|
||||
---
|
||||
|
||||
## 10. 变更记录
|
||||
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-06-02 | 初版:iframe 嵌入路由、`sessionId` 映射表、公开登记与状态 API |
|
||||
| 2026-06-02 | 新增 `GET /sessions/{session_id}/result`:返回 messages、generated_text、generated_files |
|
||||
@@ -649,31 +649,6 @@ JSON 列在 repo(`sql.py`)里用 `json.dumps/loads` 手动序列化(与 `a
|
||||
|
||||
---
|
||||
|
||||
## 7B. 业务链条持久化(`roundtable_chains.py`,2026-06)
|
||||
|
||||
「业务链条」是用户在配置页编排的**可复用、命名、有序**的智能体编排模板;选中后 Step 2 由总控**按链条顺序派活**(前端编排,后端零改动)。链条持久化仿 §7A,新增一张表 + 一组 CRUD 路由。完整前后端设计见 `frontend-web/docs/multi-agent-business-chain-dev.md`。
|
||||
|
||||
### 7B.1 数据模型(`deerflow.persistence.roundtable_chains`)
|
||||
|
||||
- 表 **`roundtable_chains`**:`id`(PK str64) / `user_id`(idx) / `title`(512) / `description`(512, nullable) / `seats`(**`PortableLongText`** JSON:有序 `[{agent_id,name,description}, ...]`,`seats[0]` 先发言) / `created_at` / `updated_at`(**`BeijingDateTime`**)。
|
||||
- ORM `RoundtableChainRow` 登记进 `persistence/models/__init__.py`,启动 `Base.metadata.create_all` 自动建表;store `make_roundtable_chain_store(sf)` 接到 `app.state.roundtable_chain_store`(`deps.py`)。
|
||||
|
||||
### 7B.2 路由(`app/gateway/routers/roundtable_chains.py`,前缀 `/api/roundtable-chains`)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `` | 当前用户全部链条(含 seats,按 `updated_at` 倒序) |
|
||||
| POST | `` | 新建(可带客户端 `id`);校验席位 `2 ≤ N ≤ 8` + 去重 |
|
||||
| GET | `/{id}` | 取单条;非属主 → 404 |
|
||||
| PUT | `/{id}` | partial 更新(`exclude_unset`);带 seats 时同样校验 |
|
||||
| DELETE | `/{id}` | 204;非属主 → 404 |
|
||||
|
||||
- 鉴权/属主用 `_current_user_id(request)`,跨用户一律 404,与 `roundtable_drafts` 一致。
|
||||
- 席位约束 `MIN_SEATS=2 / MAX_SEATS=8` 与前端 `CHAIN_MIN/MAX_SEATS`、推荐弹窗 `MIN/MAX_SELECTED` 对齐;前端配置页保存前先校验,后端 `_validate_seats` 兜底(400)。
|
||||
- 前端 `api/chains.ts` 直接打这组路由(snake↔camel 边界映射);测试 `tests/test_roundtable_chains.py`(CRUD + 部分更新 + 跨用户隔离)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 内置席位 SOUL 设计要点
|
||||
|
||||
每个 `roundtable-*`(除 intent / recommender)遵循统一结构:角色定位、核心职责、输出风格(emoji + 简短结论)、边界(只产出本席位、等总控派活)。
|
||||
|
||||
@@ -1,514 +0,0 @@
|
||||
# 多智能体规划 · 后台挂起执行 + 进度可视化 —— 分步实现方案
|
||||
|
||||
> 目标:在「多智能体规划」第二步增加**后台挂起**能力——把当前任务挂到后台,自动一路跑到
|
||||
> 最后一步(Step3 结果绘制完成)为止;历史记录下拉框里进行中的任务显示加载动画;点击进行中
|
||||
> 的任务弹出一个**大弹窗**,里面是**横向的智能体派活链路流程图**(节点 = 各席位智能体,图标 +
|
||||
> 名称,不同颜色,正在发言的节点和它的连接线带加载动画);弹窗里有「查看」按钮,点击进入正在
|
||||
> 进行中的任务实时观察。
|
||||
|
||||
---
|
||||
|
||||
## 0. 现状与架构事实(动手前必读)
|
||||
|
||||
研究自现有代码,决定了整套方案的形态。**关键结论:当前编排循环跑在前端,离开页面即中断。**
|
||||
|
||||
### 0.1 前端现状
|
||||
|
||||
| 事实 | 位置 |
|
||||
|------|------|
|
||||
| Step2 编排循环(leader → 顺序派 special → leader … 直到 `dispatched===[]` 共识 / `MAX_CYCLES=8`)**完全由前端 hook 驱动** | `src/roundtable-planning/hooks/useStep2Orchestration.ts:992-1204`(recommend)、`1228-1439`(chain) |
|
||||
| 循环由 `currentStep===2` 的 `useEffect` 启停;离开 Step2 的 cleanup 会 `orchestrationCancelRef.current=true` + `abortAllInFlight()` 中断 | `useStep2Orchestration.ts:1468-1541`(尤其 cleanup `1534-1537`、`1471-1477`) |
|
||||
| 共识判定:`leaderRes.dispatched.length===0` → `setHasConsensus(true)` | `useStep2Orchestration.ts:1091-1099` |
|
||||
| 进入 Step3:手动点按钮,前置 `hasConsensus===true` | `Step2Panel.tsx:370-381`、`RoundtablePlanningPage.tsx:586-595` |
|
||||
| Step3「结果绘制」:前端流式调 `roundtable-report` 智能体,写出 `outputs/方案总览.html`,`phase→"done"` 即完成 | `src/roundtable-planning/hooks/useStep3Report.ts:36,51,165,560-596,636-652` |
|
||||
| 草稿持久化:`/api/roundtable-drafts`,含 step1/step2/step3 三段快照 + `furthestStep(1|2|3)`,**无显式 status 字段** | `src/roundtable-planning/api/drafts.ts:25-110,112,184-225` |
|
||||
| 历史下拉 UI(触发按钮 + 列表 + 重命名/删除) | `RoundtablePlanningPage.tsx:771-892` |
|
||||
| 自动保存触发点(Step1 回复完 / Step2 每轮气泡收尾 / Step3 报告保存 / 步骤前进) | `useDraftPersistence.ts:248-316` |
|
||||
| 加载草稿恢复:`handleLoadDraft` → `getDraft` → `cancelAllStreams` → hydrate → `setCurrentStep(furthestStep)` | `useDraftPersistence.ts:308-368` |
|
||||
|
||||
### 0.2 后端现状
|
||||
|
||||
| 事实 | 位置 |
|
||||
|------|------|
|
||||
| `/api/multi-agent/init`:为各席位并行建 thread + 单例总控 `roundtable-coordinator`,SSE 返回 `seat_ready`/`coordinator_ready`/`init_done(thread_ids)` | `app/gateway/routers/multi_agent.py:647-823` |
|
||||
| `/api/multi-agent/run/stream`:驱动**单轮** leader 或 special 的 LLM 执行,末尾 JSON 帧 `status`(派活列表 / `[]` 共识 / `clarification` / `error`) | `multi_agent.py:1076-1142` |
|
||||
| `/api/multi-agent/report/init`:为 Step3 报告建 thread | `multi_agent.py`(report/init) |
|
||||
| **后端不存在编排循环**——leader 派活由前端解释后再逐个发 `run/stream`;后端只做单轮执行 + 把派活/完成「广播」追加进各 thread 历史消息 | `multi_agent.py:24-29,487-502,1108-1142` |
|
||||
| 派活格式:`agent_orchestration` 工具调用 `{agent_name, task}`(旧式 bash 解析兼容) | `multi_agent.py:525-552` |
|
||||
|
||||
### 0.3 可复用模板:AI 写作的「后台多阶段流程」
|
||||
|
||||
AI 写作已经把「后台跑多阶段流程 + 前端 SSE 订阅 + 可 resume + 定时清理」跑通了,**圆桌后台作业直接照搬这套**:
|
||||
|
||||
| 模板要素 | 位置 |
|
||||
|------|------|
|
||||
| LangGraph 图驱动多阶段(researching/writing_outline/writing_draft/reviewing/…),条件路由推进,`interrupt()` 暂停点等用户输入 | `packages/harness/deerflow/agents/ai_writing/graph.py`、`nodes/*`(`pause_material_confirm` 等 `45-290`) |
|
||||
| Session 持久化表 `ai_writing_sessions`:`id/user_id/title/status/draft_*/transcript(LONGTEXT)/...`,`status∈{in_progress,done,error}` | `packages/harness/deerflow/persistence/ai_writing_sessions/model.py:14-39` |
|
||||
| `transcript` 用 `PortableLongText`(MySQL LONGTEXT,避开 64KB),时间用 `BeijingDateTime` | 同上 `27-38` |
|
||||
| 进度走 LangGraph Server 标准流 `stream_mode=["messages-tuple","values"]`,**graph 在 langgraph runtime 里跑,任意 worker 可 join stream**(这是后台健壮性的关键) | `ai_writing.py:1-17`、`langgraph.json:16-18` |
|
||||
| Checkpointer 共享 `AsyncSqliteSaver`,落 `.deer-flow/data/checkpoints.db`(需挂数据卷) | `runtime/checkpointer/async_provider.py` |
|
||||
| Cleanup cron(leader 文件锁、活跃保护、先删 CP 再删 DB、admin 手动触发) | `app/gateway/ai_writing_cleanup.py:26,34-59,111-135,193-215,307-367` |
|
||||
| **多 worker 坑**:模块级内存 dict(`_event_queues`)每 worker 一份会丢会话;SSE 进程内直调会整段 buffer。解法:状态进 DB+checkpointer、SSE 走真 TCP loopback `127.0.0.1`(`trust_env=False` 禁代理) | `multi_agent.py:271-293`、`AIWritingPage.tsx` 文件头注释 §3/§5 |
|
||||
|
||||
### 0.4 决策(已与产品确认)
|
||||
|
||||
1. **后端真后台执行**:把编排循环搬到后端 run,刷新 / 关页 / 换设备都不中断。
|
||||
2. **流程图节点 = 智能体派活链路**:横向排列的席位节点,正在发言的节点 + 连接线加载动画。
|
||||
|
||||
> ⚠️ **本方案最大的工作量在 Phase 2**:把目前跑在前端 TS 里的编排循环(`runOrchestration`/`runChainOrchestration`)
|
||||
> 移植成一个后端 **LangGraph「圆桌总控编排图」**。这是与 AI 写作一致、可在 LangGraph runtime 里
|
||||
> 后台跑且天然 checkpoint 的健壮路径。下文每个阶段都给出验收标准,可独立提测。
|
||||
|
||||
---
|
||||
|
||||
## 1. 总体架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────── 前端 (frontend-web) ───────────────────────────────┐
|
||||
│ Step2Panel ──「后台挂起」按钮──▶ POST /api/roundtable-jobs (start) │
|
||||
│ HistoryDropdown ── 进行中任务显示 spinner ──▶ 点击 ──▶ DispatchChainModal(大弹窗) │
|
||||
│ DispatchChainModal ── 订阅 GET /api/roundtable-jobs/{id}/stream(SSE) │
|
||||
│ 横向派活链路流程图(节点=席位, 活动节点+连线动画) ──「查看」──▶ 进入任务实时观察 │
|
||||
└───────────────────────────────────────────────────────────────────────────────────┘
|
||||
│ SSE 进度 / 快照
|
||||
┌─────────────────────────────── 后端 (backend) ─────────────────────────────────────┐
|
||||
│ /api/roundtable-jobs 路由层(start/get/stream/resume/cancel) ── 仿 ai_writing.py │
|
||||
│ │ 启动后台 run │
|
||||
│ LangGraph「圆桌总控编排图」 roundtable_orchestrator ── 仿 ai_writing graph │
|
||||
│ init → leader_call → parse_dispatch → dispatch_seats(顺序) → loop ↺ │
|
||||
│ → consensus → report_draw(写 HTML) → done (clarification 点 interrupt()) │
|
||||
│ │ 每次状态转移持久化进度 │
|
||||
│ roundtable_jobs 表(status/phase/dispatch_chain/active_speaker/...) + 共享 checkpointer│
|
||||
│ cleanup cron (仿 ai_writing_cleanup.py) │
|
||||
└───────────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**核心思路**:后台作业 = 一个 LangGraph 图的一次 run。图节点内部复用现有的单轮执行能力
|
||||
(`multi_agent.py` 里 leader/special 的 run 逻辑),把「解释派活 → 顺序派活 → 回到 leader」的
|
||||
循环从前端搬进图节点。前端从「驱动者」退化为「观察者」:通过 SSE 订阅进度渲染。
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据契约(前后端共享,先定义后实现)
|
||||
|
||||
### 2.1 作业状态机 `JobStatus`
|
||||
|
||||
```
|
||||
queued # 已创建,等待 worker 拉起
|
||||
running # 编排进行中(Step2 派活循环 / Step3 绘制)
|
||||
awaiting_input # 命中 clarification,等用户回答(interrupt 挂起)
|
||||
done # 已跑到 Step3 绘制完成
|
||||
error # 出错终止
|
||||
cancelled # 用户取消
|
||||
```
|
||||
|
||||
### 2.2 作业阶段 `JobPhase`(细化给流程图/进度条用)
|
||||
|
||||
```
|
||||
initializing # 建 thread / 总控就绪
|
||||
leader_thinking # 总控在决策派活
|
||||
dispatching # 正在顺序派活给席位(配合 active_speaker)
|
||||
consensus # 共识达成(dispatched=[])
|
||||
report_drawing # Step3 报告绘制中
|
||||
report_done # 绘制完成
|
||||
```
|
||||
|
||||
### 2.3 派活链路节点 `DispatchChainNode`(流程图核心数据)
|
||||
|
||||
```ts
|
||||
interface DispatchChainNode {
|
||||
agentId: string; // 席位 agent_id(小写)
|
||||
name: string; // 显示名
|
||||
avatarType: string; // 复用 getAvatarTypeFor(agentId, index) → 决定颜色
|
||||
order: number; // 横向排列次序(chain 模式=链条顺序;recommend 模式=席位/发言顺序)
|
||||
state: 'pending' | 'active' | 'done'; // 节点状态(驱动颜色/动画)
|
||||
lastSpokeCycle?: number; // 最近发言轮次(可选,做 tooltip)
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 进度事件 `JobProgressEvent`(SSE 推送的归一化事件)
|
||||
|
||||
```ts
|
||||
type JobProgressEvent =
|
||||
| { type: 'status'; status: JobStatus }
|
||||
| { type: 'phase'; phase: JobPhase; cycle: number }
|
||||
| { type: 'chain'; nodes: DispatchChainNode[] } // 派活链路全量/增量
|
||||
| { type: 'active'; agentId: string | null } // 当前发言席位(null=总控/无)
|
||||
| { type: 'consensus'; percentage: number }
|
||||
| { type: 'dialogue'; bubble: Step2DialogueLike } // 新增/收尾的对话气泡(供「查看」实时渲染)
|
||||
| { type: 'clarification'; question: string } // 命中澄清
|
||||
| { type: 'done'; step3: DraftStep3Snapshot } // 终态:最终报告快照
|
||||
| { type: 'error'; detail: string };
|
||||
```
|
||||
|
||||
> SSE 通道可直接复用 LangGraph Server 的 `values` 流(图状态快照),由前端 hook 把 `values`
|
||||
> 映射成上述归一化事件;也可在路由层做映射后再下发。**推荐前端映射**(少一层后端耦合)。
|
||||
|
||||
### 2.5 作业记录 `RoundtableJob`(持久化 + 列表展示)
|
||||
|
||||
```ts
|
||||
interface RoundtableJob {
|
||||
id: string;
|
||||
draftId: string; // 关联的草稿
|
||||
userId: string;
|
||||
status: JobStatus;
|
||||
phase: JobPhase;
|
||||
cycle: number; // 当前轮次(0..MAX_CYCLES)
|
||||
dispatchChain: DispatchChainNode[];
|
||||
activeAgentId: string | null;
|
||||
consensusPercentage: number;
|
||||
pendingClarification: string | null;
|
||||
threadIds: Record<string,string> | null; // 复用 ThreadIdMap
|
||||
coordinatorName: string | null;
|
||||
orchestrationMode: 'recommend' | 'chain';
|
||||
chain?: { id: string; title: string } | null;
|
||||
error: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 —— 后端:圆桌作业持久化模型
|
||||
|
||||
**目标**:建一张 `roundtable_jobs` 表(仿 `ai_writing_sessions`),存作业状态/阶段/派活链路,
|
||||
并在 `roundtable_drafts` 列表里能 join 出当前作业状态。
|
||||
|
||||
**涉及文件(新增/改)**
|
||||
- 新增 `packages/harness/deerflow/persistence/roundtable_jobs/model.py`(ORM 模型,仿 `ai_writing_sessions/model.py:14-39`)
|
||||
- 新增 `.../roundtable_jobs/sql.py`(Repository:`create / get / update_progress / list_by_user / list_older_than / delete`,仿 `ai_writing_sessions/sql.py`)
|
||||
- 新增 alembic 迁移 `persistence/migrations/versions/<date>_create_roundtable_jobs.py`
|
||||
- 改 `roundtable_drafts` 的列表查询:返回里带上 `status`/`jobId`(LEFT JOIN roundtable_jobs,或在 draft 行上冗余一个 `status` 字段,二选一,见下)
|
||||
|
||||
**步骤**
|
||||
1. 字段设计(对齐 §2.5):`id(PK)`、`draft_id`、`user_id`、`status`、`phase`、`cycle(Int)`、
|
||||
`dispatch_chain(PortableLongText/JSON)`、`active_agent_id`、`consensus_percentage(Int)`、
|
||||
`pending_clarification(Text)`、`thread_ids(JSON)`、`coordinator_name`、`orchestration_mode`、
|
||||
`chain(JSON)`、`error(Text)`、`created_at/updated_at(BeijingDateTime)`。
|
||||
2. `dispatch_chain` / `thread_ids` / `chain` 用 `PortableLongText` 存 JSON 字符串,repo 层序列化。
|
||||
3. 列表 join:给 `DraftMeta` 增加 `status?: JobStatus` 与 `jobId?: string`。
|
||||
**推荐方案**:草稿与作业 1:1(一个草稿最多一个活跃作业),在 draft 列表查询 LEFT JOIN
|
||||
`roundtable_jobs` 取最新作业的 `status`。
|
||||
4. 索引:`(user_id, updated_at)`、`(draft_id)`、`(status)`(cleanup/列表用)。
|
||||
|
||||
**验收**
|
||||
- 迁移可 `alembic upgrade head` 跑通,MySQL 上 `dispatch_chain` 为 `longtext`。
|
||||
- 单测:create→update_progress→get 往返;`list_by_user` 按 `updated_at` 倒序;`list_older_than` 分批。
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 —— 后端:圆桌总控编排 LangGraph 图(核心 / 最大工作量)
|
||||
|
||||
**目标**:把前端的编排循环移植成一个后端 LangGraph 图,可在 LangGraph runtime 里后台跑、天然
|
||||
checkpoint,一路跑到 Step3 绘制完成。
|
||||
|
||||
**涉及文件(新增)**
|
||||
- `packages/harness/deerflow/agents/roundtable_orchestrator/graph.py`(图装配,仿 `ai_writing/graph.py`)
|
||||
- `.../roundtable_orchestrator/state.py`(图状态 schema:intent、selectedAgents、threadIds、coordinatorName、
|
||||
mode/chain、dialogues、dispatchChain、activeAgentId、cycle、consensus、phase、step3 等)
|
||||
- `.../roundtable_orchestrator/nodes/*.py`(各节点)
|
||||
- 在 `langgraph.json` 注册新 assistant `roundtable_orchestrator`(仿 ai_writing assistant 注册,参考 `langgraph.json:16-18`)
|
||||
|
||||
**图节点设计(把前端循环逐段翻译过来)**
|
||||
|
||||
| 节点 | 职责 | 对应前端逻辑 |
|
||||
|------|------|------|
|
||||
| `init` | 复用 `_create_thread_direct` 批量建席位 thread + 总控就绪(或接收前端已建好的 `threadIds` 直接用) | `useStep2Orchestration.ts:1485-1531` / `multi_agent.py:647-823` |
|
||||
| `leader_call` | 跑一轮总控(agent_type=leader),拿 `status`(派活列表 / `[]` / `clarification`) | 单轮逻辑 `multi_agent.py:1076-1142` |
|
||||
| `route_after_leader`(条件边) | `clarification`→`interrupt`;有派活→`dispatch_seats`;`[]`→`consensus` | `useStep2Orchestration.ts:1091-1181` |
|
||||
| `dispatch_seats` | **顺序**派活:对 dispatched 里每个席位依次跑 special run;每开始一个就更新 `activeAgentId` + 进度持久化 | `useStep2Orchestration.ts:1101-1181`(务必串行,勿并行,详见前端 §5.4 注释) |
|
||||
| `clarification_pause` | `interrupt()` 挂起,`status→awaiting_input`,写 `pending_clarification` | 仿 ai_writing `pause_*` `45-290` |
|
||||
| `consensus` | `setHasConsensus`、`consensus=100`,落库后进入 `report_draw` | `1091-1099` |
|
||||
| `report_draw` | 跑 `roundtable-report`(先 report/init 建 thread),流式写 `outputs/方案总览.html`,产出 `DraftStep3Snapshot` | `useStep3Report.ts:91-149,636-652,560-596` |
|
||||
| `done` | `status→done`、写 `step3` 进作业 + 回写草稿 step3 快照 | `RoundtablePlanningPage.tsx:613-617` |
|
||||
|
||||
**关键实现要点**
|
||||
1. **复用单轮执行**:节点内部不要重写 LLM 调用,抽出 `multi_agent.py` 里 leader/special/report 的
|
||||
单轮 run 为可被图节点直接 `await` 的内部函数(去掉 HTTP 层,进程内调 LangGraph)。
|
||||
2. **派活广播**:保留现有把「总控给 X 派了活 / X 完成」追加进各 thread 历史的广播
|
||||
(`multi_agent.py:487-502`),让席位读到全局上下文。
|
||||
3. **MAX_CYCLES 兜底**:图里维护 `cycle`,≥8 强制收敛(对齐 `roundtable-constants.ts:13`)。
|
||||
4. **chain 模式**:`orchestration_mode==='chain'` 时按 `chain.seats` 固定顺序派活,跳过总控自由决策的
|
||||
部分(对齐 `runChainOrchestration` `1228-1439`)。
|
||||
5. **进度落库**:每个节点结束 `update_progress(jobId, {phase, cycle, dispatchChain, activeAgentId,
|
||||
consensus, dialogues_delta})`,前端 SSE 才有实时数据。
|
||||
6. **dialogues 累积**:把每轮气泡写进图状态 `dialogues`,既供「查看」实时渲染,也供作业结束回写草稿 step2。
|
||||
|
||||
**验收**
|
||||
- 用脚本(仿 `scripts/probe_ai_writing_assistant.py`)直接对图发起一次 run,不开前端,能从 intent +
|
||||
selectedAgents 一路跑到写出 `方案总览.html`,作业 `status` 终态 `done`。
|
||||
- 命中 clarification 时图 `interrupt`,`status=awaiting_input`,`pending_clarification` 有值。
|
||||
- chain 模式按链条顺序派活,recommend 模式由总控自由派活,两条路径都能收敛。
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 —— 后端:作业控制 + 进度 SSE 接口
|
||||
|
||||
**目标**:暴露 start/get/stream/resume/cancel,后台 run 走 LangGraph Server(任意 worker 可 join)。
|
||||
|
||||
**涉及文件(新增/改)**
|
||||
- 新增 `app/gateway/routers/roundtable_jobs.py`(仿 `ai_writing.py` 的 CRUD + 控制壳)
|
||||
- 在 gateway `app.py` 注册路由
|
||||
- 新增 `app/gateway/roundtable_jobs_cleanup.py`(仿 `ai_writing_cleanup.py`)+ 在启动处挂 cron
|
||||
|
||||
**接口契约**
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| `POST` | `/api/roundtable-jobs` | 入参:`{draftId, intent, selectedAgents, threadIds?, coordinatorName?, model, orchestrationMode, chain?}`。建作业记录(`queued`) + 启动图 run(LangGraph Server thread),返回 `RoundtableJob` |
|
||||
| `GET` | `/api/roundtable-jobs/{id}` | 作业快照(轮询兜底 / 弹窗首帧) |
|
||||
| `GET` | `/api/roundtable-jobs/{id}/stream` | SSE 进度流:透传图 `values`/`messages-tuple`,或映射成 §2.4 事件下发。**走真 TCP loopback**(`multi_agent.py:271-293`),禁代理 |
|
||||
| `POST` | `/api/roundtable-jobs/{id}/resume` | 入参 `{answer}`,回填 clarification → 图 resume,`status→running` |
|
||||
| `POST` | `/api/roundtable-jobs/{id}/cancel` | 取消图 run,`status→cancelled` |
|
||||
| `GET` | `/api/roundtable-jobs?draftId=` | 按草稿查活跃作业(页面恢复用) |
|
||||
| `POST` | `/api/roundtable-jobs/admin/cleanup` | 管理员手动清理(仿 ai_writing admin cleanup `307-367`) |
|
||||
|
||||
**多 worker 健壮性(务必遵守,照搬 ai_writing 教训)**
|
||||
- 作业状态只存 **DB + checkpointer**,**不要**用模块级内存 dict 存 per-session 状态。
|
||||
- SSE 必须经真 TCP `127.0.0.1` loopback,不能 `httpx.ASGITransport(app=...)` 进程内直调(会整段 buffer)。
|
||||
- 图 run 跑在 LangGraph runtime(独立于处理 HTTP 的 worker),刷新/换 worker 都能重新 join stream。
|
||||
- 数据卷:`.deer-flow/data/checkpoints.db` 必须挂出,否则容器重启丢状态。
|
||||
|
||||
**验收**
|
||||
- `POST /start` 后立刻 `GET /{id}` 能拿到 `queued/running`;关掉 SSE 再重连能继续收进度。
|
||||
- 杀掉发起 start 的 worker(或多 worker 轮询),`resume`/`stream` 仍命中同一作业。
|
||||
- cleanup 干跑:活跃作业永不删,先删 CP 再删 DB。
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 —— 前端:作业 API 客户端 + 数据模型扩展
|
||||
|
||||
**目标**:前端有一层干净的作业 API 与类型,历史列表能拿到 status。
|
||||
|
||||
**涉及文件(新增/改)**
|
||||
- 新增 `src/roundtable-planning/api/roundtable-jobs.ts`:
|
||||
```ts
|
||||
export async function startJob(payload: StartJobPayload): Promise<RoundtableJob>
|
||||
export async function getJob(id: string): Promise<RoundtableJob>
|
||||
export function streamJob(id: string, on: (e: JobProgressEvent) => void): () => void // 返回 unsubscribe
|
||||
export async function resumeJob(id: string, answer: string): Promise<void>
|
||||
export async function cancelJob(id: string): Promise<void>
|
||||
```
|
||||
- `streamJob` 复用项目现有 SSE/`streamMultiAgent` 的封装方式(`api/multi-agent.ts:403+`),把
|
||||
LangGraph `values` 映射成 §2.4 的归一化事件。
|
||||
- 改 `src/roundtable-planning/api/drafts.ts`:`DraftMeta` 增 `status?: JobStatus; jobId?: string`
|
||||
(`drafts.ts:76-83` + 字段映射 `122-178` 加 snake_case 转换)。
|
||||
- 新增 `src/roundtable-planning/lib/job-types.ts`:把 §2.1–2.5 的类型集中导出。
|
||||
|
||||
**验收**:`pnpm typecheck` 通过;`getJob`/`streamJob` 能拉到 Phase 3 的数据。
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 —— 前端:Step2「后台挂起」按钮
|
||||
|
||||
**目标**:Step2 顶栏/底栏加按钮,点击把当前任务交给后端后台跑,前端停止本地循环、转为观察者。
|
||||
|
||||
**涉及文件(改)**
|
||||
- `src/roundtable-planning/components/Step2Panel.tsx`(按钮放在现有「进入第三步」附近 `370-381`)
|
||||
- `src/roundtable-planning/pages/RoundtablePlanningPage.tsx`(接线 + 观察者模式)
|
||||
- `src/roundtable-planning/hooks/useStep2Orchestration.ts`(暴露「停止本地循环并交后台」的方法)
|
||||
|
||||
**步骤**
|
||||
1. Step2Panel 加「⏼ 后台挂起」按钮,`onBackgroundSuspend` 回调。
|
||||
2. 页面 `handleBackgroundSuspend`:
|
||||
- `ensureDraft(2)` 拿到 `draftId`(`useDraftPersistence.ts` ensureDraft)。
|
||||
- 收集当前状态:`intentReady`、`selectedAgents`、`threadIds`、`coordinatorName`、`model`、
|
||||
`orchestrationMode`、`chain`(全部来自 `step2`/`step1` 现有状态)。
|
||||
- `startJob(...)` → 拿到 `jobId`。
|
||||
- **停止前端本地编排**:调用 `step2.stopOrchestration()` + 置一个 `backgroundedJobId` 标记,
|
||||
让 `currentStep===2` 的 useEffect **不再自启本地循环**(在 `useStep2Orchestration.ts:1468-1541`
|
||||
的启动条件里加 `&& !backgrounded`)。
|
||||
- toast「已转入后台,可离开页面,任务会自动跑到结果绘制完成」。
|
||||
- 刷新历史列表(`drafts` 重新拉,进行中项即出现 spinner)。
|
||||
3. **观察者模式**:若当前停留在 Step2 且该 draft 有 running 作业,订阅 `streamJob`,把进度事件渲染成
|
||||
对话气泡/活动席位(复用现有 Step2 渲染),而不是本地驱动。
|
||||
|
||||
**注意:避免「双跑」**——后台已接管后,前端绝不能再并行驱动同一批 thread。用 `backgroundedJobId`
|
||||
互斥;加载草稿时若发现有 running 作业,直接进观察者模式。
|
||||
|
||||
**验收**:点「后台挂起」→ 可立即切到别的任务/路由;回来仍在跑;不会出现前后端同时派活。
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 —— 前端:历史下拉框「进行中」加载动画
|
||||
|
||||
**目标**:历史下拉里 `status==='running'`(或 `awaiting_input`)的草稿条目显示加载动画;点击进行中
|
||||
条目不直接 load,而是打开流程图弹窗。
|
||||
|
||||
**涉及文件(改)**
|
||||
- `src/roundtable-planning/pages/RoundtablePlanningPage.tsx` 的 `HistoryDropdown`(`771-892`)
|
||||
|
||||
**步骤**
|
||||
1. 列表项左侧名称前/后,按 `d.status` 渲染状态标记:
|
||||
- `running` → 旋转的 `Loader2`(`animate-spin`)+「进行中」。
|
||||
- `awaiting_input` → 呼吸点 +「待澄清」(琥珀色)。
|
||||
- `done`/无 → 维持现状(显示 `Step {furthestStep}`)。
|
||||
- `error` → 红点 +「出错」。
|
||||
2. 点击行为分流:
|
||||
- `running`/`awaiting_input` → `openDispatchChainModal(d.jobId)`(Phase 7),**不**直接 `onLoad`。
|
||||
- 其它 → 维持 `onLoad(id)`。
|
||||
3. 列表需要 `status`,确保 Phase 4 的 `DraftMeta.status` 已就位;下拉打开时 `refresh()` 拉最新。
|
||||
|
||||
**验收**:后台挂起后,历史下拉对应条目转圈;点击弹出流程图弹窗。
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 —— 前端:派活链路流程图弹窗(横向 / 大弹窗 / 动画)
|
||||
|
||||
**目标**:大弹窗,横向排列的智能体派活链路。节点 = 席位(图标 + 名称),不同颜色;正在发言的
|
||||
节点和它的连接线带加载动画;含「查看」按钮。
|
||||
|
||||
**涉及文件(新增)**
|
||||
- `src/roundtable-planning/components/DispatchChainModal.tsx`
|
||||
|
||||
**实现选型**:链路是「总控 → 席位序列」的线性结构 + 需要对**单个节点和单条连线**做精确动画,
|
||||
**推荐手写 flex 横向布局 + CSS 动画连接线**(轻、可控)。项目已装 `@xyflow/react@12`(带 `animated`
|
||||
边)作为备选,但手写对本场景的「节点 loading + 连线流动」更直接。
|
||||
|
||||
**结构(手写方案草图)**
|
||||
```tsx
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[88vh] flex-col gap-0 sm:max-w-5xl"> {/* 大弹窗 */}
|
||||
<DialogHeader>… 任务标题 + 阶段标签(leader_thinking/dispatching/report_drawing…) …</DialogHeader>
|
||||
|
||||
{/* 横向链路:总控 ──▶ 席位1 ──▶ 席位2 ──▶ … ──▶ [共识] ──▶ [报告绘制] */}
|
||||
<div className="custom-scrollbar overflow-x-auto py-8">
|
||||
<div className="flex items-center gap-0 min-w-max px-6">
|
||||
<CoordinatorNode active={phase==='leader_thinking'} />
|
||||
{nodes.map((n,i)=>(
|
||||
<Fragment key={n.agentId}>
|
||||
<Connector active={n.state==='active'} /> {/* 活动连线:流动虚线动画 */}
|
||||
<ChainNode node={n} /> {/* 活动节点:彩色+脉冲光环 */}
|
||||
</Fragment>
|
||||
))}
|
||||
<Connector active={phase==='consensus'} />
|
||||
<MilestoneNode label="共识" done={consensus===100} />
|
||||
<Connector active={phase==='report_drawing'} />
|
||||
<MilestoneNode label="报告绘制" active={phase==='report_drawing'} done={phase==='report_done'} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t pt-3 sm:justify-between">
|
||||
<span className="text-xs text-muted-foreground">轮次 {cycle}/8 · {statusLabel}</span>
|
||||
<Button onClick={onView}>查看</Button> {/* Phase 8 */}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
**节点颜色**:`avatarType = getAvatarTypeFor(agentId, order)` → `chainTagClasses(avatarType)`
|
||||
(复用 `lib/business-chain.ts:56-72` + `lib/roundtable-constants.ts`),与业务链条卡片同一套色。
|
||||
|
||||
**节点状态样式**
|
||||
- `pending`:灰色描边、低透明度。
|
||||
- `active`:席位色填充 + 外圈 `animate-ping`/脉冲光环 + 图标旋转或呼吸。
|
||||
- `done`:席位色实心 + 右上角 ✓。
|
||||
|
||||
**连接线加载动画(active 连线)**:CSS 流动虚线(marching-ants)或渐变扫光:
|
||||
```css
|
||||
/* roundtable-planning.css 追加 */
|
||||
@keyframes chain-flow { to { background-position: 20px 0; } }
|
||||
.chain-connector--active {
|
||||
background-image: linear-gradient(90deg, var(--brand-500) 50%, transparent 50%);
|
||||
background-size: 20px 100%;
|
||||
animation: chain-flow 0.6s linear infinite;
|
||||
}
|
||||
```
|
||||
|
||||
**数据来源**:弹窗 mount 时 `getJob(jobId)` 取首帧,随后 `streamJob(jobId, onEvent)` 实时更新
|
||||
`nodes`/`activeAgentId`/`phase`/`consensus`/`cycle`;`done` 事件后停止动画、全节点置 `done`。
|
||||
弹窗关闭时 `unsubscribe`。
|
||||
|
||||
**验收**:弹窗够大、横向滚动;活动席位节点和它左侧连线在动;阶段推进时高亮顺移;`done` 后静止。
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 —— 前端:「查看」进入进行中任务并实时观察
|
||||
|
||||
**目标**:弹窗「查看」→ 进入该任务,停在 Step2 实时看后台进度(气泡/活动席位随 SSE 刷新),
|
||||
跑完自动呈现 Step3 结果。
|
||||
|
||||
**涉及文件(改)**
|
||||
- `RoundtablePlanningPage.tsx`(接 `onView`)
|
||||
- `useDraftPersistence.ts`(`handleLoadDraft` 增「带作业」分支)
|
||||
- `useStep2Orchestration.ts`(观察者渲染)
|
||||
|
||||
**步骤**
|
||||
1. `onView`:关弹窗 → `handleLoadDraft(draftId)` 恢复该草稿到 Step2(`useDraftPersistence.ts:308-368`),
|
||||
但**跳过本地编排自启**(识别到有 running 作业 → 进观察者模式,对齐 Phase 5 的互斥标记)。
|
||||
2. 订阅 `streamJob(jobId)`,把 `dialogue` 事件 append 进 `step2RoundtableDialogues`、`active` 事件
|
||||
驱动 `activeSpeakerId` 高亮、`consensus` 更新百分比。
|
||||
3. 命中 `awaiting_input`:在 Step2 弹出澄清输入(复用现有 `pendingClarification` UI),用户回答 →
|
||||
`resumeJob(jobId, answer)`(而非本地 `submitClarification`)。
|
||||
4. `done` 事件:用返回的 `DraftStep3Snapshot` 走 `hydrateStep3` + 允许切到 Step3 查看报告
|
||||
(`useStep3Report.hydrateSaved` `708-767`);作业列表该项变「已完成」。
|
||||
|
||||
**验收**:从历史「查看」进入能实时看到后台在跑;澄清能在前端回答并续跑;跑完能看到最终报告。
|
||||
|
||||
---
|
||||
|
||||
## Phase 9 —— 边界、健壮性与收尾
|
||||
|
||||
1. **双跑互斥**:任一 draft 同一时刻只允许一个执行者(前端本地 or 后台作业)。加载草稿/进入 Step2
|
||||
前先 `GET /api/roundtable-jobs?draftId=`,有 running 就进观察者模式。
|
||||
2. **后台命中澄清**:`awaiting_input` 时历史下拉显示「待澄清」徽标;流程图弹窗显示「等待澄清」;
|
||||
只有「查看」进去才能回答(或在弹窗里直接给一个回答框,调用 `resumeJob`)。
|
||||
3. **取消**:流程图弹窗/Step2 提供「停止后台」→ `cancelJob`,`status→cancelled`,前端可恢复本地编辑。
|
||||
4. **错误恢复**:`error` 事件展示 detail;允许「重试」= 重新 `startJob`(沿用已建 threadIds)。
|
||||
5. **MAX_CYCLES / 预算**:后台图内兜底(对齐前端 `MAX_CYCLES=8`),到顶强制收敛并照常进 Step3。
|
||||
6. **多 worker**:严格遵守 Phase 3 的「状态进 DB+checkpointer、SSE 走 loopback」;部署若多 worker
|
||||
需 sticky 或全靠 DB 态(推荐后者)。
|
||||
7. **清理**:`roundtable_jobs` cleanup cron(活跃保护、先删 CP 再删 DB),与 draft 生命周期对齐。
|
||||
8. **历史列表性能**:draft 列表 join 作业 status 时注意 N+1;用一次 LEFT JOIN 取最新作业。
|
||||
|
||||
---
|
||||
|
||||
## 端到端验证清单(提测前逐条过)
|
||||
|
||||
- [ ] Step2 点「后台挂起」→ 立刻可离开页面 / 切到别的任务,后台继续跑。
|
||||
- [ ] 历史下拉里该任务显示加载动画(转圈)。
|
||||
- [ ] 点击进行中任务 → 大弹窗、横向派活链路、活动节点+连线在动、节点不同颜色。
|
||||
- [ ] 阶段推进(leader→派活→共识→报告绘制)时弹窗高亮顺移。
|
||||
- [ ] 弹窗「查看」→ 进入任务实时观察气泡/活动席位。
|
||||
- [ ] 后台命中澄清 → 列表「待澄清」+ 弹窗提示 + 可回答续跑(`resumeJob`)。
|
||||
- [ ] 一路自动跑到 Step3「绘制完成」,最终报告可在 Step3 查看,作业 `done`。
|
||||
- [ ] 浏览器刷新 / 关页重开后,任务仍在后台跑、状态可恢复(真后台验证)。
|
||||
- [ ] 取消后台 → `cancelled`,无残留双跑。
|
||||
- [ ] 多 worker(或杀 worker)下 stream/resume 不丢会话。
|
||||
- [ ] cleanup 干跑不误删活跃作业。
|
||||
|
||||
---
|
||||
|
||||
## 工作量与排期建议
|
||||
|
||||
| 阶段 | 内容 | 粗估 | 依赖 |
|
||||
|------|------|------|------|
|
||||
| P0 | 数据契约定型(本文 §2) | 0.5d | — |
|
||||
| P1 | 后端作业表 + repo + 迁移 | 1d | P0 |
|
||||
| **P2** | **后端编排图(移植循环)** | **3–5d(核心难点)** | P0,P1 |
|
||||
| P3 | 后端控制 + SSE + cleanup | 1.5d | P1,P2 |
|
||||
| P4 | 前端作业 API + 类型 + draft status | 0.5d | P3 |
|
||||
| P5 | Step2 后台挂起按钮 + 互斥 | 1d | P4 |
|
||||
| P6 | 历史下拉 loading 动画 | 0.5d | P4 |
|
||||
| P7 | 派活链路流程图弹窗 | 1.5d | P4 |
|
||||
| P8 | 「查看」实时观察 + 澄清续跑 | 1.5d | P5,P7 |
|
||||
| P9 | 边界/健壮性/清理 | 1d | 全部 |
|
||||
|
||||
> 建议先打通 **P1→P2→P3** 的后端最小闭环(脚本验证图能后台跑到出报告),再做前端 P4–P8。
|
||||
> P2 是整条链路成败的关键,务必先用脱离前端的探针脚本(仿 `scripts/probe_ai_writing_assistant.py`)跑通。
|
||||
|
||||
---
|
||||
|
||||
## 附:关键参考代码索引
|
||||
|
||||
- 前端编排循环(待移植到后端):`useStep2Orchestration.ts:992-1204,1228-1439,1468-1541`
|
||||
- 共识判定 / 进 Step3:`useStep2Orchestration.ts:1091-1099`、`Step2Panel.tsx:370-381`、`RoundtablePlanningPage.tsx:586-595`
|
||||
- Step3 绘制完成标志:`useStep3Report.ts:51,165,560-596,636-652`
|
||||
- 草稿模型 / API / 自动保存 / 加载恢复:`api/drafts.ts:25-110,184-225`、`useDraftPersistence.ts:248-316,308-368`
|
||||
- 历史下拉 UI:`RoundtablePlanningPage.tsx:771-892`
|
||||
- 后端单轮执行 / 派活 / 广播:`multi_agent.py:647-823,1076-1142,525-552,487-502`、loopback `271-293`
|
||||
- AI 写作模板:`agents/ai_writing/graph.py`、`ai_writing_sessions/model.py:14-39`、`ai_writing.py`、`ai_writing_cleanup.py:307-367`、`AIWritingPage.tsx` 文件头注释
|
||||
- 颜色体系复用:`lib/business-chain.ts:56-72`、`lib/roundtable-constants.ts`(`getAvatarTypeFor`)
|
||||
- 流程图备选库:`@xyflow/react@12`(已安装)
|
||||
```
|
||||
@@ -1,124 +0,0 @@
|
||||
# 多智能体「后台挂起」—— 当前状态、遗留问题与下次继续指南
|
||||
|
||||
> 配套设计文档:`multi-agent-background-run-dev.md`(分 Phase 0-9 的总方案)。
|
||||
> 本文记录**截至本次会话的实际进展 + 还没解决的遗留问题**,方便下次直接续接。
|
||||
|
||||
---
|
||||
|
||||
## 一、整体状态:跑通到哪了
|
||||
|
||||
「后台挂起」= Step2 把研讨交给后端**进程内驱动真模型**(leader→各席位→共识→报告),
|
||||
survive 刷新/关页,对话与报告作为一个 **run 写进草稿**(和手动跑同一存取),可在历史回看。
|
||||
|
||||
**真网关已在本地验证可跑通**(2026-06-04 日志确认:roundtable-coordinator + 各席位 +
|
||||
roundtable-report 真调 deepseek、真搜索、跑到 report run success)。但还有几个**内容/产物层面的遗留问题**(见第三节)。
|
||||
|
||||
### 已完成(可用)
|
||||
- ✅ 后端持久化:`roundtable_jobs` 表(迁移 `20260604_01` / `20260605_01` dialogues / `20260605_02` step3)。
|
||||
- ✅ 编排引擎 `packages/harness/deerflow/agents/roundtable_orchestrator/`(recommend + chain + 报告,纯逻辑可单测)。
|
||||
- ✅ 控制面 `app/gateway/routers/roundtable_jobs.py`:start/get/by-draft/stream(SSE)/resume/cancel/admin-cleanup。
|
||||
- ✅ 执行器 `app/gateway/roundtable_job_executor.py`:后台任务 + `set_current_user` + 生命周期 + **每轮把对话作为 `job-<id>` run 写进 `draft.step2.runs[]`(同手动存取)** + done 写 step3。
|
||||
- ✅ 真网关 `app/gateway/roundtable_inprocess_gateway.py`:`SimpleNamespace(app)` + `start_run` 进程内驱动 agent;复用 `multi_agent._extract_dispatch` 解析派活;checkpointer 读最终 messages。
|
||||
- ✅ 模拟网关 `roundtable_orchestrator/simulated_gateway.py`(不调真模型,前端联调用)。
|
||||
- ✅ 前端:`api/roundtable-jobs.ts`、`lib/job-types.ts`、Step2「后台挂起」按钮、历史下拉 loading、`DispatchChainModal`/`DispatchChainJobModal`(流程图 + 对话面板 markdown + 加宽 + 澄清作答 + 取消 + 查看)。
|
||||
- ✅ 上下文「广播」(inline):席位/总控 prompt 带上「任务背景 + 前序各席位交付 + 别提问」约束(修「席位拿不到关键信息、反问用户、总控瞎编」)。
|
||||
- ✅ 后端测试:`tests/test_roundtable_jobs.py` / `test_roundtable_orchestrator.py` / `test_roundtable_job_executor.py` / `test_roundtable_jobs_api.py` 全过。
|
||||
|
||||
---
|
||||
|
||||
## 二、怎么启动 / 验证(每次开工先做)
|
||||
|
||||
1. **开真网关**(默认是模拟网关):已写进 `offline-backend-20260512/.env`:
|
||||
```
|
||||
ROUNDTABLE_JOB_GATEWAY=inprocess
|
||||
```
|
||||
启动日志应出现 `[roundtable-jobs] gateway = inprocess (real models)`。
|
||||
|
||||
2. **必须用 `make gateway`(不带 --reload)跑后端!**
|
||||
`make dev` 的 `--reload` 监视 `backend/` 目录,agent 运行时往 `.deer-flow/` 写文件
|
||||
(memory.json / checkpoint / 沙箱输出)会触发热重载,**把长时后台任务杀掉**。
|
||||
(改进项:给 uvicorn 加 `--reload-exclude '.deer-flow/*'`,或始终用 `make gateway`。)
|
||||
|
||||
3. **直接查库验证**(最有效,PowerShell 控制台是 GBK,导出到文件看中文):
|
||||
```bash
|
||||
cd offline-backend-20260512/backend
|
||||
# 查最新作业的 dialogues / step3
|
||||
PYTHONPATH=. uv run python -c "import sqlite3,json; con=sqlite3.connect('.deer-flow/data/deerflow.db'); ..."
|
||||
```
|
||||
- 作业表:`.deer-flow/data/deerflow.db` → 表 `roundtable_jobs`(`dialogues`/`step3` 列)。
|
||||
- 草稿表:`roundtable_drafts` → `step2` 列里应有 `runs[ {id:"job-<jobId>", jobId, jobStatus, step2RoundtableDialogues:[...], step3:{...}} ]`。
|
||||
|
||||
4. **前端验证**:后台挂起 → 等跑完 → 从历史加载该草稿 → Step2 应显示后台研讨完整对话(走现有 `normalizeStep2Runs`/`hydrateStep2Run`)。
|
||||
|
||||
---
|
||||
|
||||
## 三、遗留问题(下次重点,已按优先级排)
|
||||
|
||||
### P0 —— 报告 agent 不写 HTML 文件(结果绘制拿不到产物)
|
||||
- **现象**:后台报告 run 跑完,日志 `report html: no .html found in ...threads/<id>/user-data/outputs`;
|
||||
`run.step3.html` 回退成报告 agent 的**纯文字**(不含 `<html>`),第3步画不出东西。
|
||||
- **已做**:`build_report_prompt`(`roundtable_orchestrator/engine.py`)已对齐前端
|
||||
`useStep3Report.buildReportPrompt`,明确「用 write_file 写入 /mnt/user-data/outputs/方案总览.html」;
|
||||
`_read_report_html`(`roundtable_inprocess_gateway.py`)已改为扫 outputs 目录任意 `.html`。
|
||||
- **仍没解决**:报告 agent(deepseek-chat)还是没调 `write_file`。
|
||||
- **下次怎么查**:
|
||||
1. 对比**前端那条能写出 HTML 的报告 run** 的确切调用(`useStep3Report.ts` 里 `streamMultiAgent`
|
||||
调 `/api/multi-agent/run/stream` 的 body:`agent_type`/`excluded_tools`/`context` 等),
|
||||
和后台 `_run_agent_turn`(`roundtable_inprocess_gateway.py`,agent_name=roundtable-report,
|
||||
`excluded_tools=_REPORT_EXCLUDED`)逐项比对,找出差异(怀疑 reasoning_effort / thinking /
|
||||
某个 context 字段 / recursion 步数导致它没走到 write_file)。
|
||||
2. 看报告 run 的最终 messages(checkpoint)里有没有 write_file 的 tool_call(被 dangling 截断?)。
|
||||
3. 报告 agent 的 SOUL:`app/gateway/routers/_roundtable_seed/roundtable-report/SOUL.md`——确认它默认行为。
|
||||
|
||||
### P1 —— 内容质量(本次刚加 inline 广播,待真机验证)
|
||||
- **已做**:席位/总控 prompt 注入「任务背景 + 前序交付 + 别提问」。
|
||||
- **待验证**:真机跑一次,看席位是否基于真实上下文产出(不再反问/凭记忆瞎编)。
|
||||
- **若 inline 不够**:考虑实现**真·thread 广播**(像 `multi_agent._append_thread_message` 那样把
|
||||
「用户意图 / 派活 / 各席位交付」追加进各 thread 历史)。进程内要避开 HTTP loopback 鉴权,
|
||||
得用 checkpointer 直接 append human 消息(参考 `threads` 路由 POST `/state` 的内部逻辑)。这块较重,先看 inline 够不够。
|
||||
|
||||
### P2 —— 对话「没有步骤(steps)」
|
||||
- **现象**:手动跑的气泡带工具调用步骤卡(`onPhaseChange` → `appendStepIfNew` → `Step2Dialogue.steps`);
|
||||
后台对话只存了最终正文,没有 steps。
|
||||
- **下次怎么做**:后台 `_run_agent_turn` 跑完后,从 checkpoint 的 messages 里抽出 tool_calls,
|
||||
映射成 `Step2Dialogue.steps`(`{id,key,label,ts,args}`),在执行器 `_to_step2_dialogue` 里带上。
|
||||
纯展示增强,不影响内容/报告。
|
||||
|
||||
### P3 —— 双跑浪费 + --reload 坑(工程健壮性)
|
||||
- 进 Step2 时前端会自动跑一遍本地研讨(真模型、真线程);点后台挂起后前端那条 run 在后端还会跑完
|
||||
(`stopOrchestration` 只断客户端、停不了后端 run)→ 和后台作业短暂双跑、双倍 token。
|
||||
- 优化方向:进 Step2 改成「显式开始」而非自动跑;或后台挂起时真正 cancel 前端那条 run。
|
||||
- `make dev` 的 `--reload` 会杀后台任务(见第二节);改进项:`--reload-exclude '.deer-flow/*'`。
|
||||
|
||||
---
|
||||
|
||||
## 四、关键文件清单(下次定位用)
|
||||
|
||||
**后端**
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `app/gateway/roundtable_inprocess_gateway.py` | 真网关:进程内驱动 leader/seat/re 编排循环 + `build_report_prompt` + inline 广播(`_seat_message`/`_deliveries_block`) |
|
||||
| `.../roundtable_orchestrator/progress.py` | 进度落库 + `on_dialogue` 每轮回调 |
|
||||
| `.../persistence/roundtable_jobs/` | job 表 model/sql |
|
||||
| `app/gateway/deps.py` | `ROUNDTABLE_JOB_GATEWAY` 选网关 + 注入 executor(draft_store) |
|
||||
|
||||
**前端**
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `src/roundtable-planning/api/roundtable-jobs.ts` | 作业 API + SSE |
|
||||
| `src/roundtable-planning/lib/job-types.ts` | 类型(RoundtableJob/JobDialogue/...) |
|
||||
| `src/roundtable-planning/components/DispatchChainModal.tsx` / `DispatchChainJobModal.tsx` | 流程图弹窗 + 对话面板 |
|
||||
| `src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | 后台挂起按钮、历史下拉、**runs[] 多运行模型**(`hydrateStep2Run`/`getStep2SnapshotWithRuns`/`normalizeStep2Runs`) |
|
||||
| `src/roundtable-planning/api/drafts.ts` | `DraftStep2RunSnapshot`(带 jobId/jobStatus/step3) |
|
||||
|
||||
---
|
||||
|
||||
## 五、关键架构事实(避免重复踩坑)
|
||||
|
||||
1. **真网关进程内驱动**:`start_run(body, thread_id, SimpleNamespace(app=app))` + `set_current_user`,
|
||||
不走 HTTP(loopback 调 `/api/multi-agent/*` 不可行:`_auth_headers` 只转发用户 token + owner check)。
|
||||
2. **后台作业首启自建全新线程**(`StartParams.is_resume`),绝不复用前端本地研讨的线程(会撞 409)。
|
||||
3. **存取统一**:后台作业 = `draft.step2.runs[]` 里一个带 jobId 的 run,和手动跑同一存取/hydrate 路径,
|
||||
**不要再开 observe/回显分支**(之前试过,被否)。
|
||||
4. **聊天记录确实存上了**(job.dialogues + draft 的 run);之前"没存上"是测了旧代码 / 看的是前端本地残缺数据。
|
||||
5. 多 worker / SSE:状态进 DB、SSE 走 DB 轮询;真网关进程内无 loopback。
|
||||
@@ -1,400 +0,0 @@
|
||||
# 业务链条模式 · 需求与分步实现文档
|
||||
|
||||
> 面向「智能体推荐弹窗」的扩展:在现有**智能推荐**之外,新增**业务链条选择**与**业务链条配置**两块能力;选定业务链条后,Step 2 圆桌研讨**按链条内的固定顺序**逐个发言(而非现在的总控派活)。
|
||||
>
|
||||
> 配套阅读:
|
||||
> - 前端总览:`multi-agent-frontend-dev.md`(推荐弹窗 §6.2、Step 2 编排 §5.4、草稿持久化 §5.7)
|
||||
> - 后端总览:`multi-agent-backend-dev.md`(Step 2 §6、草稿/历史持久化 §7A)
|
||||
>
|
||||
> 本文档约定:**「席位 / 智能体」**指一个 agent;**「业务链条 / chain」**指一组有序排列的 agent 配置,是可命名、可复用、可持久化的实体。
|
||||
|
||||
---
|
||||
|
||||
## 0. 需求原文拆解
|
||||
|
||||
用户原始诉求逐条拆成可实现条目:
|
||||
|
||||
| # | 原始诉求 | 拆解 |
|
||||
|---|----------|------|
|
||||
| 1 | 推荐弹窗要有「模式选择」 | 弹窗顶部加模式切换(智能推荐 / 业务链条),两种模式互斥,确认后都产出**有序**的 `SelectedAgent[]` |
|
||||
| 2 | 「智能推荐」就是现在这个 | 现有 `RecommendAgentsDialog` 整体收进「智能推荐」模式,行为不变 |
|
||||
| 3 | 「业务链条选择」是卡片列表 | 卡片 = 标题 + 一排智能体名 tag;tag 不同颜色;tag 之间有**向左箭头**连接,表达链条次序 |
|
||||
| 4 | 业务链条需要一个**配置页面** | 左侧智能体列表(复用 `GET /api/agents`),右侧有序长条卡片(名称 + 描述);支持右侧内部拖拽排序、左→右拖入指定位置 |
|
||||
| 5 | 配置页用弹窗还是新页面? | **本文档决策:独立路由页面**(理由见 §2.1) |
|
||||
| 6 | 选定链条后,Step 2 用链条内智能体研讨 | 复用既有 Step 2 init/stream;**总控(leader)按链条顺序逐个派活**(§6) |
|
||||
| 7 | 规划成分步可实现的需求文档 | 见 §7 分阶段实现 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与非目标
|
||||
|
||||
### 1.1 目标
|
||||
- 推荐弹窗成为「**席位来源选择器**」:无论智能推荐还是业务链条,最终都向 `onConfirm` 交付一个**有序的 `{ agent_id, name }[]`** + 一个 `orchestrationMode`。
|
||||
- 业务链条是**用户可创建、命名、保存、复用、删除**的有序智能体编排模板。
|
||||
- Step 2 支持两种编排:
|
||||
- `recommend` 模式 → 现有总控**自由派活**循环(不动)。
|
||||
- `chain` 模式 → 总控仍是派活方,但**被约束按链条顺序逐个派活**(新增)。
|
||||
|
||||
### 1.2 非目标(本期不做,留扩展点)
|
||||
- 链条内每个席位的 per-seat prompt/temperature 真实覆盖(仍仅前端记录,沿用现状)。
|
||||
- 链条的分支 / 条件流转(只做线性顺序链)。
|
||||
- 链条跨用户共享 / 市场(先做「本人私有链条」)。
|
||||
- 链条版本管理 / 协作编辑。
|
||||
|
||||
---
|
||||
|
||||
## 2. 关键设计决策
|
||||
|
||||
### 2.1 决策 A:配置页面用「独立路由页面」而非弹窗 ✅
|
||||
|
||||
**结论:业务链条的「编辑/管理」用独立路由页面 `/page/workspace/roundtable/chains`;业务链条的「选择」仍内嵌在推荐弹窗里(卡片列表)。**
|
||||
|
||||
理由:
|
||||
|
||||
| 维度 | 弹窗 | 独立页面 ✅ |
|
||||
|------|------|------------|
|
||||
| 双栏 + 拖拽 + 长条卡片 | 弹窗宽度(`sm:max-w-3xl`)+ `max-h-[85vh]` 拖拽空间局促,左右栏挤 | 整屏宽度,拖拽手感好 |
|
||||
| 复用入口 | 只能从推荐弹窗进 | 可从侧栏、推荐弹窗「管理链条」按钮、深链接进入 |
|
||||
| 可深链接/刷新 | 不可 | `/roundtable/chains` 可直达、可分享 |
|
||||
| 推荐弹窗内嵌编辑器 | 弹窗套弹窗 / 模式套编辑,状态复杂 | 关注点分离:弹窗只「选」,页面只「编」 |
|
||||
|
||||
**分工**:
|
||||
- **推荐弹窗**:模式选择 + 业务链条**选择**(只读卡片列表,可点「去配置」跳到页面)。
|
||||
- **配置页面**:业务链条的**增删改**(双栏拖拽编辑器 + 链条列表管理)。
|
||||
|
||||
> 备选:若产品坚持不加路由,可改为**全屏 Dialog(`max-w-[90vw] h-[90vh]`)**承载编辑器,从推荐弹窗内打开。结构与页面版一致,仅外壳不同。本文档按「独立页面」实现,§7 标注了切换成本。
|
||||
|
||||
### 2.2 决策 B:链条持久化 —— 后端 MySQL(确定) ✅
|
||||
|
||||
**结论:业务链条存后端 MySQL,仿 `roundtable_drafts`(§7A)新建 `roundtable_chains` 表 + `/api/roundtable-chains` 路由,按登录用户隔离、跨设备保留。** 不走 localStorage。
|
||||
|
||||
理由:链条是可复用、需跨设备/会话保留的模板,与草稿/推荐历史性质一致;草稿当年正是因 localStorage 换设备即丢才迁后端(§7A),链条不重复踩坑。
|
||||
|
||||
落地:
|
||||
- 表/路由设计见 §5.2,是「复制 §7A 草稿持久化、把字段换成 chain」的机械工作。
|
||||
- 前端 `api/chains.ts` 直接打后端接口(snake_case↔camelCase 在这层收口,仿 `api/drafts.ts`),**不提供 localStorage 实现**。
|
||||
- 后端表必须先于前端 UI 落地(§7 阶段 0 即包含后端表/路由)。
|
||||
|
||||
### 2.3 决策 C:拖拽库选型 —— `@dnd-kit`
|
||||
|
||||
仓库**当前无任何拖拽库**(`package.json` 无 `@dnd-kit` / `react-dnd` / `sortablejs`;`@xyflow/react` 是节点图,不适合列表排序)。
|
||||
|
||||
| 方案 | 取舍 |
|
||||
|------|------|
|
||||
| **`@dnd-kit/core` + `@dnd-kit/sortable`**(推荐) | React 19 友好、可访问性好、跨容器拖入 + 列表排序 + 插入指示器开箱即用;新增 ~30KB gzip 依赖 |
|
||||
| 原生 HTML5 DnD(零依赖兜底) | 不加依赖,但跨容器插入位置指示、键盘可达性都要手写,易出 bug |
|
||||
|
||||
**建议**:新增 `@dnd-kit/core`、`@dnd-kit/sortable`、`@dnd-kit/utilities`。若产品方禁止新增依赖,退回原生 HTML5 DnD(§7 阶段 2 标注差异)。
|
||||
|
||||
### 2.4 决策 D:Step 2「总控按链条顺序派活」,后端零改动 ✅
|
||||
|
||||
**形态:总控(leader)仍是唯一派活方,但每一轮被前端约束「只能派给链条里的下一个席位」,逐个走完整条链,最后由总控收口。** 区别于 recommend 模式的「总控自由决定派给谁」。
|
||||
|
||||
为什么后端无需改动:
|
||||
- Step 2 后端 `streamMultiAgent({ agentType: "leader" })` 每轮返回 `dispatched = [[agent, task], ...]`;`agentType: "special"` 单独驱动某席位跑一轮。这两个原语已足够。
|
||||
- 每个 special 跑完,后端 `_broadcast` 会把「子智能体 X 完成…交付内容为:…」append 进**其它所有席位 + 总控 thread** 的历史(后端 §6.4),所以链路上下文天然累积。
|
||||
- chain 模式只是**改变前端怎么提示 leader、以及怎么校验它的派活**——把每轮 leader 的提示词约束成「请向链条第 i 位【X】派活」,并对返回的 `dispatched` 做「必须命中链条下一席位」的校验/纠偏。**后端路由、SOUL、表结构都不动。**
|
||||
|
||||
→ chain 模式 = 在 `useStep2Orchestration` 里新增 `runChainOrchestration`:按链条游标逐位调 leader(约束派给该位)→ 拿到 task → 调 special → 下一位 → 链尾后调一次 leader 收口。详见 §6。
|
||||
|
||||
> 与「前端绕过 leader 直接顺序 special」的区别:本方案**保留总控在每一步的协调/串接职责**(leader 结合前序进展为下一席位编写针对性任务、并在派活说明里承上启下),更符合「总控按链条顺序派活」的语义,圆桌可视化里总控气泡也照常出现。
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据模型与类型
|
||||
|
||||
新增前端类型,建议放 `frontend-web/src/roundtable-planning/lib/business-chain.ts`:
|
||||
|
||||
```ts
|
||||
/** 业务链条里的一个有序席位。 */
|
||||
export interface ChainSeat {
|
||||
agent_id: string;
|
||||
/** 落库时的快照名/描述,避免 agent 改名后链条显示错乱;展示优先用实时 agent 数据,回退到快照。 */
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** 一条可复用的业务链条。 */
|
||||
export interface BusinessChain {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
/** 有序席位列表;seats[0] 是第一个发言者。 */
|
||||
seats: ChainSeat[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/** 推荐弹窗确认后回传给主页面的统一结果。 */
|
||||
export interface SeatSelectionResult {
|
||||
/** 有序席位(与现有 handleConfirmAgents 入参兼容)。 */
|
||||
agents: Array<{ agent_id: string; name: string }>;
|
||||
/** 决定 Step 2 走哪种编排。 */
|
||||
mode: "recommend" | "chain";
|
||||
/** chain 模式下带上来源链条(用于草稿快照 / Step 2 展示链名)。 */
|
||||
chain?: Pick<BusinessChain, "id" | "title">;
|
||||
}
|
||||
```
|
||||
|
||||
**约束**:链条席位数沿用圆桌的 `2 ≤ N ≤ 8`(`MIN_SELECTED`/`MAX_SELECTED`),配置页保存时校验。
|
||||
|
||||
**tag 配色**:复用 `roundtable-constants.ts::getAvatarTypeFor(agentId, index)`,得到 `blue|purple|emerald|amber|pink|human`,再映射到已有的 avatar 配色 token(与圆桌可视化、Step 2 气泡同一套色板,视觉统一)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 现有代码改动点速查
|
||||
|
||||
| 位置 | 改动 | 说明 |
|
||||
|------|------|------|
|
||||
| `components/RecommendAgentsDialog.tsx` | 包一层「模式选择」外壳,现有内容收进「智能推荐」Tab | `onConfirm` 签名从 `agents[]` 升级为 `SeatSelectionResult`(或加可选第二参 `mode`,见 §7 阶段 3 的兼容策略) |
|
||||
| `pages/RoundtablePlanningPage.tsx` | `handleConfirmAgents` 接收 `mode`,存到新状态 `orchestrationMode` | 透传给 `useStep2Orchestration` |
|
||||
| `hooks/useStep2Orchestration.ts` | 新增 `runChainOrchestration` + `orchestrationMode` 分支 | init effect 里按 mode 选择 `runOrchestration` 或 `runChainOrchestration` |
|
||||
| `lib/roundtable-constants.ts` | 复用 `getAvatarTypeFor` / `SelectedAgent` | 不改,仅引用 |
|
||||
| `hooks/useDraftPersistence.ts` + `api/drafts.ts` | Step2 快照新增 `orchestrationMode` + `chain` 字段 | 草稿能记住「这次是链条模式」 |
|
||||
| `pages/WorkspaceRoutes.tsx` | 注册 `roundtable/chains` 路由 | 仿 `roundtable/planning` |
|
||||
| `components/page-sidebar.tsx`(如有侧栏入口) | 加「业务链条」入口(可选) | 也可只从推荐弹窗进 |
|
||||
| `api/chains.ts`(新增) | 链条 CRUD 客户端 | 直接打后端 `/api/roundtable-chains` |
|
||||
| 后端 `roundtable_chains` | 表 + 路由(MySQL) | 仿 `roundtable_drafts`,**必做** |
|
||||
|
||||
---
|
||||
|
||||
## 5. 链条持久化 API 设计
|
||||
|
||||
### 5.1 前端客户端 `api/chains.ts`(直接打后端)
|
||||
|
||||
```ts
|
||||
listChains(): Promise<BusinessChain[]> // 当前用户全部链条
|
||||
getChain(id: string): Promise<BusinessChain>
|
||||
createChain(input: Omit<BusinessChain,"id"|"createdAt"|"updatedAt">): Promise<BusinessChain>
|
||||
updateChain(id: string, patch: Partial<BusinessChain>): Promise<BusinessChain>
|
||||
deleteChain(id: string): Promise<void>
|
||||
```
|
||||
|
||||
- 实现用 `apiFetch` 打 `/api/roundtable-chains`(§5.2),snake_case↔camelCase 在这层收口(仿 `api/drafts.ts`)。
|
||||
- 不提供 localStorage 实现;后端表是前端 UI 的硬依赖。
|
||||
|
||||
### 5.2 后端表 + 路由(仿 §7A `roundtable_drafts`)
|
||||
|
||||
- 表 `roundtable_chains`:`id`(PK) / `user_id`(idx) / `title`(512) / `description`(text) / `seats`(`PortableLongText`,存 `ChainSeat[]` JSON) / `created_at` / `updated_at`(`BeijingDateTime`)。
|
||||
- 路由 `app/gateway/routers/roundtable_chains.py`,前缀 `/api/roundtable-chains`:`GET ``(列表)/ `POST ``(建)/ `GET /{id}` / `PUT /{id}`(partial,`exclude_unset`)/ `DELETE /{id}`。
|
||||
- 鉴权/属主:`_current_user_id(request)`,跨用户访问一律 404,与 `roundtable_drafts` 完全一致。
|
||||
- 启动 `Base.metadata.create_all` 自动建表(MySQL 生产 / sqlite 本地走同一套 SQLAlchemy 代码,`PortableLongText` 各自降级),登记进 `persistence/models/__init__.py`,store 接到 `app.state` 并 `include_router`。
|
||||
|
||||
> 整体是「复制 §7A 的草稿持久化、把字段换成 chain」的机械工作,风险低。
|
||||
|
||||
---
|
||||
|
||||
## 6. Step 2 链条模式编排设计(`runChainOrchestration`)
|
||||
|
||||
### 6.1 与现有 `runOrchestration` 的差异
|
||||
|
||||
| 维度 | `runOrchestration`(recommend) | `runChainOrchestration`(chain,新增) |
|
||||
|------|--------------------------------|----------------------------------------|
|
||||
| 谁派活 | 总控 leader | 总控 leader(**不变**) |
|
||||
| 派给谁由谁定 | leader 自由决定 | 前端按链条游标**约束 leader 只能派给下一席位** |
|
||||
| 发言顺序 | leader 每轮自由派 | **链条数组的固定顺序** |
|
||||
| 循环结构 | `while` 直到 leader 返回 `[]` 共识 / MAX_CYCLES | 按链条游标走一趟(每位:leader 派活 → special 交付) |
|
||||
| 收口 | leader 返回 `[]` 即共识 | 链尾后再调一次 leader 做总结收口,产出 `lastLeaderContent` + `hasConsensus` |
|
||||
|
||||
> **核心**:leader 始终在场,只是**每轮的派活目标被前端钉死成链条的下一席位**——前端在 leader 的 `newMessage` 里写明「本轮请通过 `agent_orchestration` 仅向【X】派活」,并对 `leaderRes.dispatched` 做命中校验/纠偏。
|
||||
|
||||
### 6.2 伪代码
|
||||
|
||||
```
|
||||
runChainOrchestration(seedMessage, ids, coord, chainSeats):
|
||||
isOrchestrating = true
|
||||
cancel = false
|
||||
N = chainSeats.length
|
||||
|
||||
for (let i = 0; i < N; i++): // 链条游标,逐位推进
|
||||
if cancel: break
|
||||
seat = chainSeats[i]
|
||||
seatMeta = agentNameToRole[seat.agent_id]
|
||||
|
||||
// ── (a) 总控派活给「链条下一席位」──────────────────────────
|
||||
highlightSpeaker(null) // leader 阶段不高亮任何 sub
|
||||
leaderBubble = appendStreamingDialogue("总控协调", confidence:"链条派活中")
|
||||
leaderTask = buildChainLeaderTask({
|
||||
seedMessage, // 任务目标/约束(首轮)
|
||||
position: i, total: N,
|
||||
nextSeat: seat, // 「本轮请仅向【X(seat.agent_id)】派活」
|
||||
isFirst: i === 0,
|
||||
})
|
||||
leaderRes = await streamMultiAgent(leader, ids, coord, leaderTask, model=globalModel)
|
||||
|
||||
// 约束/纠偏:取 dispatched 中命中 seat.agent_id 的那条 task;
|
||||
// 若 leader 没派或派错(派给非 seat、或派给多个),前端兜底:
|
||||
// - 命中就用它给出的 task 文本(保留总控的承上启下措辞);
|
||||
// - 没命中则用 leader 的正文 content 作为兜底 task,或回退到模板 task。
|
||||
task = pickChainTask(leaderRes, seat)
|
||||
// leader 若返回 clarification → 暂停链条,走与现状一致的澄清条(§6.3)
|
||||
finalizeDialogue(leaderBubble, leaderRes.content, "派活说明")
|
||||
|
||||
// ── (b) 被派席位交付(special,顺序、不并行)──────────────────
|
||||
markSpeakerStreaming(seatMeta.speakerId)
|
||||
seatBubble = appendStreamingDialogue(seatMeta.displayName, confidence:"调度中")
|
||||
perSeatModel = agentConfigs[seatMeta.speakerId]?.model || globalModel
|
||||
specRes = await streamMultiAgent(special, ids, seat.agent_id, task, model=perSeatModel)
|
||||
finalizeDialogue(seatBubble, specRes.content, "子智能体交付")
|
||||
markSpeakerIdle(seatMeta.speakerId)
|
||||
// 后端 _broadcast 已把本席位交付注入其余 thread 历史 → 下一席位天然可见
|
||||
|
||||
if cancel: return
|
||||
|
||||
// ── (c) 总控收口:综合全链交付,产出最终结论 ──────────────────
|
||||
leaderBubble = appendStreamingDialogue("总控协调", confidence:"汇总链条结论")
|
||||
leaderRes = await streamMultiAgent(leader, ids, coord,
|
||||
"各链条席位已按顺序完成发言,请综合所有交付,输出最终方案,不要再派活。")
|
||||
finalizeDialogue(leaderBubble, leaderRes.content, "全案盖印")
|
||||
setLastLeaderContent(leaderRes.content)
|
||||
setHasConsensus(true); setConsensusPercentage(100)
|
||||
isOrchestrating = false
|
||||
```
|
||||
|
||||
要点:
|
||||
- **派活方仍是总控**,符合「总控按链条顺序派活」的语义;圆桌可视化里每位席位前都先出现一个总控派活气泡,再出现该席位交付气泡。
|
||||
- **约束手段是 prompt + 前端校验,不动后端**:`buildChainLeaderTask` 把「本轮只能派给【X】」写进 leader 的 `newMessage`;`pickChainTask` 对 `leaderRes.dispatched` 做命中校验,派错/未派时兜底,保证链条游标严格推进。
|
||||
- **复用现有三段式气泡 / 高亮 / abort 基建**(`appendStreamingDialogue` / `finalizeDialogue` / `markSpeakerStreaming` / `activeAbortersRef` / `orchestrationCancelRef`),只是驱动顺序不同。
|
||||
- **special 仍是顺序**(不并行),与现状不变量一致(文件顶部注释 §1)。
|
||||
- **收口 leader 调用默认开**(产出 Step 3 结论);若链条末位本身是「总结陈述」类,可关掉收口,直接拿末位交付当 `lastLeaderContent`。配置位 `chainNeedsLeaderSummary`(默认 true)。
|
||||
|
||||
### 6.3 澄清 / 挂起 / 恢复 / 干预
|
||||
- **澄清**:链条中途某轮 leader 返回 `clarification` → 与现状一致,暂停链条、底部弹澄清条;用户回答后从**当前游标**继续(需记录 `chainCursor`)。
|
||||
- **挂起/恢复**:`isRoundTableRunning` toggle 复用现有机制;恢复时从 `chainCursor` 续跑。MVP 若不做断点续跑,可先只支持「整体停止 / 从头重跑」,UI 文案说明(扩展点,§7 阶段 4 标注)。
|
||||
- **干预**:可在两席位之间插入一条用户指令,由下一轮 leader 派活前消化。MVP 同上可后置。
|
||||
|
||||
### 6.4 多轮链条(扩展点,非 MVP)
|
||||
单趟遍历是 MVP。若要「链条跑完后总控判断是否再来一轮」,可在收口 leader 处复用 `dispatched===[]?` 判定:收口 leader 若仍派活则回到链条头再跑一轮,直到共识或 MAX_CYCLES。MVP 不做,避免复杂度。
|
||||
|
||||
### 6.5 init 复用
|
||||
chain 模式的 Step 2 init **完全复用** `initMultiAgent`:`agentNames = chainSeats.map(s => s.agent_id)`(**按链条顺序**传入,使 `buildRoleTemplates` 的 seat id 顺序 = 链条顺序,且后端注入 coordinator 的「可调度席位清单」也按此序)。init effect 里按 `orchestrationMode` 决定 init 完成后调 `runOrchestration` 还是 `runChainOrchestration`。
|
||||
|
||||
---
|
||||
|
||||
## 7. 分阶段实现计划
|
||||
|
||||
> 每阶段都可独立交付、独立验收。建议顺序:**数据层 → 配置页 → 弹窗模式 → 链条选择 → Step2 编排 → 持久化兼容与收尾**。
|
||||
|
||||
### 阶段 0 · 后端链条表 + 脚手架(1.5 天) ✅ 已完成
|
||||
**目标**:打地基(含后端持久化),不含前端 UI。
|
||||
- [x] **后端**:仿 §7A `roundtable_drafts` 新建 `roundtable_chains` 表 + `/api/roundtable-chains` 路由(§5.2):
|
||||
- [x] `persistence/roundtable_chains/`(model + sql repo + `make_roundtable_chain_store`),登记进 `persistence/models/__init__.py`。
|
||||
- [x] `app/gateway/routers/roundtable_chains.py`(GET 列表 / POST / GET{id} / PUT{id} / DELETE),鉴权属主同 `roundtable_drafts`;席位 2–8 + 去重校验。
|
||||
- [x] `deps.py` 接 `app.state.roundtable_chain_store`,`app.py` `include_router`。
|
||||
- [x] 后端测试 `tests/test_roundtable_chains.py`:CRUD + 部分更新 + 跨用户隔离(5 用例全过)。
|
||||
- [x] **前端**:新增 `lib/business-chain.ts`(`ChainSeat` / `BusinessChain` / `SeatSelectionResult`,§3)+ `api/chains.ts`(直接打后端,§5.1)。
|
||||
- [x] (决策 C)安装 `@dnd-kit/core` `@dnd-kit/sortable` `@dnd-kit/utilities`。
|
||||
- **验收**:✅ 后端 5 用例通过;✅ 表注册进 `Base.metadata`、路由 `/api/roundtable-chains` 加载正常;✅ ruff 0 错;✅ 前端 typecheck 新增文件 0 错。
|
||||
|
||||
### 阶段 1 · 业务链条配置页面(2–3 天) ✅ 已完成
|
||||
**目标**:能创建/编辑/保存/删除链条。
|
||||
- [x] 路由:`WorkspaceRoutes.tsx` 注册 `roundtable/chains`(套 `WorkspaceLayout`;本页**不需要** `ChatRuntime`,无沙箱依赖)。
|
||||
- [x] 页面 `roundtable-planning/pages/BusinessChainEditorPage.tsx`:
|
||||
- [x] 顶部:链条列表下拉(`<select>` 列出已存链条 + 新建项)/ 新建按钮 / 标题+描述输入 / 保存 / 删除(`window.confirm`)。
|
||||
- [x] **左栏**:候选智能体(`listAgents()` + `filterRecommendCandidates`),竖排卡片,可拖、可点「+」快捷加入;已加入的置灰「已加入」。
|
||||
- [x] **右栏**:有序长条卡片(序号 tag + 名称 + 描述 + 移除),`SortableContext` 内拖拽排序;空态有占位提示。
|
||||
- [x] 拖拽用 `@dnd-kit`:左栏 `useDraggable`,右栏 `useDroppable` + `SortableContext`/`useSortable`,`onDragEnd` 计算插入 index(pool→右栏按落点插入;seat→seat `arrayMove`);`DragOverlay` 跟手卡片。
|
||||
- [x] 校验:`2 ≤ seats ≤ 8`(前端禁用保存 + toast,后端 `_validate_seats` 兜底),重复 agent 去重提示。
|
||||
- [x] 保存走 `api/chains.ts`(create 用 `nanoid()` 客户端 id,update 走同一 id)。
|
||||
- **验收**:✅ typecheck 0 新增错误(76 个预存量错误均在无关模块)。**人工验收待跑**:新建「情报→方案→风险→总结」链保存 → 刷新后下拉重载 → 右栏拖拽排序 → 左栏拖入指定位 → 2–8 边界禁用保存。
|
||||
- **切换成本备注**:若改全屏 Dialog 方案,编辑器组件原样复用,仅去掉路由、外面套 `Dialog`。
|
||||
- **入口说明**:圆桌本身未在侧栏(`sidebar-menu.ts` 入口被注释),本页暂经直链 `/page/workspace/roundtable/chains` 或顶栏「返回圆桌」往返;阶段 3 推荐弹窗「去配置」按钮会导航到此。
|
||||
- **视觉(后续重画)**:本页**不**套圆桌 `roundtable-planning` scope,直接遵循《浅红色/深蓝色 UI 规范》——用 app 语义 token(光 `--primary` 红 hue27 / 暗 蓝 hue259,已内置自适应)+ 规范小圆角(输入/按钮/tag/卡片内项 2px = `R_CTRL`,分栏/面板 4px = `R_CARD`)+ 克制阴影(`PANEL_SHADOW`)。筛选框复用通用 `TagSearchBar`,配色随 app 中性 token,不再被红 scope 染色。
|
||||
|
||||
### 阶段 2 · 推荐弹窗「模式选择」外壳(1 天) ✅ 已完成
|
||||
**目标**:弹窗顶部模式切换,智能推荐行为不变。
|
||||
- [x] `RecommendAgentsDialog` 顶部加**分段按钮**模式切换:`智能推荐 | 业务链条`(active 态品牌色 + 卡底;DialogDescription 随模式切换文案)。
|
||||
- [x] 现有全部内容原样收进「智能推荐」面板(`{mode === "recommend" && (<>…</>)}` 包裹,**零行为变更**)。
|
||||
- [x] 「业务链条」面板放占位:居中提示 + 「去配置业务链条」按钮(`handleGoConfig` → 关弹窗 + 跳 `roundtable/chains`)。
|
||||
- [x] `onConfirm` 统一升级为回传 `SeatSelectionResult`(含 `mode`):智能推荐确认回传 `{ agents, mode: "recommend" }`;主页面 `handleConfirmAgents` 同步改签名取 `result.agents`(`result.mode` 预留给阶段 4)。
|
||||
- **验收**:✅ typecheck 0 新增错误(总数仍 76);智能推荐分支 JSX/逻辑原样保留(流式/勾选/进入研讨未改);模式切换只切显示、`state`/`selectedIds` 不卸载 → 候选池不丢。**人工验收待跑**:切到业务链条→再切回,推荐结果与勾选仍在。
|
||||
|
||||
### 阶段 3 · 业务链条选择卡片列表(1.5 天) ✅ 已完成
|
||||
**目标**:弹窗「业务链条」面板里选链条。新增 `components/BusinessChainPicker.tsx`,替换阶段 2 的占位。
|
||||
- [x] 进入该 Tab 时 `listChains()` 拉本人链条 + `listAgents()` 取存活 id/名称,渲染**卡片列表**:
|
||||
- [x] 卡片标题(链条 title)+ 「N 席」徽章 + 选中 ✓;描述行。
|
||||
- [x] 标题下一排**智能体名 tag**:按链条顺序**从左到右**、不同颜色(`getAvatarTypeFor`→`chainTagClasses`),tag 之间插 `→`(`ArrowRightIcon`)箭头连接(`seats[0]` 最左、先发言)。
|
||||
- [x] 底部「配置链条」按钮(跳 `roundtable/chains`)。
|
||||
- [x] 空态:无链条时引导「去配置页面创建第一条业务链条」+ 按钮。
|
||||
- [x] 选中一条 → 高亮(`ring-primary`);底部「进入研讨」`onConfirm({ agents: 存活席位有序, mode:"chain", chain:{id,title} })`。
|
||||
- [x] 链条里有 agent 已不存在(被删)→ 该 tag 置灰删除线 + `title` 提示 + 卡片下方 amber 警告;confirm 按**存活席位**过滤;存活 < `CHAIN_MIN_SEATS` 则禁用「进入研讨」并提示。agent 列表加载失败时 `existingIds=null` → 不误判删除。
|
||||
- **验收**:✅ typecheck 0 新增错误(总数仍 76)。卡片渲染标题 + 彩色 tag + ← 箭头;选链 → `onConfirm(mode:"chain")` → `handleConfirmAgents` 按链条顺序 `setSelectedAgents` → Step 2 左侧「参与角色」顺序 = 链条顺序(**按顺序派活在阶段 4**)。**人工 UI 验收待跑**。
|
||||
- **箭头方向**(已确认):tag 左→右排列、`→`(`ArrowRightIcon`)连接,`seats[0]` 最左、先发言,箭头顺读即链条研讨顺序。
|
||||
|
||||
### 阶段 4 · Step 2 链条顺序编排(2–3 天) ✅ 已完成
|
||||
**目标**:chain 模式按链顺序逐席发言。**后端零改动。**
|
||||
- [x] 主页面新增 `orchestrationMode` 状态,`handleConfirmAgents` 从 `SeatSelectionResult.mode` 写入(chain 模式 toast 带链名);`handleStartNewTask` 复位为 `recommend`;透传给 `useStep2Orchestration`。chain 顺序 = `selectedAgents`(确认时已按链条顺序写入),无需单独 `chainSeats`。
|
||||
- [x] `useStep2Orchestration` 新增 `runChainOrchestration`(§6.2)+ `orchestrationModeRef`/`selectedAgentsRef`;复用现有三段式气泡/高亮/abort 基建。
|
||||
- [x] 新增 `runActiveOrchestration` 分发器:init / 恢复 / 干预 / 澄清回复 4 处调用点统一改走它,按 `orchestrationModeRef` 选 `runOrchestration`(recommend)或 `runChainOrchestration`(chain)。
|
||||
- [x] `runChainOrchestration`:按 `selectedAgents` 游标,每位先 leader 约束派活(`buildChainLeaderTask` 钉死目标席位 + `pickChainTask` 命中校验/纠偏,派错/未派都兜底推进)→ 再对**该席位**跑 special;中途 leader 若 `clarification` 则暂停(MVP 回复后整链重跑)。
|
||||
- [x] `initMultiAgent` 的 `agentNames` 即 `roundtableAgentIds`(由 `selectedAgents` 派生,已是链条顺序,§6.5)。
|
||||
- [x] 收口 leader(`finishedAllSeats` 时触发),产出 `lastLeaderContent` + `hasConsensus=true` + `consensusPercentage=100` 解锁 Step 3。
|
||||
- **验收**:✅ typecheck 0 新增错误(总数仍 76);✅ 唯一直接引用 `runOrchestration` 的是 dispatcher。逻辑层:每席「总控派活气泡 → 席位交付气泡」、严格顺序、单 special 在跑、`_broadcast` 注入前序上下文、leader 纠偏推进、跑完解锁 Step 3、abort 可中断。**人工 UI 验收待跑**。
|
||||
- **MVP 边界**:chain 模式的「挂起→断点续跑」「逐席间干预」未做断点续跑,`clarification`/恢复/干预当前为**整链重跑**(`chainCursor` 列为扩展点)。
|
||||
|
||||
### 阶段 5 · 草稿持久化兼容 + 收尾(1 天) ✅ 已完成
|
||||
**目标**:草稿记住模式,回归不破。
|
||||
- [x] `Step2Snapshot`(`useStep2Orchestration.ts`)+ `DraftStep2Snapshot`(`api/drafts.ts`)新增 `orchestrationMode?: "recommend"|"chain"` 与 `chain?: {id,title}|null`。
|
||||
- [x] 恢复路径:`orchestrationMode`/`chainMeta` 是**主页面 state**,`getStep2Snapshot` 写入;**包裹版 `hydrateStep2`** 在加载时 `setOrchestrationMode(snapshot.orchestrationMode ?? "recommend")` + `setChainMeta(snapshot.chain ?? null)` 再调 `step2.hydrateFromDraft`;老草稿无字段 → 默认 `recommend`(向后兼容)。`handleStartNewTask` 复位两者。
|
||||
- [x] 文档回写:`multi-agent-frontend-dev.md` §5.7(快照形状 + `orchestrationMode/chain` 说明)、§6.2(席位来源选择器导语);`multi-agent-backend-dev.md` 新增 §7B(`roundtable_chains` 表/路由)。
|
||||
- **验收**:✅ `pnpm typecheck` 0 新增错误(总数仍 76)。逻辑:chain 会话保存草稿 → 重载 `orchestrationMode="chain"` + 链信息恢复;老草稿(无字段)默认 recommend 照常加载。**人工 UI 验收 + `pnpm build` 待跑**。
|
||||
|
||||
### 工期粗估
|
||||
| 阶段 | 估时 |
|
||||
|------|------|
|
||||
| 0 后端链条表 + 脚手架 | 1.5d |
|
||||
| 1 配置页 | 2–3d |
|
||||
| 2 弹窗模式壳 | 1d |
|
||||
| 3 链条选择卡片 | 1.5d |
|
||||
| 4 Step2 链编排 | 2–3d |
|
||||
| 5 持久化兼容收尾 | 1d |
|
||||
| **合计** | **约 9–11 人日**(含后端 `roundtable_chains` 表/路由) |
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收清单(端到端)
|
||||
|
||||
1. 推荐弹窗顶部有「智能推荐 / 业务链条」切换;智能推荐与现状逐像素一致。
|
||||
2. 配置页 `/page/workspace/roundtable/chains` 左栏列出智能体、右栏有序长条卡片;左→右拖入指定位、右栏内拖拽排序均生效;2–8 校验生效;保存/重载/删除正常。
|
||||
3. 业务链条卡片:标题 + 彩色智能体 tag + ← 箭头连接,渲染正确。
|
||||
4. 选定链条 → Step 2 席位顺序 = 链条顺序,并**按顺序逐个发言**,后序能看到前序交付。
|
||||
5. chain 跑完解锁 Step 3;草稿记住 chain 模式并可重载。
|
||||
6. 删除链条中引用的 agent 后,选择端有降级提示,不崩。
|
||||
|
||||
---
|
||||
|
||||
## 9. 风险与开放问题
|
||||
|
||||
| # | 项 | 说明 / 建议 |
|
||||
|---|----|-------------|
|
||||
| 1 | 配置页:弹窗 vs 页面 | 本文档定为**独立页面**(§2.1)。若产品要弹窗,编辑器组件可复用,仅换外壳。**请确认。** |
|
||||
| 2 | 链条持久化 | **已定:后端 MySQL `roundtable_chains`**(§2.2 / §5.2),仿草稿持久化。 |
|
||||
| 3 | Step 2 链编排 | **已定:总控按链条顺序派活**(§2.4 / §6),后端零改动。 |
|
||||
| 4 | 新增 `@dnd-kit` 依赖 | 若禁止新增依赖,退原生 HTML5 DnD(手感与可达性下降)。**请确认。** |
|
||||
| 5 | ← 箭头方向 / tag 阅读顺序 | 默认 `seats[0]` 在最左、先发言;箭头仅作视觉连接。**请确认是否要「从右往左读」。** |
|
||||
| 6 | chain 模式是否需要收口 leader 总结 | 默认需要(产出 Step 3 结论)。若末位席位自带总结可关掉。 |
|
||||
| 7 | leader 派活纠偏策略 | leader 派错/未派时前端兜底推进游标(§6.2 `pickChainTask`);可加 toast 提示「已按链条强制推进」。 |
|
||||
| 8 | chain 模式断点续跑 / 逐席干预 | MVP 不做,列为扩展点(§6.3 / §7 阶段 4)。 |
|
||||
| 9 | 链条是否私有 | 本期仅本人私有(仿草稿按 `user_id` 隔离)。共享/市场留扩展。 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 路径速查(新增/改动)
|
||||
|
||||
| 资源 | 路径 | 状态 |
|
||||
|------|------|------|
|
||||
| 链条类型 | `frontend-web/src/roundtable-planning/lib/business-chain.ts` | 新增 |
|
||||
| 链条 API 客户端 | `frontend-web/src/roundtable-planning/api/chains.ts` | 新增 |
|
||||
| 配置页 | `frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx` | 新增 |
|
||||
| 链条选择面板 | `frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx` | 新增 |
|
||||
| 链条卡片 | `frontend-web/src/roundtable-planning/components/BusinessChainCard.tsx` | 新增 |
|
||||
| 推荐弹窗(加模式壳) | `frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx` | 改 |
|
||||
| 主页面(mode 透传) | `frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | 改 |
|
||||
| Step2 编排(加 chain) | `frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts` | 改 |
|
||||
| 草稿快照(加 mode) | `hooks/useDraftPersistence.ts` + `api/drafts.ts` | 改 |
|
||||
| 路由注册 | `frontend-web/src/pages/WorkspaceRoutes.tsx` | 改 |
|
||||
| 后端链条持久化 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_chains/` | 新增 |
|
||||
| 后端链条路由 | `offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py` | 新增 |
|
||||
| 后端 store 接线 | `offline-backend-20260512/backend/app/gateway/deps.py`(`app.state.roundtable_chain_store`)+ `app.py` | 改 |
|
||||
</content>
|
||||
</invoke>
|
||||
@@ -439,9 +439,8 @@ isOrchestrating = false
|
||||
> **重大变更**:草稿从浏览器 `localStorage` 迁到**后端 MySQL**,按登录用户隔离、跨设备/会话保留。旧 `utils/drafts.ts` 已删除;`useDraftPersistence` 仍保持对外接口不变(列表项含 `name`,由后端 `title` 映射而来),主页面无需改动。后端表/路由见 backend doc「圆桌草稿持久化」。
|
||||
|
||||
- **API 客户端 `api/drafts.ts`**:`listDrafts()`(轻量元数据)/ `getDraft(id)`(全量 step1+step2)/ `createDraft` / `updateDraft`(partial)/ `deleteDraft` / `deriveDraftName`。snake_case↔camelCase 映射在这一层收口(后端 `furthest_step/created_at/updated_at` ↔ 前端 `furthestStep/...`)。
|
||||
- **快照形状**:`DraftStep1Snapshot.{intentThreadId, intentReady, interactiveMessages, timelineEntries}`、`DraftStep2Snapshot.{selectedAgents, threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit, seatStubMessages, orchestrationMode?, chain?}`;以 JSON 整存进后端 `step1`/`step2`(`PortableLongText`/LONGTEXT,避免大对话超 64KB)。
|
||||
- **快照形状**:`DraftStep1Snapshot.{intentThreadId, intentReady, interactiveMessages, timelineEntries}`、`DraftStep2Snapshot.{selectedAgents, threadIds, coordinatorName, step2RoundtableDialogues, lastLeaderContent, hasConsensus, consensusPercentage, budgetLimit, seatStubMessages}`;以 JSON 整存进后端 `step1`/`step2`(`PortableLongText`/LONGTEXT,避免大对话超 64KB)。
|
||||
- **`selectedAgents`(2026-06 修复)**:本次研讨实际选中的席位。**不存的话加载草稿后 Step 2/3 左侧「参与角色」会回退到 `DEFAULT_SELECTED_AGENTS`(默认 6 个)而非当时真正选的那几个** —— `roleTemplates`/`agentNameToRole` 都由 `selectedAgents` 派生。`useStep2Orchestration.hydrateFromDraft` 在该字段非空时 `setSelectedAgents`,老草稿(无此字段)保持当前值不强行覆盖。
|
||||
- **`orchestrationMode?` / `chain?`(2026-06 业务链条)**:Step 2 编排模式(`recommend`=总控自由派活 / `chain`=按业务链条顺序派活)+ 来源链条 `{id,title}`。是**主页面 state**,由页面在 `getStep2Snapshot` 写入、在**包裹版 `hydrateStep2`** 里读出后 `setOrchestrationMode`/`setChainMeta`(step2 hook 的 `hydrateFromDraft` 不直接消费)。老草稿无字段 → 默认 `recommend`。业务链条完整设计见 `multi-agent-business-chain-dev.md`。
|
||||
- `deriveDraftName({intentReady, interactiveMessages})`:优先 `intentReady.objective`,否则第一个 user 消息,否则 `未命名草稿 <时间>`,截断 40 字。**仅在 create 时写一次**(见下方 bug 修复)。
|
||||
- **列表加载**:挂载时 `useEffect` 调 `listDrafts()`;增删改后 `refreshList()` 重拉。
|
||||
- **自动保存策略**:仅在「前进」步骤切换时保存(1→2、2→3),后退不触发。
|
||||
@@ -482,8 +481,6 @@ isOrchestrating = false
|
||||
|
||||
### 6.2 `RecommendAgentsDialog`
|
||||
|
||||
> **席位来源选择器(2026-06 业务链条)**:弹窗顶部加了**模式分段切换** `智能推荐 | 业务链条`。智能推荐 = 本节描述的现状(零行为变更,整体收进 `{mode==="recommend"}` 分支);业务链条 = `BusinessChainPicker`(选一条已配置链条,卡片标题 + 彩色席位 tag + `→` 箭头)。`onConfirm` 统一回传 `SeatSelectionResult{ agents, mode, chain? }`;`mode==="chain"` 时 Step 2 由总控**按链条顺序派活**(`useStep2Orchestration.runChainOrchestration`)。配置页与编排详见 `multi-agent-business-chain-dev.md`。
|
||||
|
||||
- **新增 `draftId` prop(2026-06)**:本次推荐归属的草稿/会话 id。由主页面在打开弹窗前 `await drafts.ensureDraft(1)` 拿到并传入(见 §6.6)。`null` 时退化为「直接推荐、不存历史」的旧行为。
|
||||
- 打开时管线(`start(forceFresh=false)`):`listAgents() → filterRecommendCandidates()` → **若 `draftId` 有历史 → `listRecommendHistory()` 取最近一次直接展示(不重跑模型)**;否则 `streamRecommend()` 跑新推荐。
|
||||
- 阶段 UI(`InternalState.phase`):
|
||||
|
||||
@@ -1,404 +0,0 @@
|
||||
# 多智能体会商 Step2 多结果线方案
|
||||
|
||||
## 背景
|
||||
|
||||
当前「多智能体会商 / 多智能体规划」里,一个历史草稿只保存一份 `step2` 快照。用户从历史记录加载草稿后,如果再次点击「智能体推荐」并在弹窗里点击「进入研讨」,新的 Step2 研讨会继续写入同一份 `draft.step2`,结果是第一次生成内容会被第二次生成内容覆盖,或者页面只能展示最新一次结果。
|
||||
|
||||
产品期望是:
|
||||
|
||||
- 用户从历史记录进入已有草稿时,进入第二步后,左下角能看到第一次生成结果。
|
||||
- 用户再次点击「智能体推荐」并点击「进入研讨」时,要开启一条新的研讨结果线。
|
||||
- 第二次生成结果显示在左下角,但不能影响第一次生成结果。
|
||||
- 用户之后再次查看同一个历史草稿时,可以分别查看第一次生成结果和第二次生成结果。
|
||||
|
||||
因此,这个需求的核心不是简单增加一个展示区域,而是把 Step2 从「单结果」升级为「一个草稿下多条研讨 run」。
|
||||
|
||||
## 现状
|
||||
|
||||
相关位置:
|
||||
|
||||
- `src/roundtable-planning/api/drafts.ts`
|
||||
- `src/roundtable-planning/pages/RoundtablePlanningPage.tsx`
|
||||
- `src/roundtable-planning/hooks/useStep2Orchestration.ts`
|
||||
- `src/roundtable-planning/components/Step2Panel.tsx`
|
||||
- `src/roundtable-planning/components/RecommendAgentsDialog.tsx`
|
||||
|
||||
当前 `DraftStep2Snapshot` 结构大致是:
|
||||
|
||||
```ts
|
||||
export interface DraftStep2Snapshot {
|
||||
selectedAgents?: Array<{ agent_id: string; name: string }>;
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
step2RoundtableDialogues: unknown[];
|
||||
lastLeaderContent: string;
|
||||
hasConsensus: boolean;
|
||||
consensusPercentage: number;
|
||||
budgetLimit: number;
|
||||
seatStubMessages?: Record<string, unknown[]>;
|
||||
orchestrationMode?: "recommend" | "chain";
|
||||
chain?: { id: string; title: string } | null;
|
||||
}
|
||||
```
|
||||
|
||||
这意味着一个草稿只有一份:
|
||||
|
||||
- 参会智能体
|
||||
- 多智能体 thread 映射
|
||||
- 研讨气泡
|
||||
- 总控最终内容
|
||||
- 共识状态
|
||||
- 沙箱产物 stub
|
||||
- 编排模式和业务链条
|
||||
|
||||
当前 `RoundtablePlanningPage.tsx` 里的 `handleConfirmAgents` 在用户点击「进入研讨」后会:
|
||||
|
||||
1. 规范化选中的 agents。
|
||||
2. 写入 `step2.selectedAgents`。
|
||||
3. 调用 `step2.resetInitGate()`。
|
||||
4. 设置 `orchestrationMode` / `chainMeta`。
|
||||
5. 切到 `currentStep = 2`。
|
||||
|
||||
它没有先把当前 Step2 结果归档成「第一次生成结果」,也没有为新的研讨创建独立 run。因此第二次研讨天然会复用同一个 `draft.step2` 写入位置。
|
||||
|
||||
## 目标模型
|
||||
|
||||
建议引入 Step2 run 概念:一个草稿可以有多条 Step2 结果线,每条结果线保存一份完整的 Step2 快照。
|
||||
|
||||
```ts
|
||||
interface Step2RunSnapshot {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
source: "initial" | "recommend" | "chain" | "manual";
|
||||
selectedAgents?: Array<{ agent_id: string; name: string }>;
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
step2RoundtableDialogues: unknown[];
|
||||
lastLeaderContent: string;
|
||||
hasConsensus: boolean;
|
||||
consensusPercentage: number;
|
||||
budgetLimit: number;
|
||||
seatStubMessages?: Record<string, unknown[]>;
|
||||
orchestrationMode?: "recommend" | "chain";
|
||||
chain?: { id: string; title: string } | null;
|
||||
step3?: DraftStep3Snapshot | null;
|
||||
}
|
||||
```
|
||||
|
||||
`DraftStep2Snapshot` 保留现有字段作为当前激活 run 的镜像,同时新增 `runs` 和 `activeRunId`:
|
||||
|
||||
```ts
|
||||
export interface DraftStep2Snapshot {
|
||||
activeRunId?: string;
|
||||
runs?: Step2RunSnapshot[];
|
||||
|
||||
selectedAgents?: Array<{ agent_id: string; name: string }>;
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
step2RoundtableDialogues: unknown[];
|
||||
lastLeaderContent: string;
|
||||
hasConsensus: boolean;
|
||||
consensusPercentage: number;
|
||||
budgetLimit: number;
|
||||
seatStubMessages?: Record<string, unknown[]>;
|
||||
orchestrationMode?: "recommend" | "chain";
|
||||
chain?: { id: string; title: string } | null;
|
||||
}
|
||||
```
|
||||
|
||||
这样做的好处:
|
||||
|
||||
- 老代码仍然可以从 `draft.step2.step2RoundtableDialogues` 读取当前结果。
|
||||
- 新 UI 可以从 `draft.step2.runs` 展示第一次、第二次、第三次生成结果。
|
||||
- 老草稿没有 `runs` 时,可以在 hydrate 时把原来的 `step2` 包装成一条默认 run。
|
||||
- 暂时不需要改数据库表结构,因为 `step2` 本身就是 JSON 快照。
|
||||
|
||||
## 行为设计
|
||||
|
||||
### 1. 历史记录加载
|
||||
|
||||
用户点击历史记录加载草稿时:
|
||||
|
||||
1. 拉取完整草稿。
|
||||
2. 如果 `draft.step2.runs` 存在,使用 `activeRunId` 找到当前结果线。
|
||||
3. 如果 `draft.step2.runs` 不存在,但旧字段里有 `step2RoundtableDialogues`,自动包装成一条 run:
|
||||
|
||||
```ts
|
||||
{
|
||||
id: "legacy-run",
|
||||
title: "第一次生成结果",
|
||||
createdAt: draft.updatedAt,
|
||||
source: "initial",
|
||||
...draft.step2
|
||||
}
|
||||
```
|
||||
|
||||
4. hydrate 当前 active run 到 Step2 runtime。
|
||||
5. 左下角展示 run 列表。
|
||||
|
||||
### 2. 再次点击智能体推荐进入研讨
|
||||
|
||||
用户在已有历史草稿中再次点击「智能体推荐」并确认进入研讨时:
|
||||
|
||||
1. 先把当前页面里的 Step2 状态保存为当前 active run。
|
||||
2. 创建一条新的 run:
|
||||
|
||||
```ts
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
title: `第 ${runs.length + 1} 次生成结果`,
|
||||
createdAt: new Date().toISOString(),
|
||||
source: result.mode,
|
||||
selectedAgents: normalizedAgents,
|
||||
threadIds: null,
|
||||
coordinatorName: null,
|
||||
step2RoundtableDialogues: [],
|
||||
lastLeaderContent: "",
|
||||
hasConsensus: false,
|
||||
consensusPercentage: 0,
|
||||
budgetLimit: DEFAULT_BUDGET,
|
||||
seatStubMessages: {},
|
||||
orchestrationMode: result.mode,
|
||||
chain: result.mode === "chain" ? result.chain ?? null : null
|
||||
}
|
||||
```
|
||||
|
||||
3. 将 `activeRunId` 切到新 run。
|
||||
4. 清空 Step2 runtime。
|
||||
5. 设置新选中的 agents / mode / chain。
|
||||
6. `resetInitGate()`,进入 Step2 后重新创建一组独立 thread。
|
||||
7. 自动保存草稿。
|
||||
|
||||
关键点:第二次 run 必须使用新的 `threadIds`,不能沿用第一次 run 的 `threadIds`,否则后端 thread 历史会混在一起。
|
||||
|
||||
### 3. 左下角结果列表
|
||||
|
||||
左下角建议展示一个「生成结果」区域:
|
||||
|
||||
- 第一次生成结果
|
||||
- 第二次生成结果
|
||||
- 第三次生成结果
|
||||
|
||||
每条展示:
|
||||
|
||||
- 标题
|
||||
- 创建时间
|
||||
- 状态:研讨中 / 已达成共识 / 未完成 / 已生成报告
|
||||
- 使用模式:智能体推荐 / 业务链条
|
||||
|
||||
点击某条结果时:
|
||||
|
||||
1. 保存当前 active run。
|
||||
2. 切换 `activeRunId`。
|
||||
3. hydrate 被点击的 run。
|
||||
4. 中间 Step2 对话区显示该 run 的研讨气泡。
|
||||
5. 如果该 run 已有 `step3`,进入 Step3 时展示对应报告。
|
||||
|
||||
### 4. Step3 结果归属
|
||||
|
||||
目前 `draft.step3` 是草稿级别的一份报告。如果只保留这一份,第二次结果生成报告后仍会覆盖第一次报告。
|
||||
|
||||
建议:
|
||||
|
||||
- `draft.step3` 继续作为当前 active run 的镜像,兼容现有 Step3 页面。
|
||||
- 每条 `Step2RunSnapshot.step3` 保存自己的报告。
|
||||
- `onReportSaved` 时同时写入当前 active run 的 `step3`。
|
||||
|
||||
这样用户切换到第一次生成结果时,可以看到第一次的 Step3 报告;切换到第二次生成结果时,可以看到第二次的 Step3 报告。
|
||||
|
||||
## 建议改动点
|
||||
|
||||
### 1. `api/drafts.ts`
|
||||
|
||||
扩展类型:
|
||||
|
||||
- 新增 `Step2RunSnapshot`。
|
||||
- `DraftStep2Snapshot` 增加 `activeRunId?: string` 和 `runs?: Step2RunSnapshot[]`。
|
||||
|
||||
同时保留旧字段,避免影响现有保存、加载和 Step3。
|
||||
|
||||
### 2. `useStep2Orchestration.ts`
|
||||
|
||||
新增或暴露两个能力:
|
||||
|
||||
- `getCurrentRunSnapshot()`:从当前 Step2 runtime 生成一条 run 快照。
|
||||
- `hydrateRunSnapshot(run)`:把某条 run 恢复到 Step2 runtime。
|
||||
|
||||
`hydrateFromDraft` 继续存在,但内部可以优先选择 active run 来 hydrate。
|
||||
|
||||
### 3. `RoundtablePlanningPage.tsx`
|
||||
|
||||
新增页面级状态:
|
||||
|
||||
```ts
|
||||
const [step2Runs, setStep2Runs] = useState<Step2RunSnapshot[]>([]);
|
||||
const [activeStep2RunId, setActiveStep2RunId] = useState<string | null>(null);
|
||||
```
|
||||
|
||||
调整几个流程:
|
||||
|
||||
- `getStep2Snapshot` 输出 `runs` 和 `activeRunId`。
|
||||
- `hydrateStep2` 兼容旧草稿并恢复 runs。
|
||||
- `handleConfirmAgents` 在新建研讨前先归档当前 active run,再创建新 run。
|
||||
- `onReportSaved` 把 Step3 报告写回当前 active run。
|
||||
|
||||
### 4. `Step2Panel.tsx`
|
||||
|
||||
新增左下角结果列表展示。建议作为一个独立组件,避免 Step2Panel 继续变大:
|
||||
|
||||
```tsx
|
||||
<Step2RunSwitcher
|
||||
runs={step2Runs}
|
||||
activeRunId={activeStep2RunId}
|
||||
onSelect={handleSelectStep2Run}
|
||||
/>
|
||||
```
|
||||
|
||||
组件职责只做展示和选择,不直接读写 draft。
|
||||
|
||||
### 5. `Step3Panel.tsx`
|
||||
|
||||
传入当前 active run 的报告:
|
||||
|
||||
- 如果当前 run 有 `step3`,展示它。
|
||||
- 如果没有,按现有逻辑生成新报告。
|
||||
- 生成完成后通过 `onReportSaved` 写回 run。
|
||||
|
||||
## 兼容策略
|
||||
|
||||
老草稿没有 `runs` 字段时,不做数据迁移,前端加载时即时包装。
|
||||
|
||||
判断规则:
|
||||
|
||||
```ts
|
||||
function normalizeStep2Runs(step2: DraftStep2Snapshot): {
|
||||
runs: Step2RunSnapshot[];
|
||||
activeRunId: string | null;
|
||||
} {
|
||||
if (step2.runs?.length) {
|
||||
return {
|
||||
runs: step2.runs,
|
||||
activeRunId: step2.activeRunId ?? step2.runs[0].id,
|
||||
};
|
||||
}
|
||||
|
||||
const hasLegacyContent =
|
||||
step2.step2RoundtableDialogues?.length > 0 ||
|
||||
Boolean(step2.threadIds) ||
|
||||
Boolean(step2.lastLeaderContent);
|
||||
|
||||
if (!hasLegacyContent) {
|
||||
return { runs: [], activeRunId: null };
|
||||
}
|
||||
|
||||
const legacyRun: Step2RunSnapshot = {
|
||||
id: "legacy-run",
|
||||
title: "第一次生成结果",
|
||||
createdAt: new Date().toISOString(),
|
||||
source: step2.orchestrationMode ?? "initial",
|
||||
selectedAgents: step2.selectedAgents,
|
||||
threadIds: step2.threadIds,
|
||||
coordinatorName: step2.coordinatorName,
|
||||
step2RoundtableDialogues: step2.step2RoundtableDialogues,
|
||||
lastLeaderContent: step2.lastLeaderContent,
|
||||
hasConsensus: step2.hasConsensus,
|
||||
consensusPercentage: step2.consensusPercentage,
|
||||
budgetLimit: step2.budgetLimit,
|
||||
seatStubMessages: step2.seatStubMessages,
|
||||
orchestrationMode: step2.orchestrationMode,
|
||||
chain: step2.chain,
|
||||
};
|
||||
|
||||
return { runs: [legacyRun], activeRunId: legacyRun.id };
|
||||
}
|
||||
```
|
||||
|
||||
## 风险点
|
||||
|
||||
### 1. 自动保存覆盖
|
||||
|
||||
Step2 现在会在每个气泡收尾时自动保存。如果引入多 run,自动保存必须先把当前 runtime 合并回 active run,再写入 `draft.step2.runs`。
|
||||
|
||||
否则会出现 UI 切到第二次结果,但保存时仍更新第一次 run 的情况。
|
||||
|
||||
### 2. threadIds 混用
|
||||
|
||||
每条 run 必须拥有自己的 `threadIds`。新建 run 时要清空 `threadIds` 并让 Step2 重新 init。
|
||||
|
||||
如果复用旧 thread,会导致两次研讨的后端上下文互相污染。
|
||||
|
||||
### 3. Step3 报告覆盖
|
||||
|
||||
如果只保存草稿级 `step3`,多 run 的报告无法分别查看。
|
||||
|
||||
建议把报告写入当前 run,同时保留草稿级 `step3` 作为 active run 镜像。
|
||||
|
||||
### 4. 正在运行中的 run
|
||||
|
||||
如果配合后台任务能力,run 还需要带 job 信息:
|
||||
|
||||
```ts
|
||||
jobId?: string | null;
|
||||
jobStatus?: JobStatus | null;
|
||||
```
|
||||
|
||||
这样左下角可以显示「第二次生成结果正在运行」,并打开派活链路弹窗。
|
||||
|
||||
## 分阶段实现建议
|
||||
|
||||
### P0:纯前端多 run 快照
|
||||
|
||||
目标:先解决“第一次 / 第二次结果分别可查看”。
|
||||
|
||||
- 扩展 `DraftStep2Snapshot` 类型。
|
||||
- 页面维护 `step2Runs` / `activeStep2RunId`。
|
||||
- `handleConfirmAgents` 新建 run。
|
||||
- 左下角展示 run 列表。
|
||||
- 切换 run 能恢复不同 Step2 对话。
|
||||
|
||||
不改后端表结构。
|
||||
|
||||
### P1:Step3 绑定 run
|
||||
|
||||
目标:每次研讨结果对应自己的报告。
|
||||
|
||||
- `Step2RunSnapshot.step3` 保存报告。
|
||||
- `onReportSaved` 写回 active run。
|
||||
- 切换 run 时同步 Step3 显示。
|
||||
|
||||
### P2:后台任务绑定 run
|
||||
|
||||
目标:和后台挂起执行方案打通。
|
||||
|
||||
- run 增加 `jobId` / `jobStatus`。
|
||||
- 历史列表和左下角都能显示某条 run 正在执行。
|
||||
- 点击正在执行的 run 打开派活链路弹窗。
|
||||
|
||||
### P3:后端 run 表
|
||||
|
||||
如果后续一个草稿下 run 数量很多,或者需要服务端按 run 查询、删除、重命名,再考虑新增后端表:
|
||||
|
||||
- `roundtable_runs`
|
||||
- `draft_id`
|
||||
- `run_id`
|
||||
- `title`
|
||||
- `status`
|
||||
- `step2_snapshot`
|
||||
- `step3_snapshot`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
短期不建议一开始就上表,先用现有 `step2` JSON 快照落地更快。
|
||||
|
||||
## 验收清单
|
||||
|
||||
- 从历史记录加载旧草稿时,左下角出现「第一次生成结果」。
|
||||
- 点击「第一次生成结果」能看到原来的 Step2 研讨气泡。
|
||||
- 在历史草稿中重新点击「智能体推荐」并「进入研讨」后,左下角新增「第二次生成结果」。
|
||||
- 第二次研讨开始时,Step2 对话区为空或只显示新 run 初始化内容。
|
||||
- 第二次 run 使用新的 `threadIds`,不会复用第一次 run 的后端线程。
|
||||
- 切回「第一次生成结果」时,第一次的对话、共识状态、沙箱产物仍可查看。
|
||||
- 切回「第二次生成结果」时,第二次的对话继续显示。
|
||||
- 刷新页面后再次加载该历史草稿,两条结果线都还在。
|
||||
- 如果两次都生成了 Step3 报告,切换不同结果线时能看到各自对应的报告。
|
||||
@@ -1,610 +0,0 @@
|
||||
# 圆桌 DAG 编排 + 并行执行显示 —— 分步实现需求文档
|
||||
|
||||
> 目标读者:实现本功能的前端 / 后端工程师。
|
||||
> 本文是**可逐步落地**的需求 + 设计文档,每个 Phase 都能独立交付、独立验收。
|
||||
|
||||
---
|
||||
|
||||
## 0. 背景与目标
|
||||
|
||||
### 0.1 现状
|
||||
|
||||
Step 2 圆桌目前有两种编排模式(`orchestrationMode`,见 `hooks/useStep2Orchestration.ts`):
|
||||
|
||||
- **`free`(自由模式)**:总控(leader)自由决定派活给谁,`runOrchestration` 循环驱动。
|
||||
- **`chain`(线性链条)**:用户把席位排成一条**线性**链 `A → B → C`,`runChainOrchestration` 按顺序逐个派活,最后总控收口。链路用 `DispatchChainModal.tsx` 的**横向流程图**展示(总控 → 席位1 → 席位2 → … → 共识 → 报告)。
|
||||
|
||||
链条数据现在只是一个**有序数组** `selectedAgents`(线性),节点状态类型见 `lib/job-types.ts`(`ChainNodeState = pending | active | done`)。
|
||||
|
||||
### 0.2 目标(本次新增)
|
||||
|
||||
1. **DAG 编排**:用户可视化指定**智能体之间的直接依赖关系**——有的串行(`A → B → C`),有的并行(`A B C` 同时跑)。编排结果是一张**流程图(DAG)**。
|
||||
2. **并行执行**:DAG 里同一层(互不依赖)的智能体**并行**跑,墙钟时间从「串行求和」降到「并行取最慢」。
|
||||
3. **并行禁止写文件**:并行的智能体**禁用文件写入 / 沙箱命令**,避免它们同时打开沙箱互相打架。产物以消息正文交付;落文件留给串行 / 收口阶段。
|
||||
4. **并行高性能显示**:并行时收起左侧、顶部头像条切换、主区同时显示 2 个智能体的消息流,其余**只接流不渲染**,避免页面卡死。
|
||||
|
||||
### 0.3 设计总原则
|
||||
|
||||
- **复用** 现有 special run / leader run / rollback 抢占 / 轻量交付状态接口(`GET /threads/{id}/last-ai-message`),不另起炉灶。
|
||||
- **线性链是 DAG 的特例**:`chain` 模式可平滑迁移成"每层 1 个节点"的 DAG,老数据不破坏。
|
||||
- **分层(Stage)优先于自由连线**:编辑交互首选「分层」模型(每个 stage 放若干并行席位,stage 间串行),实现成本低、覆盖 95% 的"串 / 并"诉求;自由连线画布作为后续可选增强(见 §2.3)。
|
||||
|
||||
### 0.4 已确认的设计决策(✅ 本期按这些实现)
|
||||
|
||||
> 以下三项已与需求方确认锁定,实现时直接按此,不再二选一。
|
||||
|
||||
1. **✅ 编排模型 = 分层 Stage**:本期只做分层(每个 stage 放若干并行席位、stage 间串行)。**自由连线画布本期不做**,仅在数据模型层预留(§1.2)以免将来返工。
|
||||
2. **✅ 并行跳过 leader、前端直派**:DAG 由用户显式指定,执行时前端**直接**对 stage 内席位发起 special run,**不**每个 stage 再跑一轮总控派活;只在最后 `finalSynthesis` 跑一轮 leader 综合(即 §3.2 的方案 B)。
|
||||
3. **✅ 主区固定 2 列,窄屏降 1 列**:并行时主区最多并排显示 2 个被选中智能体的消息流;窄屏(断点见 §5.3)降为 1 列 + 头像条横向滚动。不做 3+ 列。
|
||||
|
||||
---
|
||||
|
||||
## 1. 数据模型
|
||||
|
||||
### 1.1 DAG 的「分层」表示(推荐,主模型)
|
||||
|
||||
把 DAG 表示成**有序的 stage 列表**,每个 stage 内的席位并行、stage 之间串行:
|
||||
|
||||
```ts
|
||||
// lib/dag-types.ts (新建)
|
||||
|
||||
/** 一个并行批次:stage 内所有席位并行执行。 */
|
||||
export interface OrchestrationStage {
|
||||
/** stage 稳定 id(拖拽重排 / diff 用)。 */
|
||||
id: string;
|
||||
/** 本 stage 并行执行的席位(agent_id 列表)。1 个 = 退化为串行节点。 */
|
||||
agentIds: string[];
|
||||
}
|
||||
|
||||
/** 用户编排出的执行计划。stages 顺序即串行顺序。 */
|
||||
export interface OrchestrationPlan {
|
||||
/** 编排模式标识,持久化进 step2。 */
|
||||
mode: "dag";
|
||||
/** 串行执行的批次;每批内并行。 */
|
||||
stages: OrchestrationStage[];
|
||||
/** 收口:是否在所有 stage 完成后由总控综合(默认 true)。 */
|
||||
finalSynthesis: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
- **串行** `A → B → C` = `stages: [{agentIds:[A]}, {agentIds:[B]}, {agentIds:[C]}]`。
|
||||
- **并行** `A B C` = `stages: [{agentIds:[A,B,C]}]`。
|
||||
- **混合** `A →(B C)→ D` = `stages: [{[A]}, {[B,C]}, {[D]}]`。
|
||||
|
||||
> 为什么用分层而不是「邻接表 / 边」:执行调度本就是「拓扑分批」,分层就是分批的结果,前端不必再做拓扑排序,也天然避免环;编辑器交互更直观(拖卡片进 stage)。真正的自由连线 DAG 见 §2.3(可选)。
|
||||
|
||||
### 1.2 通用 DAG 表示(§2.3 自由连线时才需要)
|
||||
|
||||
若将来做自由连线画布,再引入边表示,并在执行前做一次**拓扑排序 → 分层**,落回 §1.1 的 stages 结构给调度器用:
|
||||
|
||||
```ts
|
||||
export interface DagEdge { from: string; to: string } // from 完成后才能跑 to
|
||||
export interface DagGraph { nodes: string[]; edges: DagEdge[] }
|
||||
// 执行前:toposortToStages(graph) -> OrchestrationStage[]
|
||||
```
|
||||
|
||||
### 1.3 持久化
|
||||
|
||||
- 复用 `roundtable_drafts.step2` JSON(见后端 `app.gateway.routers` roundtable-drafts + `hooks/useStep2Orchestration.ts` 里对 `step2` 的读写)。
|
||||
- 在 `step2` 增加字段:`orchestrationPlan: OrchestrationPlan | null`。
|
||||
- `orchestrationMode` 扩展为 `"free" | "chain" | "dag"`;`chain` 旧数据读取时即时转换为单节点-stage 的 `dag`(兼容层,不改老 draft)。
|
||||
|
||||
### 1.4 校验(纯函数,可单测)
|
||||
|
||||
`lib/dag-types.ts` 内提供:
|
||||
|
||||
```ts
|
||||
validatePlan(plan: OrchestrationPlan, selectedAgentIds: string[]): {
|
||||
ok: boolean;
|
||||
errors: string[]; // 面向用户的中文提示
|
||||
}
|
||||
```
|
||||
|
||||
校验项:
|
||||
- 每个 `agentId` 必须在本次选定席位 `selectedAgents` 内(防止编排了不存在的席位 → 复用后端 `_partition_dispatch` 同思路的"必须在 roster 内")。
|
||||
- 不允许空 stage;不允许同一席位出现在多个 stage(一个席位只跑一次)。
|
||||
- 至少 1 个 stage。
|
||||
- (可选)单 stage 并行度上限提示(如 > 6 并行给 warning:远程模型并发 + 沙箱压力)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 编排编辑器(流程图构建)交互
|
||||
|
||||
### 2.1 入口与整体布局
|
||||
|
||||
- 在 Step 2 顶部模式切换里新增 **「依赖编排」**(`dag`)模式(与现有 自由 / 链条 并列)。
|
||||
- 选择 `dag` 后弹出 / 进入**编排编辑器**(复用 Dialog 大弹窗,参考 `DispatchChainModal` 的尺寸 `w-[94vw] max-w-[1240px]`)。
|
||||
|
||||
### 2.2 推荐交互:分层(Stage)编辑器(Phase 2 实现)
|
||||
|
||||
```
|
||||
┌ 依赖编排 ───────────────────────────────────────── [校验✓] [保存] ┐
|
||||
│ 可选席位(点/拖加入某个 stage) │
|
||||
│ [情报收集] [环境评估] [方案设计] [风险审查] [执行规划] [前端实现] │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ Stage 1(并行) Stage 2(并行) Stage 3 │
|
||||
│ ┌──────────┐ ─────▶ ┌──────────┐ ┌────────┐ ─▶ ┌──────────┐ │
|
||||
│ │ 情报收集 │ │ 方案设计 │ │风险审查│ │ 执行规划 │ │
|
||||
│ └──────────┘ └──────────┘ └────────┘ └──────────┘ │
|
||||
│ [+ 拖席位到此] [+] [+] │
|
||||
│ [+ 新建 Stage(串在最后)] │
|
||||
│ ─────────────────────────────────────────────────────────────── │
|
||||
│ 说明:同一 Stage 内的席位会**并行**执行;Stage 之间**串行** │
|
||||
│ (后一个 Stage 能看到前面所有 Stage 的交付摘要)。 │
|
||||
└───────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
交互要点:
|
||||
- **加入 stage**:从顶部可选席位**拖拽**到某个 stage,或点席位 →「加入 Stage N」。
|
||||
- **移动 / 重排**:席位可在 stage 间拖动;stage 可整体左右拖动重排(改串行顺序)。
|
||||
- **删除**:席位 / 空 stage 可删。
|
||||
- **并行度提示**:stage 内 ≥2 席位即标「并行」徽标;过多给 warning。
|
||||
- **实时校验**:`validatePlan` 实时跑,错误在顶部红条提示,「保存」在校验通过前禁用。
|
||||
- **预览流程图**:编辑区下方实时渲染只读流程图缩略(复用 §7 的 DAG 流程图组件)。
|
||||
|
||||
> 拖拽可用现成轻量方案(HTML5 DnD 或 `@dnd-kit`,看项目是否已有依赖;没有就先用「点选 + 加入/移出按钮」的无拖拽版,Phase 2 先上无拖拽、Phase 2.5 再加拖拽)。
|
||||
|
||||
### 2.3 可选增强:自由连线画布(后续,非本期必须)
|
||||
|
||||
- 节点自由摆放 + 拉线建边 → `DagGraph`(§1.2)。
|
||||
- 实现成本高(画布 / 连线 / 命中检测 / 自动布局),且分层模型已覆盖绝大多数诉求。**本期不做**,仅在数据模型上预留 §1.2,避免将来返工。
|
||||
|
||||
### 2.4 验收(Phase 2)
|
||||
|
||||
- 能拖 / 点把席位分配到多个 stage,保存进 `step2.orchestrationPlan`。
|
||||
- 刷新 / 重进 draft 能恢复编排。
|
||||
- 非法编排(空 stage / 席位重复 / 席位不在 roster)有明确中文提示且无法保存。
|
||||
|
||||
---
|
||||
|
||||
## 3. 执行调度(前端编排循环 + 后端复用)
|
||||
|
||||
### 3.1 新增 `runDagOrchestration`(`useStep2Orchestration.ts`)
|
||||
|
||||
与现有 `runOrchestration` / `runChainOrchestration` 并列,逻辑:
|
||||
|
||||
```
|
||||
for (stageIndex, stage of plan.stages):
|
||||
if isStale() break // 复用现有 epoch 抢占(人工干预/暂停)
|
||||
// 1) 总控按本 stage 的席位并行派活(一轮 leader,dispatched = stage.agentIds)
|
||||
// —— 或跳过 leader,前端直接对 stage 内每个席位发起 special run(见 3.2)
|
||||
// 2) 并行跑 stage 内所有席位
|
||||
await Promise.all(stage.agentIds.map(seat =>
|
||||
streamMultiAgent({ agentType:"special", agentName:seat,
|
||||
newMessage: buildStageTask(seat, plan, stageIndex),
|
||||
parallelNoFile: stage.agentIds.length > 1, // §4
|
||||
... })))
|
||||
// 3) 等本 stage 全部 ✅(Promise.all 天然 barrier)再进入下一 stage
|
||||
// 4) finalSynthesis:最后一轮 leader(synthesisMode=true)综合所有 stage 交付
|
||||
```
|
||||
|
||||
要点:
|
||||
- **批内并行**:`Promise.all`(不是现在的 `for await` 串行)。
|
||||
- **批间串行**:`Promise.all` 即 barrier,自动等齐。
|
||||
- **依赖可见性**:进入 stage N 的席位时,把**前面所有 stage 已交付席位的摘要**拼进它的 `newMessage`(复用 §交付状态:网关 `_collect_delivery_status` / `GET last-ai-message?max_chars=N`,前端也可在 stage 边界拉一次摘要拼进 task)。这样 `(B C)` 能看到 `A` 的产出。
|
||||
- **抢占 / 干预 / 暂停**:每个 stage 边界检查 `isStale()`(epoch),与现有自由 / 链条模式一致。
|
||||
- **rollback**:special run 已统一 `multitask_strategy=rollback`,并行多席位各自不同 thread,互不冲突。
|
||||
|
||||
### 3.2 是否每 stage 都走 leader? — ✅ 已定:前端直派(方案 B)
|
||||
|
||||
**本期采用方案 B**:DAG 由用户**显式**指定,不需要 leader 再"决定派给谁"。前端**直接**对 stage 内每个席位发起 special run,省掉每 stage 的 leader LLM 往返(更快、更可控);只在所有 stage 完成后、`finalSynthesis` 为真时跑**一轮** leader(`synthesisMode=true`)综合所有交付。
|
||||
|
||||
> (备选方案 A:每 stage 跑一轮 leader 派活——更贴现有架构但每 stage 多一次 leader LLM、更慢。**本期不采用**,留档备查。)
|
||||
|
||||
### 3.3 后端改动
|
||||
|
||||
- **基本不需要新接口**:special run(`POST /api/multi-agent/run/stream` agent_type=special)、leader run、轻量交付读(`GET /threads/{id}/last-ai-message`)、轻量追加(`POST /threads/{id}/messages/append`)都已就绪。
|
||||
- 仅需 §4 的「并行禁写文件」run policy 开关。
|
||||
|
||||
### 3.4 验收(Phase 3)
|
||||
|
||||
- `(B C)` 两席位**同时**出现在执行(后端日志两条 special run 时间重叠;前端两个流并行增长)。
|
||||
- stage 间严格串行(stage 2 不早于 stage 1 全部完成)。
|
||||
- stage N 席位的输入里能看到 stage <N 的交付摘要。
|
||||
|
||||
---
|
||||
|
||||
## 4. 并行时禁止写文件(防沙箱打架)
|
||||
|
||||
### 4.1 后端 run policy
|
||||
|
||||
`app/gateway/roundtable_run_policy.py` 现有角色:`leader` / `seat` / `report`。新增一个角色或开关:
|
||||
|
||||
- 方案:给 `seat` 增加一个「并行变体」`seat_parallel`,其 `excluded_tools` 在 `seat` 基础上**再禁** `write_file` / `bash` / `str_replace` / `present_files`(与 `report` 角色禁写的思路一致)。
|
||||
- `_special_run`(`multi_agent.py`)按请求里的一个新 flag(如 `no_file: true`)选择 `seat_parallel` policy。
|
||||
|
||||
### 4.2 前端传参
|
||||
|
||||
- `streamMultiAgent` 请求体新增 `parallel_no_file?: boolean`(或复用 `no_file`)。
|
||||
- `runDagOrchestration` 在 `stage.agentIds.length > 1`(真并行)时置 `true`;单节点 stage(实际串行)不限制,可正常写文件。
|
||||
|
||||
### 4.3 产物归属
|
||||
|
||||
- 并行席位的产物 = **消息正文**(被 `_collect_delivery_status` / 综合块读取)。
|
||||
- 需要落文件 / 画图的(如 Step3 报告、`roundtable-report`)放在**串行的收口阶段**单独跑,不在并行批次里。
|
||||
|
||||
### 4.4 验收(Phase 4)
|
||||
|
||||
- 并行 stage 的席位 run 日志显示 `Excluded ... ['write_file','bash','str_replace','present_files', ...]`。
|
||||
- 并行期间不再出现多个席位抢同一沙箱 / 写文件冲突。
|
||||
|
||||
---
|
||||
|
||||
## 5. 并行执行的前端显示
|
||||
|
||||
### 5.1 布局总览(并行进行时)
|
||||
|
||||
```
|
||||
┌ 多智能体圆桌会商中心 ─────────────────────────────────────────────┐
|
||||
│ [头像条:进行中的并行智能体] [模型选择 ▾] [暂停会商] │
|
||||
│ (情报)● (方案)● (风险)● (执行) … │
|
||||
│ ↑选中 ↑选中 loading hidden │
|
||||
│ ───────────────────────────────────────────────────────────────── │
|
||||
│ ┌─ 方案设计(选中)──────────┐ ┌─ 风险审查(选中)──────────┐ │
|
||||
│ │ 一、整体架构…(流式渲染) │ │ 1. 合规风险 IDFA/OAID… │ │
|
||||
│ │ … │ │ … │ │
|
||||
│ └────────────────────────────┘ └────────────────────────────┘ │
|
||||
└───────────────────────────────────────────────────────────────────┘
|
||||
(左侧"参与角色"栏在并行时整体收起,腾出横向空间放两列消息)
|
||||
```
|
||||
|
||||
### 5.2 关键交互(与需求逐条对齐)
|
||||
|
||||
1. **收起左侧**:并行执行时把左侧「参与角色 / 人工参与」栏整体折叠(一个 `isParallelRunning` 状态控制;非并行恢复)。
|
||||
2. **头像条**:位置在**消息列表上方、模型选择器左侧**。展示**当前并行批次的所有进行中智能体头像**:
|
||||
- **进行中** → 头像加 loading 动画(复用 `DispatchChainModal` 的 `animate-ping` 光环 + `Loader2Icon animate-spin` 角标)。
|
||||
- **已完成** → 打勾(复用现有 `CheckIcon` 角标)。
|
||||
- **选中** → 高亮描边(当前正在主区显示的那 2 个)。
|
||||
3. **主区同时显示 2 个**:被选中的 2 个智能体的消息列表**并排两列**渲染。
|
||||
4. **多于 2 个先隐藏**:未选中的智能体**不渲染 DOM**(见 §6 性能)。
|
||||
5. **点头像切换**:点击未选中的头像 → 把它设为选中之一(替换当前 2 个里的一个,或按"最近点击保留 2 个"的 LRU 策略);被换下的智能体**停止渲染但继续接流**。
|
||||
|
||||
### 5.3 选中策略 — ✅ 固定 2 列,窄屏降 1 列
|
||||
|
||||
- 维护 `selectedParallelSeats: string[]`,**最多 2**(固定 2 列)。**不做 3+ 列**。
|
||||
- **响应式**:宽屏 2 列并排;窄屏(建议断点 `< 1024px` / `lg`)降为**1 列**,头像条横向滚动切换;此时 `selectedParallelSeats` 仍可存 2 个,但主区只渲染当前 1 个(点头像切换哪个在前)。
|
||||
- 默认选中本批次**最先开始**的(宽屏 2 个 / 窄屏 1 个)。
|
||||
- 点头像:若已选中则取消;未选中则加入(超过上限时按 LRU 踢掉最早选中的)。被换下的席位**停止渲染但继续接流**(§6)。
|
||||
|
||||
### 5.4 验收(Phase 5)
|
||||
|
||||
- 并行时左侧收起、出现头像条、主区两列。
|
||||
- 进行中头像有 loading;完成打勾;选中有高亮。
|
||||
- 点头像能切换主区显示的智能体,且切换是**即时**的(数据已在内存,见 §6)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 流式高性能策略(避免页面卡死)
|
||||
|
||||
> 核心矛盾:N 个并行席位同时高频 `onTextDelta`,若都 setState + Markdown 渲染会卡死。
|
||||
|
||||
### 6.1 全接收、惰性渲染
|
||||
|
||||
- **所有**并行 run 的流都正常订阅(不丢数据):每个席位的增量文本**累积进 store / ref**(`Map<agentId, { text, status, charCount }>`)。
|
||||
- **只有被选中(主区显示)的席位**才把累积文本挂到 DOM 渲染(Markdown)。
|
||||
- **未选中**席位:只更新内存 + 头像条上的轻量进度(字数 / loading),**不挂载消息 DOM**。
|
||||
- 切换选中时:把该席位已累积的全文一次性渲染 + 继续接增量。
|
||||
|
||||
### 6.2 渲染节流
|
||||
|
||||
- 选中席位的流式渲染**节流/合帧**:用 `requestAnimationFrame` 或 ~50–100ms 批量 flush 累积的 token,而不是每个 token 一次 setState。
|
||||
- Markdown 渲染对"流式中"可降级(纯文本 / 轻量渲染),**完成后**再做完整 Markdown(现有 `MessageBubble` 的 `streaming` 标志可复用:streaming 时简渲、done 后全渲)。
|
||||
|
||||
### 6.3 头像条进度
|
||||
|
||||
- 头像条只显示**廉价**信息:loading 动画 + 可选字数(`charCount`,从内存 Map 取,节流更新,例如每 500ms 刷一次)。不触发消息体渲染。
|
||||
|
||||
### 6.4 数据流(建议结构)
|
||||
|
||||
```
|
||||
streamMultiAgent(seat).onTextDelta(text) →
|
||||
parallelStore.append(seatId, text) // 纯内存累积,O(1)
|
||||
if seatId ∈ selectedParallelSeats: // 仅选中的进 React state(节流)
|
||||
scheduleFlush(seatId) // rAF / 节流 setState
|
||||
else:
|
||||
bumpCharCount(seatId) // 仅更新头像条进度(节流)
|
||||
```
|
||||
|
||||
### 6.5 验收(Phase 6)
|
||||
|
||||
- 6+ 席位并行时页面不卡(输入 / 滚动 / 切换流畅)。
|
||||
- 切到一个之前隐藏的席位,能立刻看到它**到目前为止的全部**输出(证明流没丢、只是没渲染)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 流程图执行态可视化(DAG 版)
|
||||
|
||||
- 把现有 `DispatchChainModal.tsx`(横向**线性**流程图)扩展 / 复刻成 **DAG 流程图**:
|
||||
- 按 stage 分**列**,stage 内席位**纵向并列**,stage 间用连接线。
|
||||
- 节点状态复用 `ChainNodeState`(pending / active / done)+ 现有动画(`animate-ping` / `Loader2Icon` / `CheckIcon`)。
|
||||
- 末尾接 `共识 → 结果绘制` 里程碑(沿用现有)。
|
||||
- 用途:编辑器里的只读预览(§2.2 底部)+ 执行时的总览(可选小窗 / 顶部缩略)。
|
||||
|
||||
```
|
||||
总控 ─▶ ┌ Stage1 ┐ ─▶ ┌ Stage2 ┐ ─▶ ┌ Stage3 ┐ ─▶ 🤝共识 ─▶ 📊报告
|
||||
│ 情报● │ │ 方案✓ │ │ 执行○ │
|
||||
└────────┘ │ 风险● │ └────────┘
|
||||
└────────┘
|
||||
```
|
||||
|
||||
### 7.1 验收(Phase 7)
|
||||
|
||||
- 编辑器底部 / 执行时能看到 DAG 流程图,节点状态随执行实时更新(active/done)。
|
||||
|
||||
### 7.2 实施结论(2026-06-07,✅ 已完成)
|
||||
|
||||
- **新组件** `components/DagFlowChart.tsx`:纯展示组件,复刻 `DispatchChainModal` 的节点 / 连线 / 里程碑视觉(`animate-ping` 光环 + `Loader2Icon` 转圈 + `CheckIcon` 打勾 + `rt-chain-connector` 连线动画),但按 **stage 分列**:总控 ─▶ ┌Stage1┐ ─▶ ┌Stage2┐ ─▶ 🤝共识 ─▶ 📊结果绘制,stage 内席位**纵向并列**。`compact` 紧凑模式给缩略图用。所有状态由 props 传入(`stages` / `resolveSeat` / `seatStates` / `coordinatorState` / `consensusState` / `reportState`);不传 `seatStates` → 全 pending(只读预览)。`aggregateStageState` 把 stage 内席位聚合成列状态(任一 active→active、全 done→done)驱动 stage 间连线。
|
||||
- **编辑器只读预览**(§2.2 底部):`BusinessChainEditorPage` 底部加「编排预览」section,把 `stageGroups`(去空 stage) 映射成 `DagFlowChart` 的 stages + `resolvePreviewSeat`(席位名/配色),`compact`、全 pending、随编辑实时变。
|
||||
- **执行态总览**(Step 2 顶部缩略):`useStep2Orchestration` 新增 `dagNodeStates: Record<agentId, ChainNodeState>`,`runDagOrchestration` 跨 stage 维护(进入 stage→该批 active、席位终态→done、`resetStep2State` 清空);经 `RoundtablePlanningPage` 把 `orchestrationMode/orchestrationPlan/dagNodeStates` 传到 `Step2Panel`,dag 模式下会话区顶部 `compact` 渲染 DagFlowChart。总控/共识里程碑状态由 `Step2Panel` 从节点状态 + `isOrchestrating`/`hasConsensus` 派生(中转轮时无席位 active + isOrchestrating → 总控 active)。
|
||||
- 改动文件:`components/DagFlowChart.tsx`(新)、`pages/BusinessChainEditorPage.tsx`、`hooks/useStep2Orchestration.ts`、`components/Step2Panel.tsx`、`pages/RoundtablePlanningPage.tsx`。typecheck 零新增(基线 65)。
|
||||
- **下次实地验收**:①编辑器底部随分层编辑实时画出流程图;②Step2 dag 模式顶部出现缩略流程图,跑起来后当前 stage 列点亮(active 转圈)、完成打勾、中转时总控亮、最后共识亮。
|
||||
|
||||
---
|
||||
|
||||
## 8. 分步实现计划(每步可独立交付 + 验收)
|
||||
|
||||
| Phase | 内容 | 主要文件 | 依赖 | 可独立验收 |
|
||||
|---|---|---|---|---|
|
||||
| **1** | DAG 数据模型 + 校验 + 持久化字段 | `lib/dag-types.ts`(新)、draft step2 读写 | — | 纯函数单测:`validatePlan` / `chain→dag` 兼容转换 |
|
||||
| **2** | 分层编排编辑器(先无拖拽:点选加入/移出) | `components/DagPlanEditor.tsx`(新)、Step2 模式切换 | P1 | 能编排并保存/恢复,非法编排有提示 |
|
||||
| **2.5** | 编辑器拖拽 | 同上 + `@dnd-kit`(若引入) | P2 | 拖拽分配/重排 |
|
||||
| **3** | `runDagOrchestration`:批内并行 + 批间串行 + 依赖摘要注入 | `hooks/useStep2Orchestration.ts`、`api/multi-agent.ts` | P1 | 后端日志并行 special run 时间重叠;stage 严格串行 |
|
||||
| **4** | 并行禁写文件 run policy | `roundtable_run_policy.py`、`multi_agent.py`、`api/multi-agent.ts` | P3 | 并行 run 的 excluded_tools 含 write_file/bash/... |
|
||||
| **5** | 并行显示骨架:收起左侧 + 头像条 + 两列 + 选中切换 | `components/Step2Panel.tsx`、`components/ParallelSeatStrip.tsx`(新) | P3 | 见 §5.4 |
|
||||
| **6** | 流式高性能:全接收 / 惰性渲染 / 节流 | `hooks/useStep2Orchestration.ts`(parallelStore)、`Step2Panel` | P5 | 见 §6.5(6+ 并行不卡) |
|
||||
| **7** | DAG 流程图执行态可视化 | `components/DagFlowChart.tsx`(复刻自 `DispatchChainModal`) | P2、P3 | 见 §7.1 |
|
||||
| **8** | 边界 / 异常 | 各处 | 全部 | 见 §9 |
|
||||
|
||||
> 落地顺序建议:**P1 → P3 → P4 → P5 → P6**(先把"能并行跑 + 能看 + 不卡"打通),**P2 编辑器**可与 P3 并行开发(先用一份 hardcode/简单 UI 的 plan 驱动 P3 联调),**P7 / P2.5 / 2.3** 作为增强后置。
|
||||
|
||||
---
|
||||
|
||||
## 9. 边界与异常(Phase 8)
|
||||
|
||||
- **某并行节点失败 / 超时**:单个席位失败不拖垮整 stage;标该节点 error,stage 其余 ✅ 仍推进;收口时把"X 失败"如实告知总控(复用交付状态 ⬜ + 失败标记)。
|
||||
- **依赖未满足**:调度器保证 stage 串行,理论不会发生;防御性校验 stage N 启动前 stage <N 必须全部终态(done/failed)。
|
||||
- **人工干预 / 暂停 / 恢复**:在 stage 边界检查 `isStale()`(epoch);干预指令插入后,从"下一个未开始的 stage"继续(已完成 stage 不重跑)。
|
||||
- **并行禁写文件 vs 席位本想写文件**:在席位 SOUL / 派活提示里说明"本轮为并行研讨,产出请直接写在回复正文,不要写文件",降低模型困惑。
|
||||
- **窄屏**:主区两列降为一列 + 头像条横向滚动。
|
||||
- **单 stage 全并行(一次性 N 个)**:给并行度 warning(远程模型并发限流 / 成本)。
|
||||
|
||||
---
|
||||
|
||||
## 10. 与现有代码的衔接点速查
|
||||
|
||||
- 编排循环:`hooks/useStep2Orchestration.ts`(`runOrchestration` / `runChainOrchestration` 旁新增 `runDagOrchestration`;复用 `isStale()` epoch、`abortAllInFlight`、`appendStreamingDialogue` / `appendToDialogue`)。
|
||||
- 流式 API:`api/multi-agent.ts`(`streamMultiAgent`,新增 `parallel_no_file` 入参;`onTextDelta(text, messageId)` 已是按席位回调,天然支持并行分流)。
|
||||
- 显示:`components/Step2Panel.tsx`(消息列表 `step2RoundtableDialogues.map`;新增头像条 `ParallelSeatStrip` + 两列容器 + 折叠左侧)。
|
||||
- 流程图:`components/DispatchChainModal.tsx` / `lib/job-types.ts` / `lib/business-chain.ts`(复刻成 DAG 版)。
|
||||
- 常量 / 元数据:`lib/roundtable-constants.ts`(`BUILTIN_AGENT_META` 头像/中文名、`buildAgentNameToRole`)。
|
||||
- 后端 run policy:`app/gateway/roundtable_run_policy.py`(新增 `seat_parallel`)、`app/gateway/routers/multi_agent.py`(`_special_run` 读 `no_file` flag)。
|
||||
- 交付摘要:`GET /api/threads/{id}/last-ai-message?max_chars=N`(依赖注入 stage N 输入用)。
|
||||
|
||||
---
|
||||
|
||||
## 11. 一句话总结
|
||||
|
||||
把「线性链条」升级为「分层 DAG」:**编辑器**用分层 stage(拖卡片)表达串/并;**调度**用 `Promise.all` 批内并行、批间 barrier 串行,依赖摘要在 stage 边界注入;**并行禁写文件**避免沙箱打架;**显示**收起左侧、头像条切换、主区两列、未选中只接流不渲染(rAF 节流)保证不卡。全部建立在现有 special/leader run + 轻量交付接口之上,后端几乎零新接口。
|
||||
|
||||
---
|
||||
|
||||
## 12. 已知问题与排查记录(2026-06-06)
|
||||
|
||||
### 12.1 ❗并行席位「步骤条」不显示 —— 根因:`seat_parallel` 工具面禁得过多
|
||||
|
||||
**现象**:并行两列里的席位气泡看不到步骤条(`MessageStepsCard`),而串行席位有。
|
||||
|
||||
**排查结论(已定位,待修)**:
|
||||
|
||||
1. **步骤条只渲染「工具调用」步骤**。`MessageStepsCard` 的 `isToolStep` 只认 `tool_calling:<toolName>` 的步骤进折叠卡;纯阶段步骤(`connecting:` / `streaming:` 这类 reasoning)只渲染成**底部一行小灯泡**,不出大卡(见 `MessageStepsCard.tsx` L80-100)。
|
||||
2. **并行席位几乎调不动任何会产生步骤的工具**。后端 `app/gateway/roundtable_run_policy.py`:
|
||||
- `_SEAT_EXCLUDED = [web_search, ask_clarification, present_files, view_image, agent_orchestration]` —— **注意 `web_search` 对所有席位(串行也)就禁了**(注释「圆桌研讨里联网搜索一律禁用」)。
|
||||
- `_SEAT_PARALLEL_EXCLUDED = _SEAT_EXCLUDED + [write_file, bash, str_replace]`。
|
||||
- 于是并行席位可用工具几乎只剩 `read_file / ls / task / write_todos / memory / hindsight_*`,研究/分析类席位基本一个都不会调 → **没有任何 `tool_calling` 步骤** → 步骤条(大卡)不出现。
|
||||
3. **对比串行为何「有步骤条」**:串行席位(`seat` policy)**能 `write_file`**,会产生 `tool_calling:write_file` 步骤 → 大卡出现。并行被禁了 write_file + 又没有 search,所以一个工具步都没有。
|
||||
|
||||
**用户诉求**:并行时**只禁真正会冲突的写操作**,搜索信息 / 调用技能等**必要操作不要禁**。
|
||||
|
||||
**关键事实(影响方案选择)**:圆桌每个席位是**独立 thread = 独立 sandbox**(后端 per-thread 隔离 `.deer-flow/users/{uid}/threads/{tid}/`)。所以并行席位写文件**在后端并不会互相冲突**;当初 §4「并行禁写文件」的「打架」其实主要是**前端单沙箱预览**(`activeArtifactThreadId` 只能同时显示一个)+ 产物归集到正文的简化考量,并非后端真冲突。→ 这意味着「并行禁写」的必要性比原设想弱,可放宽。
|
||||
|
||||
**✅ 决策:选 A(2026-06-06 已实施)**:把 `seat_parallel` 放宽到**只禁 `write_file / bash / str_replace`**(防前端单沙箱预览串台 + 产物走正文)+ 仍禁 `ask_clarification / present_files / view_image / agent_orchestration`(打断/越权类),但**放开 `web_search` 与技能**(搜索不碰 sandbox;技能靠 `read_file`)。改在 `app/gateway/roundtable_run_policy.py` 的 `_SEAT_PARALLEL_EXCLUDED`(**不再继承** seat 的 web_search 禁用,显式列举),测试 `tests/test_roundtable_run_policy.py` 同步更新(`test_seat_parallel_allows_web_search_and_skills`)。
|
||||
→ 并行席位现在能联网搜索/用技能 → 产生 `tool_calling` 步骤 → 步骤条显示。
|
||||
|
||||
> 备选 B(连写文件也放开,沙箱按 thread 隔离)/ C(重新评估「圆桌串行也一律禁 web_search」)**本期不做**,留档。串行席位目前仍禁搜索(`seat` policy 不变)。
|
||||
|
||||
### 12.2 其它已修(本轮反馈)
|
||||
|
||||
- 最左侧**全局工作区侧栏**并行时收起:改用与「打开沙箱」同一路径 `useSidebarSafe()?.setOpen(false)`(之前只收了圆桌「参与角色」栏 `step2SidebarExpanded`)。并行结束**不自动展开**(与沙箱收起不回弹一致)。
|
||||
- 并行两列改回**完整 Markdown**(撤销 §6.2 流式纯文本降级 `plainWhileStreaming`),与串行同款 `Step2Message`。
|
||||
- 头像条**不再显示字数**(去掉 charCount 徽标 + 字段)。
|
||||
- **致命 bug 已修**:用户干预 / 并行跑完后**不再重跑整张 DAG**。`dagPlanExecutedRef` 闸门 + DAG 各阶段跑完后释放锁 `await runOrchestration(handoff)` 交回总控继续派活/收口;后续干预/恢复一律走 leader 自由派活。
|
||||
- **并行两列宽表溢出已修(2026-06-07)**:模型输出的 Markdown **表格**(如「法律免责条款」表)没有宽度约束 / 横向滚动容器,在并行两列的窄列里会**超出左列、盖到相邻列**。修法:`MessageBubble.sharedMarkdownComponents.table`(+ `markdown-components.tsx`)把 `<table>` 套进 `<div class="max-w-full overflow-x-auto">`——表格保持自然宽度、超出时**列内横向滚动**而非撑破列宽。`min-w-0` 链(grid `minmax(0,1fr)` → 列 `min-w-0` → 气泡 `flex-1 min-w-0`)本就齐全,只差 table 这一层。
|
||||
- **关于「并行席位无步骤条」**:步骤条(`MessageStepsCard` 大卡)只在席位产生 `tool_calling` 步骤时出现(§12.1)。纯分析型席位若本轮**直接写分析正文、没调用任何工具**(web_search 等),就只显示一行「正在输出」思考指示、不出大卡——这是设计使然,非解析失败。要确认是否真调了工具:浏览器 devtools 设 `window.__MULTI_AGENT_DEBUG__=true` 看 `frame# event=messages` 里有无 tool_calls,或查后端 special run 日志。
|
||||
|
||||
### 12.3 ✅需求变更:Stage 间「经总控中转 + 广播」(2026-06-06 提出,2026-06-07 已实施)
|
||||
|
||||
**当前实现(方案 B,§3.2)**:DAG 各 stage 由前端**直接**派活,stage 间不跑 leader,靠前端把 `buildDependencySummary` 拼进下一 stage 的 task(`priorSummary`)。各 stage 跑完后才 `await runOrchestration` 交回总控收口。
|
||||
|
||||
**新诉求**:业务链条调用时,**每一轮 stage 之间都要经总控中转 + 广播**,而不是前端直接串下去。具体:
|
||||
|
||||
1. 第 1 轮并行调用 stage1 的 A、B、C;
|
||||
2. A/B/C **全部完成后**,把这一批的**输出广播出来**(广播给其它席位 + 总控 thread);
|
||||
3. **总控(leader)接收**这批交付 → 再**往下继续派活**(派 stage2 的 D、E);
|
||||
4. 派活时把(承上的)消息**广播给下一轮的 D、E**;
|
||||
5. 以此类推,直到所有 stage 跑完再收口。
|
||||
|
||||
> 即把 §3.2 的「方案 B 前端直派」改成 **「方案 A' = 计划约束下的总控中转」**:stage 边界插入一轮 leader,由它综合上一批交付并驱动下一批(下一批席位仍由编排计划**钉死**为 D、E,不是 leader 自由乱派——leader 负责「承上启下 + 广播上下文」,计划负责「派给谁」)。
|
||||
|
||||
**实施要点(下次会话)**:
|
||||
|
||||
- **每个 stage 完成 → 广播该批输出**:后端 `_special_run` 已对每个 seat 做 detached 后广播(「子智能体X完成…交付内容为:…」→ 其它席位 + 总控)。但它是 **detached/best-effort + 截断**;要让总控**可靠**地在下一轮前看到,需在 stage barrier 处**等广播落地**(或前端在 stage 边界用 `GET /threads/{id}/last-ai-message` 主动收齐各席位交付再喂给 leader)。
|
||||
- **插入 leader 中转轮**:`runDagOrchestration` 在进入下一 stage 前,跑一轮 leader(`synthesisMode=true` 让后端注入上一批**完整**交付),让总控产出一段「承上启下」说明;把这段说明(而非机械 `priorSummary`)作为下一 stage 席位 task 的上下文 / 或广播给下一批席位 thread(复用 `POST /threads/{id}/messages/append`)。
|
||||
- **下一批仍由计划钉死**:leader 中转轮**不**自由决定派给谁(计划已定 D、E);可参考 `buildChainLeaderTask` 的「约束 leader 只派给指定席位」思路,或干脆 leader 只做综合、前端照计划派 D、E 并把 leader 综合 + 广播注入其 task。
|
||||
- **与「并行禁写」「§12.1 放开搜索」并存**:中转/广播不影响工具面。
|
||||
- **与致命 bug 修复并存**:最后一个 stage 跑完仍走「交回总控(`runOrchestration`)收口/继续派活」;用户干预仍走 leader、不重跑 DAG。
|
||||
|
||||
**影响文件(预估)**:`hooks/useStep2Orchestration.ts`(`runDagOrchestration` 的 stage 循环里加 leader 中转 + 广播收齐)、可能复用后端 `_leader_run`(synthesisMode 注入全文)/ `/messages/append`(广播)/ `/last-ai-message`(收齐交付)。后端基本无需新接口。
|
||||
|
||||
**✅ 实施结论(2026-06-07)**:后端**零改动**,全部在前端 `runDagOrchestration` 内完成,复用既有「传摘要按需传全文」优化链路:
|
||||
|
||||
1. **中转轮位置**:每个**非最后**的 stage barrier 到达后(`Promise.all` 收齐本批交付),插入**一轮 leader**「中转轮」,展示为「总控协调(承上启下)」气泡。最后一个 stage 后**不**插中转,综合/收口仍交给末尾的 `runOrchestration` 接管(与致命 bug 修复一致)。
|
||||
2. **可靠收齐交付(传摘要)**:中转轮跑 `streamMultiAgent({agentType:"leader", synthesisMode:false})`。后端 `_leader_run` 本就在每轮 leader 前用 `_collect_delivery_status`(走**轻量 `GET /last-ai-message?max_chars=600` 接口**)**客观核对**各席位真实交付并把**摘要**注入总控输入(`_build_seat_status_block`)——比 detached 广播可靠(直接实读各席位 thread,不依赖 best-effort 广播是否落地)。故中转轮传 `synthesisMode:false`:只综合本批、**不收口**,只注入摘要;**完整交付正文**留到末尾收口的 synthesis 轮(`_build_full_deliverables_block`,全 ✅ 才注入)按需读取——即「**传摘要按需传全文**」。
|
||||
3. **承上启下驱动下一批**:中转轮产出的综合说明存进 `leaderBridge`,作为**下一 stage** 席位 task 的上下文(`buildStageTask({priorSummary: leaderBridge, priorIsBridge:true})`,措辞为「承上启下综合说明」而非机械「交付摘要」),替代原来的 `buildDependencySummary(deliveries)`。中转失败/无产出时回退机械依赖摘要,不阻断。
|
||||
4. **下一批由计划钉死**:中转轮提示(`buildDagTransitTask`)明确告知总控「下一阶段席位已由编排计划钉死为 X、Y,你**不要**自行 `agent_orchestration` 派活、**不要**收口」,前端仍照计划直派下一批;即便弱模型仍派了活,前端忽略其 `dispatched`、只取 `content` 当 bridge。
|
||||
5. **广播**:本批输出广播给其它席位 + 总控 = 后端 `_special_run` 既有 detached 后广播(截断摘要);承上消息给下一批 = 注入其 task `new_message`(席位 run 输入必见,比 `/messages/append` 更可靠,省一次写盘)。
|
||||
6. **澄清/抢占/失败**:中转轮触发澄清 → 暂停 DAG(回复后走 leader 自由派活、不重跑 DAG,沿用 `dagPlanExecutedRef` 闸门);被 `isStale()`/abort 抢占 → 静默收尾;异常 → 该气泡标错误、清空 bridge 继续。
|
||||
|
||||
**纯函数**:`lib/dag-types.ts` 新增 `buildDagTransitTask`(中转轮提示)+ `buildStageTask` 加 `priorIsBridge` 形参;`lib/dag-types.test.ts` 加 4 个单测(共 27 个全过)。
|
||||
|
||||
> ⚠️ **§12.3 的「单独中转轮 + 前端直派」已被 §12.4 取代**(2026-06-07 同日):见下。`buildDagTransitTask` / `buildStageTask` / `buildDependencySummary` 仍保留在 lib(带单测),但 `runDagOrchestration` 不再调用它们。
|
||||
|
||||
### 12.4 ✅ 需求再变更:DAG 改成「总控驱动每 stage 派活」(2026-06-07,§3.2 方案 A)
|
||||
|
||||
**新诉求(用户原话)**:把并行(DAG)逻辑改得和串行一样——串行时总控给多个智能体派活、它们完成后汇报给总控、总控再继续往下派活;并行也改成「由总控判断要执行哪些步骤,这些步骤完成后再汇报给总控,总控继续往下派活」。
|
||||
|
||||
**已确认的两个关键决策**:
|
||||
1. **每批「派给谁」**:✅ **计划钉死成员,但走总控派活**(不是总控自由乱派)。即 stage 成员仍由编排计划定死,但改成总控每轮**真正** `agent_orchestration` 派活给这些席位(约束只派给它们、且每个都派到)。= §3.2「方案 A」+ 批内并行。
|
||||
2. **适用范围**:✅ **只改 dag 模式**(recommend 仍串行逐个、chain 不动)。
|
||||
|
||||
**实现(后端零改动,全在 `runDagOrchestration`)**:每个 stage 两步——
|
||||
- **(a) 总控派活轮**:跑一轮 `leader`,提示 `buildDagStageLeaderTask`(首 stage 带任务背景;后续 stage 靠后端注入的此前交付摘要承上;列出本 stage 钉死席位的 `agent_name`,要求逐个派到、不派清单外、不收口)。`synthesisMode:false` → 后端 `_build_seat_status_block` 走轻量 `last-ai-message` 注入各席位**摘要**(传摘要按需传全文)。
|
||||
- **(b) 批内并行**:本 stage 席位并行跑总控派给各自的 task——`pickDagSeatTask(leaderRes.dispatched, content, agentId, name)` 命中其 `agent_name`;总控漏派/写偏则回退总控正文/模板,**不借用别席位 task**(多席位并行避免张冠李戴)。完成后交付经后端 `_special_run` detached 后广播回总控;下一 stage 的派活轮即可看到。
|
||||
- barrier 后进入下一 stage,总控再派下一批。全部 stage 跑完 → 交回 `runOrchestration` 收口(synthesisMode 按需注入全文)。
|
||||
|
||||
**相比 §12.3 的变化**:§12.3 是「前端直派 + stage 间单独插一轮只综合的中转轮」;§12.4 把**每个 stage 本身**就改成「总控派活轮 + 批内并行」——总控既承上(注入的摘要)又派活,不再需要单独的中转轮。与 chain 模式同构,区别是一次钉死一批、批内并行。
|
||||
|
||||
**纯函数**:`lib/dag-types.ts` 新增 `buildDagStageLeaderTask`(每 stage 派活提示)+ `pickDagSeatTask`(取某席位 task,多席位不张冠李戴);`lib/dag-types.test.ts` 加 5 个单测(共 32 个全过)。`runDagOrchestration` 不再用 `buildStageTask/buildDependencySummary/buildDagTransitTask`(保留在 lib)。
|
||||
|
||||
**Phase 7 流程图兼容**:总控派活轮无席位 active + `isOrchestrating` → 流程图「总控」节点显示 active(承上派活中),随后该 stage 列点亮,无缝衔接。
|
||||
|
||||
**下次实地验收**:①每个 stage 前出现「总控协调(派活中/承上派活中)」气泡,内容是总控对本 stage 席位的派活说明;②后端日志该轮 leader 的 `_partition_dispatch` 派给的正是本 stage 钉死席位;③这些席位并行跑(special 日志时间重叠);④完成后下一 stage 总控派活轮能看到上一批交付摘要(`[leader-roster]` 日志)。
|
||||
|
||||
### 12.5 ✅ 流程图弹窗化 + DAG「快速/沙箱」执行子模式(2026-06-07)
|
||||
|
||||
三个改动一起做:
|
||||
|
||||
**(1) 流程图改成弹窗**:Step2 顶部不再内联 `DagFlowChart`(挤占两列消息高度,截图 `样式优化-1.png`)。改成一条细栏「分层编排执行中 · 共 N 个阶段 + [查看编排流程图]」按钮,点开 `Dialog` 看**全尺寸**流程图(非 compact)。编辑器(`BusinessChainEditorPage`)底部预览保持内联不变。改 `Step2Panel.tsx`(加 `dagFlowOpen` state + Dialog)。
|
||||
|
||||
**(2) 推荐弹窗新增 DAG 执行子模式(快速/沙箱)**:`BusinessChainPicker` 在选中链条**有分层并行**(走 dag)时,「进入研讨」旁出现「执行方式:快速·双列禁写 / 沙箱·单列可写」二选一(只 dag 时出现,§已确认)。
|
||||
- **快速(`fast`,默认)**:同 stage 席位**并行**、主区**双列**、`parallelNoFile=true`(seat_parallel **禁写文件**)。= 之前的行为。
|
||||
- **沙箱(`sandbox`)**:同 stage 席位**仍并行**,但主区**单列**(`selectedParallelSeats` 最多 1)、`parallelNoFile=false`(seat policy **允许写文件**);点头像切换查看,沙箱跟随当前席位。
|
||||
- 数据流:`SeatSelectionResult.dagExecMode` → `RoundtablePlanningPage` state(持久化进 `DraftStep2(Run)Snapshot.dagExecMode`,mirror orchestrationPlan)→ `useStep2Orchestration` option → `runDagOrchestration`。
|
||||
|
||||
**(3) 沙箱跟随当前显示席位(防 bug 重点,用户特别强调)**:`useStep2Orchestration`
|
||||
- `sandboxFollowThreadIdRef` = 当前显示席位的 thread;`openSeatVirtualArtifact` 的 auto 守卫:sandbox 模式下,**后台并行席位**写文件时若不是当前显示席位,**不自动打开**(否则沙箱内容和主区列表错位)。
|
||||
- 切换显示席位的 effect(keyed on `selectedParallelSeats` + `dagExecMode`):该席位**有产物**(stub 里有 write_file/str_replace)→ `openSeatLatestArtifact` 强制打开它最新的文件;**无产物**→ 关闭沙箱面板(`setActiveArtifactThreadId(null)` + `deselectArtifact` + `setArtifactsOpen(false)`)。
|
||||
- 当前显示席位 live 写文件 → `handleArtifactToolPhase` auto-open,守卫放行(follow===该席位)。后台席位写 → 守卫拦掉、只缓存 stub;切到它时 effect 再打开。
|
||||
- `toggleParallelSeat`:sandbox 单列 = 点头像切换(始终保留 1 个,不取消到空);fast 双列 = 原 LRU 2 个。
|
||||
|
||||
**改动文件**:`lib/business-chain.ts`(SeatSelectionResult.dagExecMode)、`components/BusinessChainPicker.tsx`(子模式 UI)、`api/drafts.ts`(快照 dagExecMode)、`pages/RoundtablePlanningPage.tsx`(state/snapshot/hydrate/confirm/reset 全链)、`hooks/useStep2Orchestration.ts`(option + parallelNoFile/单列 + 沙箱跟随 + toggle cap)、`components/Step2Panel.tsx`(流程图弹窗 + 单列 grid)。typecheck 零新增(基线 65),dag-types 32 单测全过。
|
||||
|
||||
**下次实地验收(沙箱跟随是重点)**:①推荐弹窗选有并行的链条 → 出现「快速/沙箱」二选一;②快速=双列禁写(无沙箱);③沙箱=单列、席位能写文件、点头像切换列表时沙箱内容**同步**切换:有产物显示、无产物关闭;④后台席位写文件不会把沙箱抢到非当前列表;⑤流程图点按钮弹窗、全尺寸、节点随执行点亮。
|
||||
|
||||
### 12.6 ✅ 修:并行席位「跟着派活」(2026-06-07)
|
||||
|
||||
**现象**(截图 `bug-1.png`):总控派活后,并行席位的气泡里显示的是**总控的整段派活叙述**(「我来调度各智能体…第1阶段任务清单:席位1…席位2…」),还去调 `skill_list` 找 `agent_orchestration`——席位在**模仿派活**。
|
||||
|
||||
**根因**:总控这轮**没真正调用 `agent_orchestration` 工具**(弱模型 deepseek 用文字罗列了派活计划)→ 后端解析的 `dispatched=[]` → 前端 `pickDagSeatTask` 回退把**总控整段叙述**喂给**每个**席位 → 席位照抄叙述、模仿派活。(席位本就被 run policy 禁了 `agent_orchestration`,**派不了活**,只是空转一轮叙述 + 调 `skill_list`。)
|
||||
|
||||
**修 v1(护栏,效果不足)**:`pickDagSeatTask` 加角色边界护栏 + 回退措辞改「挑出属于你的那份」;`buildDagStageLeaderTask` 加「必须真正调用工具」。→ **没拦住**:总控对 deepseek 仍只写文字不调工具(`dispatched` 还是空),席位拿到的**任务正文本身**就是那段「我来调度…席位1/2/3」,护栏被淹没;3/4 席位仍模仿派活,有的甚至绕道 `bash`+`python requests.get('/api/agents')` 调内部接口派活(bug-1~4)。
|
||||
|
||||
**✅ 修 v2(根治:回退绝不注入派活叙述)**:`pickDagSeatTask` 去掉 `leaderContent` 入参——
|
||||
- **命中**总控结构化派活(真调了工具)→ `请你作为「X」,完成总控派给你的本阶段任务:<dispatched task>` + 护栏;
|
||||
- **未命中 / `dispatched` 空**(总控只写文字)→ **绝不**把总控派活叙述喂给席位,改给**干净本职任务**:「请你作为「X」,从你的专业职责出发,独立完成本阶段属于你的分析与交付。任务背景与此前各阶段交付已在你的会话上下文里(后端广播注入),据此承接」+ 护栏。护栏强化到「不要用 bash/python/脚本访问内部接口(/api/agents、agent_orchestration)派活、不要复述『我来调度』总控口吻」。
|
||||
- `buildDagStageLeaderTask` 的「必须真正调用工具」保留(让命中率更高、尽量走 exact-match)。
|
||||
- `dag-types.test.ts` 改 2 单测(共 33 过)。typecheck 65。
|
||||
|
||||
> ⚠️ **残留次要污染源(若仍复发再处理)**:DAG 每个 stage 的 leader 轮,后端 `_leader_run` 会把 leader 的输入(含「请你作为总控…派活」)**预广播**进所有席位线程(`总控智能体收到用户消息:…`)。席位的**当轮任务**已干净(修 v2),这条只是历史上下文,理论上被「最新的干净任务 + 护栏」压制。若实测仍模仿派活,下一步:后端给 DAG leader 轮加 `suppress_pre_broadcast` / 或广播一条**净化**过的背景(去掉派活措辞)。
|
||||
|
||||
### 12.7 ✅ 修 4 项(2026-06-07):席位仍派活(v3 后端)+ 沙箱关不掉 + 并行不自动下滑 + 流程图只到 Step2
|
||||
|
||||
实测 §12.6 仍有席位派活(视觉设计师 `skill_manage` 找 agent-orchestration、列 dispatch all 4)——**坐实**了「残留次要污染源」:后端 leader 预广播把派活 prompt 推进席位线程,弱模型从历史里捡到派活意图。一起修了 4 项:
|
||||
|
||||
1. **席位仍派活(根治·后端)**:`_leader_run` 加 `suppress_pre_broadcast` 参数;DAG「每 stage 派活轮」前端传 `suppressPreBroadcast: true`(`api/multi-agent.ts` → `suppress_pre_broadcast`,run/stream 读取)。为真时:**不**把总控派活 prompt 预广播给席位、**也不**把「总控给 X 派活」detached 广播给兄弟席位。recommend/chain 不传该 flag,行为不变。配套:`pickDagSeatTask` 加 `seedMessage` 入参——预广播抑制后席位线程没有用户原始消息了,把**任务背景**直接写进席位 task(此前各阶段交付仍由 special 完成后广播注入)。→ 席位线程里**任何地方都没有派活语句**,根治模仿。
|
||||
2. **沙箱关闭按钮关不掉**:沙箱跟随 effect 之前每次 render(deps 身份变)都重跑 → 把用户刚关的沙箱强制重开。加 `lastReconciledSeatRef` 守卫:**只在显示席位真正变化时**才开/关沙箱;用户手动关闭(席位没变)时 effect 早退,不再回弹。
|
||||
3. **并行时自动下滑失效**:平铺列表 ↔ 网格(两列/单列)切换时 `scrollHeight` 突变、`scrollTop` 被浏览器 clamp → `useAutoScroll` 误判「用户上滚」而停跟随。`Step2Panel` 加 effect:`isParallelRunning` / `selectedParallelSeats` 变化时 `scrollStep2ToBottom()` 重新拉到底 + 恢复跟随。
|
||||
4. **流程图只展示 Step2**:`DagFlowChart` 加 `showReportMilestone`(默认 true);Step2 弹窗 + 编辑器预览传 `false` —— 只到「共识」(Step2 终点),去掉「结果绘制」(那是 Step3)。
|
||||
|
||||
**改动**:后端 `multi_agent.py`(`_leader_run` suppress 两处广播 + run/stream 读 flag);前端 `api/multi-agent.ts`、`lib/dag-types.ts`(pickDagSeatTask + seedMessage)、`hooks/useStep2Orchestration.ts`(传 flag + seed + 沙箱跟随守卫)、`components/Step2Panel.tsx`(自动下滑 effect + showReportMilestone)、`components/DagFlowChart.tsx`、`pages/BusinessChainEditorPage.tsx`。`dag-types.test.ts` 共 **34 单测过**;前端 tsc 65 基线零新增;后端 `py_compile` OK。
|
||||
|
||||
### 12.8 ✅ 席位产出 md 文件 + 禁写 .py(2026-06-07)
|
||||
|
||||
**现象**:某次 7 席位**全都没产出 md 文件**;且一直有的问题——席位**有时写 .py 脚本**(用户要求:不写 .py,其它格式可以)。
|
||||
|
||||
**根因(没 md)**:两层都堵了——(a)**快速模式** `seat_parallel` policy 禁了 `write_file`(原「产物走正文」设计);(b)DAG 席位**提示词**写的是「直接输出你的交付正文」,即使沙箱模式(允许写)也不会去写文件。
|
||||
|
||||
**修**:
|
||||
1. **后端 policy 放开写文件、统一禁 bash**(`roundtable_run_policy.py`):
|
||||
- `seat_parallel`:去掉 `write_file` / `str_replace` 禁用(**可写 md**),保留禁 `bash` + 打断/越权类;仍放开 `web_search`。
|
||||
- `seat`:新增禁 `bash`(原来允许)——圆桌席位是「文档产出者」,不该跑 shell/python 脚本(弱模型爱写 .py 再跑生成产物,乱且抓不到预览);改为**直接 write_file 写成品文档**。
|
||||
- `report`:`bash` 已随 `seat` 禁,改为 `[*_SEAT_EXCLUDED, "str_replace"]`(集合不变)。
|
||||
- 测试 `test_roundtable_run_policy.py`:`test_seats_can_write_files_but_not_run_bash`(seat / seat_parallel 都可写文件、都禁 bash),6 用例过。
|
||||
2. **前端提示词**(`pickDagSeatTask`)加「产物要求」:**把完整交付写成一个 .md 文件**(write_file 到 outputs)+ 正文给摘要;**禁写 .py 或任何脚本文件**,.md/.html/.txt 等其它格式都可以,唯独不要 .py。
|
||||
3. **快速模式(双列)不自动开沙箱**(`openSeatVirtualArtifact` 新增守卫):放开写文件后,双列里多席位并发写文件会让单沙箱预览反复串台——fast 模式 DAG 并行批次**完全不自动开沙箱**(文件仍写盘,Step3/文件卡可查看);沙箱模式(单列)仍按「沙箱跟随当前席位」预览。
|
||||
|
||||
**关于 .py 的强度**:`write_file` 是全局工具(所有 agent 共用),无法在网关层按扩展名硬拦,故 .py 禁止走**提示词**(强约束)+ **禁 bash**(写了也跑不了,脚本生成产物的动机被掐断)。若实测仍偶发写 .py 且需**硬拦**,下一步:给 lead_agent 的 `write_file` 工具加一个「按 run context 禁某些扩展名」的开关,网关 `_run_payload` 对圆桌席位置 `[".py"]`(只影响圆桌 run,不动其它 agent)。
|
||||
|
||||
**改动**:`roundtable_run_policy.py`、`test_roundtable_run_policy.py`、`lib/dag-types.ts`、`lib/dag-types.test.ts`(35 过)、`hooks/useStep2Orchestration.ts`。前端 tsc 65、后端 py_compile OK + 6 policy 单测过。⚠️ **改了后端,需重启 Gateway**。
|
||||
|
||||
### 12.9 ✅ 席位「按需取前序全文」只读工具 read_peer_delivery(2026-06-07)
|
||||
|
||||
**诉求**:A,B,C → D,E 时,D,E 默认拿 A,B,C 的**摘要**,但识别到需要**全文**时也能取到。之前只有总控(leader)能「摘要默认+按需全文」(跨 thread 读),下游席位**不能**(席位间 thread 隔离、只有 1500 字截断广播)。
|
||||
|
||||
**实现**(真·按需,全文只在调用工具时才进 LLM 上下文):
|
||||
1. **新 harness 只读工具** `read_peer_delivery(seat_name)`(`packages/harness/deerflow/tools/builtins/roundtable_peers_tool.py`):从 `runtime.context["roundtable_peer_deliveries"]`(网关注入的 `[{name,content}]`)按席位名取全文;空名→列出可取席位;非圆桌场景→「无」。只读 context dict,**不跨 thread、不碰 sandbox、无网络**,纯 `deerflow.*`(不违反 harness→app 防火墙)。注册进 `tools/tools.py` 的 builtin(始终在,只读无副作用)。
|
||||
2. **网关注入 context**:`_run_payload` 加 `extra_context`;`_special_run` 收 `peer_deliveries` → 注入 `context.roundtable_peer_deliveries`;run/stream 读 `peer_deliveries`(规整 `[{name,content}]`)。**不进 prompt → 不调工具就不耗 token**。
|
||||
3. **run policy**:`read_peer_delivery` 从 `leader`(总控自己跨 thread 读,不需要)+ `report`(Step3 不读前序)排除;**席位(seat/seat_parallel)保留**。
|
||||
4. **前端**:`runDagOrchestration` 跨 stage 累积**完整交付** `priorDeliveries`,stage>0 席位的 special run 带 `peerDeliveries`(`api/multi-agent.ts` → `peer_deliveries`);`pickDagSeatTask` 加 `hasPeers` 形参,有前序时在 task 里提示「需要某前序席位全文时调用 read_peer_delivery('<名>'),平时用摘要」。
|
||||
|
||||
**最终语义**:D、E 默认看 A、B、C 的**摘要**(1500 字广播);识别到不够 → 调 `read_peer_delivery('情报收集')` 取**全文**。总控仍是摘要默认+综合轮全文。**= 用户要的「摘要默认、按需全文」对席位也成立了。**
|
||||
|
||||
**改动**:后端 `roundtable_peers_tool.py`(新)、`tools/tools.py`、`roundtable_run_policy.py`、`multi_agent.py`;前端 `api/multi-agent.ts`、`lib/dag-types.ts`、`lib/dag-types.test.ts`(36 过)、`hooks/useStep2Orchestration.ts`。前端 tsc 65;后端 py_compile OK + 6 policy 单测过 + 工具导入/行为 smoke 通过。⚠️ **改了后端 + 新增 harness 工具,需重启 Gateway**。
|
||||
|
||||
### 12.10 ✅ 修:沙箱模式第一个席位不自动开沙箱(2026-06-07,前端)
|
||||
|
||||
**现象**:沙箱(单列)模式并行时,**第一个**席位的消息列表不自动打开沙箱(要手动开);切换到其它席位时正常自动打开。
|
||||
|
||||
**根因**:沙箱跟随 effect(§12.5)在选中席位**还没写产物**时走「关闭沙箱」分支,里面同时调了 `deselectArtifact()` **和** `setArtifactsOpen(false)`。后者是 context 的 **wrapped setOpen**(`context.tsx:73`):`关闭时若 autoOpen 为真 → 把 autoOpen 置 false`。于是第一个席位刚选中(还没写文件)→ autoOpen 被关 → 该席位**随后 live 写文件**时,`openSeatVirtualArtifact` 的 `!flags.autoOpen` 守卫拦掉自动打开。切换到**已写过**文件的席位能开,是因为那条路走 `openSeatLatestArtifact`(force,auto=false,绕过 autoOpen 守卫)。
|
||||
|
||||
**修**(一行):关闭分支只保留 `deselectArtifact()`(它走 raw `setOpen(false)`,**不**动 autoOpen),删掉多余的 `setArtifactsOpen(false)`。这样面板关掉、但 autoOpen 仍为真 → 第一个席位 live 写文件时正常自动开沙箱。(若用户**手动**关过沙箱,autoOpen 本就为 false,不会自动重开 —— 尊重用户。)改 `hooks/useStep2Orchestration.ts` 沙箱跟随 effect。tsc 65。
|
||||
|
||||
### 12.11 ✅ 修:快速模式又生成文件了 + Fragment 警告(2026-06-07);记录无关 404
|
||||
|
||||
1. **快速模式不应生成文件(回退 §12.8 的过度放开)**:§12.8 为「让席位产 md」把 `seat_parallel`(快速模式)也放开了 write_file —— 但**快速模式(双列)的设计就是产物走正文、不写文件**,写文件归**沙箱模式**(单列 + 沙箱预览)。修:
|
||||
- `roundtable_run_policy.py`:`seat_parallel` 重新**禁 write_file / str_replace**(保留禁 bash);`seat`(沙箱模式)仍可写。
|
||||
- `useStep2Orchestration.ts`:`parallelNoFile = dagExecMode !== "sandbox"`(快速模式一律禁写,含单节点 stage;沙箱模式放开)。
|
||||
- `pickDagSeatTask` 加 `allowFiles` 形参:沙箱(true)=写 .md 文件 + 禁 .py;快速(false)=产物走正文、不写文件。hook 按 `dagExecMode === "sandbox"` 传。
|
||||
- 测试:`test_sandbox_seat_writes_files_fast_parallel_does_not`(6 过);`dag-types.test.ts` 拆成沙箱/快速两个产物要求用例(37 过)。
|
||||
- **语义**:快速=双列、产物走正文、不写文件;沙箱=单列、写 .md、沙箱跟随预览。
|
||||
2. **`Invalid prop data-lov-id supplied to React.Fragment`**:`DagFlowChart` 的 stage 循环用了具名 `<Fragment key>`,dev 代码标注插件(lovable-tagger)给它注入 `data-lov-id`,而 Fragment 只接受 key/children → React 警告。修:换成 `<div className="contents" key=...>`(display:contents 不生成盒子,Connector/StageColumn 仍是外层 flex 直接子项,布局不变,且能接收 data-*)。移除 `Fragment` import。
|
||||
3. **(无关,非本期引入)`/api/getTokenByEmail?email=…%40local.deerflow` 404 ×60**:来自 `src/core/studio/api.ts` 的 `fetchStudioToken`(studio/expert-auth 引导),用户邮箱被映射到 legacy `@local.deerflow` 域、后端 `getTokenByEmail` 不认 → 404,React Query 跨多个 `useStudioToken` 调用点重试 → 重复多次。**与圆桌 DAG 改动无关**(圆桌头像是本地 SVG,本期未新增任何前端 GET)。需要的话单独排查 studio token 链路。
|
||||
|
||||
---
|
||||
|
||||
## 13. 实施进度(截至 2026-06-06,下次会话续)
|
||||
|
||||
| Phase | 内容 | 状态 |
|
||||
|---|---|---|
|
||||
| 1 | DAG 数据模型 + 校验 + 持久化字段(`lib/dag-types.ts`,24 单测) | ✅ 完成 |
|
||||
| 2 | 分层编辑器(**做进业务链条页**):链条加 `stages` 字段(前后端 + alembic `20260607_03` + 11 后端单测);`BusinessChainEditorPage` 竖列分组 Stage(DnD + 按钮);纯逻辑 `lib/chain-stages.ts`(19 单测) | ✅ 完成 |
|
||||
| 3 | `runDagOrchestration`:批内 `Promise.all` 并行 + 批间 barrier 串行 + 依赖摘要注入(方案 B 前端直派) | ✅ 完成 |
|
||||
| 4 | 并行 run policy `seat_parallel`(`roundtable_run_policy.py` + `multi_agent.py` 读 `parallel_no_file`,6 后端单测);**§12.1 方案 A 已放开 web_search/技能** | ✅ 完成 |
|
||||
| 5 | 并行显示:收起左侧 + 头像条 + 两列 + 选中切换 | ✅ 完成(本轮修了侧栏/Markdown/字数) |
|
||||
| 6 | 流式高性能:ref 全量累积 + 节流 flush(仅选中渲染) | ✅ 完成 |
|
||||
| 7 | DAG 流程图执行态可视化(`DispatchChainModal` 复刻成按 stage 分列;后改成**弹窗**见 §12.5) | ✅ 完成(2026-06-07) |
|
||||
|
||||
**端到端闭环已通**:链条页编排分层 → 存库 → 选链条进 Step2 → `runDagOrchestration` 并行直派 → 各阶段完交回总控派活/收口。
|
||||
|
||||
**✅ §12.3 已实施(2026-06-07)**:见 §12.3「实施结论」。`runDagOrchestration` 在每个非末尾 stage barrier 后插入 leader 中转轮(`synthesisMode:false` 走轻量 `last-ai-message` 注入摘要、收口轮才按需全文),承上启下驱动下一批;后端零改动;`dag-types.ts` 加 `buildDagTransitTask` + `buildStageTask.priorIsBridge`,27 单测全过。
|
||||
|
||||
**下次会话 TODO(优先级从高到低)**:
|
||||
1. **运行时实地验收 §12.3**:①每个 stage 之间出现「总控协调(承上启下)」气泡,内容是对上一批的综合;②后端日志中转轮 `[leader-roster] ... 注入席位交付状态` 显示上一批 ✅、未跑的 ⬜;③下一 stage 席位 task 里带的是总控承上说明(非机械摘要);④中转轮不应真的派活(即使派了前端也忽略)。
|
||||
2. 运行时实地验收(旧):①§12.1 后并行席位能搜索、步骤条显示;②并行 special 日志时间重叠 + `Excluded` 不含 web_search 含 write_file;③用户干预后是「总控派活」而非重跑 DAG;④最左全局侧栏 + 参与角色栏并行收起且不回弹。
|
||||
3. **✅ Phase 7 DAG 流程图可视化已完成(2026-06-07)**:见 §7.2。`DagFlowChart` 组件 + 编辑器只读预览 + Step2 执行态缩略(`dagNodeStates` 实时驱动)。
|
||||
4. (可选)串行也确定性注入上一轮交付摘要。
|
||||
|
||||
**关键文件速查**:
|
||||
- 前端:`hooks/useStep2Orchestration.ts`(`runDagOrchestration` / `dagPlanExecutedRef` / 并行 store+flush)、`components/Step2Panel.tsx`(两列+头像条)、`components/ParallelSeatStrip.tsx`、`lib/dag-types.ts`、`lib/chain-stages.ts`、`pages/BusinessChainEditorPage.tsx`、`pages/RoundtablePlanningPage.tsx`(侧栏收起 effect + handleConfirmAgents dag 分支)、`components/BusinessChainPicker.tsx`(链条→dag)。
|
||||
- 后端:`app/gateway/roundtable_run_policy.py`(`seat_parallel`,**待放宽**)、`app/gateway/routers/multi_agent.py`(`_special_run` 读 `parallel_no_file`)、`persistence/roundtable_chains/`(`stages` 列)、`migrations/versions/20260607_03_chain_stages.py`。
|
||||
- 测试:`frontend-web` 用 esbuild 转译 `.test.ts` 后 `node --test`(项目无 vitest);后端 `PYTHONPATH=. uv run pytest tests/test_roundtable_chain_stages.py tests/test_roundtable_run_policy.py`。
|
||||
|
||||
@@ -49,7 +49,6 @@ frontend-web/
|
||||
│ ├─ ScheduledTaskRunDetailPage.tsx
|
||||
│ ├─ PublicSharePage.tsx 免登录分享只读
|
||||
│ ├─ PublicScheduledTaskPage.tsx
|
||||
│ ├─ EmbedChatPage.tsx iframe 嵌入问答(见 docs/embed-iframe-chat.md)
|
||||
│ ├─ LoginPage.tsx 用户名直登(开发/内网用)
|
||||
│ └─ ...
|
||||
├─ core/ 「领域核心」: API 客户端、领域 hooks、类型
|
||||
@@ -187,7 +186,6 @@ ReactDOM.createRoot(#root)
|
||||
/login/:username LoginPage(按用户名直登)
|
||||
/landing LandingPage
|
||||
/share/:shareCode PublicSharePage(免登录只读)
|
||||
/embed/chats/:thread_id EmbedChatPage(iframe 嵌入问答,见 docs/embed-iframe-chat.md)
|
||||
/public/scheduled-tasks/:taskId PublicScheduledTaskPage(免登录)
|
||||
/page/* PageRoutes(含侧边栏与 Header)
|
||||
* NotFoundPage
|
||||
@@ -666,7 +664,6 @@ const { models, tokenUsageEnabled, isLoading } = useModels();
|
||||
|
||||
- 创建分享:[components/workspace/thread-share-dialogs.tsx](../src/components/workspace/thread-share-dialogs.tsx) + `core/thread-shares/`
|
||||
- 只读页 `PublicSharePage`(路由 `/share/:shareCode`)/ `PublicScheduledTaskPage`(`/public/scheduled-tasks/:taskId`),不需要登录
|
||||
- iframe 嵌入问答 `EmbedChatPage`(`/embed/chats/:thread_id`):第三方用普通 iframe 嵌套,父页面用 `sessionId` 轮询公开 API 判断问答是否结束,详见 [embed-iframe-chat.md](./embed-iframe-chat.md)
|
||||
- 后端会用 share token 做权限校验,前端通过 `apiFetch` 自动带
|
||||
|
||||
---
|
||||
@@ -1141,7 +1138,6 @@ setSettings("context", { model_name: "gpt-5" }); // 仅当前线程
|
||||
| `/landing` | App.tsx | LandingPage.tsx |
|
||||
| `/share/:shareCode` | App.tsx | PublicSharePage.tsx |
|
||||
| `/public/scheduled-tasks/:taskId` | App.tsx | PublicScheduledTaskPage.tsx |
|
||||
| `/embed/chats/:thread_id` | App.tsx | EmbedChatPage.tsx([embed-iframe-chat.md](./embed-iframe-chat.md)) |
|
||||
| `/page/workspace/*` | PageRoutes.tsx → WorkspaceRoutes.tsx | ChatsPage / ChatPage / AgentsPage / NewAgentPage / AgentChatPage / SkillsPage / SkillEvolutionPage / MemoryPage / ScheduledTasksPage / ScheduledTaskRunDetailPage / AdminUserHubPage / AdminScheduledTasksPage / TagsPage / RecommendedQuestionsPage / PromptPrefixSettingsPage / LlmMetricsPage |
|
||||
| `/page/strategy/*` | PageRoutes.tsx → StrategyRoutes.tsx | GeneralQA + LightApp* + 复用 Workspace 页面 |
|
||||
| `/page/canvas/*` | PageRoutes.tsx → open-canvas/routes/OpenCanvasRoutes.tsx | CanvasHomePage / CanvasThreadPage / AIWritingPage / ArticleTypesConfigPage |
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CM 助手</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Generated
+10140
-10540
File diff suppressed because it is too large.
Load diff
@@ -6,8 +6,6 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build",
|
||||
"build:sit": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode sit",
|
||||
"build:network": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build --mode network",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
@@ -19,10 +17,6 @@
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fontsource/noto-sans-sc": "^5.2.9",
|
||||
"@langchain/core": "^1.1.15",
|
||||
"@langchain/langgraph-sdk": "^1.5.3",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
@@ -56,7 +50,6 @@
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"ai": "^6.0.33",
|
||||
"axios": "1.16.1",
|
||||
"best-effort-json-parser": "^1.2.1",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -65,7 +58,6 @@
|
||||
"codemirror": "^6.0.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"dnd-kit": "^0.0.2",
|
||||
"docx": "^9.5.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="智能助手中心">
|
||||
<defs>
|
||||
<!-- 品牌玫红渐变填充的圆角磁贴 -->
|
||||
<linearGradient id="tile" x1="6" y1="4" x2="58" y2="60" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FB7185"/>
|
||||
<stop offset="1" stop-color="#E11D48"/>
|
||||
</linearGradient>
|
||||
<!-- 气泡内 AI 闪光星:玫红渐变,与白色气泡形成对比 -->
|
||||
<linearGradient id="spark" x1="24" y1="18" x2="40" y2="36" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FB7185"/>
|
||||
<stop offset="1" stop-color="#E11D48"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- 磁贴背景 -->
|
||||
<rect width="64" height="64" rx="15" fill="url(#tile)"/>
|
||||
|
||||
<!-- 辅助小闪光,点出「多种智能工具」 -->
|
||||
<path fill="#FFFFFF" fill-opacity="0.85"
|
||||
d="M47 43 Q47.6 46.4 50.5 47 Q47.6 47.6 47 51 Q46.4 47.6 43.5 47 Q46.4 46.4 47 43 Z"/>
|
||||
|
||||
<!-- 助手对话气泡(白色)+ 尾巴 -->
|
||||
<rect x="12" y="13" width="40" height="28" rx="10" fill="#FFFFFF"/>
|
||||
<path fill="#FFFFFF" d="M21 40 H31 L19 49 Z"/>
|
||||
|
||||
<!-- 气泡内的 AI 闪光星 -->
|
||||
<path fill="url(#spark)"
|
||||
d="M32 17.5 Q33.2 25.8 41.5 27 Q33.2 28.2 32 36.5 Q30.8 28.2 22.5 27 Q30.8 25.8 32 17.5 Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -2,7 +2,6 @@ import { useMemo } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
import { FontScaleApplier } from "@/components/font-scale";
|
||||
import { QueryClientProvider } from "@/components/query-client-provider";
|
||||
import { AuthSyncWatcher } from "@/core/auth/sync";
|
||||
import { I18nProvider } from "@/core/i18n/context";
|
||||
@@ -17,9 +16,6 @@ import LoginPage from "./pages/LoginPage";
|
||||
import PageRoutes from "./pages/PageRoutes";
|
||||
import PublicScheduledTaskPage from "./pages/PublicScheduledTaskPage";
|
||||
import PublicSharePage from "./pages/PublicSharePage";
|
||||
import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
|
||||
import EmbedChatPage from "./pages/EmbedChatPage";
|
||||
import { ChatRuntime } from "./pages/WorkspaceRoutes";
|
||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
|
||||
function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
@@ -43,7 +39,6 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
<ThemeProvider>
|
||||
<ChatProvider>
|
||||
<AuthSyncWatcher />
|
||||
<FontScaleApplier />
|
||||
{children}
|
||||
</ChatProvider>
|
||||
</ThemeProvider>
|
||||
@@ -64,29 +59,11 @@ export function App() {
|
||||
<Route path="/landing" element={<LandingPage />} />
|
||||
{/* Public read-only conversation viewer — no authentication. */}
|
||||
<Route path="/share/:shareCode" element={<PublicSharePage />} />
|
||||
{/* Public read-only roundtable share — landing + full result report. */}
|
||||
<Route
|
||||
path="/roundtable-share/:shareCode"
|
||||
element={<PublicRoundtableSharePage />}
|
||||
/>
|
||||
<Route
|
||||
path="/roundtable-share/:shareCode/report"
|
||||
element={<PublicRoundtableSharePage report />}
|
||||
/>
|
||||
{/* Public read-only scheduled-task runs viewer — no authentication. */}
|
||||
<Route
|
||||
path="/public/scheduled-tasks/:taskId"
|
||||
element={<PublicScheduledTaskPage />}
|
||||
/>
|
||||
{/* Embedded workspace Q&A — no page/workspace sidebars; parent passes message/username/theme */}
|
||||
<Route
|
||||
path="/embed/chats/:thread_id"
|
||||
element={
|
||||
<ChatRuntime>
|
||||
<EmbedChatPage />
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
<Route path="/page/*" element={<PageRoutes />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
|
||||
/**
|
||||
* 基准字号 = 16px,对应《深蓝色 UI 规范》§2.1.2 后台系统正文字号(16px Regular)。
|
||||
* 用户在「外观 → 字号」里选择的倍数通过 CSS 变量 `--ui-font-scale` 生效:globals.css
|
||||
* 只把 Tailwind 的 `--text-*` 字号 token 乘以该倍数,**不动间距 token**,所以放大 / 缩小
|
||||
* 的只有文字本身,padding / margin / 宽高等布局尺寸保持不变,不会把页面挤错位。
|
||||
* `fontScale === 1` 即默认 16px。
|
||||
*/
|
||||
export const BASE_FONT_PX = 16;
|
||||
|
||||
export const FONT_SCALE_OPTIONS: { value: number; label: string; px: number }[] =
|
||||
[
|
||||
{ value: 0.875, label: "小", px: 14 },
|
||||
{ value: 1, label: "标准", px: 16 },
|
||||
{ value: 1.125, label: "大", px: 18 },
|
||||
{ value: 1.25, label: "特大", px: 20 },
|
||||
];
|
||||
|
||||
/**
|
||||
* 把用户选择的字号倍数写到 <html> 的 `--ui-font-scale` CSS 变量。挂在应用根部,
|
||||
* 保证刷新后即时生效、全局统一。只设变量、不直接改 font-size,因此布局不受影响。
|
||||
* 不渲染任何 DOM。
|
||||
*/
|
||||
export function FontScaleApplier() {
|
||||
const [settings] = useLocalSettings();
|
||||
const { fontScale } = settings.display;
|
||||
|
||||
useEffect(() => {
|
||||
const scale =
|
||||
typeof fontScale === "number" && Number.isFinite(fontScale) && fontScale > 0
|
||||
? fontScale
|
||||
: 1;
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty("--ui-font-scale", String(scale));
|
||||
// 清掉旧实现可能残留的根级 font-size 内联样式(旧版用它做整页缩放)。
|
||||
root.style.removeProperty("font-size");
|
||||
}, [fontScale]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { injectInPageAnchorHandler } from "@/core/utils/markdown";
|
||||
|
||||
/**
|
||||
* Sandboxed preview of a generated HTML document.
|
||||
*
|
||||
* Renders via a `blob:` URL rather than `srcDoc`. A srcDoc document's base URL
|
||||
* is `about:srcdoc`, so clicking an in-page anchor (`<a href="#section">`)
|
||||
* navigates the frame to `about:blank#section` and BLANKS it. A `blob:` URL is
|
||||
* a real document URL, so fragment links scroll within the frame as expected —
|
||||
* all without enabling scripts (the sandbox stays script-free for security; the
|
||||
* server already sanitizes script vectors and this is defense-in-depth).
|
||||
*
|
||||
* The `charset=utf-8` on the blob type is required: a blob: URL whose
|
||||
* Content-Type omits the charset is decoded with the OS default (GBK/Latin-1 on
|
||||
* Chinese Windows), garbling CJK text when the HTML lacks a `<meta charset>`.
|
||||
*/
|
||||
export function HtmlPreviewFrame({
|
||||
html,
|
||||
className = "h-[70vh] w-full rounded-md border bg-white",
|
||||
}: {
|
||||
html: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [url, setUrl] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
const blob = new Blob([injectInPageAnchorHandler(html ?? "")], {
|
||||
type: "text/html;charset=utf-8",
|
||||
});
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
setUrl(objectUrl);
|
||||
return () => URL.revokeObjectURL(objectUrl);
|
||||
}, [html]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
title="HTML 预览"
|
||||
sandbox="allow-scripts allow-forms"
|
||||
src={url}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
.sidebar-contnet {
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 4rem;
|
||||
z-index: 40;
|
||||
|
||||
@@ -55,6 +55,14 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
|
||||
}, 180);
|
||||
};
|
||||
|
||||
const closeNavPanel = () => {
|
||||
if (hoverCloseTimerRef.current !== null) {
|
||||
window.clearTimeout(hoverCloseTimerRef.current);
|
||||
hoverCloseTimerRef.current = null;
|
||||
}
|
||||
setHoveredTopId(null);
|
||||
};
|
||||
|
||||
const hasActiveChild = (item: MenuItem): boolean => {
|
||||
if (item.path && pathname === item.path) {
|
||||
return true;
|
||||
@@ -75,9 +83,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
|
||||
if (matchedTopItem) {
|
||||
setActiveTopId(matchedTopItem.id);
|
||||
}
|
||||
// 不在导航时强制关闭 flyout:flyout 绝对定位在 group 盒子之外,强制关闭后鼠标
|
||||
// 仍停在 flyout 区域却收不到 group 的 mouseenter,导致连续点击第二个任务时面板
|
||||
// 消失且不回来。开合只交给 hover(mouseleave 有 180ms 宽限),即可连续点选。
|
||||
closeNavPanel();
|
||||
}, [pathname, visibleMenuItems]);
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
@@ -116,12 +122,14 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
|
||||
if (item.children?.length) {
|
||||
const firstPath = findFirstRoutePath(item.children);
|
||||
if (firstPath) {
|
||||
closeNavPanel();
|
||||
navigate(firstPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.path) {
|
||||
closeNavPanel();
|
||||
navigate(item.path);
|
||||
}
|
||||
};
|
||||
@@ -133,6 +141,7 @@ export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDar
|
||||
}
|
||||
|
||||
if (item.path) {
|
||||
closeNavPanel();
|
||||
navigate(item.path);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -14,7 +14,7 @@ const buttonVariants = cva(
|
||||
destructive:
|
||||
"cursor-pointer bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"cursor-pointer border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-[#213D5E] dark:bg-[#022248] dark:text-[#CCD2DA] dark:hover:border-[#213D5E] dark:hover:bg-[#0a2f58] dark:hover:text-[#CCD2DA] dark:[&_svg]:text-[#CCD2DA]",
|
||||
"cursor-pointer border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"cursor-pointer bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
@@ -37,27 +37,27 @@ const buttonVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
const Button = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
>(({ className, variant = "default", size = "default", asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="button"
|
||||
{...(variant !== undefined && { "data-variant": variant })}
|
||||
{...(size !== undefined && { "data-size": size })}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
const Button = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
>(({ className, variant = "default", size = "default", asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="button"
|
||||
{...(variant !== undefined && { "data-variant": variant })}
|
||||
{...(size !== undefined && { "data-size": size })}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -34,7 +34,7 @@ const emptyMediaVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6 dark:text-[#55A6FE] dark:bg-[#022248]",
|
||||
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -62,7 +62,7 @@ function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-title"
|
||||
className={cn("text-lg font-medium tracking-tight dark:text-[#AACEFF]", className)}
|
||||
className={cn("text-lg font-medium tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -73,7 +73,7 @@ function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
<div
|
||||
data-slot="empty-description"
|
||||
className={cn(
|
||||
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4 dark:text-[#55A6FE]",
|
||||
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -3,14 +3,6 @@ import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
const usesDarkNativePicker =
|
||||
type === "number" ||
|
||||
type === "date" ||
|
||||
type === "datetime-local" ||
|
||||
type === "time" ||
|
||||
type === "month" ||
|
||||
type === "week";
|
||||
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
@@ -18,8 +10,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
className={cn(
|
||||
"h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base outline-none transition-[color,box-shadow] md:text-sm",
|
||||
"border-input placeholder:text-muted-foreground",
|
||||
"dark:border-[#1B477E] dark:bg-[#0D2D54] dark:text-[#AACEFF] dark:placeholder:text-[#566B7F] dark:rounded-[4px] dark:text-[16px] md:dark:text-[16px]",
|
||||
usesDarkNativePicker && "dark:[color-scheme:dark]",
|
||||
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF] dark:placeholder:text-[#566B7F] rounded-[4px] text-[16px]",
|
||||
"file:text-foreground selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] dark:focus-visible:border-[#3b82f6] dark:focus-visible:ring-[#3b82f6]/30",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Compute the page-number window to render, inserting -1 sentinels for gaps.
|
||||
* Always shows first/last and a window around the current page. */
|
||||
function buildPageItems(current: number, totalPages: number): number[] {
|
||||
if (totalPages <= 7) {
|
||||
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
}
|
||||
const items = new Set<number>([1, totalPages, current]);
|
||||
for (const d of [1, 2]) {
|
||||
if (current - d >= 1) items.add(current - d);
|
||||
if (current + d <= totalPages) items.add(current + d);
|
||||
}
|
||||
const sorted = [...items].sort((a, b) => a - b);
|
||||
const out: number[] = [];
|
||||
let prev = 0;
|
||||
for (const p of sorted) {
|
||||
if (prev && p - prev > 1) out.push(-1); // gap sentinel
|
||||
out.push(p);
|
||||
prev = p;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface PaginationProps {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
onPageChange: (page: number) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Controlled, compact pagination control. Renders nothing when a single page. */
|
||||
export function Pagination({
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
onPageChange,
|
||||
className,
|
||||
}: PaginationProps) {
|
||||
const totalPages = Math.max(1, Math.ceil(total / Math.max(pageSize, 1)));
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const items = buildPageItems(page, totalPages);
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={cn("flex items-center justify-center gap-1", className)}
|
||||
aria-label="分页"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
aria-label="上一页"
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
{items.map((p, idx) =>
|
||||
p === -1 ? (
|
||||
<span
|
||||
key={`gap-${idx}`}
|
||||
className="text-muted-foreground px-1 text-sm select-none"
|
||||
>
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
key={p}
|
||||
variant={p === page ? "default" : "outline"}
|
||||
size="icon-sm"
|
||||
onClick={() => onPageChange(p)}
|
||||
aria-current={p === page ? "page" : undefined}
|
||||
>
|
||||
{p}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
aria-label="下一页"
|
||||
>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -22,31 +22,10 @@ function ResizablePanelGroup({
|
||||
);
|
||||
}
|
||||
|
||||
// react-resizable-panels v4 changed size semantics: a bare *number* is now
|
||||
// interpreted as PIXELS, while a unit-less *string* is a percentage. Every call
|
||||
// site in this app passes sizes as percentages (the v2/v3 convention), so a
|
||||
// value like `maxSize={60}` would otherwise pin a panel to 60px and freeze the
|
||||
// layout/drag. Normalize numbers to "<n>%" to preserve the intended percentage.
|
||||
const asPercent = (v: number | string | undefined) =>
|
||||
typeof v === "number" ? `${v}%` : v;
|
||||
|
||||
function ResizablePanel({
|
||||
defaultSize,
|
||||
minSize,
|
||||
maxSize,
|
||||
collapsedSize,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||
return (
|
||||
<ResizablePrimitive.Panel
|
||||
data-slot="resizable-panel"
|
||||
defaultSize={asPercent(defaultSize)}
|
||||
minSize={asPercent(minSize)}
|
||||
maxSize={asPercent(maxSize)}
|
||||
collapsedSize={asPercent(collapsedSize)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
@@ -60,19 +39,14 @@ function ResizableHandle({
|
||||
<ResizablePrimitive.Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
// Thin visible divider, but a generous invisible hit area on both sides
|
||||
// (after:w-4 → 16px) so the handle is easy to grab and drag. Hovering /
|
||||
// dragging tints the divider so it's obvious it's interactive.
|
||||
"bg-border hover:bg-primary/60 active:bg-primary relative flex w-px items-center justify-center transition-colors",
|
||||
"after:absolute after:inset-y-0 after:left-1/2 after:w-4 after:-translate-x-1/2 after:cursor-col-resize",
|
||||
"focus-visible:ring-ring focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden",
|
||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border z-10 flex h-8 w-4 items-center justify-center rounded-sm border shadow-sm">
|
||||
<GripVerticalIcon className="size-3.5" />
|
||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||
<GripVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.Separator>
|
||||
|
||||
@@ -24,70 +24,69 @@ function SelectValue({
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}
|
||||
>(({ className, size = "default", children, ...props }, ref) => {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEE2] dark:data-[placeholder]:text-[#566B7F] dark:hover:bg-[#022248] dark:[&_svg:not([class*='text-'])]:text-[#AACEFF] dark:focus-visible:border-[#3b82f6] dark:focus-visible:ring-[#3b82f6]/30",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
});
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}
|
||||
>(({ className, size = "default", children, ...props }, ref) => {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
});
|
||||
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "item-aligned", align = "center", ...props }, ref) => {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "item-aligned", align = "center", ...props }, ref) => {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
@@ -96,44 +95,40 @@ function SelectLabel({
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn(
|
||||
"text-muted-foreground px-2 py-1.5 text-xs dark:text-[#566B7F]",
|
||||
className,
|
||||
)}
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
"dark:text-[#AACEFF] dark:focus:bg-[#0062D9] dark:focus:text-white dark:data-[highlighted]:bg-[#0062D9] dark:data-[highlighted]:text-white dark:[&_svg:not([class*='text-'])]:text-[#AACEFF] dark:focus:[&_svg:not([class*='text-'])]:text-white dark:data-[highlighted]:[&_svg:not([class*='text-'])]:text-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
});
|
||||
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
});
|
||||
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
@@ -156,7 +151,7 @@ function SelectScrollUpButton({
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1 dark:text-[#AACEFF]",
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -174,7 +169,7 @@ function SelectScrollDownButton({
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1 dark:text-[#AACEFF]",
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -13,7 +13,7 @@ function Switch({
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-[#55A6FE] dark:data-[state=checked]:bg-[#0062D9] inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -32,8 +32,7 @@ const tabsListVariants = cva(
|
||||
variant: {
|
||||
default:
|
||||
"bg-muted dark:group-data-[orientation=horizontal]/tabs:h-8! dark:items-stretch dark:gap-0 dark:overflow-hidden dark:rounded-[2px] dark:border dark:border-[#55A6FE] dark:bg-[#022248] dark:p-0",
|
||||
line:
|
||||
"gap-1 bg-transparent dark:group-data-[orientation=horizontal]/tabs:h-8! dark:items-stretch dark:gap-0 dark:overflow-visible dark:rounded-[2px] dark:border dark:border-[#55A6FE] dark:bg-[#022248] dark:p-0",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -71,16 +70,13 @@ const TabsTrigger = React.forwardRef<
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"dark:text-muted-foreground dark:hover:text-foreground",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background data-[state=active]:text-foreground",
|
||||
"dark:group-data-[variant=line]/tabs-list:relative dark:group-data-[variant=line]/tabs-list:!h-full dark:group-data-[variant=line]/tabs-list:self-stretch dark:group-data-[variant=line]/tabs-list:flex-none dark:group-data-[variant=line]/tabs-list:rounded-none dark:group-data-[variant=line]/tabs-list:border-0 dark:group-data-[variant=line]/tabs-list:bg-transparent dark:group-data-[variant=line]/tabs-list:px-4 dark:group-data-[variant=line]/tabs-list:!py-0 dark:group-data-[variant=line]/tabs-list:!leading-none dark:group-data-[variant=line]/tabs-list:text-[#CCD2DA] dark:group-data-[variant=line]/tabs-list:shadow-none dark:group-data-[variant=line]/tabs-list:hover:text-[#CCD2DA] dark:group-data-[variant=line]/tabs-list:focus-visible:ring-0 dark:group-data-[variant=line]/tabs-list:[&_svg]:text-[#CCD2DA]",
|
||||
"dark:group-data-[variant=line]/tabs-list:not(:first-child):border-l dark:group-data-[variant=line]/tabs-list:not(:first-child):border-[#1d3961] dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-l-0",
|
||||
"dark:group-data-[variant=line]/tabs-list:data-[state=active]:rounded-none dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-0 dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-[#0062D9] dark:group-data-[variant=line]/tabs-list:data-[state=active]:text-[#FFFFFF] dark:group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:group-data-[variant=line]/tabs-list:data-[state=active]:[&_svg]:text-[#FFFFFF]",
|
||||
"dark:group-data-[variant=default]/tabs-list:flex dark:group-data-[variant=default]/tabs-list:min-h-0 dark:group-data-[variant=default]/tabs-list:!h-full dark:group-data-[variant=default]/tabs-list:flex-1 dark:group-data-[variant=default]/tabs-list:self-stretch dark:group-data-[variant=default]/tabs-list:rounded-none dark:group-data-[variant=default]/tabs-list:border-0 dark:group-data-[variant=default]/tabs-list:bg-transparent dark:group-data-[variant=default]/tabs-list:px-4 dark:group-data-[variant=default]/tabs-list:!py-0 dark:group-data-[variant=default]/tabs-list:!leading-none dark:group-data-[variant=default]/tabs-list:text-[#CCD2DA] dark:group-data-[variant=default]/tabs-list:shadow-none dark:group-data-[variant=default]/tabs-list:hover:text-[#CCD2DA] dark:group-data-[variant=default]/tabs-list:focus-visible:ring-0 dark:group-data-[variant=default]/tabs-list:[&_svg]:text-[#CCD2DA]",
|
||||
"dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-input dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-input/30 dark:group-data-[variant=line]/tabs-list:data-[state=active]:text-foreground",
|
||||
"dark:group-data-[variant=default]/tabs-list:relative dark:group-data-[variant=default]/tabs-list:!h-full dark:group-data-[variant=default]/tabs-list:self-stretch dark:group-data-[variant=default]/tabs-list:flex-none dark:group-data-[variant=default]/tabs-list:rounded-none dark:group-data-[variant=default]/tabs-list:border-0 dark:group-data-[variant=default]/tabs-list:bg-transparent dark:group-data-[variant=default]/tabs-list:px-4 dark:group-data-[variant=default]/tabs-list:!py-0 dark:group-data-[variant=default]/tabs-list:!leading-none dark:group-data-[variant=default]/tabs-list:shadow-none dark:group-data-[variant=default]/tabs-list:focus-visible:ring-0 dark:group-data-[variant=default]/tabs-list:[&_svg]:text-[#CCD2DA]",
|
||||
"dark:group-data-[variant=default]/tabs-list:not(:first-child):border-l dark:group-data-[variant=default]/tabs-list:not(:first-child):border-[#1d3961] dark:group-data-[variant=default]/tabs-list:data-[state=active]:border-l-0",
|
||||
"dark:group-data-[variant=default]/tabs-list:data-[state=active]:rounded-none dark:group-data-[variant=default]/tabs-list:data-[state=active]:border-0 dark:group-data-[variant=default]/tabs-list:data-[state=active]:bg-[#0062D9] dark:group-data-[variant=default]/tabs-list:data-[state=active]:text-[#FFFFFF] dark:group-data-[variant=default]/tabs-list:data-[state=active]:shadow-none dark:group-data-[variant=default]/tabs-list:data-[state=active]:hover:text-[#FFFFFF] dark:group-data-[variant=default]/tabs-list:data-[state=active]:[&_svg]:text-[#FFFFFF]",
|
||||
"group-data-[variant=line]/tabs-list:after:pointer-events-none group-data-[variant=line]/tabs-list:after:absolute group-data-[variant=line]/tabs-list:after:inset-x-2 group-data-[variant=line]/tabs-list:after:bottom-[-5px] group-data-[variant=line]/tabs-list:after:h-0.5 group-data-[variant=line]/tabs-list:after:bg-foreground group-data-[variant=line]/tabs-list:after:opacity-0 group-data-[variant=line]/tabs-list:after:transition-opacity group-data-[variant=line]/tabs-list:after:content-[''] group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
"dark:group-data-[variant=line]/tabs-list:after:inset-x-0 dark:group-data-[variant=line]/tabs-list:after:bottom-0 dark:group-data-[variant=line]/tabs-list:after:z-[2] dark:group-data-[variant=line]/tabs-list:after:h-[2px] dark:group-data-[variant=line]/tabs-list:after:bg-[#FFFFFF]",
|
||||
"dark:group-data-[variant=default]/tabs-list:data-[state=active]:rounded-none dark:group-data-[variant=default]/tabs-list:data-[state=active]:border-0 dark:group-data-[variant=default]/tabs-list:data-[state=active]:bg-[#0062D9] dark:group-data-[variant=default]/tabs-list:data-[state=active]:text-[#FFFFFF] dark:group-data-[variant=default]/tabs-list:data-[state=active]:shadow-none dark:group-data-[variant=default]/tabs-list:data-[state=active]:[&_svg]:text-[#FFFFFF] dark:group-data-[variant=default]/tabs-list:hover:text-[#CCD2DA]",
|
||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
@@ -91,7 +90,7 @@ function V1SectionField({
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground dark:text-[#AACEE2]">{label}</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||||
{!editing && (
|
||||
<Button size="icon" variant="ghost" className="size-6" onClick={() => { setDraft(value); setEditing(true); }}>
|
||||
<Pencil className="size-3" />
|
||||
@@ -155,7 +154,7 @@ function V1FactItem({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded border p-2 space-y-1 group ">
|
||||
<div className="rounded border p-2 space-y-1 group">
|
||||
{editing ? (
|
||||
<div className="space-y-2">
|
||||
<Textarea value={draftContent} onChange={(e) => setDraftContent(e.target.value)} className="min-h-[64px] text-sm" autoFocus />
|
||||
@@ -197,7 +196,7 @@ function V1FactItem({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap dark:text-[#AACEE2]">{String(fact.content ?? "")}</p>
|
||||
<p className="text-sm whitespace-pre-wrap">{String(fact.content ?? "")}</p>
|
||||
{fact.sourceError ? <p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">避免:{String(fact.sourceError)}</p> : null}
|
||||
</>
|
||||
)}
|
||||
@@ -207,7 +206,6 @@ function V1FactItem({
|
||||
|
||||
// 新增事实表单
|
||||
function V1AddFactForm({ agent, userId }: { agent: string | null; userId: string }) {
|
||||
const { theme } = useTheme();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [content, setContent] = useState("");
|
||||
const [category, setCategory] = useState("context");
|
||||
@@ -224,12 +222,7 @@ function V1AddFactForm({ agent, userId }: { agent: string | null; userId: string
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<Button
|
||||
variant={theme === "dark" ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="mt-1 w-full"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Button variant="outline" size="sm" className="mt-1 w-full" onClick={() => setOpen(true)}>
|
||||
<Plus className="size-3" />新增事实
|
||||
</Button>
|
||||
);
|
||||
@@ -283,8 +276,8 @@ export function V1BucketView({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 用户画像字段 */}
|
||||
<div className="rounded-md border p-3 space-y-3 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground dark:text-white">用户画像</h4>
|
||||
<div className="rounded-md border p-3 space-y-3">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground">用户画像</h4>
|
||||
{allCtxFields.map((field) => (
|
||||
<V1SectionField
|
||||
key={field}
|
||||
@@ -298,8 +291,8 @@ export function V1BucketView({
|
||||
</div>
|
||||
|
||||
{/* 历史摘要字段 */}
|
||||
<div className="rounded-md border p-3 space-y-3 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground dark:text-white">历史摘要</h4>
|
||||
<div className="rounded-md border p-3 space-y-3">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground">历史摘要</h4>
|
||||
{allHistoryFields.map((field) => (
|
||||
<V1SectionField
|
||||
key={field}
|
||||
@@ -313,8 +306,8 @@ export function V1BucketView({
|
||||
</div>
|
||||
|
||||
{/* 事实记录 */}
|
||||
<div className="rounded-md border p-3 space-y-2 dark:border-[#213D5E] dark:bg-[#022248]">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground dark:text-white">事实记录({factsRaw.length} 条)</h4>
|
||||
<div className="rounded-md border p-3 space-y-2">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground">事实记录({factsRaw.length} 条)</h4>
|
||||
{factsRaw.map((fact, i) => (
|
||||
<V1FactItem key={String(fact.id ?? i)} fact={fact} agent={agent} userId={userId} />
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDownWideNarrow, Loader2, MessageSquareIcon, SearchIcon, UserIcon } from "lucide-react";
|
||||
import { Loader2, MessageSquareIcon, SearchIcon, UserIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -83,8 +83,6 @@ function UserInfoPanel({ user }: { user: UserListItem }) {
|
||||
<Badge variant="secondary">普通用户</Badge>
|
||||
)}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground font-medium">问答次数</dt>
|
||||
<dd className="tabular-nums">{user.qa_count}</dd>
|
||||
<dt className="text-muted-foreground font-medium">Skills</dt>
|
||||
<dd className="tabular-nums">{user.skill_count}</dd>
|
||||
<dt className="text-muted-foreground font-medium">Agents</dt>
|
||||
@@ -181,7 +179,7 @@ function UserAuditPanel({ userId }: { userId: string }) {
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="p-4">
|
||||
{!threadId && (
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-2 py-16 text-sm dark:text-[#AACEF8]">
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-2 py-16 text-sm">
|
||||
<MessageSquareIcon className="size-6" />
|
||||
请从左侧选择一个会话
|
||||
</div>
|
||||
@@ -255,7 +253,7 @@ function AgentMemoryPanel({ userId }: { userId: string }) {
|
||||
<SelectTrigger className="h-8 w-48" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
||||
<SelectContent>
|
||||
{/* 默认助手始终显示,对应全局记忆文件 */}
|
||||
<SelectItem value="default">默认助手</SelectItem>
|
||||
{otherBuckets.map((b) => (
|
||||
@@ -302,29 +300,21 @@ export function AdminUserHub() {
|
||||
const { data: users, isLoading: usersLoading } = useAllUsers(true);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [userQuery, setUserQuery] = useState("");
|
||||
// 活动审计 放到前面 → 默认进入审计标签页。
|
||||
const [activeTab, setActiveTab] = useState<"audit" | "info" | "memory">("audit");
|
||||
// 是否按问答次数(降序)排序用户列表。
|
||||
const [sortByQa, setSortByQa] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"info" | "audit" | "memory">("info");
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const needle = userQuery.trim().toLowerCase();
|
||||
let list = users ?? [];
|
||||
if (needle) {
|
||||
list = list.filter((u) => u.email.toLowerCase().includes(needle));
|
||||
}
|
||||
if (sortByQa) {
|
||||
// Copy before sorting so we never mutate the query cache array.
|
||||
list = [...list].sort((a, b) => (b.qa_count ?? 0) - (a.qa_count ?? 0));
|
||||
}
|
||||
return list;
|
||||
}, [users, userQuery, sortByQa]);
|
||||
const list = users ?? [];
|
||||
return needle
|
||||
? list.filter((u) => u.email.toLowerCase().includes(needle))
|
||||
: list;
|
||||
}, [users, userQuery]);
|
||||
|
||||
const selectedUser = users?.find((u) => u.id === selectedUserId) ?? null;
|
||||
|
||||
function selectUser(id: string) {
|
||||
setSelectedUserId(id);
|
||||
setActiveTab("audit");
|
||||
setActiveTab("info");
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -348,20 +338,6 @@ export function AdminUserHub() {
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSortByQa((v) => !v)}
|
||||
className={cn(
|
||||
"mx-2 mb-1 flex items-center justify-center gap-1 rounded-md border px-2 py-1 text-xs",
|
||||
sortByQa
|
||||
? "border-primary/40 bg-primary/5 text-primary"
|
||||
: "text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
title="按问答次数从高到低排序"
|
||||
>
|
||||
<ArrowDownWideNarrow className="size-3.5" />
|
||||
{sortByQa ? "按问答次数排序中" : "按问答次数排序"}
|
||||
</button>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
{usersLoading && (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
|
||||
@@ -393,12 +369,6 @@ export function AdminUserHub() {
|
||||
管理员
|
||||
</Badge>
|
||||
)}
|
||||
<span
|
||||
className="text-muted-foreground shrink-0 tabular-nums text-xs"
|
||||
title="问答次数"
|
||||
>
|
||||
{u.qa_count ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -418,8 +388,8 @@ export function AdminUserHub() {
|
||||
>
|
||||
<div className="border-b px-4 pt-3 shrink-0">
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="audit">活动审计</TabsTrigger>
|
||||
<TabsTrigger value="info">用户信息</TabsTrigger>
|
||||
<TabsTrigger value="audit">活动审计</TabsTrigger>
|
||||
<TabsTrigger value="memory">记忆管理</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
@@ -224,16 +224,16 @@ export function TagManagementPage() {
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-muted-foreground px-4 py-3 text-left font-medium dark:text-white">
|
||||
<th className="text-muted-foreground px-4 py-3 text-left font-medium">
|
||||
{t.adminTags.name}
|
||||
</th>
|
||||
<th className="text-muted-foreground px-4 py-3 text-left font-medium dark:text-white">
|
||||
<th className="text-muted-foreground px-4 py-3 text-left font-medium">
|
||||
{t.adminTags.tagDescription}
|
||||
</th>
|
||||
<th className="text-muted-foreground px-4 py-3 text-right font-medium dark:text-white">
|
||||
<th className="text-muted-foreground px-4 py-3 text-right font-medium">
|
||||
{t.adminTags.usageCount}
|
||||
</th>
|
||||
<th className="text-muted-foreground px-4 py-3 text-right font-medium dark:text-white">
|
||||
<th className="text-muted-foreground px-4 py-3 text-right font-medium">
|
||||
{t.adminTags.actions}
|
||||
</th>
|
||||
</tr>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle,CheckCircle2, Download, Loader2 } from "lucide-react";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
} from "@/core/tool-metrics/api";
|
||||
import { useToolMetrics, useToolMetricsSummary } from "@/core/tool-metrics/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
@@ -67,7 +66,6 @@ function categoryLabel(category?: string | null): string {
|
||||
}
|
||||
|
||||
export function ToolMetricsPanel() {
|
||||
const { theme } = useTheme();
|
||||
const [page, setPage] = useState(0);
|
||||
const [tool, setTool] = useState("");
|
||||
const [user, setUser] = useState("");
|
||||
@@ -81,31 +79,6 @@ export function ToolMetricsPanel() {
|
||||
const [until, setUntil] = useState("");
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [detailRow, setDetailRow] = useState<ToolMetric | null>(null);
|
||||
/** 成功 toast:仅暗色模式,浅色走 Sonner 默认 */
|
||||
const successToastDarkOptions = {
|
||||
classNames: {
|
||||
toast:
|
||||
"!rounded-[2px] !border !border-[#106455] !bg-[#052934] !text-[#FFFFFF] shadow-md",
|
||||
title: "!text-[#FFFFFF]",
|
||||
description: "!text-[#FFFFFF]",
|
||||
},
|
||||
icon: <CheckCircle2 className="size-4 shrink-0 text-[#2FD69A]" />,
|
||||
} as const;
|
||||
|
||||
/** 失败 toast:仅暗色模式,浅色走 Sonner 默认 */
|
||||
const errorToastDarkOptions = {
|
||||
classNames: {
|
||||
toast:
|
||||
"!rounded-[2px] !border !border-[#7F1A2A] !bg-[#311629] !text-[#FFFFFF] shadow-md",
|
||||
title: "!text-[#FFFFFF]",
|
||||
description: "!text-[#FFFFFF]",
|
||||
},
|
||||
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
|
||||
} as const;
|
||||
const darkToastOpts =
|
||||
theme === "dark"
|
||||
? { success: successToastDarkOptions, error: errorToastDarkOptions }
|
||||
: { success: undefined, error: undefined };
|
||||
|
||||
const filters = useMemo<ToolMetricsFilters>(() => {
|
||||
const out: ToolMetricsFilters = {};
|
||||
@@ -144,9 +117,9 @@ const errorToastDarkOptions = {
|
||||
setExporting(true);
|
||||
try {
|
||||
await downloadToolMetricsExcel(filters);
|
||||
toast.success("已导出为 Excel",darkToastOpts.success);
|
||||
toast.success("已导出为 Excel");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "导出失败",darkToastOpts.error);
|
||||
toast.error(e instanceof Error ? e.message : "导出失败");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
@@ -194,7 +167,7 @@ const errorToastDarkOptions = {
|
||||
</div>
|
||||
|
||||
{/* Summary strip */}
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-xl border bg-white p-4 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-xl border bg-white p-4">
|
||||
<div className="mr-4 flex flex-col">
|
||||
<span className="text-muted-foreground text-xs">总调用</span>
|
||||
<span className="text-lg font-semibold tabular-nums">
|
||||
@@ -240,7 +213,7 @@ const errorToastDarkOptions = {
|
||||
|
||||
{/* Per-skill breakdown — answers "was skill X invoked, and did it succeed". */}
|
||||
{summary && summary.by_skill.length > 0 && (
|
||||
<div className="rounded-xl border bg-white p-4 ">
|
||||
<div className="rounded-xl border bg-white p-4">
|
||||
<h3 className="mb-2 text-sm font-medium">各技能调用情况</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
@@ -285,7 +258,7 @@ const errorToastDarkOptions = {
|
||||
</div>
|
||||
)}
|
||||
{summary && kind === "skill" && summary.by_skill.length === 0 && (
|
||||
<div className="text-muted-foreground rounded-xl border bg-white p-4 text-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||
<div className="text-muted-foreground rounded-xl border bg-white p-4 text-sm">
|
||||
当前筛选范围内没有检测到任何技能调用记录。技能只有在被 Agent 通过
|
||||
<code className="bg-muted mx-1 rounded px-1">skill_view</code>
|
||||
读取(即真正"调用")时才会被记录;仅在系统提示里被列出、但未被读取的技能不会计入。
|
||||
@@ -293,9 +266,9 @@ const errorToastDarkOptions = {
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-3 lg:grid-cols-6 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||
<div className="grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-3 lg:grid-cols-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-muted-foreground text-xs dark:text-white">调用类型</label>
|
||||
<label className="text-muted-foreground text-xs">调用类型</label>
|
||||
<Select
|
||||
value={kind}
|
||||
onValueChange={(value) => {
|
||||
@@ -303,17 +276,17 @@ const errorToastDarkOptions = {
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="dark:bg-[#0D2D54] dark:hover:bg-[#0D2D54]">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
||||
<SelectContent>
|
||||
<SelectItem value="skill">技能(Agent Skill)</SelectItem>
|
||||
<SelectItem value="all">全部工具调用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-muted-foreground text-xs dark:text-white">技能名称</label>
|
||||
<label className="text-muted-foreground text-xs">技能名称</label>
|
||||
<Input
|
||||
value={skill}
|
||||
placeholder="按技能名筛选"
|
||||
@@ -324,7 +297,7 @@ const errorToastDarkOptions = {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-muted-foreground text-xs dark:text-white">工具名</label>
|
||||
<label className="text-muted-foreground text-xs">工具名</label>
|
||||
<Input
|
||||
value={tool}
|
||||
placeholder="按工具名筛选"
|
||||
@@ -335,7 +308,7 @@ const errorToastDarkOptions = {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-muted-foreground text-xs dark:text-white">用户名</label>
|
||||
<label className="text-muted-foreground text-xs">用户名</label>
|
||||
<Input
|
||||
value={user}
|
||||
placeholder="@ 前部分"
|
||||
@@ -346,7 +319,7 @@ const errorToastDarkOptions = {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-muted-foreground text-xs dark:text-white">状态</label>
|
||||
<label className="text-muted-foreground text-xs">状态</label>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(value) => {
|
||||
@@ -354,10 +327,10 @@ const errorToastDarkOptions = {
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full dark:bg-[#0D2D54] dark:hover:bg-[#0D2D54]">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
<SelectItem value="success">成功</SelectItem>
|
||||
<SelectItem value="error">失败</SelectItem>
|
||||
@@ -365,7 +338,7 @@ const errorToastDarkOptions = {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-muted-foreground text-xs dark:text-white">错误类型</label>
|
||||
<label className="text-muted-foreground text-xs">错误类型</label>
|
||||
<Select
|
||||
value={category}
|
||||
onValueChange={(value) => {
|
||||
@@ -373,10 +346,10 @@ const errorToastDarkOptions = {
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full dark:bg-[#0D2D54] dark:hover:bg-[#0D2D54]">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
{TOOL_ERROR_CATEGORIES.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>
|
||||
@@ -387,7 +360,7 @@ const errorToastDarkOptions = {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-muted-foreground text-xs dark:text-white">开始时间</label>
|
||||
<label className="text-muted-foreground text-xs">开始时间</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={since}
|
||||
@@ -398,7 +371,7 @@ const errorToastDarkOptions = {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-muted-foreground text-xs dark:text-white">结束时间</label>
|
||||
<label className="text-muted-foreground text-xs">结束时间</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={until}
|
||||
@@ -416,10 +389,10 @@ const errorToastDarkOptions = {
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-xl border bg-white dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||
<div className="rounded-xl border bg-white">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[960px] text-sm">
|
||||
<thead className="bg-muted/60 text-muted-foreground dark:text-white">
|
||||
<thead className="bg-muted/60 text-muted-foreground">
|
||||
<tr>
|
||||
<Th>时间</Th>
|
||||
<Th>用户</Th>
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
import {
|
||||
BotIcon,
|
||||
CopyIcon,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
LockIcon,
|
||||
MessageSquareIcon,
|
||||
PinIcon,
|
||||
ShieldIcon,
|
||||
SparklesIcon,
|
||||
SquarePenIcon,
|
||||
@@ -16,14 +14,7 @@ import {
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -52,15 +43,12 @@ import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
import {
|
||||
useAgent,
|
||||
useCreateAgent,
|
||||
useDeleteAgent,
|
||||
useSetAgentFeatured,
|
||||
useSetAgentPrivate,
|
||||
useToggleAgentFavorite,
|
||||
useToggleAgentPin,
|
||||
useUpdateAgent,
|
||||
} from "@/core/agents";
|
||||
import { getAgent } from "@/core/agents/api";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -70,131 +58,16 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
interface AgentCardProps {
|
||||
agent: Agent;
|
||||
/** "card" (default) renders the full gallery card; "list" renders a compact
|
||||
* single-row layout for the list view. All dialogs/actions are shared. */
|
||||
layout?: "card" | "list";
|
||||
}
|
||||
|
||||
interface BadgeItem {
|
||||
key: string;
|
||||
node: ReactNode;
|
||||
}
|
||||
|
||||
// Minimum number of skill/tool badges to always render before collapsing the
|
||||
// remainder into the "+N" chip.
|
||||
const MIN_VISIBLE_BADGES = 3;
|
||||
|
||||
/** Render skill/tool-group badges clamped to at most two rows. Overflow is
|
||||
* collapsed into a clickable "+N" chip that opens a dialog listing the full
|
||||
* badge set (the visible rows stay as-is — only the chip is interactive).
|
||||
*
|
||||
* Badge widths are variable, so the cutoff is measured rather than assumed: an
|
||||
* off-screen mirror of the full badge set is laid out at the real container
|
||||
* width, and we keep only the badges that land within the first two rows
|
||||
* (reserving one slot for the "+N" chip). A ResizeObserver recomputes when the
|
||||
* responsive grid changes the card width.
|
||||
*/
|
||||
function ClampedBadgeRow({ items }: { items: BadgeItem[] }) {
|
||||
const measureRef = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(items.length);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const sig = items.map((i) => i.key).join("|");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = measureRef.current;
|
||||
if (!el) return;
|
||||
const compute = () => {
|
||||
const kids = Array.from(el.children) as HTMLElement[];
|
||||
if (kids.length === 0) {
|
||||
setVisible(0);
|
||||
return;
|
||||
}
|
||||
const top0 = kids[0].offsetTop;
|
||||
const rowH = kids[0].offsetHeight;
|
||||
// +2px fudge so the second row's badges (which may sit a hair lower due
|
||||
// to line-box rounding) are not falsely treated as a third row.
|
||||
const maxBottom = top0 + rowH * 2 + 2;
|
||||
let count = 0;
|
||||
for (const k of kids) {
|
||||
if (k.offsetTop + k.offsetHeight <= maxBottom) count += 1;
|
||||
else break;
|
||||
}
|
||||
// Reserve one slot for the "+N" chip when anything overflows so the chip
|
||||
// itself never spills onto a third row.
|
||||
if (count < kids.length && count > 0) count -= 1;
|
||||
// Always surface a few badges before collapsing into "+N", even when the
|
||||
// card is narrow enough that two rows barely fit anything (otherwise a
|
||||
// skinny column could show just "+N" with no skills at all). Keeps at
|
||||
// least one badge hidden so the chip still makes sense.
|
||||
if (count < kids.length) {
|
||||
count = Math.max(count, Math.min(MIN_VISIBLE_BADGES, kids.length - 1));
|
||||
}
|
||||
setVisible(count);
|
||||
};
|
||||
compute();
|
||||
const ro = new ResizeObserver(compute);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sig]);
|
||||
|
||||
const hidden = items.length - visible;
|
||||
const showChip = hidden > 0;
|
||||
const shown = items.slice(0, visible);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Off-screen full-width mirror used only for measuring row breaks. */}
|
||||
<div
|
||||
ref={measureRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute inset-x-0 top-0 flex flex-wrap gap-1"
|
||||
>
|
||||
{items.map((i) => i.node)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{shown.map((i) => i.node)}
|
||||
{showChip && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="hover:bg-secondary/70 cursor-pointer text-xs"
|
||||
title="查看全部"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
+{hidden}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>全部技能与工具</DialogTitle>
|
||||
<DialogDescription>共 {items.length} 项</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex max-h-[60vh] flex-wrap gap-1.5 overflow-y-auto py-1">
|
||||
{items.map((i) => i.node)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
||||
const isList = layout === "list";
|
||||
export function AgentCard({ agent }: AgentCardProps) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const deleteAgent = useDeleteAgent();
|
||||
const updateAgent = useUpdateAgent();
|
||||
const createAgent = useCreateAgent();
|
||||
const setFeatured = useSetAgentFeatured();
|
||||
const setPrivate = useSetAgentPrivate();
|
||||
const toggleFavorite = useToggleAgentFavorite();
|
||||
const togglePin = useToggleAgentPin();
|
||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
@@ -233,7 +106,6 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
||||
// already own, so only offer it on built-in / others' agents.
|
||||
const canFavorite = !isOwned;
|
||||
const isFavorite = !!agent.is_favorite;
|
||||
const isPinned = !!agent.is_pinned;
|
||||
|
||||
useEffect(() => {
|
||||
if (!editOpen) {
|
||||
@@ -281,41 +153,6 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDuplicate() {
|
||||
if (createAgent.isPending) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// SOUL.md is omitted from the list payload (perf), so fetch the detail
|
||||
// row to carry the source agent's personality into the copy. Fall back
|
||||
// to whatever the list happened to include if the detail fetch fails.
|
||||
let soul = agent.soul ?? "";
|
||||
try {
|
||||
const detail = await getAgent(agent.id);
|
||||
soul = detail.soul ?? soul;
|
||||
} catch {
|
||||
// keep the list fallback
|
||||
}
|
||||
const created = await createAgent.mutateAsync({
|
||||
name: `${agent.name} 副本`,
|
||||
description: agent.description ?? "",
|
||||
soul,
|
||||
skills: agent.skills ?? [],
|
||||
model: agent.model ?? null,
|
||||
tool_groups: agent.tool_groups ?? null,
|
||||
// A copy starts as a private, unpublished draft the owner can refine
|
||||
// before sharing — never inherit the source's published/private state.
|
||||
published: false,
|
||||
is_private: false,
|
||||
});
|
||||
toast.success(
|
||||
`已复制为「${desensitize(created.name)}」,可在「我的」中编辑`,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveEdit() {
|
||||
const trimmedName = editName.trim();
|
||||
if (!trimmedName) {
|
||||
@@ -397,16 +234,6 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTogglePin() {
|
||||
const next = !isPinned;
|
||||
try {
|
||||
await togglePin.mutateAsync({ id: agent.id, pinned: next });
|
||||
toast.success(next ? "已置顶" : "已取消置顶");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleFeatured() {
|
||||
// One-click on/off — ordering is handled by the dedicated 管理 Agent 列表
|
||||
// dialog. Featured agents get appended to the end on add (Date.now() is a
|
||||
@@ -438,326 +265,242 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
||||
|
||||
const enabledSkills = skills.filter((skill) => skill.enabled);
|
||||
|
||||
// Status badges (内置 / 我的 / 来自他人 / 已发布 / 私密 / 首页推荐) — shared by
|
||||
// both the card header and the compact list row.
|
||||
const statusBadges = (
|
||||
return (
|
||||
<>
|
||||
{isBuiltin && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
内置
|
||||
</Badge>
|
||||
)}
|
||||
{isOwned && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
|
||||
>
|
||||
我的
|
||||
</Badge>
|
||||
)}
|
||||
{isFromOthers && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
|
||||
title={agent.user_id ? `来自用户 ${agent.user_id}` : "已发布"}
|
||||
>
|
||||
来自他人
|
||||
</Badge>
|
||||
)}
|
||||
{isOwned && agent.published && !agent.is_private && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||
title="该智能体已对所有用户可见"
|
||||
>
|
||||
<GlobeIcon className="size-3" />
|
||||
已发布
|
||||
</Badge>
|
||||
)}
|
||||
{agent.is_private && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
|
||||
title="该智能体位于私密广场,需要密码才能查看"
|
||||
>
|
||||
<ShieldIcon className="size-3" />
|
||||
私密
|
||||
</Badge>
|
||||
)}
|
||||
{agent.featured_order != null && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/40 text-amber-700 dark:text-amber-300 text-xs"
|
||||
title="已加入首页选择器"
|
||||
>
|
||||
<SparklesIcon className="size-3" />
|
||||
首页推荐
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const skillBadgeItems = [
|
||||
...(agent.tool_groups ?? []).map((group) => ({
|
||||
key: `tg:${group}`,
|
||||
node: (
|
||||
<Badge key={`tg:${group}`} variant="outline" className="text-xs">
|
||||
{desensitize(group)}
|
||||
</Badge>
|
||||
),
|
||||
})),
|
||||
...(agent.skills ?? []).map((skill) => ({
|
||||
key: `sk:${skill}`,
|
||||
node: (
|
||||
<Badge
|
||||
key={`sk:${skill}`}
|
||||
variant="secondary"
|
||||
className="hover:bg-secondary/70 cursor-pointer text-xs"
|
||||
title={t.settings.skills.viewDetailAction}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDetailSkillName(skill);
|
||||
}}
|
||||
>
|
||||
{desensitize(skill)}
|
||||
</Badge>
|
||||
),
|
||||
})),
|
||||
];
|
||||
const hasSkillsOrTools =
|
||||
(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0;
|
||||
|
||||
const showActionRow =
|
||||
canEdit || canDelete || isOwned || isAdmin || canFavorite;
|
||||
|
||||
// The icon action buttons, shared by both layouts.
|
||||
const actionButtons = (
|
||||
<>
|
||||
{canFavorite && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
!isList && "mr-auto",
|
||||
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>
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
isPinned && "text-emerald-600 hover:text-emerald-700",
|
||||
!isList && !canFavorite && "mr-auto",
|
||||
)}
|
||||
onClick={() => void handleTogglePin()}
|
||||
disabled={togglePin.isPending}
|
||||
title={isPinned ? "取消置顶" : "置顶"}
|
||||
>
|
||||
<PinIcon className={cn("h-3.5 w-3.5", isPinned && "fill-current")} />
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
agent.featured_order != null &&
|
||||
"text-amber-600 hover:text-amber-700",
|
||||
)}
|
||||
onClick={() => void handleToggleFeatured()}
|
||||
disabled={setFeatured.isPending}
|
||||
title={
|
||||
agent.featured_order != null
|
||||
? "从首页选择器移除"
|
||||
: "加入首页选择器"
|
||||
}
|
||||
>
|
||||
<SparklesIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setTagPickerOpen(true)}
|
||||
title={t.adminTags.manageTags}
|
||||
>
|
||||
<TagsIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{isOwned && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => void handleTogglePublished()}
|
||||
disabled={updateAgent.isPending}
|
||||
title={agent.published ? "取消发布" : "发布"}
|
||||
>
|
||||
{agent.published ? (
|
||||
<LockIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<GlobeIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => void handleDuplicate()}
|
||||
disabled={createAgent.isPending}
|
||||
title="复制为新智能体"
|
||||
>
|
||||
<CopyIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
{canEdit && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setEditOpen(true)}
|
||||
title="编辑智能体"
|
||||
>
|
||||
<SquarePenIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
title={t.agents.delete}
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const chatButton = (
|
||||
<Button
|
||||
size="sm"
|
||||
className={cn(isList ? "shrink-0" : "w-full")}
|
||||
onClick={handleChat}
|
||||
>
|
||||
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t.agents.chat}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const cardLayout = (
|
||||
<Card className="group flex min-w-0 flex-col transition-shadow hover:shadow-md">
|
||||
<CardHeader className="min-w-0 pb-2">
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<BotIcon className="h-5 w-5" />
|
||||
<Card className="group flex min-w-0 flex-col transition-shadow hover:shadow-md">
|
||||
<CardHeader className="min-w-0 pb-3">
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<BotIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<CardTitle
|
||||
className="block w-full min-w-0 overflow-hidden text-base text-ellipsis whitespace-nowrap"
|
||||
title={desensitize(agent.name)}
|
||||
>
|
||||
{desensitize(agent.name)}
|
||||
</CardTitle>
|
||||
{agent.model && (
|
||||
<Badge variant="secondary" className="mt-0.5 text-xs">
|
||||
{desensitize(agent.model)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<CardTitle
|
||||
className="block w-full min-w-0 overflow-hidden text-base text-ellipsis whitespace-nowrap"
|
||||
title={desensitize(agent.name)}
|
||||
>
|
||||
{desensitize(agent.name)}
|
||||
</CardTitle>
|
||||
{agent.model && (
|
||||
<Badge variant="secondary" className="mt-0.5 text-xs">
|
||||
{desensitize(agent.model)}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{isBuiltin && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
内置
|
||||
</Badge>
|
||||
)}
|
||||
{isOwned && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
|
||||
>
|
||||
我的
|
||||
</Badge>
|
||||
)}
|
||||
{isFromOthers && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
|
||||
title={agent.user_id ? `来自用户 ${agent.user_id}` : "已发布"}
|
||||
>
|
||||
来自他人
|
||||
</Badge>
|
||||
)}
|
||||
{isOwned && agent.published && !agent.is_private && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||
title="该智能体已对所有用户可见"
|
||||
>
|
||||
<GlobeIcon className="size-3" />
|
||||
已发布
|
||||
</Badge>
|
||||
)}
|
||||
{agent.is_private && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
|
||||
title="该智能体位于私密广场,需要密码才能查看"
|
||||
>
|
||||
<ShieldIcon className="size-3" />
|
||||
私密
|
||||
</Badge>
|
||||
)}
|
||||
{agent.featured_order != null && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/40 text-amber-700 dark:text-amber-300 text-xs"
|
||||
title="已加入首页选择器"
|
||||
>
|
||||
<SparklesIcon className="size-3" />
|
||||
首页推荐
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1">
|
||||
{statusBadges}
|
||||
</div>
|
||||
</div>
|
||||
{agent.description && (
|
||||
<CardDescription className="mt-2 line-clamp-2 text-sm">
|
||||
{desensitize(agent.description)}
|
||||
</CardDescription>
|
||||
{agent.description && (
|
||||
<CardDescription className="mt-2 line-clamp-2 text-sm">
|
||||
{desensitize(agent.description)}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{(agent.tags?.length ?? 0) > 0 && (
|
||||
<CardContent className="pt-0 pb-2">
|
||||
<TagChipList tags={agent.tags!} />
|
||||
</CardContent>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{(agent.tags?.length ?? 0) > 0 && (
|
||||
<CardContent className="pt-0 pb-2">
|
||||
<TagChipList tags={agent.tags!} />
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{hasSkillsOrTools && (
|
||||
<CardContent className="pt-0 pb-2">
|
||||
<ClampedBadgeRow items={skillBadgeItems} />
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-2">
|
||||
{showActionRow && (
|
||||
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||
{actionButtons}
|
||||
</div>
|
||||
{(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0 && (
|
||||
<CardContent className="pt-0 pb-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{agent.tool_groups?.map((group) => (
|
||||
<Badge
|
||||
key={`tg:${group}`}
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
>
|
||||
{desensitize(group)}
|
||||
</Badge>
|
||||
))}
|
||||
{agent.skills?.map((skill) => (
|
||||
<Badge
|
||||
key={`sk:${skill}`}
|
||||
variant="secondary"
|
||||
className="hover:bg-secondary/70 cursor-pointer text-xs"
|
||||
title={t.settings.skills.viewDetailAction}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDetailSkillName(skill);
|
||||
}}
|
||||
>
|
||||
{desensitize(skill)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
{chatButton}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const listLayout = (
|
||||
<div className="group flex min-w-0 items-center gap-3 rounded-lg border p-2.5 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C]">
|
||||
<div className="bg-primary/10 text-primary flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<BotIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="min-w-0 truncate text-sm font-medium"
|
||||
title={desensitize(agent.name)}
|
||||
>
|
||||
{desensitize(agent.name)}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-1">{statusBadges}</div>
|
||||
</div>
|
||||
{agent.description && (
|
||||
<p className="text-muted-foreground mt-0.5 truncate text-xs">
|
||||
{desensitize(agent.description)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{showActionRow && actionButtons}
|
||||
{chatButton}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isList ? listLayout : cardLayout}
|
||||
<CardFooter className="mt-auto flex flex-col items-stretch gap-2 pt-3">
|
||||
{(canEdit || canDelete || isOwned || isAdmin || canFavorite) && (
|
||||
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||
{canFavorite && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"mr-auto h-8 w-8 shrink-0",
|
||||
isFavorite && "text-amber-500 hover:text-amber-600",
|
||||
)}
|
||||
onClick={() => void handleToggleFavorite()}
|
||||
disabled={toggleFavorite.isPending}
|
||||
title={isFavorite ? "取消收藏" : "收藏到「我的」"}
|
||||
>
|
||||
<StarIcon
|
||||
className={cn("h-3.5 w-3.5", isFavorite && "fill-current")}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
agent.featured_order != null &&
|
||||
"text-amber-600 hover:text-amber-700",
|
||||
)}
|
||||
onClick={() => void handleToggleFeatured()}
|
||||
disabled={setFeatured.isPending}
|
||||
title={
|
||||
agent.featured_order != null
|
||||
? "从首页选择器移除"
|
||||
: "加入首页选择器"
|
||||
}
|
||||
>
|
||||
<SparklesIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setTagPickerOpen(true)}
|
||||
title={t.adminTags.manageTags}
|
||||
>
|
||||
<TagsIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{isOwned && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => void handleTogglePublished()}
|
||||
disabled={updateAgent.isPending}
|
||||
title={agent.published ? "取消发布" : "发布"}
|
||||
>
|
||||
{agent.published ? (
|
||||
<LockIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<GlobeIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{(isOwned || isAdmin) && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
agent.is_private &&
|
||||
"text-purple-600 hover:text-purple-700",
|
||||
)}
|
||||
onClick={() => void handleTogglePrivate()}
|
||||
disabled={updateAgent.isPending || setPrivate.isPending}
|
||||
title={
|
||||
agent.is_private ? "移出私密广场" : "放入私密广场"
|
||||
}
|
||||
>
|
||||
<ShieldIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setEditOpen(true)}
|
||||
title="编辑智能体"
|
||||
>
|
||||
<SquarePenIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
title={t.agents.delete}
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Button size="sm" className="w-full" onClick={handleChat}>
|
||||
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t.agents.chat}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
@@ -792,7 +535,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
||||
已选 {selectedSkills.length} 个
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2">
|
||||
<div className="rounded-md border p-2">
|
||||
{isSkillsLoading ? (
|
||||
<p className="text-muted-foreground text-sm">技能加载中...</p>
|
||||
) : enabledSkills.length === 0 ? (
|
||||
@@ -800,13 +543,13 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
||||
当前没有可用技能
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{enabledSkills.map((skill) => {
|
||||
const checked = selectedSkills.includes(skill.name);
|
||||
return (
|
||||
<div
|
||||
key={skill.name}
|
||||
className="flex w-full min-w-0 items-center"
|
||||
className="inline-flex max-w-full items-center"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -814,7 +557,7 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
||||
variant={checked ? "default" : "outline"}
|
||||
onClick={() => toggleSkill(skill.name)}
|
||||
title={desensitize(skill.description ?? "")}
|
||||
className="h-8 min-w-0 flex-1 rounded-r-none"
|
||||
className="h-8 max-w-[200px] rounded-r-none"
|
||||
>
|
||||
<span className="truncate">{desensitize(skill.name)}</span>
|
||||
</Button>
|
||||
@@ -934,13 +677,15 @@ export function AgentCard({ agent, layout = "card" }: AgentCardProps) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TagPickerDialog
|
||||
open={tagPickerOpen}
|
||||
onOpenChange={setTagPickerOpen}
|
||||
targetType="agent"
|
||||
targetId={agent.id}
|
||||
currentTags={agent.tags ?? []}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<TagPickerDialog
|
||||
open={tagPickerOpen}
|
||||
onOpenChange={setTagPickerOpen}
|
||||
targetType="agent"
|
||||
targetId={agent.id}
|
||||
currentTags={agent.tags ?? []}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
<SkillDetailDialog
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDownUpIcon, BotIcon, LockIcon, PlusIcon, ShieldIcon } from "lucide-react";
|
||||
import { BotIcon, LockIcon, PlusIcon, ShieldIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
|
||||
import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents";
|
||||
import type { Agent, AgentSort } from "@/core/agents";
|
||||
import { useAgents, usePrivateAgents } from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { usePrivateSquareStatus, useViewMode } from "@/core/system-settings/hooks";
|
||||
import { usePrivateSquareStatus } from "@/core/system-settings/hooks";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
import { AgentCard } from "./agent-card";
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
// 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.
|
||||
@@ -29,26 +26,24 @@ const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password";
|
||||
export function AgentGallery() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const [viewMode, setViewMode] = useViewMode("agent");
|
||||
const [activeTab, setActiveTab] = useState<"all" | "mine" | "private">(
|
||||
"mine",
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
const [sort, setSort] = useState<AgentSort>("time_desc");
|
||||
// Per-tab 1-based page index, preserved while switching tabs.
|
||||
const [pages, setPages] = useState<{ all: number; mine: number; private: number }>(
|
||||
{ all: 1, mine: 1, private: 1 },
|
||||
);
|
||||
// Tag filtering stays server-side; name search must run on the *desensitized*
|
||||
// form because that's what the user sees on the card. If we sent the search
|
||||
// string to the backend it would compare against the raw stored name and
|
||||
// miss every desensitized hit (e.g. typing "TS分析" wouldn't match the
|
||||
// stored "态势分析").
|
||||
const { agents, isLoading } = useAgents({ tagIds });
|
||||
|
||||
// Search + tag filtering are both server-side now. The backend mirrors the
|
||||
// frontend's desensitize map, so typing the displayed abbreviation (e.g.
|
||||
// "TS") still matches the stored raw name (e.g. "态势分析"). Whenever the
|
||||
// filters change, every tab returns to its first page.
|
||||
const tagKey = tagIds.join(",");
|
||||
useEffect(() => {
|
||||
setPages({ all: 1, mine: 1, private: 1 });
|
||||
}, [search, tagKey, sort]);
|
||||
const needle = search.trim().toLowerCase();
|
||||
const matchesSearch = (a: Agent) =>
|
||||
!needle || desensitize(a.name).toLowerCase().includes(needle);
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
|
||||
const { data: privateStatus } = usePrivateSquareStatus();
|
||||
const privateFeatureAvailable = !!privateStatus?.enabled;
|
||||
@@ -61,37 +56,11 @@ export function AgentGallery() {
|
||||
});
|
||||
const [passwordInput, setPasswordInput] = useState("");
|
||||
|
||||
// Only the active tab fetches. "广场" maps to the server "square" scope
|
||||
// (built-in/published, never private), "我的" to "mine" (owned + favorited).
|
||||
const mainScope = activeTab === "mine" ? "mine" : "square";
|
||||
const mainPage = activeTab === "mine" ? pages.mine : pages.all;
|
||||
const {
|
||||
agents: mainAgents,
|
||||
total: mainTotal,
|
||||
isLoading: mainLoading,
|
||||
} = useAgentsPaginated({
|
||||
scope: mainScope,
|
||||
search,
|
||||
tagIds,
|
||||
sort,
|
||||
page: mainPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
enabled: activeTab !== "private",
|
||||
});
|
||||
|
||||
const {
|
||||
agents: privateAgents,
|
||||
total: privateTotal,
|
||||
isLoading: isPrivateLoading,
|
||||
error: privateError,
|
||||
} = usePrivateAgentsPaginated(privatePassword, {
|
||||
search,
|
||||
tagIds,
|
||||
sort,
|
||||
page: pages.private,
|
||||
pageSize: PAGE_SIZE,
|
||||
enabled: activeTab === "private",
|
||||
});
|
||||
} = 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.
|
||||
@@ -103,9 +72,49 @@ export function AgentGallery() {
|
||||
}
|
||||
}, [privateError, privatePassword]);
|
||||
|
||||
const activeTotal = activeTab === "private" ? privateTotal : mainTotal;
|
||||
const setTabPage = (tab: "all" | "mine" | "private", page: number) =>
|
||||
setPages((prev) => ({ ...prev, [tab]: page }));
|
||||
// The "广场" tab must never surface private-square agents, even to their
|
||||
// own owner — moving an agent into the private square is supposed to hide
|
||||
// it from the public square entirely. (The backend listing endpoint
|
||||
// already filters them out for non-owners; this extra clause covers the
|
||||
// owner's own private agents, which still come back in the unified list
|
||||
// because owners always see their own.)
|
||||
const allAgents = useMemo(
|
||||
() =>
|
||||
agents.filter(
|
||||
(a) => (a.builtin || a.published) && !a.is_private && matchesSearch(a),
|
||||
),
|
||||
// matchesSearch closes over `needle`; depending on `search` is sufficient
|
||||
// and avoids re-running on every render via the function reference.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[agents, search],
|
||||
);
|
||||
|
||||
// "我的" shows the user's own agents plus any agent they've favorited (收藏)
|
||||
// from the square — favorited public agents have a different user_id but a
|
||||
// truthy is_favorite flag.
|
||||
const myAgents = useMemo(
|
||||
() =>
|
||||
agents.filter(
|
||||
(a) =>
|
||||
((a.user_id !== null && a.user_id === currentUserId) ||
|
||||
a.is_favorite) &&
|
||||
matchesSearch(a),
|
||||
),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[agents, currentUserId, search],
|
||||
);
|
||||
|
||||
const filteredPrivateAgents = useMemo(() => {
|
||||
const wanted = new Set(tagIds);
|
||||
return privateAgents.filter((a) => {
|
||||
if (!matchesSearch(a)) return false;
|
||||
if (wanted.size > 0) {
|
||||
if (!(a.tags ?? []).some((tg) => wanted.has(tg.id))) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [privateAgents, search, tagIds]);
|
||||
|
||||
const handleNewAgent = () => {
|
||||
router.push("/page/workspace/agents/new");
|
||||
@@ -127,56 +136,13 @@ export function AgentGallery() {
|
||||
setPrivatePassword(null);
|
||||
};
|
||||
|
||||
const renderGrid = (list: Agent[]) =>
|
||||
viewMode === "list" ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{list.map((agent) => (
|
||||
<AgentCard key={agent.id} agent={agent} layout="list" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{list.map((agent) => (
|
||||
<AgentCard key={agent.id} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
/** Loading / empty / (grid + pagination) for one paginated tab. */
|
||||
const renderListBody = (opts: {
|
||||
list: Agent[];
|
||||
total: number;
|
||||
isLoading: boolean;
|
||||
tab: "all" | "mine" | "private";
|
||||
emptyDescription: string;
|
||||
showNewButton?: boolean;
|
||||
}) => {
|
||||
const { list, total, isLoading, tab, emptyDescription, showNewButton } = opts;
|
||||
// keepPreviousData keeps the old page visible while a new page loads, so
|
||||
// only show the spinner on the very first load (no rows yet).
|
||||
if (isLoading && list.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
||||
{t.common.loading}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (list.length === 0) {
|
||||
return renderEmpty(emptyDescription, showNewButton);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{renderGrid(list)}
|
||||
<Pagination
|
||||
page={pages[tab]}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={total}
|
||||
onPageChange={(p) => setTabPage(tab, p)}
|
||||
className="pt-2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const renderGrid = (list: typeof agents) => (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{list.map((agent) => (
|
||||
<AgentCard key={agent.id} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderEmpty = (description: string, showNewButton = false) => (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||
@@ -252,15 +218,6 @@ export function AgentGallery() {
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSubmitPassword();
|
||||
}}
|
||||
// This is a one-off access code, not a login credential. Tell
|
||||
// browsers / password managers not to save or autofill it (which
|
||||
// would also stop them from spilling a saved account into the
|
||||
// name search box on this page).
|
||||
name="private-square-access-code"
|
||||
autoComplete="off"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-form-type="other"
|
||||
/>
|
||||
<Button onClick={handleSubmitPassword}>进入私密广场</Button>
|
||||
</div>
|
||||
@@ -268,13 +225,19 @@ export function AgentGallery() {
|
||||
);
|
||||
}
|
||||
|
||||
return renderListBody({
|
||||
list: privateAgents,
|
||||
total: privateTotal,
|
||||
isLoading: isPrivateLoading,
|
||||
tab: "private",
|
||||
emptyDescription: "私密广场暂时还没有智能体。",
|
||||
});
|
||||
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 (
|
||||
@@ -313,70 +276,48 @@ export function AgentGallery() {
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
共 {activeTotal} 个
|
||||
</span>
|
||||
<ViewModeToggle value={viewMode} onChange={setViewMode} />
|
||||
{activeTab === "private" && privatePassword && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLockPrivate}
|
||||
title="锁定私密广场,下次需要重新输入密码"
|
||||
>
|
||||
<LockIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
锁定
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{activeTab === "private" && privatePassword && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLockPrivate}
|
||||
title="锁定私密广场,下次需要重新输入密码"
|
||||
>
|
||||
<LockIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
锁定
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<TagSearchBar
|
||||
targetType="agent"
|
||||
onSearchChange={setSearch}
|
||||
tagIds={tagIds}
|
||||
onTagIdsChange={setTagIds}
|
||||
actions={
|
||||
<Select value={sort} onValueChange={(v) => setSort(v as AgentSort)}>
|
||||
<SelectTrigger
|
||||
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
|
||||
title="排序"
|
||||
>
|
||||
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
||||
<SelectItem value="time_desc">最新创建</SelectItem>
|
||||
<SelectItem value="time_asc">最早创建</SelectItem>
|
||||
<SelectItem value="updated_desc">最近更新</SelectItem>
|
||||
<SelectItem value="updated_asc">最早更新</SelectItem>
|
||||
<SelectItem value="name_asc">名称 A-Z</SelectItem>
|
||||
<SelectItem value="name_desc">名称 Z-A</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||
{renderListBody({
|
||||
list: mainAgents,
|
||||
total: mainTotal,
|
||||
isLoading: mainLoading,
|
||||
tab: "all",
|
||||
emptyDescription: t.agents.emptyDescription,
|
||||
})}
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : allAgents.length === 0 ? (
|
||||
renderEmpty(t.agents.emptyDescription)
|
||||
) : (
|
||||
renderGrid(allAgents)
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||
{renderListBody({
|
||||
list: mainAgents,
|
||||
total: mainTotal,
|
||||
isLoading: mainLoading,
|
||||
tab: "mine",
|
||||
emptyDescription: t.agents.emptyDescription,
|
||||
showNewButton: true,
|
||||
})}
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : myAgents.length === 0 ? (
|
||||
renderEmpty(t.agents.emptyDescription, true)
|
||||
) : (
|
||||
renderGrid(myAgents)
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{privateFeatureAvailable && (
|
||||
|
||||
@@ -136,21 +136,21 @@ export function AgentManageDialog({
|
||||
{agents.map((agent, index) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="flex min-w-0 items-start gap-2 px-3 py-2 hover:bg-accent/30"
|
||||
className="flex items-center gap-2 px-3 py-2 hover:bg-accent/30"
|
||||
>
|
||||
<span className="text-muted-foreground w-6 shrink-0 text-center text-xs tabular-nums">
|
||||
{index + 1}
|
||||
</span>
|
||||
<BotIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
||||
<BotIcon className="text-muted-foreground size-4 shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm">{agent.name}</span>
|
||||
{agent.description && (
|
||||
<span className="line-clamp-2 whitespace-normal break-words text-[11px] leading-snug text-muted-foreground">
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
{agent.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Tooltip content="上移">
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -222,13 +222,12 @@ export function AgentManageDialog({
|
||||
key={agent.id}
|
||||
value={`${agent.name} ${agent.description ?? ""}`}
|
||||
onSelect={() => void add(agent)}
|
||||
className="flex w-full min-w-0 items-start overflow-hidden"
|
||||
>
|
||||
<BotIcon className="text-muted-foreground mr-2 mt-0.5 size-4 shrink-0" />
|
||||
<BotIcon className="text-muted-foreground mr-2 size-4 shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm">{agent.name}</span>
|
||||
{agent.description && (
|
||||
<span className="line-clamp-2 whitespace-normal break-words text-[10px] leading-snug text-muted-foreground">
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
{agent.description}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -120,15 +120,15 @@ export function AgentSelector({
|
||||
value={`${agent.name} ${agent.description ?? ""}`}
|
||||
onSelect={() => handleSelect(agent)}
|
||||
className={cn(
|
||||
"flex w-full min-w-0 items-start gap-2 overflow-hidden",
|
||||
"flex items-center gap-2",
|
||||
agent.id === selectedAgentId && "bg-accent",
|
||||
)}
|
||||
>
|
||||
<BotIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
||||
<BotIcon className="text-muted-foreground size-4 shrink-0" />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm">{agent.name}</span>
|
||||
{agent.description && (
|
||||
<span className="line-clamp-2 whitespace-normal break-words text-[10px] leading-snug text-muted-foreground">
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
{agent.description}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
@@ -44,29 +43,12 @@ import {
|
||||
useArtifactContent,
|
||||
useUpdateArtifactContent,
|
||||
} from "@/core/artifacts/hooks";
|
||||
import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
import { installSkill } from "@/core/skills/api";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
import {
|
||||
checkCodeFile,
|
||||
getFileName,
|
||||
isImageFile,
|
||||
} from "@/core/utils/files";
|
||||
import {
|
||||
downloadMermaidSvgFile,
|
||||
isMermaidFile,
|
||||
openMermaidSvgInNewTab,
|
||||
renderMermaidToSvg,
|
||||
} from "@/core/utils/mermaid";
|
||||
import {
|
||||
createMarkdownAnchorResolver,
|
||||
injectInPageAnchorHandler,
|
||||
isInPageAnchor,
|
||||
scrollToInPageAnchor,
|
||||
} from "@/core/utils/markdown";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { checkCodeFile, getFileName } from "@/core/utils/files";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -79,12 +61,6 @@ import { useArtifacts } from "./context";
|
||||
|
||||
const SANDBOX_RESUME_EVENT = "workspace:sandbox-resume-autoscroll";
|
||||
|
||||
function extractTextFromChildren(children: ReactNode): string {
|
||||
if (typeof children === "string" || typeof children === "number") return String(children);
|
||||
if (Array.isArray(children)) return children.map(extractTextFromChildren).join("");
|
||||
return "";
|
||||
}
|
||||
|
||||
function findScrollableArtifactElement(root: HTMLElement | null) {
|
||||
if (!root) {
|
||||
return null;
|
||||
@@ -109,36 +85,29 @@ export function ArtifactFileDetail({
|
||||
className,
|
||||
filepath: filepathFromProps,
|
||||
threadId,
|
||||
onClose,
|
||||
}: {
|
||||
className?: string;
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { artifacts, setOpen, select } = useArtifacts();
|
||||
const { isMock, thread, publicShareCode } = useThread();
|
||||
const isWriteFileVirtual = useMemo(() => {
|
||||
const isWriteFile = useMemo(() => {
|
||||
return filepathFromProps.startsWith("write-file:");
|
||||
}, [filepathFromProps]);
|
||||
const filepath = useMemo(() => {
|
||||
if (isWriteFileVirtual) {
|
||||
if (isWriteFile) {
|
||||
const url = new URL(filepathFromProps);
|
||||
return decodeURIComponent(url.pathname);
|
||||
}
|
||||
return filepathFromProps;
|
||||
}, [filepathFromProps, isWriteFileVirtual]);
|
||||
/** write_file 流式预览走 tool args 文本;图片等二进制产物始终走 artifacts API。 */
|
||||
const useWriteFileStreaming = useMemo(() => {
|
||||
return isWriteFileVirtual && !isImageFile(filepath);
|
||||
}, [filepath, isWriteFileVirtual]);
|
||||
}, [filepathFromProps, isWriteFile]);
|
||||
const isSkillFile = useMemo(() => {
|
||||
return filepath.endsWith(".skill");
|
||||
}, [filepath]);
|
||||
const { isCodeFile, language } = useMemo(() => {
|
||||
if (useWriteFileStreaming) {
|
||||
if (isWriteFile) {
|
||||
let nextLanguage = checkCodeFile(filepath).language;
|
||||
nextLanguage ??= "text";
|
||||
return { isCodeFile: true, language: nextLanguage };
|
||||
@@ -147,25 +116,18 @@ export function ArtifactFileDetail({
|
||||
return { isCodeFile: true, language: "markdown" };
|
||||
}
|
||||
return checkCodeFile(filepath);
|
||||
}, [filepath, useWriteFileStreaming, isSkillFile]);
|
||||
}, [filepath, isWriteFile, isSkillFile]);
|
||||
const isSupportPreview = useMemo(() => {
|
||||
return (
|
||||
language === "html" ||
|
||||
language === "markdown" ||
|
||||
language === "mermaid"
|
||||
);
|
||||
return language === "html" || language === "markdown";
|
||||
}, [language]);
|
||||
const { content, isApplyingEdit } = useArtifactContent({
|
||||
threadId,
|
||||
filepath: filepathFromProps,
|
||||
enabled: isCodeFile && !useWriteFileStreaming,
|
||||
enabled: isCodeFile && !isWriteFile,
|
||||
});
|
||||
|
||||
const canEditArtifact =
|
||||
language === "markdown" &&
|
||||
!useWriteFileStreaming &&
|
||||
!isSkillFile &&
|
||||
!isMock;
|
||||
language === "markdown" && !isWriteFile && !isSkillFile && !isMock;
|
||||
const [draftContent, setDraftContent] = useState("");
|
||||
const [hasLocalEdits, setHasLocalEdits] = useState(false);
|
||||
const updateArtifact = useUpdateArtifactContent();
|
||||
@@ -173,14 +135,12 @@ export function ArtifactFileDetail({
|
||||
const isHtmlStreaming = language === "html" && thread.isLoading;
|
||||
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
|
||||
const fileTypeLabel = useMemo(() => {
|
||||
if (isImageFile(filepath)) return "IMG";
|
||||
if (isMermaidFile(filepath) || language === "mermaid") return "MMD";
|
||||
if (language === "html") return "HTML";
|
||||
if (language === "markdown") return "MD";
|
||||
if (isSkillFile) return "SKILL";
|
||||
if (language) return language.toUpperCase();
|
||||
return "FILE";
|
||||
}, [filepath, isSkillFile, language]);
|
||||
}, [isSkillFile, language]);
|
||||
|
||||
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
|
||||
const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true);
|
||||
@@ -440,61 +400,6 @@ export function ArtifactFileDetail({
|
||||
}
|
||||
}, [displayContent, filepath]);
|
||||
|
||||
const handleOpenWriteFileInNewTab = useCallback(() => {
|
||||
const source = displayContent ?? "";
|
||||
if (!source.trim()) {
|
||||
toast.error("文件内容为空,暂时无法打开");
|
||||
return;
|
||||
}
|
||||
if (language === "mermaid") {
|
||||
void openMermaidSvgInNewTab(source).catch((error) => {
|
||||
console.error("Failed to open mermaid artifact:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to open artifact");
|
||||
});
|
||||
return;
|
||||
}
|
||||
const blobType =
|
||||
language === "html"
|
||||
? "text/html;charset=utf-8"
|
||||
: language === "markdown"
|
||||
? "text/markdown;charset=utf-8"
|
||||
: "text/plain;charset=utf-8";
|
||||
const blob = new Blob([source], { type: blobType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
}, [displayContent, language]);
|
||||
|
||||
const handleDownloadWriteFile = useCallback(() => {
|
||||
const source = displayContent ?? "";
|
||||
if (!source.trim()) {
|
||||
toast.error("文件内容为空,暂时无法下载");
|
||||
return;
|
||||
}
|
||||
if (language === "mermaid") {
|
||||
void downloadMermaidSvgFile(source, filepath).catch((error) => {
|
||||
console.error("Failed to download mermaid artifact:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Download failed");
|
||||
});
|
||||
return;
|
||||
}
|
||||
const blobType =
|
||||
language === "html"
|
||||
? "text/html;charset=utf-8"
|
||||
: language === "markdown"
|
||||
? "text/markdown;charset=utf-8"
|
||||
: "text/plain;charset=utf-8";
|
||||
const blob = new Blob([source], { type: blobType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = getFileName(filepath);
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, [displayContent, filepath, language]);
|
||||
|
||||
return (
|
||||
<Artifact className={cn(className)}>
|
||||
<ArtifactHeader className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 px-4 py-3">
|
||||
@@ -504,7 +409,7 @@ export function ArtifactFileDetail({
|
||||
{fileTypeLabel}
|
||||
</span>
|
||||
<ArtifactTitle className="min-w-0">
|
||||
{useWriteFileStreaming ? (
|
||||
{isWriteFile ? (
|
||||
<div className="truncate px-3">{getFileName(filepath)}</div>
|
||||
) : hasMultipleArtifacts ? (
|
||||
<Select value={filepath} onValueChange={select}>
|
||||
@@ -581,7 +486,7 @@ export function ArtifactFileDetail({
|
||||
onClick={handleSaveArtifact}
|
||||
/>
|
||||
)}
|
||||
{!useWriteFileStreaming && filepath.endsWith(".skill") && (
|
||||
{!isWriteFile && filepath.endsWith(".skill") && (
|
||||
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
||||
<ArtifactAction
|
||||
icon={isInstalling ? LoaderIcon : PackageIcon}
|
||||
@@ -595,18 +500,19 @@ export function ArtifactFileDetail({
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ArtifactAction
|
||||
icon={SquareArrowOutUpRightIcon}
|
||||
label={t.common.openInNewWindow}
|
||||
tooltip={t.common.openInNewWindow}
|
||||
onClick={async () => {
|
||||
if (useWriteFileStreaming) {
|
||||
handleOpenWriteFileInNewTab();
|
||||
return;
|
||||
}
|
||||
if (language === "mermaid") {
|
||||
{!isWriteFile && (
|
||||
<ArtifactAction
|
||||
icon={SquareArrowOutUpRightIcon}
|
||||
label={t.common.openInNewWindow}
|
||||
tooltip={t.common.openInNewWindow}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await openMermaidSvgInNewTab(displayContent ?? "");
|
||||
await openArtifactInNewTab({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
publicShareCode,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
@@ -615,25 +521,9 @@ export function ArtifactFileDetail({
|
||||
: "Failed to open artifact",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await openArtifactInNewTab({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
publicShareCode,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to open artifact",
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isCodeFile && (
|
||||
<ArtifactAction
|
||||
icon={CopyIcon}
|
||||
@@ -651,7 +541,7 @@ export function ArtifactFileDetail({
|
||||
tooltip={t.clipboard.copyToClipboard}
|
||||
/>
|
||||
)}
|
||||
{!useWriteFileStreaming && language === "markdown" && (
|
||||
{!isWriteFile && language === "markdown" && (
|
||||
<ArtifactAction
|
||||
icon={isExportingWord ? LoaderIcon : FileType2Icon}
|
||||
label="另存为 Word"
|
||||
@@ -660,18 +550,19 @@ export function ArtifactFileDetail({
|
||||
onClick={handleDownloadAsWord}
|
||||
/>
|
||||
)}
|
||||
<ArtifactAction
|
||||
icon={DownloadIcon}
|
||||
label={t.common.download}
|
||||
tooltip={t.common.download}
|
||||
onClick={async () => {
|
||||
if (useWriteFileStreaming) {
|
||||
handleDownloadWriteFile();
|
||||
return;
|
||||
}
|
||||
if (language === "mermaid") {
|
||||
{!isWriteFile && (
|
||||
<ArtifactAction
|
||||
icon={DownloadIcon}
|
||||
label={t.common.download}
|
||||
tooltip={t.common.download}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await downloadMermaidSvgFile(displayContent ?? "", filepath);
|
||||
await downloadArtifactFile({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
publicShareCode,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
@@ -680,30 +571,14 @@ export function ArtifactFileDetail({
|
||||
: "Download failed",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await downloadArtifactFile({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
publicShareCode,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Download failed",
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ArtifactAction
|
||||
className="hover:bg-background"
|
||||
icon={XIcon}
|
||||
label={t.common.close}
|
||||
onClick={() => (onClose ? onClose() : setOpen(false))}
|
||||
onClick={() => setOpen(false)}
|
||||
tooltip={t.common.close}
|
||||
/>
|
||||
</ArtifactActions>
|
||||
@@ -732,14 +607,10 @@ export function ArtifactFileDetail({
|
||||
)}
|
||||
{isSupportPreview &&
|
||||
viewMode === "preview" &&
|
||||
(language === "markdown" ||
|
||||
language === "html" ||
|
||||
language === "mermaid") && (
|
||||
(language === "markdown" || language === "html") && (
|
||||
<ArtifactFilePreview
|
||||
content={desensitize(displayContent ?? "")}
|
||||
language={language ?? "text"}
|
||||
threadId={threadId}
|
||||
publicShareCode={publicShareCode}
|
||||
animated={showPreviewSwitchPulse}
|
||||
activeCursor={isApplyingEdit}
|
||||
/>
|
||||
@@ -780,121 +651,18 @@ export function ArtifactFileDetail({
|
||||
);
|
||||
}
|
||||
|
||||
function SandboxPreviewImage({
|
||||
src,
|
||||
alt,
|
||||
threadId,
|
||||
publicShareCode,
|
||||
...props
|
||||
}: React.ImgHTMLAttributes<HTMLImageElement> & {
|
||||
threadId: string;
|
||||
publicShareCode?: string;
|
||||
}) {
|
||||
if (!src || typeof src !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let url = src;
|
||||
if (src.startsWith("/mnt/")) {
|
||||
if (publicShareCode) {
|
||||
const normalized = src.startsWith("/") ? src : `/${src}`;
|
||||
url = `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
|
||||
publicShareCode,
|
||||
)}/files${normalized}`;
|
||||
} else {
|
||||
url = resolveArtifactURL(src, threadId);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
{...props}
|
||||
className={cn(
|
||||
"my-3 max-w-full overflow-hidden rounded-lg",
|
||||
props.className,
|
||||
)}
|
||||
src={url}
|
||||
alt={alt}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArtifactFilePreview({
|
||||
content,
|
||||
language,
|
||||
threadId,
|
||||
publicShareCode,
|
||||
animated = false,
|
||||
activeCursor = false,
|
||||
}: {
|
||||
content: string;
|
||||
language: string;
|
||||
threadId: string;
|
||||
publicShareCode?: string;
|
||||
animated?: boolean;
|
||||
activeCursor?: boolean;
|
||||
}) {
|
||||
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
|
||||
const [mermaidSvg, setMermaidSvg] = useState<string | null>(null);
|
||||
const [mermaidError, setMermaidError] = useState<string | null>(null);
|
||||
const markdownComponents = useMemo(() => {
|
||||
const anchorResolver = createMarkdownAnchorResolver(content ?? "");
|
||||
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
|
||||
const Tag = `h${level}` as const;
|
||||
return ({ children, id, ...props }: HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<Tag id={id ?? anchorResolver.getHeadingId(level, extractTextFromChildren(children))} {...props}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
h1: heading(1),
|
||||
h2: heading(2),
|
||||
h3: heading(3),
|
||||
h4: heading(4),
|
||||
h5: heading(5),
|
||||
h6: heading(6),
|
||||
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
if (typeof props.children === "string" && /^citation:(.+)$/.test(props.children)) {
|
||||
return <ArtifactLink {...props} />;
|
||||
}
|
||||
const { className, ...rest } = props;
|
||||
const resolvedHref = anchorResolver.resolveHref(
|
||||
props.href,
|
||||
extractTextFromChildren(props.children),
|
||||
);
|
||||
if (resolvedHref === null) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-primary/80 decoration-dotted underline underline-offset-2 cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
title="未在当前 Markdown 中找到对应章节"
|
||||
>
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const finalHref = resolvedHref ?? props.href;
|
||||
return (
|
||||
<ArtifactLink
|
||||
{...rest}
|
||||
className={className}
|
||||
href={finalHref}
|
||||
onClick={(event) => {
|
||||
if (isInPageAnchor(finalHref)) {
|
||||
event.preventDefault();
|
||||
scrollToInPageAnchor(event.currentTarget, finalHref);
|
||||
}
|
||||
rest.onClick?.(event);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
}, [content]);
|
||||
|
||||
useEffect(() => {
|
||||
if (language !== "html") {
|
||||
@@ -902,14 +670,7 @@ export function ArtifactFilePreview({
|
||||
return;
|
||||
}
|
||||
|
||||
// 必须带 charset=utf-8:blob: URL 的 Content-Type 不含 charset 时,iframe 会按
|
||||
// 系统默认编码(中文 Windows 常为 GBK/Latin-1)解码,模型生成的 HTML 若没写
|
||||
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
|
||||
// Intercept in-page anchor clicks so they scroll within the iframe instead
|
||||
// of navigating the sandboxed blob: document (which blanks the frame).
|
||||
const blob = new Blob([injectInPageAnchorHandler(content ?? "")], {
|
||||
type: "text/html;charset=utf-8",
|
||||
});
|
||||
const blob = new Blob([content ?? ""], { type: "text/html" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setHtmlPreviewUrl(url);
|
||||
|
||||
@@ -918,42 +679,10 @@ export function ArtifactFilePreview({
|
||||
};
|
||||
}, [content, language]);
|
||||
|
||||
useEffect(() => {
|
||||
if (language !== "mermaid") {
|
||||
setMermaidSvg(null);
|
||||
setMermaidError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setMermaidSvg(null);
|
||||
setMermaidError(null);
|
||||
|
||||
void renderMermaidToSvg(content ?? "")
|
||||
.then((svg) => {
|
||||
if (!cancelled) {
|
||||
setMermaidSvg(svg);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to render mermaid preview:", error);
|
||||
if (!cancelled) {
|
||||
setMermaidError(
|
||||
error instanceof Error ? error.message : "Mermaid 渲染失败",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [content, language]);
|
||||
|
||||
if (language === "markdown") {
|
||||
return (
|
||||
<div
|
||||
data-artifact-preview-scroll
|
||||
data-markdown-anchor-root
|
||||
className={cn(
|
||||
"size-full overflow-auto px-5 py-4",
|
||||
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
|
||||
@@ -962,16 +691,7 @@ export function ArtifactFilePreview({
|
||||
<Streamdown
|
||||
className="size-full"
|
||||
{...streamdownPlugins}
|
||||
components={{
|
||||
...markdownComponents,
|
||||
img: (props) => (
|
||||
<SandboxPreviewImage
|
||||
{...props}
|
||||
threadId={threadId}
|
||||
publicShareCode={publicShareCode}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
components={{ a: ArtifactLink }}
|
||||
>
|
||||
{content ?? ""}
|
||||
</Streamdown>
|
||||
@@ -996,38 +716,5 @@ export function ArtifactFilePreview({
|
||||
);
|
||||
}
|
||||
|
||||
if (language === "mermaid") {
|
||||
return (
|
||||
<div
|
||||
data-artifact-preview-scroll
|
||||
className={cn(
|
||||
"size-full overflow-auto bg-[#fcfdfc] px-5 py-4",
|
||||
animated && "animate-[sandbox-preview-enter_320ms_ease-out]",
|
||||
)}
|
||||
>
|
||||
{mermaidSvg ? (
|
||||
<div
|
||||
className="mx-auto flex min-h-full items-start justify-center rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"
|
||||
dangerouslySetInnerHTML={{ __html: mermaidSvg }}
|
||||
/>
|
||||
) : mermaidError ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
{mermaidError}
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap break-words rounded-xl bg-slate-100 p-4 text-xs text-slate-700">
|
||||
{content ?? ""}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex size-full items-center justify-center gap-2 text-sm">
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
正在生成图表预览
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -16,11 +16,7 @@ import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { useSkills } from "@/core/skills/hooks";
|
||||
import type { SkillDisplayConfig } from "@/core/skills/type";
|
||||
import {
|
||||
displayConfigFromResultSet,
|
||||
normalizeDisplaySources,
|
||||
} from "@/core/skill-display/display-adapter";
|
||||
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
|
||||
import {
|
||||
buildSkillDisplayMap,
|
||||
collectRuntimeDisplaySources,
|
||||
@@ -67,14 +63,9 @@ function logReferenceDebug(
|
||||
});
|
||||
}
|
||||
|
||||
const ChatBox: React.FC<{
|
||||
children: React.ReactNode;
|
||||
threadId: string;
|
||||
disableArtifacts?: boolean;
|
||||
}> = ({
|
||||
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
|
||||
children,
|
||||
threadId,
|
||||
disableArtifacts = false,
|
||||
}) => {
|
||||
const { thread } = useThread();
|
||||
const pathname = usePathname();
|
||||
@@ -280,16 +271,12 @@ const ChatBox: React.FC<{
|
||||
}
|
||||
|
||||
// Update artifacts from the current thread
|
||||
const nextArtifacts = disableArtifacts ? [] : (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 (
|
||||
!disableArtifacts &&
|
||||
(hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) &&
|
||||
!selectedArtifact
|
||||
) {
|
||||
if ((hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) && !selectedArtifact) {
|
||||
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
@@ -304,7 +291,6 @@ const ChatBox: React.FC<{
|
||||
// }
|
||||
|
||||
if (
|
||||
!disableArtifacts &&
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
|
||||
autoSelectFirstArtifact
|
||||
) {
|
||||
@@ -321,18 +307,14 @@ const ChatBox: React.FC<{
|
||||
selectedArtifact,
|
||||
setArtifacts,
|
||||
thread.values.artifacts,
|
||||
disableArtifacts,
|
||||
]);
|
||||
|
||||
const artifactPanelOpen = useMemo(() => {
|
||||
if (disableArtifacts) {
|
||||
return false;
|
||||
}
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
|
||||
return artifactsOpen && artifacts?.length > 0;
|
||||
}
|
||||
return artifactsOpen;
|
||||
}, [artifactsOpen, artifacts, disableArtifacts]);
|
||||
}, [artifactsOpen, artifacts]);
|
||||
const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen);
|
||||
const showTabs = !!selectedArtifact && hasCitationReferences;
|
||||
|
||||
@@ -361,10 +343,6 @@ const ChatBox: React.FC<{
|
||||
}
|
||||
}, [sidePanelOpen]);
|
||||
|
||||
if (disableArtifacts) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup
|
||||
id={`${resizableIdBase}-panels`}
|
||||
@@ -625,10 +603,9 @@ function adaptBackendSourceWithSkillDisplay(
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const display = findDisplayConfigForBackendSource(source, match.display);
|
||||
const normalized = normalizeDisplaySources(
|
||||
buildPayloadAtPath(display.resultPath || "results", [rawItem]),
|
||||
display,
|
||||
buildPayloadAtPath(match.display.resultPath || "results", [rawItem]),
|
||||
match.display,
|
||||
)[0];
|
||||
if (!normalized) {
|
||||
return null;
|
||||
@@ -637,27 +614,13 @@ function adaptBackendSourceWithSkillDisplay(
|
||||
...source,
|
||||
...normalized,
|
||||
skillName: match.skillName,
|
||||
mode: display.mode,
|
||||
tableColumns: display.tableColumns,
|
||||
mode: match.display.mode,
|
||||
tableColumns: match.display.tableColumns,
|
||||
index: source.index,
|
||||
raw: source.raw,
|
||||
};
|
||||
}
|
||||
|
||||
function findDisplayConfigForBackendSource(
|
||||
source: BackendReferenceSource,
|
||||
display: SkillDisplayConfig,
|
||||
) {
|
||||
const resultSetKey = source.resultSetKey;
|
||||
if (resultSetKey) {
|
||||
const resultSet = display.resultSets?.find((item) => item.key === resultSetKey);
|
||||
if (resultSet) {
|
||||
return displayConfigFromResultSet(display, resultSet);
|
||||
}
|
||||
}
|
||||
return { ...display, resultSets: [] };
|
||||
}
|
||||
|
||||
function findBackendSourceDisplay(
|
||||
source: BackendReferenceSource,
|
||||
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
|
||||
|
||||
@@ -19,43 +19,32 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
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 { useAgentSelector } from "@/core/agents/hooks";
|
||||
import {
|
||||
useCreateCustomPrompt,
|
||||
useCustomPrompts,
|
||||
useDeleteCustomPrompt,
|
||||
useUpdateCustomPrompt,
|
||||
} from "@/core/custom-prompts";
|
||||
import {
|
||||
useUpdateUserPreferences,
|
||||
useUserPreferences,
|
||||
} from "@/core/user-preferences";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
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,
|
||||
currentAgentId,
|
||||
}: {
|
||||
onInsert: (content: string) => void;
|
||||
className?: string;
|
||||
currentAgentId?: string | null;
|
||||
}) {
|
||||
const allPrompts = useCustomPrompts();
|
||||
const prompts = allPrompts.filter(
|
||||
(prompt) => !prompt.agent_id || prompt.agent_id === currentAgentId,
|
||||
);
|
||||
const prompts = useCustomPrompts();
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canLeft, setCanLeft] = useState(false);
|
||||
const [canRight, setCanRight] = useState(false);
|
||||
@@ -71,7 +60,7 @@ export function CustomPromptBar({
|
||||
updateArrows();
|
||||
window.addEventListener("resize", updateArrows);
|
||||
return () => window.removeEventListener("resize", updateArrows);
|
||||
}, [prompts, updateArrows]);
|
||||
}, [updateArrows, prompts]);
|
||||
|
||||
const scrollByDir = (dir: 1 | -1) => {
|
||||
scrollRef.current?.scrollBy({ left: dir * 220, behavior: "smooth" });
|
||||
@@ -81,7 +70,7 @@ export function CustomPromptBar({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("flex min-w-0 items-center gap-1", className)}>
|
||||
<div className={cn("flex items-center gap-1", className)}>
|
||||
{prompts.length > 0 && (
|
||||
<>
|
||||
{overflowing && (
|
||||
@@ -100,7 +89,7 @@ export function CustomPromptBar({
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={updateArrows}
|
||||
className="min-w-0 flex-1 overflow-x-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
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) => (
|
||||
@@ -134,7 +123,7 @@ export function CustomPromptBar({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{prompts.length === 0 && <div className="min-w-0 flex-1" />}
|
||||
{prompts.length === 0 && <div className="flex-1" />}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -164,26 +153,16 @@ function ManagePromptsDialog({
|
||||
const createPrompt = useCreateCustomPrompt();
|
||||
const updatePrompt = useUpdateCustomPrompt();
|
||||
const deletePrompt = useDeleteCustomPrompt();
|
||||
const { agents } = useAgentSelector();
|
||||
const preferences = useUserPreferences();
|
||||
const updatePreferences = useUpdateUserPreferences();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [agentId, setAgentId] = useState<string | undefined>(undefined);
|
||||
|
||||
const saving = createPrompt.isPending || updatePrompt.isPending;
|
||||
|
||||
const selectableAgents = agents;
|
||||
|
||||
const agentNameById = (id?: string | null) =>
|
||||
id ? agents.find((agent) => agent.id === id)?.name ?? null : null;
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingId(null);
|
||||
setTitle("");
|
||||
setContent("");
|
||||
setAgentId(undefined);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -192,24 +171,15 @@ function ManagePromptsDialog({
|
||||
toast.error("提示词内容不能为空");
|
||||
return;
|
||||
}
|
||||
if (!agentId) {
|
||||
toast.error("请选择关联智能体");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingId) {
|
||||
await updatePrompt.mutateAsync({
|
||||
id: editingId,
|
||||
input: { title: trimmedTitle, content, agent_id: agentId },
|
||||
input: { title: trimmedTitle, content },
|
||||
});
|
||||
toast.success("提示词已更新");
|
||||
} else {
|
||||
await createPrompt.mutateAsync({
|
||||
title: trimmedTitle,
|
||||
content,
|
||||
agent_id: agentId,
|
||||
});
|
||||
await createPrompt.mutateAsync({ title: trimmedTitle, content });
|
||||
toast.success("提示词已添加");
|
||||
}
|
||||
resetForm();
|
||||
@@ -219,31 +189,17 @@ function ManagePromptsDialog({
|
||||
};
|
||||
|
||||
const handleEdit = (id: string) => {
|
||||
const target = prompts.find((prompt) => prompt.id === id);
|
||||
const target = prompts.find((p) => p.id === id);
|
||||
if (!target) return;
|
||||
setEditingId(id);
|
||||
setTitle(target.title);
|
||||
setContent(target.content);
|
||||
setAgentId(
|
||||
target.agent_id && selectableAgents.some((agent) => agent.id === target.agent_id)
|
||||
? target.agent_id
|
||||
: undefined,
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggleHideAdmin = async (next: boolean) => {
|
||||
try {
|
||||
await updatePreferences.mutateAsync({ hide_admin_prompt_prefix: next });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deletePrompt.mutateAsync(id);
|
||||
if (editingId === id) resetForm();
|
||||
toast.success("提示词已删除");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
@@ -261,73 +217,25 @@ function ManagePromptsDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>管理常用提示词</DialogTitle>
|
||||
<DialogDescription>
|
||||
保存常用提示词后,会显示在输入框上方。提示词与账号绑定,可跨设备使用。
|
||||
保存常用的提示词,保存后会以小标签形式显示在输入框上方,点击即可填入。提示词与你的账号绑定,可跨设备使用。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-start justify-between gap-3 rounded-md border p-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">只用我自己的提示词</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
开启后,输入框不再显示管理员配置的提示词前缀开关,本账号仅使用自己保存的提示词。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.hide_admin_prompt_prefix}
|
||||
disabled={updatePreferences.isPending}
|
||||
onCheckedChange={(value) => void handleToggleHideAdmin(value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="名称(可选,例如:周报总结)"
|
||||
/>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="提示词内容"
|
||||
className="min-h-28"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<p className="text-muted-foreground text-xs">关联智能体</p>
|
||||
<Select value={agentId} onValueChange={setAgentId}>
|
||||
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate [&>span]:text-left">
|
||||
<SelectValue placeholder="请选择已配置的智能体" />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
className="max-h-[40vh] w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)] overflow-hidden"
|
||||
>
|
||||
{selectableAgents.map((agent) => (
|
||||
<SelectItem
|
||||
key={agent.id}
|
||||
value={agent.id}
|
||||
className="max-w-full pr-8"
|
||||
>
|
||||
<span className="block max-w-full truncate">
|
||||
{desensitize(agent.name)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{selectableAgents.length === 0
|
||||
? "当前账号还没有已配置的智能体。"
|
||||
: "关联后,仅在选中该智能体的对话里显示此提示词。"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
{editingId && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={resetForm}
|
||||
disabled={saving}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={resetForm} disabled={saving}>
|
||||
取消编辑
|
||||
</Button>
|
||||
)}
|
||||
@@ -352,11 +260,6 @@ function ManagePromptsDialog({
|
||||
<p className="truncate text-sm font-medium">
|
||||
{prompt.title || "未命名提示词"}
|
||||
</p>
|
||||
{prompt.agent_id && (
|
||||
<p className="mt-0.5 truncate text-[11px] text-sky-600 dark:text-sky-400">
|
||||
仅 {desensitize(agentNameById(prompt.agent_id) || "未知智能体")} 可见
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground mt-0.5 line-clamp-2 text-xs whitespace-pre-wrap">
|
||||
{prompt.content}
|
||||
</p>
|
||||
|
||||
@@ -86,8 +86,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
|
||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||
import { useUserPreferences } from "@/core/user-preferences";
|
||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||
import {
|
||||
isThreadRagModeLocked,
|
||||
lockThreadRagMode,
|
||||
@@ -132,7 +131,6 @@ export function InputBox({
|
||||
onStop,
|
||||
promptPrefix,
|
||||
agentSelector,
|
||||
assistantId,
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
||||
assistantId?: string | null;
|
||||
@@ -205,11 +203,7 @@ export function InputBox({
|
||||
null,
|
||||
);
|
||||
|
||||
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
||||
const userPreferences = useUserPreferences();
|
||||
const showAdminRecommendedQuestions =
|
||||
!userPreferences.hide_admin_prompt_prefix &&
|
||||
recommendedQuestions.length > 0;
|
||||
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
||||
|
||||
useEffect(() => {
|
||||
if (models.length === 0) {
|
||||
@@ -516,7 +510,7 @@ export function InputBox({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && showAdminRecommendedQuestions && (
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && recommendedQuestions.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
|
||||
{recommendedQuestions.map((q) => (
|
||||
<button
|
||||
@@ -570,7 +564,6 @@ export function InputBox({
|
||||
{isNewThread && (
|
||||
<CustomPromptBar
|
||||
className="px-1"
|
||||
currentAgentId={agentSelector?.selectedAgentId ?? assistantId ?? null}
|
||||
onInsert={(content) => {
|
||||
const current = textInput.value?.trim() ?? "";
|
||||
textInput.setInput(current ? `${current}\n${content}` : content);
|
||||
@@ -590,7 +583,7 @@ export function InputBox({
|
||||
// 对话态:常规白卡 + 中性边框 + 轻阴影
|
||||
isNewThread
|
||||
? "rounded-3xl bg-transparent border-0 shadow-none ring-0"
|
||||
: "rounded-3xl bg-background border border-border shadow-sm transition-shadow hover:shadow-md focus-within:border-ring/40 focus-within:shadow-md focus-within:ring-1 focus-within:ring-ring/20 dark:bg-slate-800 dark:border-slate-700",
|
||||
: "rounded-3xl bg-background border border-border shadow-sm dark:bg-slate-800 dark:border-slate-700",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { Trash2Icon } from "lucide-react";
|
||||
import { 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 { useI18n } from "@/core/i18n/hooks";
|
||||
import { useDeleteMessage } from "@/core/threads/hooks";
|
||||
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
/**
|
||||
* Hover affordance that deletes a conversation turn (a user question together
|
||||
* with its answer) after a confirmation step. The actual removal is persisted
|
||||
* server-side; `onDeleted` receives the ids the server removed so the parent
|
||||
* can hide those bubbles immediately without waiting for the stream cache to
|
||||
* refresh.
|
||||
*/
|
||||
export function DeleteMessageButton({
|
||||
threadId,
|
||||
messageId,
|
||||
onDeleted,
|
||||
className,
|
||||
}: {
|
||||
threadId: string;
|
||||
messageId: string;
|
||||
onDeleted?: (deletedIds: string[]) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const deleteMessage = useDeleteMessage();
|
||||
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
const deletedIds = await deleteMessage.mutateAsync({
|
||||
threadId,
|
||||
messageId,
|
||||
});
|
||||
onDeleted?.(deletedIds);
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "删除消息失败。",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip content={t.inputBox.deleteMessage}>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className={className}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Trash2Icon size={12} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.inputBox.deleteMessageConfirmTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t.inputBox.deleteMessageConfirmDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={deleteMessage.isPending}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => void handleConfirm()}
|
||||
disabled={deleteMessage.isPending}
|
||||
>
|
||||
{t.common.delete}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
import {
|
||||
MessageResponse,
|
||||
type MessageResponseProps,
|
||||
} from "@/components/ai-elements/message";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
import {
|
||||
createMarkdownAnchorResolver,
|
||||
isInPageAnchor,
|
||||
scrollToInPageAnchor,
|
||||
} from "@/core/utils/markdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { CitationLink } from "../citations/citation-link";
|
||||
|
||||
function isExternalUrl(href: string | undefined): boolean {
|
||||
return !!href && /^https?:\/\//.test(href);
|
||||
}
|
||||
|
||||
function extractTextFromChildren(children: ReactNode): string {
|
||||
if (typeof children === "string" || typeof children === "number") return String(children);
|
||||
if (Array.isArray(children)) return children.map(extractTextFromChildren).join("");
|
||||
return "";
|
||||
}
|
||||
|
||||
export type MarkdownContentProps = {
|
||||
content: string;
|
||||
isLoading: boolean;
|
||||
rehypePlugins: MessageResponseProps["rehypePlugins"];
|
||||
className?: string;
|
||||
remarkPlugins?: MessageResponseProps["remarkPlugins"];
|
||||
components?: MessageResponseProps["components"];
|
||||
};
|
||||
|
||||
/** Renders markdown content. */
|
||||
export function MarkdownContent({
|
||||
content,
|
||||
rehypePlugins,
|
||||
className,
|
||||
remarkPlugins = streamdownPlugins.remarkPlugins,
|
||||
components: componentsFromProps,
|
||||
}: MarkdownContentProps) {
|
||||
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
|
||||
const components = useMemo(() => {
|
||||
const heading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
|
||||
const Tag = `h${level}` as const;
|
||||
return ({ children, id, ...props }: HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<Tag id={id ?? anchorResolver.getHeadingId(level, extractTextFromChildren(children))} {...props}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
h1: heading(1),
|
||||
h2: heading(2),
|
||||
h3: heading(3),
|
||||
h4: heading(4),
|
||||
h5: heading(5),
|
||||
h6: heading(6),
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { AnchorHTMLAttributes } from "react";
|
||||
|
||||
import {
|
||||
MessageResponse,
|
||||
type MessageResponseProps,
|
||||
} from "@/components/ai-elements/message";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { CitationLink } from "../citations/citation-link";
|
||||
|
||||
function isExternalUrl(href: string | undefined): boolean {
|
||||
return !!href && /^https?:\/\//.test(href);
|
||||
}
|
||||
|
||||
export type MarkdownContentProps = {
|
||||
content: string;
|
||||
isLoading: boolean;
|
||||
rehypePlugins: MessageResponseProps["rehypePlugins"];
|
||||
className?: string;
|
||||
remarkPlugins?: MessageResponseProps["remarkPlugins"];
|
||||
components?: MessageResponseProps["components"];
|
||||
};
|
||||
|
||||
/** Renders markdown content. */
|
||||
export function MarkdownContent({
|
||||
content,
|
||||
rehypePlugins,
|
||||
className,
|
||||
remarkPlugins = streamdownPlugins.remarkPlugins,
|
||||
components: componentsFromProps,
|
||||
}: MarkdownContentProps) {
|
||||
const components = useMemo(() => {
|
||||
return {
|
||||
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
if (props.href?.startsWith("#reference-")) {
|
||||
return <CitationLink {...props} />;
|
||||
@@ -69,68 +43,40 @@ export function MarkdownContent({
|
||||
if (typeof props.children === "string") {
|
||||
const match = /^citation:(.+)$/.exec(props.children);
|
||||
if (match) {
|
||||
const [, text] = match;
|
||||
return <CitationLink {...props}>{text}</CitationLink>;
|
||||
}
|
||||
}
|
||||
const { className, target, rel, ...rest } = props;
|
||||
const external = isExternalUrl(props.href);
|
||||
const resolvedHref = anchorResolver.resolveHref(
|
||||
props.href,
|
||||
extractTextFromChildren(props.children),
|
||||
);
|
||||
if (resolvedHref === null) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-primary/80 decoration-dotted underline underline-offset-2 cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
title="未在当前 Markdown 中找到对应章节"
|
||||
>
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const finalHref = resolvedHref ?? props.href;
|
||||
return (
|
||||
<a
|
||||
{...rest}
|
||||
href={finalHref}
|
||||
className={cn(
|
||||
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
|
||||
className,
|
||||
)}
|
||||
target={target ?? (external ? "_blank" : undefined)}
|
||||
rel={rel ?? (external ? "noopener noreferrer" : undefined)}
|
||||
onClick={(event) => {
|
||||
// HashRouter would turn a `#section` click into a route change → 404.
|
||||
// Scroll to the heading ourselves instead.
|
||||
if (isInPageAnchor(finalHref)) {
|
||||
event.preventDefault();
|
||||
scrollToInPageAnchor(event.currentTarget, finalHref);
|
||||
}
|
||||
rest.onClick?.(event);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
...componentsFromProps,
|
||||
};
|
||||
}, [anchorResolver, componentsFromProps]);
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
return (
|
||||
<div data-markdown-anchor-root className="contents">
|
||||
<MessageResponse
|
||||
className={className}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
</MessageResponse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const [, text] = match;
|
||||
return <CitationLink {...props}>{text}</CitationLink>;
|
||||
}
|
||||
}
|
||||
const { className, target, rel, ...rest } = props;
|
||||
const external = isExternalUrl(props.href);
|
||||
return (
|
||||
<a
|
||||
{...rest}
|
||||
className={cn(
|
||||
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
|
||||
className,
|
||||
)}
|
||||
target={target ?? (external ? "_blank" : undefined)}
|
||||
rel={rel ?? (external ? "noopener noreferrer" : undefined)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
...componentsFromProps,
|
||||
};
|
||||
}, [componentsFromProps]);
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
const displayContent = desensitize(content);
|
||||
|
||||
return (
|
||||
<MessageResponse
|
||||
className={className}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
>
|
||||
{displayContent}
|
||||
</MessageResponse>
|
||||
);
|
||||
}
|
||||
@@ -258,12 +258,12 @@ function ToolCall({
|
||||
null);
|
||||
const displayItems =
|
||||
displayMatch && result !== undefined
|
||||
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: displayMatch.skillName,
|
||||
mode: item.displayMode ?? displayMatch.display.mode,
|
||||
}))
|
||||
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
skillName: displayMatch.skillName,
|
||||
mode: displayMatch.display.mode,
|
||||
}))
|
||||
: [];
|
||||
if (displayItems.length > 0) {
|
||||
return (
|
||||
|
||||
@@ -1,41 +1,33 @@
|
||||
import type { Message } from '@langchain/langgraph-sdk';
|
||||
import { ClockIcon, FileIcon, Loader2Icon, PencilIcon } from 'lucide-react';
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { ClockIcon, FileIcon, Loader2Icon } from "lucide-react";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type AnchorHTMLAttributes,
|
||||
type ImgHTMLAttributes,
|
||||
} from 'react';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
} from "react";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
|
||||
import { Loader } from '@/components/ai-elements/loader';
|
||||
import { Loader } from "@/components/ai-elements/loader";
|
||||
import {
|
||||
Message as AIElementMessage,
|
||||
MessageContent as AIElementMessageContent,
|
||||
MessageResponse as AIElementMessageResponse,
|
||||
MessageToolbar,
|
||||
} from '@/components/ai-elements/message';
|
||||
} from "@/components/ai-elements/message";
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from '@/components/ai-elements/reasoning';
|
||||
import { Task, TaskTrigger } from '@/components/ai-elements/task';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
UploadedFilePreviewDialog,
|
||||
useAuthedObjectUrl,
|
||||
} from '@/components/workspace/messages/uploaded-file-preview-dialog';
|
||||
import { resolveArtifactURL } from '@/core/artifacts/utils';
|
||||
import { useChatAskTimes } from '@/core/chat-timestamps';
|
||||
import { getBackendBaseURL } from '@/core/config';
|
||||
import { useI18n } from '@/core/i18n/hooks';
|
||||
} from "@/components/ai-elements/reasoning";
|
||||
import { Task, TaskTrigger } from "@/components/ai-elements/task";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
||||
import { useChatAskTimes } from "@/core/chat-timestamps";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
extractReasoningContentFromMessage,
|
||||
@@ -43,65 +35,23 @@ import {
|
||||
stripArtifactTag,
|
||||
stripUploadedFilesTag,
|
||||
type FileInMessage,
|
||||
} from '@/core/messages/utils';
|
||||
import { useRehypeSplitWordsIntoSpans } from '@/core/rehype';
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import {
|
||||
applyInlineCitations,
|
||||
type RuntimeDisplaySource,
|
||||
} from '@/core/skill-display/runtime';
|
||||
import { humanMessagePlugins } from '@/core/streamdown';
|
||||
import { cn } from '@/lib/utils';
|
||||
} from "@/core/skill-display/runtime";
|
||||
import { humanMessagePlugins } from "@/core/streamdown";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { CitationLink } from '../citations/citation-link';
|
||||
import { CopyButton } from '../copy-button';
|
||||
import { Tooltip } from '../tooltip';
|
||||
import { SkillDisplayBlock } from '../skill-display-block';
|
||||
import { ThreadContext } from './context';
|
||||
import { DeleteMessageButton } from './delete-message-button';
|
||||
import { DownloadWordButton } from './download-word-button';
|
||||
import { MarkdownContent, type MarkdownContentProps } from './markdown-content';
|
||||
import { MessageTokenUsage } from './message-token-usage';
|
||||
|
||||
type EmbeddedPageContext = {
|
||||
title?: string;
|
||||
url?: string;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
function extractTagContent(source: string, tagName: string) {
|
||||
const match = new RegExp(
|
||||
`<${tagName}>\\s*([\\s\\S]*?)\\s*</${tagName}>`,
|
||||
'i',
|
||||
).exec(source);
|
||||
return match?.[1]?.trim() ?? '';
|
||||
}
|
||||
|
||||
function parseEmbeddedPageContext(source: string): {
|
||||
content: string;
|
||||
pageContext: EmbeddedPageContext | null;
|
||||
} {
|
||||
const match = /<page_context>\s*([\s\S]*?)\s*<\/page_context>/i.exec(source);
|
||||
if (!match) {
|
||||
return { content: source, pageContext: null };
|
||||
}
|
||||
|
||||
const body = match[1] ?? '';
|
||||
const pageContext = {
|
||||
title: extractTagContent(body, 'title') || undefined,
|
||||
url: extractTagContent(body, 'url') || undefined,
|
||||
content: extractTagContent(body, 'content') || undefined,
|
||||
};
|
||||
const cleanedContent = source
|
||||
.replace(match[0], '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
if (!pageContext.title && !pageContext.url && !pageContext.content) {
|
||||
return { content: cleanedContent, pageContext: null };
|
||||
}
|
||||
|
||||
return { content: cleanedContent, pageContext };
|
||||
}
|
||||
import { CitationLink } from "../citations/citation-link";
|
||||
import { CopyButton } from "../copy-button";
|
||||
import { SkillDisplayBlock } from "../skill-display-block";
|
||||
import { ThreadContext } from "./context";
|
||||
import { DownloadWordButton } from "./download-word-button";
|
||||
import { MarkdownContent, type MarkdownContentProps } from "./markdown-content";
|
||||
import { MessageTokenUsage } from "./message-token-usage";
|
||||
|
||||
/**
|
||||
* Resolve an in-conversation file path to a fetchable URL.
|
||||
@@ -115,7 +65,7 @@ function useArtifactResolver(threadId: string): (path: string) => string {
|
||||
return useCallback(
|
||||
(path: string) => {
|
||||
if (publicShareCode) {
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`;
|
||||
const normalized = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
|
||||
publicShareCode,
|
||||
)}/files${normalized}`;
|
||||
@@ -128,68 +78,50 @@ function useArtifactResolver(threadId: string): (path: string) => string {
|
||||
|
||||
export function MessageListItem({
|
||||
className,
|
||||
fitRowToContent = false,
|
||||
message,
|
||||
isLoading,
|
||||
threadId,
|
||||
tokenUsageEnabled = false,
|
||||
displaySources = [],
|
||||
extraRehypePlugins,
|
||||
onEditResubmit,
|
||||
onMessageDeleted,
|
||||
}: {
|
||||
className?: string;
|
||||
fitRowToContent?: boolean;
|
||||
message: Message;
|
||||
isLoading?: boolean;
|
||||
threadId: string;
|
||||
tokenUsageEnabled?: boolean;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onMessageDeleted?: (deletedIds: string[]) => void;
|
||||
}) {
|
||||
const isHuman = message.type === 'human';
|
||||
const isHuman = message.type === "human";
|
||||
const messageMarkdown =
|
||||
extractContentFromMessage(message) ??
|
||||
extractReasoningContentFromMessage(message) ??
|
||||
'';
|
||||
"";
|
||||
return (
|
||||
<AIElementMessage
|
||||
className={cn(
|
||||
'group/conversation-message relative',
|
||||
fitRowToContent ? 'max-w-full w-fit' : 'w-full',
|
||||
isHuman && fitRowToContent && 'ml-auto',
|
||||
className,
|
||||
)}
|
||||
from={isHuman ? 'user' : 'assistant'}
|
||||
className={cn("group/conversation-message relative w-full", className)}
|
||||
from={isHuman ? "user" : "assistant"}
|
||||
>
|
||||
<MessageContent
|
||||
className={isHuman ? 'w-fit' : 'w-full'}
|
||||
className={isHuman ? "w-fit" : "w-full"}
|
||||
message={message}
|
||||
isLoading={isLoading}
|
||||
threadId={threadId}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
displaySources={displaySources}
|
||||
extraRehypePlugins={extraRehypePlugins}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onMessageDeleted={onMessageDeleted}
|
||||
/>
|
||||
{/*
|
||||
Human messages carry their own inline copy/edit toolbar inside
|
||||
MessageContent (above the timestamp). Only assistant answers use the
|
||||
floating bottom toolbar here.
|
||||
*/}
|
||||
{!isLoading && !isHuman && (
|
||||
{!isLoading && (
|
||||
<MessageToolbar
|
||||
className={cn(
|
||||
'-bottom-8',
|
||||
'absolute right-0 left-0 z-20 opacity-0 transition-opacity delay-200 duration-300 group-hover/conversation-message:opacity-100',
|
||||
isHuman ? "-bottom-9 justify-end" : "-bottom-8",
|
||||
"absolute right-0 left-0 z-20 opacity-0 transition-opacity delay-200 duration-300 group-hover/conversation-message:opacity-100",
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
<CopyButton clipboardData={messageMarkdown} />
|
||||
{messageMarkdown && (
|
||||
{!isHuman && messageMarkdown && (
|
||||
<DownloadWordButton
|
||||
markdown={messageMarkdown}
|
||||
fileName="message"
|
||||
@@ -202,131 +134,6 @@ export function MessageListItem({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline copy/edit affordances + editor for the user's own question bubble.
|
||||
*
|
||||
* Layout: bubble → hover toolbar (copy + edit) → timestamp. The toolbar sits
|
||||
* in normal flow above the timestamp (not floating) so it never covers the
|
||||
* following message. Clicking edit turns the bubble into a textarea seeded with
|
||||
* the question; confirming sends the edited text as a brand-new turn (the
|
||||
* original question stays in the thread).
|
||||
*/
|
||||
function HumanMessageBlock({
|
||||
text,
|
||||
pageContext,
|
||||
filesList,
|
||||
messageTime,
|
||||
components,
|
||||
onEditResubmit,
|
||||
deleteButton,
|
||||
}: {
|
||||
text: string;
|
||||
pageContext?: EmbeddedPageContext | null;
|
||||
filesList: React.ReactNode;
|
||||
messageTime: { label: string; full: string } | null;
|
||||
components: MarkdownContentProps["components"];
|
||||
onEditResubmit?: (text: string) => void;
|
||||
deleteButton?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(text);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) {
|
||||
setDraft(text);
|
||||
}
|
||||
}, [text, editing]);
|
||||
|
||||
const confirmEdit = useCallback(() => {
|
||||
const value = draft.trim();
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
onEditResubmit?.(value);
|
||||
setEditing(false);
|
||||
}, [draft, onEditResubmit]);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="ml-auto flex w-full max-w-[min(85%,42rem)] flex-col gap-2">
|
||||
{filesList}
|
||||
<Textarea
|
||||
value={draft}
|
||||
autoFocus
|
||||
onChange={(event) => setDraft(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
confirmEdit();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
className="min-h-20 resize-none text-[15px] leading-7"
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setEditing(false)}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button size="sm" onClick={confirmEdit} disabled={!draft.trim()}>
|
||||
{t.common.confirm}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const messageResponse = text ? (
|
||||
<AIElementMessageResponse
|
||||
remarkPlugins={humanMessagePlugins.remarkPlugins}
|
||||
rehypePlugins={humanMessagePlugins.rehypePlugins}
|
||||
components={components}
|
||||
parseIncompleteMarkdown={false}
|
||||
>
|
||||
{text}
|
||||
</AIElementMessageResponse>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="ml-auto flex flex-col items-end gap-1">
|
||||
{filesList}
|
||||
{messageResponse && (
|
||||
<AIElementMessageContent className="w-fit">
|
||||
{messageResponse}
|
||||
</AIElementMessageContent>
|
||||
)}
|
||||
<MessageTimeStamp time={messageTime} align="right" />
|
||||
{pageContext && <EmbeddedPageContextCard pageContext={pageContext} />}
|
||||
{(text || deleteButton) && (
|
||||
<div className="flex items-center gap-0.5 opacity-0 transition-opacity duration-200 group-hover/conversation-message:opacity-100">
|
||||
{text && <CopyButton clipboardData={text} className="size-6" />}
|
||||
{text && onEditResubmit && (
|
||||
<Tooltip content={t.inputBox.editQuestion}>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="size-6"
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<PencilIcon size={11} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{deleteButton}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom image component that handles artifact URLs
|
||||
*/
|
||||
@@ -334,7 +141,7 @@ function MessageImage({
|
||||
src,
|
||||
alt,
|
||||
threadId,
|
||||
maxWidth = '90%',
|
||||
maxWidth = "90%",
|
||||
...props
|
||||
}: React.ImgHTMLAttributes<HTMLImageElement> & {
|
||||
threadId: string;
|
||||
@@ -343,13 +150,13 @@ function MessageImage({
|
||||
const resolveArtifact = useArtifactResolver(threadId);
|
||||
if (!src) return null;
|
||||
|
||||
const imgClassName = cn('overflow-hidden rounded-lg', `max-w-[${maxWidth}]`);
|
||||
const imgClassName = cn("overflow-hidden rounded-lg", `max-w-[${maxWidth}]`);
|
||||
|
||||
if (typeof src !== 'string') {
|
||||
if (typeof src !== "string") {
|
||||
return <img className={imgClassName} src={src} alt={alt} {...props} />;
|
||||
}
|
||||
|
||||
const url = src.startsWith('/mnt/') ? resolveArtifact(src) : src;
|
||||
const url = src.startsWith("/mnt/") ? resolveArtifact(src) : src;
|
||||
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
@@ -366,8 +173,6 @@ function MessageContent_({
|
||||
tokenUsageEnabled = false,
|
||||
displaySources = [],
|
||||
extraRehypePlugins,
|
||||
onEditResubmit,
|
||||
onMessageDeleted,
|
||||
}: {
|
||||
className?: string;
|
||||
message: Message;
|
||||
@@ -376,8 +181,6 @@ function MessageContent_({
|
||||
tokenUsageEnabled?: boolean;
|
||||
displaySources?: RuntimeDisplaySource[];
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onMessageDeleted?: (deletedIds: string[]) => void;
|
||||
}) {
|
||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||
|
||||
@@ -393,14 +196,11 @@ function MessageContent_({
|
||||
}, [extraRehypePlugins]);
|
||||
|
||||
const rehypePlugins = useMemo(
|
||||
() =>
|
||||
extraRehypePlugins?.length
|
||||
? [...baseRehypePlugins, ...extraRehypePlugins]
|
||||
: baseRehypePlugins,
|
||||
[baseRehypePlugins, extraRehypePlugins],
|
||||
() => (stableExtraPlugins.length ? [...stableExtraPlugins, ...baseRehypePlugins] : baseRehypePlugins),
|
||||
[stableExtraPlugins, baseRehypePlugins],
|
||||
);
|
||||
const askTimes = useChatAskTimes();
|
||||
const isHuman = message.type === 'human';
|
||||
const isHuman = message.type === "human";
|
||||
const resolveArtifact = useArtifactResolver(threadId);
|
||||
const components = useMemo(
|
||||
() => ({
|
||||
@@ -408,16 +208,13 @@ function MessageContent_({
|
||||
<MessageImage {...props} threadId={threadId} maxWidth="90%" />
|
||||
),
|
||||
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
if (href?.startsWith('#reference-')) {
|
||||
if (href?.startsWith("#reference-")) {
|
||||
return <CitationLink href={href} {...props} />;
|
||||
}
|
||||
if (
|
||||
typeof props.children === 'string' &&
|
||||
/^citation:/i.test(props.children)
|
||||
) {
|
||||
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);
|
||||
return (
|
||||
<a
|
||||
@@ -445,7 +242,7 @@ function MessageContent_({
|
||||
const files = useMemo(() => {
|
||||
const files = message.additional_kwargs?.files;
|
||||
if (!Array.isArray(files) || files.length === 0) {
|
||||
if (rawContent.includes('<uploaded_files>')) {
|
||||
if (rawContent.includes("<uploaded_files>")) {
|
||||
// If the content contains the <uploaded_files> tag, we return the parsed files from the content for backward compatibility.
|
||||
return parseUploadedFiles(rawContent);
|
||||
}
|
||||
@@ -456,19 +253,10 @@ function MessageContent_({
|
||||
|
||||
const contentToDisplay = useMemo(() => {
|
||||
if (isHuman) {
|
||||
return rawContent
|
||||
? stripArtifactTag(stripUploadedFilesTag(rawContent))
|
||||
: '';
|
||||
return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : "";
|
||||
}
|
||||
return applyInlineCitations(rawContent ?? '', displaySources);
|
||||
return applyInlineCitations(rawContent ?? "", displaySources);
|
||||
}, [rawContent, isHuman, displaySources]);
|
||||
const parsedHumanContent = useMemo(
|
||||
() =>
|
||||
isHuman
|
||||
? parseEmbeddedPageContext(contentToDisplay)
|
||||
: { content: contentToDisplay, pageContext: null },
|
||||
[contentToDisplay, isHuman],
|
||||
);
|
||||
|
||||
const filesList =
|
||||
files && files.length > 0 ? (
|
||||
@@ -476,14 +264,14 @@ function MessageContent_({
|
||||
) : null;
|
||||
|
||||
// Uploading state: mock AI message shown while files upload
|
||||
if (message.additional_kwargs?.element === 'task') {
|
||||
if (message.additional_kwargs?.element === "task") {
|
||||
return (
|
||||
<AIElementMessageContent className={className}>
|
||||
<Task defaultOpen={false}>
|
||||
<TaskTrigger title="">
|
||||
<div className="text-muted-foreground flex w-full cursor-default items-center gap-2 text-sm select-none">
|
||||
<Loader className="size-4" />
|
||||
<span>{contentToDisplay}</span>
|
||||
<span>{desensitize(contentToDisplay)}</span>
|
||||
</div>
|
||||
</TaskTrigger>
|
||||
</Task>
|
||||
@@ -497,7 +285,7 @@ function MessageContent_({
|
||||
<AIElementMessageContent className={className}>
|
||||
<Reasoning isStreaming={isLoading}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{reasoningContent}</ReasoningContent>
|
||||
<ReasoningContent>{desensitize(reasoningContent)}</ReasoningContent>
|
||||
</Reasoning>
|
||||
<MessageTokenUsage
|
||||
enabled={tokenUsageEnabled}
|
||||
@@ -509,30 +297,26 @@ function MessageContent_({
|
||||
}
|
||||
|
||||
if (isHuman) {
|
||||
// Only the user's own question carries a delete affordance, and only once
|
||||
// it exists server-side (real, non-optimistic id). Deleting it cascades to
|
||||
// its answer on the backend.
|
||||
const canDelete =
|
||||
!!onMessageDeleted && !!message.id && !message.id.startsWith('opt-');
|
||||
return (
|
||||
<HumanMessageBlock
|
||||
text={parsedHumanContent.content}
|
||||
pageContext={parsedHumanContent.pageContext}
|
||||
filesList={filesList}
|
||||
messageTime={messageTime}
|
||||
const messageResponse = contentToDisplay ? (
|
||||
<AIElementMessageResponse
|
||||
remarkPlugins={humanMessagePlugins.remarkPlugins}
|
||||
rehypePlugins={humanMessagePlugins.rehypePlugins}
|
||||
components={components}
|
||||
onEditResubmit={onEditResubmit}
|
||||
deleteButton={
|
||||
canDelete ? (
|
||||
<DeleteMessageButton
|
||||
threadId={threadId}
|
||||
messageId={message.id!}
|
||||
onDeleted={(ids) => onMessageDeleted?.(ids)}
|
||||
className="size-6"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
parseIncompleteMarkdown={false}
|
||||
>
|
||||
{desensitize(contentToDisplay)}
|
||||
</AIElementMessageResponse>
|
||||
) : null;
|
||||
return (
|
||||
<div className={cn("ml-auto flex flex-col gap-2", className)}>
|
||||
{filesList}
|
||||
{messageResponse && (
|
||||
<AIElementMessageContent className="w-fit">
|
||||
{messageResponse}
|
||||
</AIElementMessageContent>
|
||||
)}
|
||||
<MessageTimeStamp time={messageTime} align="right" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -545,7 +329,7 @@ function MessageContent_({
|
||||
displaySources={displaySources}
|
||||
isLoading={isLoading}
|
||||
renderSkillDisplayBlocks={false}
|
||||
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
|
||||
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
|
||||
components={components}
|
||||
/>
|
||||
</div>
|
||||
@@ -561,27 +345,6 @@ function MessageContent_({
|
||||
);
|
||||
}
|
||||
|
||||
function EmbeddedPageContextCard({
|
||||
pageContext,
|
||||
}: {
|
||||
pageContext: EmbeddedPageContext;
|
||||
}) {
|
||||
const previewText = [pageContext.title, pageContext.url, pageContext.content]
|
||||
.filter(Boolean)
|
||||
.join(' | ');
|
||||
|
||||
return (
|
||||
<div className="bg-muted/70 ml-auto flex w-fit min-w-0 max-w-[min(24rem,calc(100vw-2rem),100%)] flex-col gap-2 rounded-2xl border px-3 py-2 text-sm shadow-sm">
|
||||
<div
|
||||
className="text-muted-foreground line-clamp-2 min-w-0 [overflow-wrap:anywhere] break-words text-xs"
|
||||
title={previewText}
|
||||
>
|
||||
{previewText}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -590,10 +353,10 @@ function EmbeddedPageContextCard({
|
||||
function formatAskedTime(
|
||||
clientTs: unknown,
|
||||
): { label: string; full: string } | null {
|
||||
if (typeof clientTs !== 'string' || !clientTs) return 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 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())}`;
|
||||
@@ -606,17 +369,17 @@ function formatAskedTime(
|
||||
*/
|
||||
function MessageTimeStamp({
|
||||
time,
|
||||
align = 'right',
|
||||
align = "right",
|
||||
}: {
|
||||
time: { label: string; full: string } | null;
|
||||
align?: 'left' | 'right';
|
||||
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',
|
||||
"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}
|
||||
>
|
||||
@@ -630,39 +393,39 @@ function MessageTimeStamp({
|
||||
* Get file extension and check helpers
|
||||
*/
|
||||
const getFileExt = (filename: string) =>
|
||||
filename.split('.').pop()?.toLowerCase() ?? '';
|
||||
filename.split(".").pop()?.toLowerCase() ?? "";
|
||||
|
||||
const FILE_TYPE_MAP: Record<string, string> = {
|
||||
json: 'JSON',
|
||||
csv: 'CSV',
|
||||
txt: 'TXT',
|
||||
md: 'Markdown',
|
||||
py: 'Python',
|
||||
js: 'JavaScript',
|
||||
ts: 'TypeScript',
|
||||
tsx: 'TSX',
|
||||
jsx: 'JSX',
|
||||
html: 'HTML',
|
||||
css: 'CSS',
|
||||
xml: 'XML',
|
||||
yaml: 'YAML',
|
||||
yml: 'YAML',
|
||||
pdf: 'PDF',
|
||||
png: 'PNG',
|
||||
jpg: 'JPG',
|
||||
jpeg: 'JPEG',
|
||||
gif: 'GIF',
|
||||
svg: 'SVG',
|
||||
zip: 'ZIP',
|
||||
tar: 'TAR',
|
||||
gz: 'GZ',
|
||||
json: "JSON",
|
||||
csv: "CSV",
|
||||
txt: "TXT",
|
||||
md: "Markdown",
|
||||
py: "Python",
|
||||
js: "JavaScript",
|
||||
ts: "TypeScript",
|
||||
tsx: "TSX",
|
||||
jsx: "JSX",
|
||||
html: "HTML",
|
||||
css: "CSS",
|
||||
xml: "XML",
|
||||
yaml: "YAML",
|
||||
yml: "YAML",
|
||||
pdf: "PDF",
|
||||
png: "PNG",
|
||||
jpg: "JPG",
|
||||
jpeg: "JPEG",
|
||||
gif: "GIF",
|
||||
svg: "SVG",
|
||||
zip: "ZIP",
|
||||
tar: "TAR",
|
||||
gz: "GZ",
|
||||
};
|
||||
|
||||
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'];
|
||||
const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"];
|
||||
|
||||
function getFileTypeLabel(filename: string): string {
|
||||
const ext = getFileExt(filename);
|
||||
return FILE_TYPE_MAP[ext] ?? (ext.toUpperCase() || 'FILE');
|
||||
return FILE_TYPE_MAP[ext] ?? (ext.toUpperCase() || "FILE");
|
||||
}
|
||||
|
||||
function isImageFile(filename: string): boolean {
|
||||
@@ -673,7 +436,7 @@ function isImageFile(filename: string): boolean {
|
||||
* Format bytes to human-readable size string
|
||||
*/
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
if (bytes === 0) return "—";
|
||||
const kb = bytes / 1024;
|
||||
if (kb < 1024) return `${kb.toFixed(1)} KB`;
|
||||
return `${(kb / 1024).toFixed(1)} MB`;
|
||||
@@ -715,17 +478,8 @@ function RichFileCard({
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const resolveArtifact = useArtifactResolver(threadId);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const isUploading = file.status === 'uploading';
|
||||
const isUploading = file.status === "uploading";
|
||||
const isImage = isImageFile(file.filename);
|
||||
const fileUrl = file.path ? resolveArtifact(file.path) : null;
|
||||
// Thumbnails must be fetched with auth: the artifact route is gated and a
|
||||
// plain <img src> sends no Authorization header → 401 (broken image). Fetch
|
||||
// the bytes via apiFetch and display the resulting object URL instead.
|
||||
const { objectUrl: thumbUrl, loading: thumbLoading } = useAuthedObjectUrl(
|
||||
fileUrl,
|
||||
isImage && !isUploading && !!fileUrl,
|
||||
);
|
||||
|
||||
if (isUploading) {
|
||||
return (
|
||||
@@ -754,81 +508,50 @@ function RichFileCard({
|
||||
);
|
||||
}
|
||||
|
||||
if (!fileUrl) return null;
|
||||
if (!file.path) return null;
|
||||
|
||||
const preview = (
|
||||
<UploadedFilePreviewDialog
|
||||
filename={file.filename}
|
||||
fileUrl={fileUrl}
|
||||
open={previewOpen}
|
||||
onOpenChange={setPreviewOpen}
|
||||
/>
|
||||
);
|
||||
const fileUrl = resolveArtifact(file.path);
|
||||
|
||||
if (isImage) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewOpen(true)}
|
||||
title={file.filename}
|
||||
className="group border-border/40 relative block cursor-pointer overflow-hidden rounded-lg border"
|
||||
>
|
||||
{thumbUrl ? (
|
||||
<img
|
||||
src={thumbUrl}
|
||||
alt={file.filename}
|
||||
className="h-32 w-auto max-w-60 object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted/40 text-muted-foreground flex h-32 w-44 items-center justify-center gap-2 text-xs">
|
||||
{thumbLoading ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<FileIcon className="size-4" />
|
||||
<span className="truncate">{file.filename}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{preview}
|
||||
</>
|
||||
<a
|
||||
href={fileUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group border-border/40 relative block overflow-hidden rounded-lg border"
|
||||
>
|
||||
<img
|
||||
src={fileUrl}
|
||||
alt={file.filename}
|
||||
className="h-32 w-auto max-w-60 object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewOpen(true)}
|
||||
title={file.filename}
|
||||
className="bg-background border-border/40 hover:bg-accent/50 flex max-w-50 min-w-30 cursor-pointer flex-col gap-1 rounded-lg border p-3 text-left shadow-sm transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<FileIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
||||
<span
|
||||
className="text-foreground truncate text-sm font-medium"
|
||||
title={file.filename}
|
||||
>
|
||||
{file.filename}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-normal"
|
||||
>
|
||||
{getFileTypeLabel(file.filename)}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{formatBytes(file.size)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{preview}
|
||||
</>
|
||||
<div className="bg-background border-border/40 flex max-w-50 min-w-30 flex-col gap-1 rounded-lg border p-3 shadow-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<FileIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
||||
<span
|
||||
className="text-foreground truncate text-sm font-medium"
|
||||
title={file.filename}
|
||||
>
|
||||
{file.filename}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-normal"
|
||||
>
|
||||
{getFileTypeLabel(file.filename)}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{formatBytes(file.size)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -846,8 +569,8 @@ function AssistantDisplayContent({
|
||||
displaySources: RuntimeDisplaySource[];
|
||||
isLoading: boolean;
|
||||
renderSkillDisplayBlocks: boolean;
|
||||
rehypePlugins: MarkdownContentProps['rehypePlugins'];
|
||||
components: MarkdownContentProps['components'];
|
||||
rehypePlugins: MarkdownContentProps["rehypePlugins"];
|
||||
components: MarkdownContentProps["components"];
|
||||
}) {
|
||||
const { parts, placedKeys } = useMemo(
|
||||
() =>
|
||||
@@ -863,14 +586,14 @@ function AssistantDisplayContent({
|
||||
return [];
|
||||
}
|
||||
return groupDisplaySources(displaySources).filter(
|
||||
(group) => group.mode !== 'citation' && !placedKeys.has(group.key),
|
||||
(group) => group.mode !== "citation" && !placedKeys.has(group.key),
|
||||
);
|
||||
}, [displaySources, placedKeys, renderSkillDisplayBlocks]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) =>
|
||||
part.type === 'markdown' ? (
|
||||
part.type === "markdown" ? (
|
||||
<MarkdownContent
|
||||
key={`markdown-${index}`}
|
||||
content={part.content}
|
||||
@@ -899,11 +622,11 @@ function AssistantDisplayContent({
|
||||
}
|
||||
|
||||
type DisplayPart =
|
||||
| { type: 'markdown'; content: string }
|
||||
| { type: "markdown"; content: string }
|
||||
| {
|
||||
type: 'display';
|
||||
type: "display";
|
||||
key: string;
|
||||
mode: RuntimeDisplaySource['mode'];
|
||||
mode: RuntimeDisplaySource["mode"];
|
||||
sources: RuntimeDisplaySource[];
|
||||
};
|
||||
|
||||
@@ -920,16 +643,16 @@ function splitSkillDisplayPlaceholders(
|
||||
const start = match.index ?? 0;
|
||||
const before = content.slice(cursor, start);
|
||||
if (before.trim()) {
|
||||
parts.push({ type: 'markdown', content: before });
|
||||
parts.push({ type: "markdown", content: before });
|
||||
}
|
||||
const attrs = parseDisplayAttrs(match[1] ?? '');
|
||||
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',
|
||||
type: "display",
|
||||
key: group.key,
|
||||
mode: group.mode,
|
||||
sources: group.sources,
|
||||
@@ -939,29 +662,26 @@ function splitSkillDisplayPlaceholders(
|
||||
}
|
||||
const rest = content.slice(cursor);
|
||||
if (rest.trim() || parts.length === 0) {
|
||||
parts.push({ type: 'markdown', content: rest });
|
||||
parts.push({ type: "markdown", content: rest });
|
||||
}
|
||||
return { parts, placedKeys };
|
||||
}
|
||||
|
||||
function parseDisplayAttrs(raw: string): {
|
||||
tool?: string;
|
||||
mode?: RuntimeDisplaySource['mode'];
|
||||
} {
|
||||
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,
|
||||
mode: attrs.mode as RuntimeDisplaySource["mode"] | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function findDisplayGroup(
|
||||
sources: RuntimeDisplaySource[],
|
||||
tool?: string,
|
||||
mode?: RuntimeDisplaySource['mode'],
|
||||
mode?: RuntimeDisplaySource["mode"],
|
||||
) {
|
||||
return groupDisplaySources(sources).find((group) => {
|
||||
if (mode && group.mode !== mode) {
|
||||
@@ -982,18 +702,20 @@ function groupDisplaySources(sources: RuntimeDisplaySource[]) {
|
||||
{
|
||||
key: string;
|
||||
skillName: string;
|
||||
mode: RuntimeDisplaySource['mode'];
|
||||
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: [],
|
||||
};
|
||||
const group =
|
||||
groups.get(key) ??
|
||||
{
|
||||
key,
|
||||
skillName: source.skillName,
|
||||
mode: source.mode,
|
||||
sources: [],
|
||||
};
|
||||
group.sources.push(source);
|
||||
groups.set(key, group);
|
||||
}
|
||||
@@ -1001,5 +723,5 @@ function groupDisplaySources(sources: RuntimeDisplaySource[]) {
|
||||
}
|
||||
|
||||
function normalizeName(name: string) {
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
||||
return name.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { CheckIcon, SendIcon, XIcon } from "lucide-react";
|
||||
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
@@ -37,7 +37,6 @@ import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
|
||||
import { ArtifactFileList } from "../artifacts/artifact-file-list";
|
||||
import { StreamingIndicator } from "../streaming-indicator";
|
||||
|
||||
import { ThreadContext } from "./context";
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { MessageGroup } from "./message-group";
|
||||
import { MessageListItem } from "./message-list-item";
|
||||
@@ -53,34 +52,24 @@ export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
|
||||
|
||||
export function MessageList({
|
||||
className,
|
||||
contentClassName,
|
||||
fitMessageRowsToContent = false,
|
||||
threadId,
|
||||
thread,
|
||||
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
|
||||
tokenUsageEnabled = false,
|
||||
extraRehypePlugins,
|
||||
onClarificationSubmit,
|
||||
onEditResubmit,
|
||||
onSetupAgentApprove,
|
||||
onSetupAgentReject,
|
||||
onSetupWritingApprove,
|
||||
onSetupWritingReject,
|
||||
}: {
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
fitMessageRowsToContent?: boolean;
|
||||
threadId: string;
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
paddingBottom?: number;
|
||||
tokenUsageEnabled?: boolean;
|
||||
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
||||
onClarificationSubmit?: (answer: string) => void | Promise<void>;
|
||||
onEditResubmit?: (text: string) => void;
|
||||
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
|
||||
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
}) {
|
||||
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
|
||||
const rehypePlugins = useMemo(
|
||||
@@ -88,39 +77,7 @@ export function MessageList({
|
||||
[baseRehypePlugins, extraRehypePlugins],
|
||||
);
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
const { t } = useI18n();
|
||||
const threadContext = useContext(ThreadContext);
|
||||
// Read-only contexts (public share, mock previews) must not offer deletion.
|
||||
const allowDelete = !threadContext?.publicShareCode && !threadContext?.isMock;
|
||||
|
||||
// Locally hide messages the user just deleted. The server-side checkpoint is
|
||||
// trimmed immediately, but the live `useStream` cache still holds the message
|
||||
// until the next refetch — filtering by id here makes the removal instant and
|
||||
// keeps it hidden for the rest of the session (a reload reads the trimmed
|
||||
// state). Reset whenever the active thread changes.
|
||||
const [hiddenMessageIds, setHiddenMessageIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
useEffect(() => {
|
||||
setHiddenMessageIds(new Set());
|
||||
}, [threadId]);
|
||||
const handleMessageDeleted = useCallback((deletedIds: string[]) => {
|
||||
if (deletedIds.length === 0) return;
|
||||
setHiddenMessageIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of deletedIds) next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const allMessages = thread.messages;
|
||||
const messages = useMemo(
|
||||
() =>
|
||||
hiddenMessageIds.size === 0
|
||||
? allMessages
|
||||
: allMessages.filter((m) => !m.id || !hiddenMessageIds.has(m.id)),
|
||||
[allMessages, hiddenMessageIds],
|
||||
);
|
||||
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
|
||||
@@ -260,12 +217,7 @@ export function MessageList({
|
||||
<Conversation
|
||||
className={cn("flex size-full flex-col justify-center", className)}
|
||||
>
|
||||
<ConversationContent
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
<ConversationContent className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12">
|
||||
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
|
||||
{groupMessages(messages, (group) => {
|
||||
if (group.type === "human" || group.type === "assistant") {
|
||||
@@ -283,17 +235,12 @@ export function MessageList({
|
||||
return (
|
||||
<MessageListItem
|
||||
key={`${group.id}/${msg.id}`}
|
||||
fitRowToContent={fitMessageRowsToContent}
|
||||
message={msg}
|
||||
isLoading={thread.isLoading}
|
||||
threadId={threadId}
|
||||
tokenUsageEnabled={tokenUsageEnabled}
|
||||
displaySources={displaySources}
|
||||
extraRehypePlugins={extraRehypePlugins}
|
||||
onEditResubmit={onEditResubmit}
|
||||
onMessageDeleted={
|
||||
allowDelete ? handleMessageDeleted : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -360,23 +307,6 @@ export function MessageList({
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
} else if (group.type === "assistant:setup-writing-approval") {
|
||||
const message = group.messages[0];
|
||||
return message ? (
|
||||
<div key={group.id} className="w-full">
|
||||
<SetupWritingApprovalCard
|
||||
disabled={thread.isLoading}
|
||||
message={message}
|
||||
onApprove={onSetupWritingApprove}
|
||||
onReject={onSetupWritingReject}
|
||||
/>
|
||||
<MessageTokenUsageList
|
||||
enabled={tokenUsageEnabled}
|
||||
isLoading={thread.isLoading}
|
||||
messages={group.messages}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
} else if (group.type === "assistant:present-files") {
|
||||
const files: string[] = [];
|
||||
for (const message of group.messages) {
|
||||
@@ -555,16 +485,6 @@ export type SetupAgentApprovalPayload = {
|
||||
skills?: string[];
|
||||
};
|
||||
|
||||
export type SetupWritingApprovalPayload = {
|
||||
user_intent?: string;
|
||||
article_type?: string;
|
||||
word_count_target?: number | null;
|
||||
target_audience?: string;
|
||||
writing_mode?: string;
|
||||
material_source?: string;
|
||||
outline?: string;
|
||||
};
|
||||
|
||||
function findPreviousHumanMessageId(
|
||||
messages: Message[],
|
||||
beforeMessageId?: string,
|
||||
@@ -715,155 +635,6 @@ function SetupAgentApprovalCard({
|
||||
);
|
||||
}
|
||||
|
||||
function getSetupWritingApprovalPayload(
|
||||
message: unknown,
|
||||
): SetupWritingApprovalPayload | null {
|
||||
if (typeof message !== "object" || message === null) {
|
||||
return null;
|
||||
}
|
||||
const additionalKwargs = Reflect.get(message, "additional_kwargs");
|
||||
if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
|
||||
return null;
|
||||
}
|
||||
const approval = Reflect.get(additionalKwargs, "setup_writing_approval");
|
||||
if (typeof approval !== "object" || approval === null) {
|
||||
return null;
|
||||
}
|
||||
return approval as SetupWritingApprovalPayload;
|
||||
}
|
||||
|
||||
const WRITING_MODE_LABELS: Record<string, string> = {
|
||||
strict: "严格依据素材",
|
||||
loose: "可结合通用知识",
|
||||
};
|
||||
const MATERIAL_SOURCE_LABELS: Record<string, string> = {
|
||||
knowledge_base: "知识库",
|
||||
skill: "技能检索",
|
||||
};
|
||||
|
||||
function SetupWritingApprovalCard({
|
||||
disabled,
|
||||
message,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
message: unknown;
|
||||
onApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
onReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
|
||||
}) {
|
||||
const payload = getSetupWritingApprovalPayload(message);
|
||||
const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const approvalPayload = payload;
|
||||
const busy = disabled || submitting !== null;
|
||||
|
||||
async function run(action: "approve" | "reject") {
|
||||
if (busy) {
|
||||
return;
|
||||
}
|
||||
setSubmitting(action);
|
||||
try {
|
||||
if (action === "approve") {
|
||||
await onApprove?.(approvalPayload);
|
||||
} else {
|
||||
await onReject?.(approvalPayload);
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(null);
|
||||
}
|
||||
}
|
||||
|
||||
const rows: Array<{ label: string; value: string }> = [];
|
||||
if (approvalPayload.article_type) {
|
||||
rows.push({ label: "文章类型", value: approvalPayload.article_type });
|
||||
}
|
||||
if (typeof approvalPayload.word_count_target === "number") {
|
||||
rows.push({ label: "目标字数", value: `${approvalPayload.word_count_target} 字` });
|
||||
}
|
||||
if (approvalPayload.target_audience) {
|
||||
rows.push({ label: "目标读者", value: approvalPayload.target_audience });
|
||||
}
|
||||
if (approvalPayload.writing_mode) {
|
||||
rows.push({
|
||||
label: "写作模式",
|
||||
value: WRITING_MODE_LABELS[approvalPayload.writing_mode] ?? approvalPayload.writing_mode,
|
||||
});
|
||||
}
|
||||
if (approvalPayload.material_source) {
|
||||
rows.push({
|
||||
label: "素材来源",
|
||||
value: MATERIAL_SOURCE_LABELS[approvalPayload.material_source] ?? approvalPayload.material_source,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs font-medium">写作配置建议</div>
|
||||
<div className="text-sm font-medium">确认后将应用到左侧写作表单。</div>
|
||||
</div>
|
||||
|
||||
{approvalPayload.user_intent ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs">写作意图</div>
|
||||
<p className="text-sm leading-6">{approvalPayload.user_intent}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{rows.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1.5">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="space-y-0.5">
|
||||
<div className="text-muted-foreground text-xs">{row.label}</div>
|
||||
<p className="text-sm font-medium">{row.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{approvalPayload.outline ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground text-xs">已有大纲</div>
|
||||
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
|
||||
{approvalPayload.outline}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy || !onReject}
|
||||
onClick={() => void run("reject")}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
忽略
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={busy || !onApprove}
|
||||
onClick={() => void run("approve")}
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
应用到表单
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getClarificationPayload(message: unknown): ClarificationPayload | null {
|
||||
if (typeof message !== "object" || message === null) {
|
||||
return null;
|
||||
@@ -940,14 +711,9 @@ function ClarificationCard({
|
||||
return;
|
||||
}
|
||||
if (!allowMultiple) {
|
||||
// 单选:仅当「只有这一张待答澄清卡」时才点击即发送。多张澄清卡同时出现时,
|
||||
// 点击只记录本卡选择,待最后一张的「全部发送」统一提交——否则点了第一张就立刻
|
||||
// 提交整批,后面的卡片再也无法选择(见 bug:选了第一个,第二个不让选)。
|
||||
const next = { selectedIds: [optionId], custom: selection.custom };
|
||||
const next = { selectedIds: [optionId], custom: "" };
|
||||
onSelectionChange(next);
|
||||
if (!multiPending) {
|
||||
void onSubmitAll(next);
|
||||
}
|
||||
void onSubmitAll(next);
|
||||
return;
|
||||
}
|
||||
const next = selection.selectedIds.includes(optionId)
|
||||
@@ -987,11 +753,7 @@ function ClarificationCard({
|
||||
{options.length > 0 ? (
|
||||
<div className="mt-4 grid gap-2">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{allowMultiple
|
||||
? "可多选(再次点击取消选择)"
|
||||
: multiPending
|
||||
? "请选择一项"
|
||||
: "请选择一项(点击即发送)"}
|
||||
{allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}
|
||||
</div>
|
||||
{options.map((option) => {
|
||||
const optionId = option.id || option.label;
|
||||
@@ -1000,7 +762,7 @@ function ClarificationCard({
|
||||
<Button
|
||||
key={optionId}
|
||||
type="button"
|
||||
variant={isSelected && (allowMultiple || multiPending) ? "default" : "outline"}
|
||||
variant={isSelected && allowMultiple ? "default" : "outline"}
|
||||
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
|
||||
disabled={disabled}
|
||||
onClick={() => handleOptionClick(optionId)}
|
||||
@@ -1020,7 +782,7 @@ function ClarificationCard({
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{isSelected && (allowMultiple || multiPending) ? (
|
||||
{isSelected && allowMultiple ? (
|
||||
<CheckIcon className="ml-2 h-4 w-4 shrink-0" />
|
||||
) : null}
|
||||
</Button>
|
||||
@@ -1045,10 +807,10 @@ function ClarificationCard({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isPending && (allowMultiple || multiPending || payload.allow_custom !== false) ? (
|
||||
{isPending && (allowMultiple || payload.allow_custom !== false) ? (
|
||||
isLastPending ? (
|
||||
<div className="mt-3 flex items-center justify-end gap-2">
|
||||
{multiPending ? (
|
||||
{multiPending && allowMultiple ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
将一起提交以上 {pendingCount} 个问题的回答
|
||||
</span>
|
||||
@@ -1060,10 +822,10 @@ function ClarificationCard({
|
||||
onClick={() => void onSubmitAll()}
|
||||
>
|
||||
<SendIcon className="h-4 w-4" />
|
||||
{submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"}
|
||||
{submitting ? "发送中…" : multiPending && allowMultiple ? "全部发送" : "发送回答"}
|
||||
</Button>
|
||||
</div>
|
||||
) : allowMultiple || multiPending ? (
|
||||
) : allowMultiple ? (
|
||||
<div className="text-muted-foreground mt-3 text-right text-xs">
|
||||
选择后等待最后一题统一提交
|
||||
</div>
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { DownloadIcon, Loader2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
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 " +
|
||||
"[&_table]:my-4 [&_table]:w-full [&_table]:border-collapse " +
|
||||
"[&_th]:border [&_th]:px-2 [&_th]:py-1 [&_td]:border [&_td]:px-2 [&_td]:py-1 " +
|
||||
"[&_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";
|
||||
|
||||
const IMAGE_EXT = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"];
|
||||
const TEXT_EXT = [
|
||||
"txt", "md", "markdown", "csv", "tsv", "json", "log", "yaml", "yml",
|
||||
"xml", "html", "htm", "css", "js", "ts", "tsx", "jsx", "py", "java",
|
||||
"go", "rs", "c", "cpp", "h", "hpp", "sh", "sql", "ini", "toml", "env",
|
||||
];
|
||||
// Office documents are converted to a sibling Markdown file server-side
|
||||
// (uploads.auto_convert_documents). We preview that .md instead of the binary.
|
||||
const OFFICE_EXT = ["xlsx", "xls", "docx", "doc", "pptx", "ppt"];
|
||||
|
||||
function extOf(filename: string): string {
|
||||
const i = filename.lastIndexOf(".");
|
||||
return i >= 0 ? filename.slice(i + 1).toLowerCase() : "";
|
||||
}
|
||||
|
||||
type PreviewKind = "image" | "text" | "office" | "pdf" | "binary";
|
||||
|
||||
function kindOf(filename: string): PreviewKind {
|
||||
const ext = extOf(filename);
|
||||
if (IMAGE_EXT.includes(ext)) return "image";
|
||||
if (ext === "pdf") return "pdf";
|
||||
if (OFFICE_EXT.includes(ext)) return "office";
|
||||
if (TEXT_EXT.includes(ext)) return "text";
|
||||
return "binary";
|
||||
}
|
||||
|
||||
function withReplacedExtension(url: string): string {
|
||||
// Replace the path extension with .md, keeping any query string intact.
|
||||
const [base, query] = url.split("?");
|
||||
const i = base.lastIndexOf(".");
|
||||
const swapped = (i >= 0 ? base.slice(0, i) : base) + ".md";
|
||||
return query ? `${swapped}?${query}` : swapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch an auth-gated artifact (the artifact route requires an
|
||||
* `Authorization: Bearer` header, which a plain <img>/<iframe> cannot send —
|
||||
* hence the 401s) and expose it as an object URL for inline display.
|
||||
*/
|
||||
export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !url) {
|
||||
setObjectUrl(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
let created: string | null = null;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
apiFetch(url)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
if (cancelled) return;
|
||||
created = URL.createObjectURL(blob);
|
||||
setObjectUrl(created);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled)
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (created) URL.revokeObjectURL(created);
|
||||
};
|
||||
}, [url, enabled]);
|
||||
|
||||
return { objectUrl, loading, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview dialog for a file attached to a chat message. All fetches go through
|
||||
* `apiFetch` so the `Authorization` header is attached (artifacts are auth-gated).
|
||||
*
|
||||
* - image → fetched as blob, rendered inline
|
||||
* - text → fetched and shown (markdown rendered for .md, otherwise <pre>)
|
||||
* - office → fetches the server-converted sibling `<name>.md` and renders it
|
||||
* - pdf → fetched as blob, embedded via <iframe>
|
||||
* - else → download link
|
||||
*/
|
||||
export function UploadedFilePreviewDialog({
|
||||
filename,
|
||||
fileUrl,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
filename: string;
|
||||
/** Absolute artifact URL for the original uploaded file. */
|
||||
fileUrl: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const kind = useMemo(() => kindOf(filename), [filename]);
|
||||
const ext = useMemo(() => extOf(filename), [filename]);
|
||||
|
||||
// image / pdf → fetch as authed blob and display via object URL.
|
||||
const blobEnabled = open && (kind === "image" || kind === "pdf");
|
||||
const { objectUrl, loading: blobLoading, error: blobError } =
|
||||
useAuthedObjectUrl(fileUrl, blobEnabled);
|
||||
|
||||
// text / office → fetch text (office reads the converted sibling .md).
|
||||
const textUrl = useMemo(
|
||||
() => (kind === "office" ? withReplacedExtension(fileUrl) : fileUrl),
|
||||
[kind, fileUrl],
|
||||
);
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const [textLoading, setTextLoading] = useState(false);
|
||||
const [textError, setTextError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || (kind !== "text" && kind !== "office")) return;
|
||||
let cancelled = false;
|
||||
setTextLoading(true);
|
||||
setTextError(null);
|
||||
setText(null);
|
||||
apiFetch(textUrl)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.text();
|
||||
})
|
||||
.then((body) => {
|
||||
if (!cancelled) setText(body);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled)
|
||||
setTextError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setTextLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, kind, textUrl]);
|
||||
|
||||
const renderAsMarkdown =
|
||||
kind === "office" || ext === "md" || ext === "markdown";
|
||||
|
||||
const loadingView = (
|
||||
<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>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
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 pr-12">
|
||||
<DialogTitle className="truncate text-sm">{filename}</DialogTitle>
|
||||
<DialogDescription className="mt-0.5 truncate text-xs">
|
||||
<a
|
||||
href={fileUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground inline-flex items-center gap-1 underline-offset-2 hover:underline"
|
||||
>
|
||||
<DownloadIcon className="size-3" />
|
||||
{t.common.download}
|
||||
</a>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto px-5 py-4">
|
||||
{kind === "image" ? (
|
||||
blobLoading ? (
|
||||
loadingView
|
||||
) : blobError || !objectUrl ? (
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center">
|
||||
<img
|
||||
src={objectUrl}
|
||||
alt={filename}
|
||||
className="max-h-full max-w-full rounded border"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : kind === "pdf" ? (
|
||||
blobLoading ? (
|
||||
loadingView
|
||||
) : blobError || !objectUrl ? (
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
||||
) : (
|
||||
<iframe
|
||||
src={objectUrl}
|
||||
title={filename}
|
||||
className="h-full min-h-[500px] w-full rounded border"
|
||||
/>
|
||||
)
|
||||
) : kind === "text" || kind === "office" ? (
|
||||
textLoading ? (
|
||||
loadingView
|
||||
) : textError ? (
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
||||
) : renderAsMarkdown ? (
|
||||
<MarkdownContent
|
||||
className={markdownClassName}
|
||||
content={text ?? ""}
|
||||
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-wrap">
|
||||
{text ?? ""}
|
||||
</pre>
|
||||
)
|
||||
) : (
|
||||
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function BinaryFallback({ fileUrl, label }: { fileUrl: string; label: string }) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex flex-col items-center gap-3 rounded-lg border border-dashed px-4 py-12 text-center text-sm">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
{label}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -213,7 +213,7 @@ export function RecentChatList() {
|
||||
className="block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||
href={pathOfThread(thread)}
|
||||
>
|
||||
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||
{desensitize(titleOfThread(thread))}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
||||
@@ -236,7 +236,7 @@ export function RecentChatList() {
|
||||
onSelect={() =>
|
||||
handleRenameClick(
|
||||
thread.thread_id,
|
||||
titleOfThread(thread, t.pages.newChat),
|
||||
titleOfThread(thread),
|
||||
)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { ChevronDown, Loader2, MoreHorizontal, Pencil, Share2, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuButton,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { ShareRoundtableDraftDialog } from "@/components/workspace/roundtable-draft-share-dialogs";
|
||||
import { formatRelative } from "@/open-canvas/components/ai-writing/AIWritingHistoryDropdown";
|
||||
import { useRoundtableDrafts } from "@/roundtable-planning/hooks/useRoundtableDrafts";
|
||||
import type { DraftMeta } from "@/roundtable-planning/api/drafts";
|
||||
import { isIMEComposing } from "@/lib/ime";
|
||||
|
||||
const ROUNDTABLE_PATH = "/page/workspace/roundtable/planning";
|
||||
|
||||
function openDraft(
|
||||
draft: DraftMeta,
|
||||
isOnPage: boolean,
|
||||
router: ReturnType<typeof useRouter>,
|
||||
) {
|
||||
if (isOnPage) {
|
||||
// 已在会商页面:直接派事件让页面加载该草稿(仿 AI 写作的 open-session)。
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("roundtable:open-draft", { detail: draft.id }),
|
||||
);
|
||||
} else {
|
||||
// 不在该页面:把草稿 id 暂存到 sessionStorage,跳转后由页面消费。
|
||||
sessionStorage.setItem("roundtable:pending-draft", draft.id);
|
||||
router.push(ROUNDTABLE_PATH);
|
||||
}
|
||||
}
|
||||
|
||||
/** 把草稿的后台作业状态 / 步骤映射成侧边栏小标签(仿页面内 HistoryDropdown)。 */
|
||||
function StatusBadge({ draft }: { draft: DraftMeta }) {
|
||||
if (draft.status === "running") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-primary">
|
||||
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
||||
进行中
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (draft.status === "awaiting_input") {
|
||||
return <span className="text-[10px] font-medium text-amber-500">待澄清</span>;
|
||||
}
|
||||
if (draft.status === "error") {
|
||||
return <span className="text-[10px] font-medium text-rose-500">出错</span>;
|
||||
}
|
||||
return (
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
第 {draft.furthestStep} 步
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecentRoundtableList() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { drafts, loading, refresh, rename, remove } = useRoundtableDrafts();
|
||||
const [collapsed, setCollapsed] = useState(
|
||||
() => localStorage.getItem("sidebar:recent-roundtable-collapsed") === "true",
|
||||
);
|
||||
|
||||
// 重命名对话框状态。
|
||||
const [renameId, setRenameId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
// 分享对话框 —— 正在分享的草稿 id。
|
||||
const [shareId, setShareId] = useState<string | null>(null);
|
||||
|
||||
const toggleCollapsed = () => {
|
||||
const next = !collapsed;
|
||||
setCollapsed(next);
|
||||
localStorage.setItem("sidebar:recent-roundtable-collapsed", String(next));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const handleRenameSubmit = useCallback(async () => {
|
||||
const id = renameId;
|
||||
const title = renameValue.trim();
|
||||
if (!id || !title) {
|
||||
setRenameId(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await rename(id, title);
|
||||
toast.success("✏️ 已重命名");
|
||||
} catch {
|
||||
toast.error("重命名失败,请重试");
|
||||
} finally {
|
||||
setRenameId(null);
|
||||
setRenameValue("");
|
||||
}
|
||||
}, [renameId, renameValue, rename]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (draft: DraftMeta) => {
|
||||
if (!window.confirm("确定删除这条会商记录吗?此操作不可恢复。")) return;
|
||||
try {
|
||||
await remove(draft.id);
|
||||
toast.success("🗑 已删除会商记录");
|
||||
} catch {
|
||||
toast.error("删除失败,请重试");
|
||||
}
|
||||
},
|
||||
[remove],
|
||||
);
|
||||
|
||||
if (!loading && drafts.length === 0) return null;
|
||||
|
||||
const isOnPage = pathname === ROUNDTABLE_PATH;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarGroup className="px-2 pt-3 mt-0 border-t border-sidebar-border/60">
|
||||
<SidebarGroupLabel
|
||||
className="text-muted-foreground/70 px-3 mb-1 text-[10px] font-semibold tracking-[0.12em] uppercase cursor-pointer select-none flex items-center justify-between hover:text-muted-foreground transition-colors"
|
||||
onClick={toggleCollapsed}
|
||||
>
|
||||
<span>多智能体会商</span>
|
||||
<ChevronDown
|
||||
className={`w-3 h-3 shrink-0 transition-transform duration-200 ${collapsed ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent
|
||||
className={`group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 overflow-hidden transition-all duration-200 ${
|
||||
collapsed ? "max-h-0 opacity-0 pointer-events-none" : "max-h-[600px] opacity-100"
|
||||
}`}
|
||||
>
|
||||
<SidebarMenu>
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
{loading && drafts.length === 0 ? (
|
||||
<p className="px-3 py-1 text-[12px] text-muted-foreground">加载中…</p>
|
||||
) : (
|
||||
drafts.slice(0, 10).map((draft) => (
|
||||
<SidebarMenuItem key={draft.id} className="group/side-menu-item">
|
||||
<SidebarMenuButton
|
||||
className={
|
||||
"rounded-lg h-auto py-1.5 px-3 text-muted-foreground " +
|
||||
"hover:text-foreground hover:bg-sidebar-accent/60"
|
||||
}
|
||||
onClick={() => openDraft(draft, isOnPage, router)}
|
||||
>
|
||||
<div className="flex flex-col items-start min-w-0 w-full">
|
||||
<span className="text-[13px] truncate w-full leading-snug">
|
||||
{draft.title || "未命名草稿"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<StatusBadge draft={draft} />
|
||||
<span className="text-[10px] text-muted-foreground">·</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatRelative(draft.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction
|
||||
showOnHover
|
||||
className="bg-background/50 hover:bg-background top-2"
|
||||
>
|
||||
<MoreHorizontal />
|
||||
<span className="sr-only">更多</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-44 rounded-lg"
|
||||
side="right"
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setRenameId(draft.id);
|
||||
setRenameValue(draft.title || "");
|
||||
}}
|
||||
>
|
||||
<Pencil className="text-muted-foreground" />
|
||||
<span>重命名</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setShareId(draft.id)}>
|
||||
<Share2 className="text-muted-foreground" />
|
||||
<span>分享</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => void handleDelete(draft)}>
|
||||
<Trash2 className="text-muted-foreground" />
|
||||
<span>删除</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{/* 重命名对话框 */}
|
||||
<Dialog open={renameId !== null} onOpenChange={(o) => !o && setRenameId(null)}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>重命名会商</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
placeholder="输入新名称"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !isIMEComposing(e)) {
|
||||
e.preventDefault();
|
||||
void handleRenameSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRenameId(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => void handleRenameSubmit()}>保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 分享对话框 */}
|
||||
<ShareRoundtableDraftDialog
|
||||
draftId={shareId}
|
||||
open={shareId !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setShareId(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,322 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
ExternalLinkIcon,
|
||||
Loader2,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, 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 type { DraftShare, DraftShareMode, DraftSharePreview } from "@/core/roundtable-draft-shares/api";
|
||||
import { buildPublicDraftShareUrl, extractShareCode } from "@/core/roundtable-draft-shares/api";
|
||||
import {
|
||||
useCreateDraftShare,
|
||||
useImportDraftShare,
|
||||
usePreviewDraftShare,
|
||||
useRevokeDraftShare,
|
||||
} from "@/core/roundtable-draft-shares/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog that produces a share link for a single roundtable draft. Two modes:
|
||||
*
|
||||
* - `view` — mints a public, read-only link that anyone can open without logging
|
||||
* in; it renders the draft's Step 3 result report (方案总览).
|
||||
* - `import` — mints a short code; a recipient enters it in the import dialog to
|
||||
* copy the whole 会商 draft into their own account.
|
||||
*/
|
||||
export function ShareRoundtableDraftDialog({
|
||||
draftId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
draftId: string | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const createShare = useCreateDraftShare();
|
||||
const revokeShare = useRevokeDraftShare();
|
||||
const [mode, setMode] = useState<DraftShareMode>("view");
|
||||
const [share, setShare] = useState<DraftShare | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Reset to the default mode (read-only link) every time the dialog opens.
|
||||
useEffect(() => {
|
||||
if (open) setMode("view");
|
||||
}, [open, draftId]);
|
||||
|
||||
// Mint / reuse the link whenever the dialog opens or the mode changes.
|
||||
useEffect(() => {
|
||||
if (!open || !draftId) return;
|
||||
setShare(null);
|
||||
setCopied(false);
|
||||
createShare
|
||||
.mutateAsync({ draftId, mode })
|
||||
.then(setShare)
|
||||
.catch((err) => toast.error(errorMessage(err)));
|
||||
// createShare is stable enough; re-running only on open/draftId/mode is intended.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, draftId, mode]);
|
||||
|
||||
const isView = mode === "view";
|
||||
const publicUrl = isView && share ? buildPublicDraftShareUrl(share.share_code) : "";
|
||||
|
||||
async function handleCopy() {
|
||||
if (!share) return;
|
||||
const text = isView ? publicUrl : share.share_code;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
toast.success(isView ? "链接已复制" : "分享码已复制");
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
toast.error("复制到剪贴板失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke() {
|
||||
if (!share || !window.confirm("撤销后此分享立即失效,确定吗?")) return;
|
||||
try {
|
||||
await revokeShare.mutateAsync(share.share_code);
|
||||
toast.success("已撤销分享");
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>分享会商</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isView
|
||||
? "生成一个只读链接,任何人无需登录都能查看这次会商生成的结果报告。"
|
||||
: "生成一个分享码,对方在「导入会商」里输入它即可把这份会商复制到自己的账号继续研讨。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Mode selector */}
|
||||
<div className="bg-muted flex gap-1 rounded-lg p-1">
|
||||
{(
|
||||
[
|
||||
{ value: "view" as const, label: "只读链接" },
|
||||
{ value: "import" as const, label: "导入副本" },
|
||||
]
|
||||
).map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setMode(option.value)}
|
||||
className={cn(
|
||||
"flex-1 rounded-md px-3 py-1.5 text-sm transition-colors",
|
||||
mode === option.value
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!share ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 py-8 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在生成分享…
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1 text-xs">
|
||||
{isView ? "只读链接" : "分享码"}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{isView ? (
|
||||
<Input
|
||||
readOnly
|
||||
value={publicUrl}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
) : (
|
||||
<code className="bg-muted flex-1 rounded-md px-3 py-2 text-center font-mono text-lg tracking-[0.3em]">
|
||||
{share.share_code}
|
||||
</code>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => void handleCopy()}>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-4" />
|
||||
) : (
|
||||
<CopyIcon className="size-4" />
|
||||
)}
|
||||
{isView ? "复制链接" : "复制码"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{isView ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
拿到链接的人都能查看这次会商的结果报告(只读)。若会商尚未生成结果报告,对方会看到提示。撤销后链接立即失效。
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(publicUrl, "_blank", "noopener")}
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
在新标签页打开
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
把这个分享码发给对方,他们在「导入会商」里输入即可复制一份。撤销后分享码立即失效。
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={revokeShare.isPending}
|
||||
onClick={() => void handleRevoke()}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
撤销分享
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Dialog where a user pastes a share code to import someone's roundtable draft. */
|
||||
export function ImportRoundtableDraftDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onImported,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Called with the new local draft id after a successful import. */
|
||||
onImported?: (draftId: string | null) => void;
|
||||
}) {
|
||||
const [input, setInput] = useState("");
|
||||
const [preview, setPreview] = useState<DraftSharePreview | null>(null);
|
||||
const previewShare = usePreviewDraftShare();
|
||||
const importShare = useImportDraftShare();
|
||||
|
||||
// Reset state whenever the dialog closes.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setInput("");
|
||||
setPreview(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
async function handlePreview() {
|
||||
const code = extractShareCode(input);
|
||||
if (!code) {
|
||||
toast.error("请输入分享码");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setPreview(await previewShare.mutateAsync(code));
|
||||
} catch (err) {
|
||||
setPreview(null);
|
||||
toast.error(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
const code = preview?.share_code ?? extractShareCode(input);
|
||||
if (!code) {
|
||||
toast.error("请输入分享码");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await importShare.mutateAsync(code);
|
||||
if (result.imported > 0) {
|
||||
toast.success("导入成功,已加入「多智能体会商」历史");
|
||||
} else {
|
||||
toast.info("这份会商之前已导入过");
|
||||
}
|
||||
onImported?.(result.draft_id);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>导入会商</DialogTitle>
|
||||
<DialogDescription>
|
||||
输入对方分享的「导入副本」分享码,把这份会商复制到你自己的账号。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
setPreview(null);
|
||||
}}
|
||||
placeholder="粘贴分享码或链接"
|
||||
className="font-mono"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handlePreview()}
|
||||
disabled={previewShare.isPending || !input.trim()}
|
||||
>
|
||||
{previewShare.isPending && <Loader2 className="size-4 animate-spin" />}
|
||||
预览
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{preview && (
|
||||
<div className="space-y-2 rounded-lg border p-3 text-sm">
|
||||
<p className="font-medium">{preview.title || "未命名会商"}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
来自用户 {preview.shared_by}
|
||||
</p>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => void handleImport()}
|
||||
disabled={importShare.isPending}
|
||||
>
|
||||
{importShare.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<DownloadIcon className="size-4" />
|
||||
)}
|
||||
{importShare.isPending ? "正在导入…" : "导入到我的账号"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { LayoutGridIcon, ListIcon, MoonIcon, SunIcon } from "lucide-react";
|
||||
import { MoonIcon, SunIcon } from "lucide-react";
|
||||
import { useMemo, type ComponentType, type SVGProps } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
|
||||
import { FONT_SCALE_OPTIONS } from "@/components/font-scale";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
@@ -22,12 +21,12 @@ import { getStoredAuth } from "@/core/auth";
|
||||
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import {
|
||||
useAppearanceSettings,
|
||||
useCitationDisplaySettings,
|
||||
useUpdateAppearanceSettings,
|
||||
useUpdateCitationDisplaySettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import {
|
||||
useAppearanceSettings,
|
||||
useCitationDisplaySettings,
|
||||
useUpdateAppearanceSettings,
|
||||
useUpdateCitationDisplaySettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
@@ -43,15 +42,13 @@ export function AppearanceSettingsPage() {
|
||||
const currentTheme = theme;
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const { data: appearance } = useAppearanceSettings();
|
||||
const updateAppearance = useUpdateAppearanceSettings();
|
||||
const { data: citationDisplay } = useCitationDisplaySettings();
|
||||
const updateCitationDisplay = useUpdateCitationDisplaySettings();
|
||||
const globalDefault = appearance?.default_theme ?? "light";
|
||||
const globalSkillView = appearance?.default_skill_view ?? "list";
|
||||
const globalAgentView = appearance?.default_agent_view ?? "card";
|
||||
const cleanupWords =
|
||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||
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;
|
||||
@@ -67,27 +64,6 @@ export function AppearanceSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefaultView = async (
|
||||
kind: "skill" | "agent",
|
||||
value: "card" | "list",
|
||||
) => {
|
||||
const current = kind === "skill" ? globalSkillView : globalAgentView;
|
||||
if (value === current) return;
|
||||
try {
|
||||
await updateAppearance.mutateAsync(
|
||||
kind === "skill"
|
||||
? { default_skill_view: value }
|
||||
: { default_agent_view: value },
|
||||
);
|
||||
const label = kind === "skill" ? "技能" : "智能体";
|
||||
toast.success(
|
||||
`已将${label}默认展示模式设置为${value === "card" ? "卡片" : "列表"}`,
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const themeOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -169,85 +145,6 @@ export function AppearanceSettingsPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
<SettingsSection
|
||||
title="默认展示模式(管理员)"
|
||||
description="技能与智能体列表对所有还没有手动切换过的用户的默认展示模式;已经自己切换过的用户保留其个人选择不受影响。"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{(
|
||||
[
|
||||
{ kind: "skill", label: "技能", current: globalSkillView },
|
||||
{ kind: "agent", label: "智能体", current: globalAgentView },
|
||||
] as const
|
||||
).map(({ kind, label, current }) => (
|
||||
<div key={kind} className="flex flex-wrap items-center gap-2">
|
||||
<span className="w-14 text-sm font-medium">{label}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant={current === "card" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => void handleSetDefaultView(kind, "card")}
|
||||
disabled={updateAppearance.isPending}
|
||||
>
|
||||
<LayoutGridIcon className="mr-1.5 size-4" />
|
||||
卡片
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={current === "list" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => void handleSetDefaultView(kind, "list")}
|
||||
disabled={updateAppearance.isPending}
|
||||
>
|
||||
<ListIcon className="mr-1.5 size-4" />
|
||||
列表
|
||||
</Button>
|
||||
<p className="text-muted-foreground ml-2 text-xs">
|
||||
当前系统默认:{current === "card" ? "卡片" : "列表"}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
title="字号"
|
||||
description="调整界面整体字号,方便放大或缩小阅读。默认 16px(标准)。"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Select
|
||||
value={String(settings.display.fontScale ?? 1)}
|
||||
onValueChange={(value) => {
|
||||
const scale = Number(value);
|
||||
if (Number.isFinite(scale) && scale > 0) {
|
||||
setSettings("display", { fontScale: scale });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[220px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FONT_SCALE_OPTIONS.map((item) => (
|
||||
<SelectItem key={item.value} value={String(item.value)}>
|
||||
{item.label}({item.px}px)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="text-muted-foreground rounded-lg border p-4 text-sm leading-relaxed">
|
||||
预览:多智能体圆桌研讨 · The quick brown fox · 0123456789
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
@@ -320,16 +217,16 @@ export function AppearanceSettingsPage() {
|
||||
管理员配置。每行一个词或短句,参考文献卡片和详情弹框会自动移除这些广告词、导航词或免责声明。
|
||||
</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 });
|
||||
}}
|
||||
<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"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { PenLineIcon, PlusIcon, SearchIcon, Trash2Icon, Heart, BookOpen, Map, Zap, Target, ShieldAlert } from "lucide-react";
|
||||
import { PenLineIcon, PlusIcon, SearchIcon, Trash2Icon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -229,7 +229,7 @@ export function MemorySettingsPage() {
|
||||
<SelectTrigger className="h-8 w-44" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">
|
||||
{m.workNotesDefaultAgent}
|
||||
</SelectItem>
|
||||
@@ -464,41 +464,13 @@ const HISTORY_LABELS: Record<string, string> = {
|
||||
longTermBackground: "长期背景",
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, Record<string, string>> = {
|
||||
zh: {
|
||||
preference: "偏好",
|
||||
knowledge: "知识",
|
||||
context: "背景",
|
||||
behavior: "行为",
|
||||
goal: "目标",
|
||||
correction: "纠偏",
|
||||
},
|
||||
en: {
|
||||
preference: "Preference",
|
||||
knowledge: "Knowledge",
|
||||
context: "Context",
|
||||
behavior: "Behavior",
|
||||
goal: "Goal",
|
||||
correction: "Correction",
|
||||
},
|
||||
};
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
preference: "bg-blue-50 text-blue-700 border border-blue-200/50 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-900/30",
|
||||
knowledge: "bg-green-50 text-green-700 border border-green-200/50 dark:bg-green-950/30 dark:text-green-400 dark:border-green-900/30",
|
||||
context: "bg-amber-50 text-amber-700 border border-amber-200/50 dark:bg-amber-950/30 dark:text-amber-400 dark:border-amber-900/30",
|
||||
behavior: "bg-purple-50 text-purple-700 border border-purple-200/50 dark:bg-purple-950/30 dark:text-purple-400 dark:border-purple-900/30",
|
||||
goal: "bg-orange-50 text-orange-700 border border-orange-200/50 dark:bg-orange-950/30 dark:text-orange-400 dark:border-orange-900/30",
|
||||
correction: "bg-red-50 text-red-700 border border-red-200/50 dark:bg-red-950/30 dark:text-red-400 dark:border-red-900/30",
|
||||
};
|
||||
|
||||
const CATEGORY_ICONS: Record<string, React.ElementType> = {
|
||||
preference: Heart,
|
||||
knowledge: BookOpen,
|
||||
context: Map,
|
||||
behavior: Zap,
|
||||
goal: Target,
|
||||
correction: ShieldAlert,
|
||||
preference: "bg-blue-100 text-blue-700",
|
||||
knowledge: "bg-green-100 text-green-700",
|
||||
context: "bg-yellow-100 text-yellow-700",
|
||||
behavior: "bg-purple-100 text-purple-700",
|
||||
goal: "bg-orange-100 text-orange-700",
|
||||
correction: "bg-red-100 text-red-700",
|
||||
};
|
||||
|
||||
/** V1 记忆只读视图:展示 memory.json 的全部字段,Tab 布局。 */
|
||||
@@ -512,16 +484,13 @@ function MemoryV1View({
|
||||
memory_usage: string;
|
||||
};
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const isZh = locale.startsWith("zh");
|
||||
const labelMap = isZh ? CATEGORY_LABELS.zh : CATEGORY_LABELS.en;
|
||||
const ctxEntries = Object.entries(memory.user_context ?? {}).filter(([, v]) => v);
|
||||
const historyEntries = Object.entries(memory.history ?? {}).filter(([, v]) => v);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 说明横幅 */}
|
||||
<div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground dark:border-[#213D5E] dark:bg-[#022248]">
|
||||
<div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground">
|
||||
V1 模式:记忆由系统在对话结束后自动提取,无需手动管理。
|
||||
</div>
|
||||
|
||||
@@ -540,71 +509,45 @@ function MemoryV1View({
|
||||
|
||||
{/* 事实记录 Tab */}
|
||||
<TabsContent value="facts" className="mt-3">
|
||||
<div className="rounded-lg border p-4 space-y-3 dark:border-[#213D5E] dark:bg-[#022248]">
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-medium">事实记录</h3>
|
||||
<span className="text-xs text-muted-foreground">{memory.memory_usage}</span>
|
||||
</div>
|
||||
{memory.facts.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground ">
|
||||
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
|
||||
暂无事实记录,对话后系统会自动提取。
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 pt-2">
|
||||
{memory.facts.map((fact) => {
|
||||
const categoryLabel = labelMap[fact.category] ?? fact.category;
|
||||
const Icon = CATEGORY_ICONS[fact.category] ?? PlusIcon;
|
||||
return (
|
||||
<div
|
||||
key={fact.id}
|
||||
className="group relative flex flex-col justify-between rounded-xl border border-border/40 bg-gradient-to-br from-card to-card/50 hover:from-card hover:to-card hover:border-primary/30 hover:shadow-md hover:-translate-y-0.5 transition-all duration-300 p-5 space-y-4 dark:border-[#213D5E]/40 dark:from-[#022248]/40 dark:to-[#022248]/10 dark:hover:from-[#022248]/60 dark:hover:to-[#022248]/30 overflow-hidden"
|
||||
>
|
||||
{/* Subtle top highlight */}
|
||||
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-primary/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium border shadow-sm ${
|
||||
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700 border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{categoryLabel}
|
||||
</span>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className="text-[11px] text-muted-foreground font-semibold tracking-wider">
|
||||
{Math.round(fact.confidence * 100)}%
|
||||
</span>
|
||||
{/* Confidence Progress Bar */}
|
||||
<div className="h-1 w-12 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-500"
|
||||
style={{ width: `${Math.round(fact.confidence * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[14px] text-foreground/90 leading-relaxed whitespace-pre-wrap [overflow-wrap:anywhere]">
|
||||
{desensitize(fact.content)}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{memory.facts.map((fact) => (
|
||||
<div key={fact.id} className="rounded-md border p-3 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-xs font-medium ${
|
||||
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{fact.category}
|
||||
</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${Math.round(fact.confidence * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{fact.sourceError && (
|
||||
<div className="mt-auto pt-2">
|
||||
<div className="rounded-lg bg-red-50/80 dark:bg-red-950/30 border border-red-100 dark:border-red-900/50 p-3 text-xs text-red-600 dark:text-red-400 shadow-sm">
|
||||
<div className="flex items-center gap-1.5 font-semibold mb-1">
|
||||
<ShieldAlert className="w-3.5 h-3.5" />
|
||||
<span>避免</span>
|
||||
</div>
|
||||
<p className="leading-relaxed opacity-90">{desensitize(fact.sourceError)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{Math.round(fact.confidence * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(fact.content)}</p>
|
||||
{fact.sourceError && (
|
||||
<p className="text-xs text-red-600 bg-red-50 rounded px-2 py-1">
|
||||
避免:{desensitize(fact.sourceError)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -613,7 +556,7 @@ function MemoryV1View({
|
||||
{/* 用户画像 & 历史摘要 Tab */}
|
||||
<TabsContent value="profile" className="mt-3 space-y-4">
|
||||
{/* 用户画像 (userContext) */}
|
||||
<div className="rounded-lg border p-4 space-y-2 dark:border-[#213D5E] dark:bg-[#022248]">
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h3 className="text-base font-medium">用户画像</h3>
|
||||
{ctxEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无画像信息,对话后系统会自动归纳。</p>
|
||||
@@ -632,7 +575,7 @@ function MemoryV1View({
|
||||
</div>
|
||||
|
||||
{/* 历史摘要 (history) */}
|
||||
<div className="rounded-lg border p-4 space-y-2 dark:border-[#213D5E] dark:bg-[#022248]">
|
||||
<div className="rounded-lg border p-4 space-y-2">
|
||||
<h3 className="text-base font-medium">历史摘要</h3>
|
||||
{historyEntries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无历史摘要。</p>
|
||||
|
||||
@@ -3,19 +3,16 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
ArchiveIcon,
|
||||
ArrowDownUpIcon,
|
||||
BookmarkIcon,
|
||||
CheckCircle2,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
CopyIcon,
|
||||
FileArchive,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
Loader2,
|
||||
LockIcon,
|
||||
PinIcon,
|
||||
PinOffIcon,
|
||||
RefreshCwIcon,
|
||||
SparklesIcon,
|
||||
TagsIcon,
|
||||
@@ -39,12 +36,12 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
@@ -52,30 +49,23 @@ import {
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
} from "@/components/ui/item";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||
import { ViewModeToggle } from "@/components/workspace/view-mode-toggle";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
||||
import {
|
||||
useDeleteCustomSkill,
|
||||
useDuplicateSkill,
|
||||
useInstallSkillUpload,
|
||||
useReconcileCustomSkills,
|
||||
useSetSkillFeatured,
|
||||
useSetSkillPinned,
|
||||
useSetSkillPublished,
|
||||
useSkills,
|
||||
useValidateSkillUpload,
|
||||
} from "@/core/skills/hooks";
|
||||
import { useViewMode } from "@/core/system-settings/hooks";
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
import { env } from "@/env";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
@@ -97,52 +87,9 @@ export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
type SkillSort =
|
||||
| "time_desc"
|
||||
| "time_asc"
|
||||
| "activity_desc"
|
||||
| "activity_asc"
|
||||
| "usage_desc"
|
||||
| "usage_asc"
|
||||
| "name_asc"
|
||||
| "name_desc";
|
||||
|
||||
/** 重新扫描成功 toast:仅暗色模式使用,浅色走 Sonner 默认样式 */
|
||||
const rescanSuccessToastDarkOptions = {
|
||||
classNames: {
|
||||
toast:
|
||||
"!rounded-[2px] !border !border-[#106455] !bg-[#052934] !text-[#FFFFFF] shadow-md",
|
||||
title: "!text-[#FFFFFF]",
|
||||
description: "!text-[#FFFFFF]",
|
||||
},
|
||||
icon: <CheckCircle2 className="size-4 shrink-0 text-[#2FD69A]" />,
|
||||
} as const;
|
||||
|
||||
/** 重新扫描无变更 toast:仅暗色模式使用,浅色走 Sonner 默认样式 */
|
||||
const rescanInfoToastDarkOptions = {
|
||||
classNames: {
|
||||
toast:
|
||||
"!rounded-[2px] !border !border-[#704812] !bg-[#202222] !text-[#FFFFFF] shadow-md",
|
||||
title: "!text-[#FFFFFF]",
|
||||
description: "!text-[#FFFFFF]",
|
||||
},
|
||||
icon: <InfoIcon className="size-4 shrink-0 text-[#FF9900]" />,
|
||||
} as const;
|
||||
|
||||
/** 重新扫描失败 toast:仅暗色模式使用,浅色走 Sonner 默认样式 */
|
||||
const rescanErrorToastDarkOptions = {
|
||||
classNames: {
|
||||
toast:
|
||||
"!rounded-[2px] !border !border-[#7F1A2A] !bg-[#311629] !text-[#FFFFFF] shadow-md",
|
||||
title: "!text-[#FFFFFF]",
|
||||
description: "!text-[#FFFFFF]",
|
||||
},
|
||||
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
|
||||
} as const;
|
||||
|
||||
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const { theme } = useTheme();
|
||||
const router = useRouter();
|
||||
const [filter, setFilter] = useState<string>("mine");
|
||||
const [installOpen, setInstallOpen] = useState(false);
|
||||
@@ -150,12 +97,10 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
const [sort, setSort] = useState<SkillSort>("time_desc");
|
||||
const [page, setPage] = useState(1);
|
||||
const [viewMode, setViewMode] = useViewMode("skill");
|
||||
const reconcileMutation = useReconcileCustomSkills();
|
||||
// Name + tag filtering is resolved server-side via the /api/skills query.
|
||||
const { skills, isLoading, error } = useSkills({ search, tagIds, sort });
|
||||
const { skills, isLoading, error } = useSkills({ search, tagIds });
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
@@ -201,36 +146,25 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
}, [skills, currentUserId]);
|
||||
|
||||
const filteredSkills = filter === "mine" ? activeMine : activeAll;
|
||||
const orderedSkills = useMemo(
|
||||
() =>
|
||||
filteredSkills
|
||||
.map((skill, index) => ({ skill, index }))
|
||||
.sort((a, b) => {
|
||||
// 置顶(featured)决定排序;钉选(pinned)只管防归档,不影响顺序。
|
||||
const featuredDelta =
|
||||
Number(!!b.skill.featured) - Number(!!a.skill.featured);
|
||||
return featuredDelta || a.index - b.index;
|
||||
})
|
||||
.map(({ skill }) => skill),
|
||||
[filteredSkills],
|
||||
);
|
||||
const staleCount = staleVisible.length;
|
||||
const archivedCount = archivedVisible.length;
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(orderedSkills.length / PAGE_SIZE));
|
||||
const totalPages = Math.max(1, Math.ceil(filteredSkills.length / PAGE_SIZE));
|
||||
// Filter / search / tag changes may shrink the result set — pull the page
|
||||
// back into range without forcing a reset on every keystroke.
|
||||
useEffect(() => {
|
||||
if (page > totalPages) setPage(totalPages);
|
||||
}, [page, totalPages]);
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search, tagIds, sort]);
|
||||
const pagedSkills = useMemo(
|
||||
() => orderedSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||
[orderedSkills, page],
|
||||
() => filteredSkills.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||
[filteredSkills, page],
|
||||
);
|
||||
|
||||
const handleCreateSkill = () => {
|
||||
onClose?.();
|
||||
router.push("/?mode=skill");
|
||||
};
|
||||
|
||||
const handleRescan = async () => {
|
||||
try {
|
||||
const result = await reconcileMutation.mutateAsync();
|
||||
@@ -240,21 +174,13 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
"{count}",
|
||||
String(result.adopted.length),
|
||||
),
|
||||
theme === "dark" ? rescanSuccessToastDarkOptions : undefined,
|
||||
);
|
||||
} else {
|
||||
toast.info(
|
||||
t.settings.skills.rescanSkillsNoop,
|
||||
theme === "dark" ? rescanInfoToastDarkOptions : undefined,
|
||||
);
|
||||
toast.info(t.settings.skills.rescanSkillsNoop);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : String(err),
|
||||
theme === "dark" ? rescanErrorToastDarkOptions : undefined,
|
||||
);
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
|
||||
};
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
@@ -330,6 +256,10 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
<Upload className="size-4" />
|
||||
{t.settings.skills.installSkill}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleCreateSkill}>
|
||||
<SparklesIcon className="size-4" />
|
||||
{t.settings.skills.createSkill}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<TagSearchBar
|
||||
@@ -337,57 +267,24 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
onSearchChange={setSearch}
|
||||
tagIds={tagIds}
|
||||
onTagIdsChange={setTagIds}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<ViewModeToggle value={viewMode} onChange={setViewMode} />
|
||||
<Select value={sort} onValueChange={(v) => setSort(v as SkillSort)}>
|
||||
<SelectTrigger
|
||||
className="h-9 w-full gap-2 px-3 text-xs sm:w-[148px]"
|
||||
title="排序"
|
||||
>
|
||||
<ArrowDownUpIcon className="text-muted-foreground size-3.5" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
||||
<SelectItem value="time_desc">最新创建</SelectItem>
|
||||
<SelectItem value="time_asc">最早创建</SelectItem>
|
||||
<SelectItem value="activity_desc">最近使用</SelectItem>
|
||||
<SelectItem value="activity_asc">最早使用</SelectItem>
|
||||
<SelectItem value="usage_desc">使用最多</SelectItem>
|
||||
<SelectItem value="usage_asc">使用最少</SelectItem>
|
||||
<SelectItem value="name_asc">名称 A-Z</SelectItem>
|
||||
<SelectItem value="name_desc">名称 Z-A</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
|
||||
)}
|
||||
{error && <div className="text-destructive text-sm">Error: {error.message}</div>}
|
||||
{!isLoading && !error && filteredSkills.length === 0 && (
|
||||
<EmptySkill />
|
||||
)}
|
||||
{!isLoading && !error && filteredSkills.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
viewMode === "card"
|
||||
? "grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3"
|
||||
: "flex flex-col gap-3",
|
||||
)}
|
||||
>
|
||||
{pagedSkills.map((skill) => (
|
||||
<SkillRow
|
||||
key={skill.name}
|
||||
skill={skill}
|
||||
currentUserId={currentUserId}
|
||||
isAdmin={isAdmin}
|
||||
layout={viewMode === "card" ? "card" : "row"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<EmptySkill onCreateSkill={handleCreateSkill} />
|
||||
)}
|
||||
{!isLoading && !error &&
|
||||
filteredSkills.length > 0 &&
|
||||
pagedSkills.map((skill) => (
|
||||
<SkillRow
|
||||
key={skill.name}
|
||||
skill={skill}
|
||||
currentUserId={currentUserId}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
))}
|
||||
{!isLoading && !error && totalPages > 1 && (
|
||||
<div className="flex items-center justify-end gap-2 pt-1">
|
||||
<Button
|
||||
@@ -440,26 +337,18 @@ export function SkillRow({
|
||||
skill,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
layout = "row",
|
||||
}: {
|
||||
skill: Skill;
|
||||
currentUserId: string | null;
|
||||
isAdmin: boolean;
|
||||
/** "row" = full-width horizontal item (default); "card" = grid card cell. */
|
||||
layout?: "row" | "card";
|
||||
}) {
|
||||
const isCard = layout === "card";
|
||||
const { t } = useI18n();
|
||||
const setPublished = useSetSkillPublished();
|
||||
const setPinned = useSetSkillPinned();
|
||||
const setFeatured = useSetSkillFeatured();
|
||||
const deleteSkill = useDeleteCustomSkill();
|
||||
const duplicateSkillMutation = useDuplicateSkill();
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [tagPickerOpen, setTagPickerOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [duplicateOpen, setDuplicateOpen] = useState(false);
|
||||
const [duplicateName, setDuplicateName] = useState("");
|
||||
|
||||
const isCustom = skill.category === "custom";
|
||||
// Built-in flag falls back to "no DB owner" so legacy responses without
|
||||
@@ -495,31 +384,6 @@ export function SkillRow({
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenDuplicate() {
|
||||
// Suggest a "<name>-copy" default that already satisfies the backend's
|
||||
// lowercase/digit/-/_ skill-name rule. The user can edit it before saving.
|
||||
setDuplicateName(`${skill.name}-copy`);
|
||||
setDuplicateOpen(true);
|
||||
}
|
||||
|
||||
async function handleDuplicate() {
|
||||
const target = duplicateName.trim();
|
||||
if (!target) {
|
||||
toast.error("请输入新技能名称");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await duplicateSkillMutation.mutateAsync({
|
||||
sourceName: skill.name,
|
||||
newName: target,
|
||||
});
|
||||
toast.success(`已复制为「${result.skill_name}」`);
|
||||
setDuplicateOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTogglePinned() {
|
||||
const next = !skill.pinned;
|
||||
try {
|
||||
@@ -532,288 +396,166 @@ export function SkillRow({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleFeatured() {
|
||||
const next = !skill.featured;
|
||||
try {
|
||||
await setFeatured.mutateAsync({ skillName: skill.name, featured: next });
|
||||
toast.success(
|
||||
next
|
||||
? t.settings.skills.featureSuccess
|
||||
: t.settings.skills.unfeatureSuccess,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
const badgeGroup = (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={cn(isCard && "truncate font-medium")}>
|
||||
{desensitize(skill.name)}
|
||||
</span>
|
||||
{isBuiltin && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t.settings.skills.badgeBuiltin}
|
||||
</Badge>
|
||||
)}
|
||||
{isOwned && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
|
||||
>
|
||||
{t.settings.skills.badgeMine}
|
||||
</Badge>
|
||||
)}
|
||||
{isFromOthers && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
|
||||
title={
|
||||
skill.owner_user_id
|
||||
? t.settings.skills.badgeFromOthersTitle.replace(
|
||||
"{user_id}",
|
||||
skill.owner_user_id,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t.settings.skills.badgeFromOthers}
|
||||
</Badge>
|
||||
)}
|
||||
{isOwned && skill.published && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||
title={t.settings.skills.badgePublishedTitle}
|
||||
>
|
||||
<GlobeIcon className="size-3" />
|
||||
{t.settings.skills.badgePublished}
|
||||
</Badge>
|
||||
)}
|
||||
{isCustom && !isBuiltin && skill.source === "agent" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-cyan-500/30 text-cyan-700 dark:text-cyan-300 text-xs"
|
||||
title={t.settings.skills.badgeSourceAgentTitle}
|
||||
>
|
||||
{t.settings.skills.badgeSourceAgent}
|
||||
</Badge>
|
||||
)}
|
||||
{isCustom && !isBuiltin && skill.source === "upload" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-zinc-400/40 text-zinc-600 dark:text-zinc-400 text-xs"
|
||||
title={t.settings.skills.badgeSourceUploadTitle}
|
||||
>
|
||||
{t.settings.skills.badgeSourceUpload}
|
||||
</Badge>
|
||||
)}
|
||||
{skill.featured && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/30 text-amber-700 dark:text-amber-300 text-xs"
|
||||
title={t.settings.skills.badgeFeaturedTitle}
|
||||
>
|
||||
{t.settings.skills.badgeFeatured}
|
||||
</Badge>
|
||||
)}
|
||||
{skill.pinned && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
|
||||
title={t.settings.skills.badgePinnedTitle}
|
||||
>
|
||||
{t.settings.skills.badgePinned}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const metaNodes = (
|
||||
<>
|
||||
{skill.use_count !== undefined && (
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
{skill.use_count > 0
|
||||
? t.settings.skills.useCountInline.replace(
|
||||
"{count}",
|
||||
String(skill.use_count),
|
||||
)
|
||||
: t.settings.skills.useCountInlineZero}
|
||||
</div>
|
||||
)}
|
||||
{(skill.tags?.length ?? 0) > 0 && (
|
||||
<TagChipList tags={skill.tags!} className="mt-2" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const actionButtons = (
|
||||
<>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setTagPickerOpen(true)}
|
||||
title={t.adminTags.manageTags}
|
||||
>
|
||||
<TagsIcon className="size-3.5" />
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
skill.featured && "text-amber-600 hover:text-amber-700",
|
||||
)}
|
||||
disabled={setFeatured.isPending}
|
||||
onClick={() => void handleToggleFeatured()}
|
||||
title={
|
||||
skill.featured
|
||||
? t.settings.skills.unfeatureAction
|
||||
: t.settings.skills.featureAction
|
||||
}
|
||||
>
|
||||
<PinIcon
|
||||
className={cn("size-3.5", skill.featured && "fill-current")}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"h-8 w-8 shrink-0",
|
||||
skill.pinned && "text-emerald-600 hover:text-emerald-700",
|
||||
)}
|
||||
disabled={setPinned.isPending}
|
||||
onClick={() => void handleTogglePinned()}
|
||||
title={
|
||||
skill.pinned
|
||||
? t.settings.skills.unpinAction
|
||||
: t.settings.skills.pinAction
|
||||
}
|
||||
>
|
||||
<BookmarkIcon
|
||||
className={cn("size-3.5", skill.pinned && "fill-current")}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{canTogglePublish && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
disabled={setPublished.isPending}
|
||||
onClick={() => void handleTogglePublished(!skill.published)}
|
||||
title={
|
||||
skill.published
|
||||
? t.settings.skills.unpublishAction
|
||||
: t.settings.skills.publishAction
|
||||
}
|
||||
>
|
||||
{skill.published ? (
|
||||
<LockIcon className="size-3.5" />
|
||||
) : (
|
||||
<GlobeIcon className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
disabled={duplicateSkillMutation.isPending}
|
||||
onClick={handleOpenDuplicate}
|
||||
title="复制为新技能"
|
||||
>
|
||||
<CopyIcon className="size-3.5" />
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||
onClick={() => setConfirmDeleteOpen(true)}
|
||||
title={t.settings.skills.deleteAction}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isCard ? (
|
||||
<div className="flex h-full min-w-0 flex-col rounded-lg border p-3 transition-shadow hover:shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||
<div
|
||||
className="min-w-0 flex-1 cursor-pointer"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
>
|
||||
{badgeGroup}
|
||||
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm">
|
||||
{desensitize(skill.description ?? "")}
|
||||
</p>
|
||||
{metaNodes}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center justify-end gap-1 border-t pt-2 dark:border-[#213D5E]">
|
||||
{actionButtons}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Item className="w-full dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white" variant="outline">
|
||||
<ItemContent
|
||||
className="cursor-pointer"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
>
|
||||
<ItemTitle>{badgeGroup}</ItemTitle>
|
||||
<ItemDescription className="line-clamp-4">
|
||||
{desensitize(skill.description ?? "")}
|
||||
</ItemDescription>
|
||||
{metaNodes}
|
||||
</ItemContent>
|
||||
<ItemActions>{actionButtons}</ItemActions>
|
||||
</Item>
|
||||
)}
|
||||
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>复制技能</DialogTitle>
|
||||
<DialogDescription>
|
||||
将「{desensitize(skill.name)}」复制为一个属于你的新技能。请输入新名称(仅小写字母、数字、- 或 _)。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
value={duplicateName}
|
||||
onChange={(e) => setDuplicateName(e.currentTarget.value)}
|
||||
placeholder="new-skill-name"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleDuplicate();
|
||||
<Item className="w-full" variant="outline">
|
||||
<ItemContent
|
||||
className="cursor-pointer"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
>
|
||||
<ItemTitle>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>{desensitize(skill.name)}</span>
|
||||
{isBuiltin && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t.settings.skills.badgeBuiltin}
|
||||
</Badge>
|
||||
)}
|
||||
{isOwned && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
|
||||
>
|
||||
{t.settings.skills.badgeMine}
|
||||
</Badge>
|
||||
)}
|
||||
{isFromOthers && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
|
||||
title={
|
||||
skill.owner_user_id
|
||||
? t.settings.skills.badgeFromOthersTitle.replace(
|
||||
"{user_id}",
|
||||
skill.owner_user_id,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t.settings.skills.badgeFromOthers}
|
||||
</Badge>
|
||||
)}
|
||||
{isOwned && skill.published && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||
title={t.settings.skills.badgePublishedTitle}
|
||||
>
|
||||
<GlobeIcon className="size-3" />
|
||||
{t.settings.skills.badgePublished}
|
||||
</Badge>
|
||||
)}
|
||||
{isCustom && !isBuiltin && skill.source === "agent" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-cyan-500/30 text-cyan-700 dark:text-cyan-300 text-xs"
|
||||
title={t.settings.skills.badgeSourceAgentTitle}
|
||||
>
|
||||
{t.settings.skills.badgeSourceAgent}
|
||||
</Badge>
|
||||
)}
|
||||
{isCustom && !isBuiltin && skill.source === "upload" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-zinc-400/40 text-zinc-600 dark:text-zinc-400 text-xs"
|
||||
title={t.settings.skills.badgeSourceUploadTitle}
|
||||
>
|
||||
{t.settings.skills.badgeSourceUpload}
|
||||
</Badge>
|
||||
)}
|
||||
{skill.pinned && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
|
||||
title={t.settings.skills.badgePinnedTitle}
|
||||
>
|
||||
{t.settings.skills.badgePinned}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</ItemTitle>
|
||||
<ItemDescription className="line-clamp-4">
|
||||
{desensitize(skill.description ?? "")}
|
||||
</ItemDescription>
|
||||
{skill.use_count !== undefined && (
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
{skill.use_count > 0
|
||||
? t.settings.skills.useCountInline.replace(
|
||||
"{count}",
|
||||
String(skill.use_count),
|
||||
)
|
||||
: t.settings.skills.useCountInlineZero}
|
||||
</div>
|
||||
)}
|
||||
{(skill.tags?.length ?? 0) > 0 && (
|
||||
<TagChipList tags={skill.tags!} className="mt-2" />
|
||||
)}
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setTagPickerOpen(true)}
|
||||
title={t.adminTags.manageTags}
|
||||
>
|
||||
<TagsIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
disabled={setPinned.isPending}
|
||||
onClick={() => void handleTogglePinned()}
|
||||
title={
|
||||
skill.pinned
|
||||
? t.settings.skills.unpinAction
|
||||
: t.settings.skills.pinAction
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDuplicateOpen(false)}
|
||||
disabled={duplicateSkillMutation.isPending}
|
||||
>
|
||||
{t.common.cancel}
|
||||
{skill.pinned ? (
|
||||
<PinOffIcon className="size-3.5" />
|
||||
) : (
|
||||
<PinIcon className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{canTogglePublish && (
|
||||
<Button
|
||||
onClick={() => void handleDuplicate()}
|
||||
disabled={duplicateSkillMutation.isPending}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
disabled={setPublished.isPending}
|
||||
onClick={() => void handleTogglePublished(!skill.published)}
|
||||
title={
|
||||
skill.published
|
||||
? t.settings.skills.unpublishAction
|
||||
: t.settings.skills.publishAction
|
||||
}
|
||||
>
|
||||
{duplicateSkillMutation.isPending ? t.common.loading : "复制"}
|
||||
{skill.published ? (
|
||||
<LockIcon className="size-3.5" />
|
||||
) : (
|
||||
<GlobeIcon className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||
onClick={() => setConfirmDeleteOpen(true)}
|
||||
title={t.settings.skills.deleteAction}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
<Dialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
@@ -842,13 +584,15 @@ export function SkillRow({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<TagPickerDialog
|
||||
open={tagPickerOpen}
|
||||
onOpenChange={setTagPickerOpen}
|
||||
targetType="skill"
|
||||
targetId={skill.name}
|
||||
currentTags={skill.tags ?? []}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<TagPickerDialog
|
||||
open={tagPickerOpen}
|
||||
onOpenChange={setTagPickerOpen}
|
||||
targetType="skill"
|
||||
targetId={skill.name}
|
||||
currentTags={skill.tags ?? []}
|
||||
/>
|
||||
)}
|
||||
<SkillDetailDialog
|
||||
skillName={skill.name}
|
||||
open={detailOpen}
|
||||
@@ -858,7 +602,7 @@ export function SkillRow({
|
||||
);
|
||||
}
|
||||
|
||||
function EmptySkill() {
|
||||
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Empty>
|
||||
@@ -871,6 +615,9 @@ function EmptySkill() {
|
||||
{t.settings.skills.emptyDescription}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button onClick={onCreateSkill}>{t.settings.skills.emptyButton}</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
displayConfigFromResultSet,
|
||||
emptyDisplayConfig,
|
||||
inferDisplayConfig,
|
||||
normalizeDisplaySources,
|
||||
@@ -28,29 +27,9 @@ import {
|
||||
getSkillDisplaySample,
|
||||
} from "@/core/skills/api";
|
||||
import { useSetSkillDisplay } from "@/core/skills/hooks";
|
||||
import type {
|
||||
Skill,
|
||||
SkillDisplayConfig,
|
||||
SkillDisplayMode,
|
||||
SkillDisplayResultSetConfig,
|
||||
SkillLinkStrategy,
|
||||
} from "@/core/skills/type";
|
||||
import type { Skill, SkillDisplayConfig, SkillDisplayMode, SkillLinkStrategy } from "@/core/skills/type";
|
||||
|
||||
type DisplayFieldKey =
|
||||
| "resultPath"
|
||||
| "titleField"
|
||||
| "summaryField"
|
||||
| "contentField"
|
||||
| "sourceField"
|
||||
| "authorField"
|
||||
| "typeField"
|
||||
| "timeField"
|
||||
| "scoreField"
|
||||
| "urlField"
|
||||
| "idField"
|
||||
| "linkTemplate";
|
||||
|
||||
const fieldLabels: Array<[DisplayFieldKey, string]> = [
|
||||
const fieldLabels: Array<[keyof SkillDisplayConfig, string]> = [
|
||||
["resultPath", "结果列表路径"],
|
||||
["titleField", "标题字段"],
|
||||
["summaryField", "摘要字段"],
|
||||
@@ -92,56 +71,14 @@ function formatSample(sample: Record<string, unknown> | null | undefined) {
|
||||
return sample ? JSON.stringify(sample, null, 2) : "";
|
||||
}
|
||||
|
||||
function firstResultSetKey(display: SkillDisplayConfig | null | undefined) {
|
||||
return display?.resultSets?.[0]?.key ?? null;
|
||||
}
|
||||
|
||||
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 [activeResultSetKey, setActiveResultSetKey] = useState<string | null>(() =>
|
||||
firstResultSetKey(skill.display),
|
||||
);
|
||||
const [sampleText, setSampleText] = useState(() => formatSample(skill.displaySample));
|
||||
const [tableColumnsText, setTableColumnsText] = useState(() => {
|
||||
const display = skill.display ?? emptyDisplayConfig();
|
||||
const resultSet = display.resultSets?.[0];
|
||||
return JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2);
|
||||
});
|
||||
const [tableColumnsText, setTableColumnsText] = useState(() => JSON.stringify((skill.display ?? emptyDisplayConfig()).tableColumns ?? [], null, 2));
|
||||
const [loadingDbConfig, setLoadingDbConfig] = useState(false);
|
||||
const resultSets = draft.resultSets ?? [];
|
||||
const activeResultSet =
|
||||
resultSets.find((resultSet) => resultSet.key === activeResultSetKey) ?? null;
|
||||
const editingConfig = activeResultSet ?? draft;
|
||||
const previewConfig = activeResultSet
|
||||
? displayConfigFromResultSet(draft, activeResultSet)
|
||||
: draft;
|
||||
|
||||
function updateEditingConfig<K extends keyof SkillDisplayResultSetConfig>(
|
||||
key: K,
|
||||
value: SkillDisplayResultSetConfig[K],
|
||||
) {
|
||||
if (!activeResultSet) {
|
||||
setDraft(updateConfig(draft, key as keyof SkillDisplayConfig, value as never));
|
||||
return;
|
||||
}
|
||||
setDraft({
|
||||
...draft,
|
||||
resultSets: resultSets.map((resultSet) =>
|
||||
resultSet.key === activeResultSet.key
|
||||
? { ...resultSet, [key]: value }
|
||||
: resultSet,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function selectResultSet(key: string) {
|
||||
const resultSet = resultSets.find((item) => item.key === key) ?? null;
|
||||
setActiveResultSetKey(resultSet?.key ?? null);
|
||||
setTableColumnsText(JSON.stringify((resultSet ?? draft).tableColumns ?? [], null, 2));
|
||||
}
|
||||
|
||||
// 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).
|
||||
@@ -151,9 +88,7 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
useEffect(() => {
|
||||
const display = skill.display ?? emptyDisplayConfig();
|
||||
setDraft(display);
|
||||
const resultSet = display.resultSets?.[0];
|
||||
setActiveResultSetKey(resultSet?.key ?? null);
|
||||
setTableColumnsText(JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2));
|
||||
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
|
||||
}, [skill.name, skill.display]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -166,10 +101,8 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
return;
|
||||
}
|
||||
const display = fresh.display ?? emptyDisplayConfig();
|
||||
const resultSet = display.resultSets?.[0];
|
||||
setDraft(display);
|
||||
setActiveResultSetKey(resultSet?.key ?? null);
|
||||
setTableColumnsText(JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2));
|
||||
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) =>
|
||||
@@ -253,8 +186,8 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
}, [sampleText]);
|
||||
|
||||
const previewItems = useMemo(() => (
|
||||
samplePayload ? normalizeDisplaySources(samplePayload, previewConfig) : []
|
||||
), [previewConfig, samplePayload]);
|
||||
samplePayload ? normalizeDisplaySources(samplePayload, draft) : []
|
||||
), [draft, samplePayload]);
|
||||
|
||||
function generateDraft() {
|
||||
if (!samplePayload) {
|
||||
@@ -263,14 +196,8 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
}
|
||||
const nextDraft = inferDisplayConfig(samplePayload, skill.name);
|
||||
setDraft(nextDraft);
|
||||
const resultSet = nextDraft.resultSets?.[0];
|
||||
setActiveResultSetKey(resultSet?.key ?? null);
|
||||
setTableColumnsText(JSON.stringify((resultSet ?? nextDraft).tableColumns ?? [], null, 2));
|
||||
toast.success(
|
||||
resultSet
|
||||
? `已识别 ${nextDraft.resultSets?.length ?? 0} 个结果集,可分别配置`
|
||||
: "已生成展示适配草稿",
|
||||
);
|
||||
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
|
||||
toast.success("已生成展示适配草稿");
|
||||
}
|
||||
|
||||
async function loadSampleAndGenerate() {
|
||||
@@ -292,14 +219,8 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
);
|
||||
const nextDraft = inferDisplayConfig(response.sample, skill.name);
|
||||
setDraft(nextDraft);
|
||||
const resultSet = nextDraft.resultSets?.[0];
|
||||
setActiveResultSetKey(resultSet?.key ?? null);
|
||||
setTableColumnsText(JSON.stringify((resultSet ?? nextDraft).tableColumns ?? [], null, 2));
|
||||
toast.success(
|
||||
resultSet
|
||||
? `已获取样例,并识别 ${nextDraft.resultSets?.length ?? 0} 个结果集`
|
||||
: "已获取样例并生成展示适配",
|
||||
);
|
||||
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
|
||||
toast.success("已获取样例并生成展示适配");
|
||||
} catch (err) {
|
||||
generateDraft();
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
@@ -314,10 +235,8 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
sample: samplePayload ?? undefined,
|
||||
});
|
||||
const display = saved.display ?? emptyDisplayConfig();
|
||||
const resultSet = display.resultSets?.find((item) => item.key === activeResultSetKey) ?? display.resultSets?.[0];
|
||||
setDraft(display);
|
||||
setActiveResultSetKey(resultSet?.key ?? null);
|
||||
setTableColumnsText(JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2));
|
||||
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
|
||||
setSampleText(formatSample(saved.displaySample));
|
||||
toast.success("展示适配已保存");
|
||||
} catch (err) {
|
||||
@@ -329,7 +248,6 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
try {
|
||||
await setDisplay.mutateAsync({ skillName: skill.name, display: null, sample: null });
|
||||
setDraft(emptyDisplayConfig());
|
||||
setActiveResultSetKey(null);
|
||||
setTableColumnsText(JSON.stringify([], null, 2));
|
||||
toast.success("展示适配已清除");
|
||||
} catch (err) {
|
||||
@@ -365,8 +283,8 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">展示方式</span>
|
||||
<Select
|
||||
value={editingConfig.mode}
|
||||
onValueChange={(value) => updateEditingConfig("mode", value as SkillDisplayMode)}
|
||||
value={draft.mode}
|
||||
onValueChange={(value) => setDraft(updateConfig(draft, "mode", value as SkillDisplayMode))}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
@@ -382,8 +300,8 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">跳转方式</span>
|
||||
<Select
|
||||
value={editingConfig.linkStrategy}
|
||||
onValueChange={(value) => updateEditingConfig("linkStrategy", value as SkillLinkStrategy)}
|
||||
value={draft.linkStrategy}
|
||||
onValueChange={(value) => setDraft(updateConfig(draft, "linkStrategy", value as SkillLinkStrategy))}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
@@ -402,58 +320,26 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={editingConfig.maxItems}
|
||||
onChange={(event) => updateEditingConfig("maxItems", Number(event.currentTarget.value) || 1)}
|
||||
value={draft.maxItems}
|
||||
onChange={(event) => setDraft(updateConfig(draft, "maxItems", Number(event.currentTarget.value) || 1))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{resultSets.length > 0 && (
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
|
||||
<label className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">结果集配置</span>
|
||||
<Select
|
||||
value={activeResultSet?.key ?? resultSets[0]?.key}
|
||||
onValueChange={selectResultSet}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{resultSets.map((resultSet) => (
|
||||
<SelectItem key={resultSet.key} value={resultSet.key}>
|
||||
{resultSet.label || resultSet.resultPath}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
{activeResultSet && (
|
||||
<label className="flex items-center justify-end gap-2 pb-2">
|
||||
<span className="text-xs text-muted-foreground">启用当前结果集</span>
|
||||
<Switch
|
||||
checked={activeResultSet.enabled}
|
||||
onCheckedChange={(checked) => updateEditingConfig("enabled", checked)}
|
||||
/>
|
||||
</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={(editingConfig[field] as string | number | boolean | null | undefined)?.toString() ?? ""}
|
||||
onChange={(event) => updateEditingConfig(field, event.currentTarget.value as never)}
|
||||
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>
|
||||
|
||||
{editingConfig.mode === "table" && (
|
||||
{draft.mode === "table" && (
|
||||
<label className="mt-4 block space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">表格列配置 JSON</span>
|
||||
<Textarea
|
||||
@@ -463,7 +349,7 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
try {
|
||||
const value = JSON.parse(event.currentTarget.value);
|
||||
if (Array.isArray(value)) {
|
||||
updateEditingConfig("tableColumns", value);
|
||||
setDraft(updateConfig(draft, "tableColumns", value));
|
||||
}
|
||||
} catch {
|
||||
// Keep typing invalid JSON without crashing the panel.
|
||||
@@ -475,12 +361,12 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
|
||||
</label>
|
||||
)}
|
||||
|
||||
{editingConfig.mode === "custom" && (
|
||||
{draft.mode === "custom" && (
|
||||
<label className="mt-4 block space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground">自定义展示描述</span>
|
||||
<Textarea
|
||||
value={editingConfig.customInstruction}
|
||||
onChange={(event) => updateEditingConfig("customInstruction", event.currentTarget.value)}
|
||||
value={draft.customInstruction}
|
||||
onChange={(event) => setDraft(updateConfig(draft, "customInstruction", event.currentTarget.value))}
|
||||
placeholder="例如:按 publish_time 时间倒序显示为时间线,每条包含标题、摘要、来源和查看原文按钮。"
|
||||
className="min-h-24"
|
||||
/>
|
||||
|
||||
@@ -133,7 +133,8 @@ export function SkillDetailDialog({
|
||||
return stripYamlFrontmatter(contentData?.content ?? "");
|
||||
}, [contentData]);
|
||||
|
||||
const defaultTab: "detail" = "detail";
|
||||
const defaultTab: "detail" | "source" =
|
||||
skill && !skill.detail ? "source" : "detail";
|
||||
const selectedTab = activeTab ?? defaultTab;
|
||||
|
||||
function startEditing() {
|
||||
@@ -233,6 +234,10 @@ export function SkillDetailDialog({
|
||||
{tx.filesSectionTitle}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="source" className="text-xs">
|
||||
<FileCode2Icon className="mr-1 size-3.5" />
|
||||
{tx.contentSectionTitle}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
@@ -332,9 +337,13 @@ export function SkillDetailDialog({
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center">
|
||||
<FileTextIcon className="mx-auto mb-2 size-6 opacity-40" />
|
||||
<p className="text-sm">{tx.detailEmpty}</p>
|
||||
{isOwner && canManage ? (
|
||||
{isOwner ? (
|
||||
<p className="mt-1 text-xs">
|
||||
Open the SKILL.md tab to view the skill source.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-xs">{tx.detailAdminOnlyHint}</p>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
@@ -425,15 +425,10 @@ function ConfigCard() {
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"max-w-[20rem]",
|
||||
"dark:border-[#1B477E] dark:bg-[#0D2D54] dark:text-[#AACEFF] dark:data-[placeholder]:text-[#566B7F] dark:hover:bg-[#0D2D54]",
|
||||
)}
|
||||
>
|
||||
<SelectTrigger className="max-w-[20rem]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
||||
<SelectContent>
|
||||
<SelectItem value={DEFAULT_MODEL_VALUE}>
|
||||
{tt.curatorModelDefault}
|
||||
</SelectItem>
|
||||
|
||||
@@ -17,22 +17,20 @@ export function TagChip({
|
||||
className?: string;
|
||||
}) {
|
||||
const color = resolveTagColor(tag.color);
|
||||
const label = desensitize(tag.name);
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex max-w-full min-w-0 items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
|
||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
|
||||
color.chip,
|
||||
className,
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
<span className="min-w-0 truncate">{label}</span>
|
||||
{desensitize(tag.name)}
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="shrink-0 hover:opacity-70"
|
||||
className="hover:opacity-70"
|
||||
aria-label={`remove ${tag.name}`}
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
@@ -52,7 +50,7 @@ export function TagChipList({
|
||||
}) {
|
||||
if (!tags || tags.length === 0) return null;
|
||||
return (
|
||||
<div className={cn("flex min-w-0 max-w-full flex-wrap gap-1", className)}>
|
||||
<div className={cn("flex flex-wrap gap-1", className)}>
|
||||
{tags.map((tag) => (
|
||||
<TagChip key={tag.name} tag={tag} />
|
||||
))}
|
||||
|
||||
@@ -20,9 +20,9 @@ import { cn } from "@/lib/utils";
|
||||
import { resolveTagColor } from "./tag-colors";
|
||||
|
||||
/**
|
||||
* Dialog to set the tags on one resource. Clicking a tag binds or unbinds it
|
||||
* immediately (no separate save step). Only tags of ``targetType`` are offered,
|
||||
* enforcing "a tag may only tag its own type".
|
||||
* Admin-only dialog to set the tags on one resource. Clicking a tag binds or
|
||||
* unbinds it immediately (no separate save step). Only tags of ``targetType``
|
||||
* are offered, enforcing "a tag may only tag its own type".
|
||||
*/
|
||||
export function TagPickerDialog({
|
||||
open,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, SearchIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
@@ -31,14 +31,12 @@ export function TagSearchBar({
|
||||
tagIds,
|
||||
onTagIdsChange,
|
||||
className,
|
||||
actions,
|
||||
}: {
|
||||
targetType: TagType;
|
||||
onSearchChange: (value: string) => void;
|
||||
tagIds: string[];
|
||||
onTagIdsChange: (value: string[]) => void;
|
||||
className?: string;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { data: tags } = useTags(targetType);
|
||||
@@ -69,31 +67,14 @@ export function TagSearchBar({
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2.5", className)}>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<SearchIcon className="text-muted-foreground dark:text-[#60a5fa]/90 absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={t.adminTags.searchPlaceholder}
|
||||
className="pl-8"
|
||||
name="tag-search"
|
||||
// A `type="password"` field elsewhere on the page (e.g. the private
|
||||
// square access gate) makes browsers / password managers pick the
|
||||
// nearest text input as the "username" and autofill the account into
|
||||
// it. Mark this search box so it is never treated as a credential
|
||||
// field.
|
||||
autoComplete="off"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-form-type="other"
|
||||
/>
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex w-full shrink-0 justify-end sm:w-auto">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<SearchIcon className="text-muted-foreground dark:text-[#60a5fa]/90 absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={t.adminTags.searchPlaceholder}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{allTags.length > 0 && (
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
import { desensitize } from "@/lib/desensitize";
|
||||
|
||||
import { useThreadChat } from "./chats";
|
||||
import { FlipDisplay } from "./flip-display";
|
||||
@@ -17,18 +16,16 @@ export function ThreadTitle({
|
||||
threadId: string;
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { isNewThread } = useThreadChat();
|
||||
const displayTitle = sanitizeThreadTitleForDisplay(thread.values?.title);
|
||||
|
||||
useEffect(() => {
|
||||
let _title = t.pages.untitled;
|
||||
|
||||
if (thread.values?.title) {
|
||||
_title = displayTitle;
|
||||
} else if (isNewThread) {
|
||||
_title = t.pages.newChat;
|
||||
}
|
||||
const { t } = useI18n();
|
||||
const { isNewThread } = useThreadChat();
|
||||
useEffect(() => {
|
||||
let _title = t.pages.untitled;
|
||||
|
||||
if (thread.values?.title) {
|
||||
_title = thread.values.title;
|
||||
} else if (isNewThread) {
|
||||
_title = t.pages.newChat;
|
||||
}
|
||||
if (thread.isThreadLoading) {
|
||||
document.title = `Loading... - ${t.pages.appName}`;
|
||||
} else {
|
||||
@@ -38,18 +35,17 @@ export function ThreadTitle({
|
||||
isNewThread,
|
||||
t.pages.newChat,
|
||||
t.pages.untitled,
|
||||
t.pages.appName,
|
||||
thread.isThreadLoading,
|
||||
thread.values,
|
||||
displayTitle,
|
||||
]);
|
||||
t.pages.appName,
|
||||
thread.isThreadLoading,
|
||||
thread.values,
|
||||
]);
|
||||
|
||||
if (!thread.values?.title) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<FlipDisplay uniqueKey={threadId}>
|
||||
{desensitize(displayTitle)}
|
||||
</FlipDisplay>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FlipDisplay uniqueKey={threadId}>
|
||||
{desensitize(thread.values.title ?? "Untitled")}
|
||||
</FlipDisplay>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { LayoutGridIcon, ListIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ViewMode } from "@/core/system-settings/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Compact card/list switch shown in the header of the skills and agents lists.
|
||||
* Purely presentational — the parent owns the `value` (resolved from the user's
|
||||
* local override or the admin default via `useViewMode`) and persists changes.
|
||||
*/
|
||||
export function ViewModeToggle({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
value: ViewMode;
|
||||
onChange: (mode: ViewMode) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-muted/60 inline-flex items-center gap-0.5 rounded-md p-0.5",
|
||||
className,
|
||||
)}
|
||||
role="group"
|
||||
aria-label="展示模式"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant={value === "card" ? "default" : "ghost"}
|
||||
className="size-7"
|
||||
onClick={() => onChange("card")}
|
||||
title="卡片模式"
|
||||
aria-pressed={value === "card"}
|
||||
>
|
||||
<LayoutGridIcon className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant={value === "list" ? "default" : "ghost"}
|
||||
className="size-7"
|
||||
onClick={() => onChange("list")}
|
||||
title="列表模式"
|
||||
aria-pressed={value === "list"}
|
||||
>
|
||||
<ListIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,11 +20,8 @@ export function WorkspaceContainer({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
// h-full 而非 h-screen:本容器恒在 WorkspaceRoutes 的 SidebarInset(flex-1,确定高度)下,
|
||||
// h-full 既跟随容器高度(无界等嵌套不再锁死整屏),又修掉原 h-screen=100vh 比可用区域
|
||||
// 高出一个页头高度的溢出。
|
||||
return (
|
||||
<div className={cn("flex h-full w-full flex-col", className)} {...props}>
|
||||
<div className={cn("flex h-screen w-full flex-col", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,12 +6,10 @@ import {
|
||||
BrainIcon,
|
||||
CalendarClockIcon,
|
||||
ChevronsUpDown,
|
||||
DatabaseZapIcon,
|
||||
GaugeIcon,
|
||||
HistoryIcon,
|
||||
MessageSquareCodeIcon,
|
||||
MessageSquarePlusIcon,
|
||||
ScrollTextIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
ShieldCheckIcon,
|
||||
@@ -38,10 +36,6 @@ import {
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useUnreadCount } from "@/core/notifications";
|
||||
import {
|
||||
loadLogSystemSettings,
|
||||
type LogSystemSettings,
|
||||
} from "@/core/system-settings/api";
|
||||
|
||||
import { NotificationDialog } from "./notification-dialog";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -77,7 +71,6 @@ export function WorkspaceNavMenu() {
|
||||
>("appearance");
|
||||
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [logSystem, setLogSystem] = useState<LogSystemSettings | null>(null);
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const { t } = useI18n();
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
@@ -86,21 +79,6 @@ export function WorkspaceNavMenu() {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return;
|
||||
let cancelled = false;
|
||||
loadLogSystemSettings()
|
||||
.then((settings) => {
|
||||
if (!cancelled) setLogSystem(settings);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLogSystem(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isAdmin]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsDialog
|
||||
@@ -188,16 +166,6 @@ export function WorkspaceNavMenu() {
|
||||
大模型调用统计
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/settings/checkpoint-migration")
|
||||
}
|
||||
>
|
||||
<DatabaseZapIcon />
|
||||
Checkpoint 迁移
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin")}
|
||||
@@ -232,24 +200,12 @@ export function WorkspaceNavMenu() {
|
||||
技能管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||
>
|
||||
<TagsIcon />
|
||||
{t.adminTags.title}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && logSystem?.enabled && logSystem.url && (
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
window.open(
|
||||
logSystem.url,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
)
|
||||
}
|
||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||
>
|
||||
<ScrollTextIcon />
|
||||
{logSystem.label || "日志系统"}
|
||||
<TagsIcon />
|
||||
{t.adminTags.title}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
|
||||
@@ -34,6 +34,8 @@ const NAV_ITEM_CLASS =
|
||||
"data-[active=true]:bg-primary/15 data-[active=true]:text-primary data-[active=true]:shadow-none " +
|
||||
"dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25 dark:data-[active=true]:text-primary-foreground";
|
||||
|
||||
const SIDEBAR_PREVIEW_COUNT = 5;
|
||||
|
||||
export function WorkspaceNavStudio() {
|
||||
const { notebooks, isLoading, isError } = useStudioNotebooks();
|
||||
const { pathname } = useLocation();
|
||||
@@ -69,7 +71,6 @@ export function WorkspaceNavStudio() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const SIDEBAR_PREVIEW_COUNT = 2;
|
||||
const preview = notebooks.slice(0, SIDEBAR_PREVIEW_COUNT);
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { RecentChatList } from "./recent-chat-list";
|
||||
import { RecentRoundtableList } from "./recent-roundtable-list";
|
||||
import { RecentWritingList } from "./recent-writing-list";
|
||||
import { WorkspaceHeader } from "./workspace-header";
|
||||
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
|
||||
@@ -47,7 +46,6 @@ export function WorkspaceSidebar({
|
||||
<SidebarContent>
|
||||
<WorkspaceNavChatList showCollapseTrigger={compactChrome} />
|
||||
{isSidebarOpen && <RecentChatList />}
|
||||
{isSidebarOpen && <RecentRoundtableList />}
|
||||
{isSidebarOpen && <RecentWritingList />}
|
||||
</SidebarContent>
|
||||
{!compactChrome && (
|
||||
|
||||
@@ -184,21 +184,6 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
: 'light'
|
||||
: readPersistedTheme();
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
if (theme === 'dark') {
|
||||
root.setAttribute('theme-mode', 'dark');
|
||||
root.classList.add('dark');
|
||||
return;
|
||||
}
|
||||
|
||||
root.removeAttribute('theme-mode');
|
||||
root.classList.remove('dark');
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setNextTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
markExplicitChoice();
|
||||
|
||||
@@ -35,144 +35,6 @@ export interface AdminMemoryResponse {
|
||||
buckets: AdminMemoryBucket[];
|
||||
}
|
||||
|
||||
export interface AdminUserQaItem {
|
||||
run_id: string;
|
||||
thread_id: string;
|
||||
thread_title: string;
|
||||
assistant_id: string;
|
||||
status: string;
|
||||
model_name: string;
|
||||
question: string;
|
||||
answer: string;
|
||||
total_tokens: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AdminUserQaPageResponse {
|
||||
user_id: string;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total: number;
|
||||
items: AdminUserQaItem[];
|
||||
}
|
||||
|
||||
export interface AdminLeaderboardUser {
|
||||
user_id: string;
|
||||
email: string;
|
||||
system_role: string;
|
||||
thread_count: number;
|
||||
run_count: number;
|
||||
qa_count: number;
|
||||
llm_call_count: number;
|
||||
tool_call_count: number;
|
||||
skill_call_count: number;
|
||||
total_tokens: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
last_active_at: string;
|
||||
}
|
||||
|
||||
export interface AdminLeaderboardSkill {
|
||||
name: string;
|
||||
call_count: number;
|
||||
user_count: number;
|
||||
success_count: number;
|
||||
error_count: number;
|
||||
avg_duration_ms: number;
|
||||
last_used_at: string;
|
||||
}
|
||||
|
||||
export interface AdminLeaderboardDimension {
|
||||
name: string;
|
||||
count: number;
|
||||
user_count: number;
|
||||
success_count: number;
|
||||
error_count: number;
|
||||
total_tokens: number;
|
||||
last_seen_at: string;
|
||||
}
|
||||
|
||||
export interface AdminLeaderboardQuestion {
|
||||
text: string;
|
||||
user_id: string;
|
||||
email: string;
|
||||
thread_id: string;
|
||||
run_id: string;
|
||||
asked_at: string;
|
||||
}
|
||||
|
||||
export interface AdminLeaderboardTrendPoint {
|
||||
date: string;
|
||||
runs: number;
|
||||
questions: number;
|
||||
tokens: number;
|
||||
users: number;
|
||||
}
|
||||
|
||||
export interface AdminLeaderboardOverview {
|
||||
total_users: number;
|
||||
active_users: number;
|
||||
total_threads: number;
|
||||
total_runs: number;
|
||||
total_questions: number;
|
||||
total_tokens: number;
|
||||
total_skill_calls: number;
|
||||
total_tool_calls: number;
|
||||
error_runs: number;
|
||||
}
|
||||
|
||||
export interface AdminLeaderboardResponse {
|
||||
range_days: number;
|
||||
since: string;
|
||||
until: string;
|
||||
status?: "ready" | "running" | "refreshing";
|
||||
generated_at?: string;
|
||||
message?: string;
|
||||
overview: AdminLeaderboardOverview;
|
||||
users_by_activity: AdminLeaderboardUser[];
|
||||
users_by_questions: AdminLeaderboardUser[];
|
||||
users_by_tokens: AdminLeaderboardUser[];
|
||||
skills: AdminLeaderboardSkill[];
|
||||
tools: AdminLeaderboardSkill[];
|
||||
models: AdminLeaderboardDimension[];
|
||||
agents: AdminLeaderboardDimension[];
|
||||
run_statuses: AdminLeaderboardDimension[];
|
||||
recent_questions: AdminLeaderboardQuestion[];
|
||||
frequent_questions: AdminLeaderboardQuestion[];
|
||||
trends: AdminLeaderboardTrendPoint[];
|
||||
}
|
||||
|
||||
export interface AdminUserAnalyticsResponse {
|
||||
range_days: number;
|
||||
since: string;
|
||||
until: string;
|
||||
user: AdminLeaderboardUser;
|
||||
overview: AdminLeaderboardOverview;
|
||||
skills: AdminLeaderboardSkill[];
|
||||
tools: AdminLeaderboardSkill[];
|
||||
models: AdminLeaderboardDimension[];
|
||||
agents: AdminLeaderboardDimension[];
|
||||
run_statuses: AdminLeaderboardDimension[];
|
||||
recent_questions: AdminLeaderboardQuestion[];
|
||||
trends: AdminLeaderboardTrendPoint[];
|
||||
}
|
||||
|
||||
export interface AdminLeaderboardReportResponse {
|
||||
report: string;
|
||||
generated_at: string;
|
||||
cached?: boolean;
|
||||
model_name?: string | null;
|
||||
}
|
||||
|
||||
export type AdminLeaderboardReportStreamEvent =
|
||||
| { event: "meta"; data: { cached?: boolean; model_name?: string | null } }
|
||||
| { event: "cached"; data: { report: string; generated_at?: string | null; model_name?: string | null } }
|
||||
| { event: "thinking"; data: { chunk: string } }
|
||||
| { event: "text"; data: { chunk: string } }
|
||||
| { event: "done"; data: { cached?: boolean; generated_at?: string | null; model_name?: string | null; report?: string } }
|
||||
| { event: "error"; data: { message: string } };
|
||||
|
||||
const base = () => `${getBackendBaseURL()}/api/admin/users`;
|
||||
|
||||
async function parse<T>(res: Response, what: string): Promise<T> {
|
||||
@@ -202,22 +64,6 @@ export async function listUserThreadMessages(
|
||||
);
|
||||
}
|
||||
|
||||
export async function listUserQa(
|
||||
userId: string,
|
||||
params: { page?: number; pageSize?: number; q?: string } = {},
|
||||
): Promise<AdminUserQaPageResponse> {
|
||||
const search = new URLSearchParams({
|
||||
page: String(params.page ?? 1),
|
||||
page_size: String(params.pageSize ?? 20),
|
||||
});
|
||||
const query = (params.q ?? "").trim();
|
||||
if (query) search.set("q", query);
|
||||
return parse(
|
||||
await apiFetch(`${base()}/${encodeURIComponent(userId)}/qa?${search}`),
|
||||
"Failed to load user Q&A",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getUserMemory(
|
||||
userId: string,
|
||||
): Promise<AdminMemoryResponse> {
|
||||
@@ -227,133 +73,6 @@ export async function getUserMemory(
|
||||
);
|
||||
}
|
||||
|
||||
export async function getUserLeaderboard(
|
||||
range: {
|
||||
since: string;
|
||||
until: string;
|
||||
days?: number;
|
||||
},
|
||||
limit = 20,
|
||||
): Promise<AdminLeaderboardResponse> {
|
||||
const params = new URLSearchParams({
|
||||
days: String(range.days ?? 30),
|
||||
since: range.since,
|
||||
until: range.until,
|
||||
limit: String(limit),
|
||||
});
|
||||
return parse(
|
||||
await apiFetch(`${base()}/leaderboard?${params}`),
|
||||
"Failed to load leaderboard",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getUserAnalytics(
|
||||
userId: string,
|
||||
range: { since: string; until: string; days?: number },
|
||||
limit = 20,
|
||||
): Promise<AdminUserAnalyticsResponse> {
|
||||
const params = new URLSearchParams({
|
||||
days: String(range.days ?? 30),
|
||||
since: range.since,
|
||||
until: range.until,
|
||||
limit: String(limit),
|
||||
});
|
||||
return parse(
|
||||
await apiFetch(`${base()}/${encodeURIComponent(userId)}/analytics?${params}`),
|
||||
"Failed to load user analytics",
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateUserLeaderboardReport(
|
||||
range: { since: string; until: string; days?: number },
|
||||
limit = 30,
|
||||
modelName?: string,
|
||||
): Promise<AdminLeaderboardReportResponse> {
|
||||
const params = new URLSearchParams({
|
||||
days: String(range.days ?? 30),
|
||||
since: range.since,
|
||||
until: range.until,
|
||||
limit: String(limit),
|
||||
});
|
||||
if (modelName) params.set("model_name", modelName);
|
||||
return parse(
|
||||
await apiFetch(`${base()}/leaderboard/report?${params}`, {
|
||||
method: "POST",
|
||||
}),
|
||||
"Failed to generate leaderboard report",
|
||||
);
|
||||
}
|
||||
|
||||
function parseSseFrame(frame: string): AdminLeaderboardReportStreamEvent | null {
|
||||
let event = "message";
|
||||
const dataLines: string[] = [];
|
||||
for (const rawLine of frame.split(/\r?\n/)) {
|
||||
const line = rawLine.trimEnd();
|
||||
if (!line || line.startsWith(":")) continue;
|
||||
if (line.startsWith("event:")) {
|
||||
event = line.slice("event:".length).trim();
|
||||
} else if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice("data:".length).trimStart());
|
||||
}
|
||||
}
|
||||
if (!dataLines.length) return null;
|
||||
const data = JSON.parse(dataLines.join("\n")) as AdminLeaderboardReportStreamEvent["data"];
|
||||
return { event, data } as AdminLeaderboardReportStreamEvent;
|
||||
}
|
||||
|
||||
export async function streamUserLeaderboardReport({
|
||||
range,
|
||||
limit = 30,
|
||||
modelName,
|
||||
signal,
|
||||
onEvent,
|
||||
}: {
|
||||
range: { since: string; until: string; days?: number };
|
||||
limit?: number;
|
||||
modelName?: string;
|
||||
signal?: AbortSignal;
|
||||
onEvent: (event: AdminLeaderboardReportStreamEvent) => void;
|
||||
}) {
|
||||
const params = new URLSearchParams({
|
||||
days: String(range.days ?? 30),
|
||||
since: range.since,
|
||||
until: range.until,
|
||||
limit: String(limit),
|
||||
});
|
||||
if (modelName) params.set("model_name", modelName);
|
||||
const response = await apiFetch(`${base()}/leaderboard/report/stream?${params}`, {
|
||||
method: "POST",
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = (await response.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to stream leaderboard report (HTTP ${response.status})`);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error("当前浏览器不支持流式报告读取");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let buffer = "";
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
buffer += decoder.decode(value ?? new Uint8Array(), { stream: !done });
|
||||
const frames = buffer.split(/\r?\n\r?\n/);
|
||||
buffer = frames.pop() ?? "";
|
||||
for (const frame of frames) {
|
||||
const parsed = parseSseFrame(frame);
|
||||
if (parsed) onEvent(parsed);
|
||||
}
|
||||
if (done) break;
|
||||
}
|
||||
const tail = buffer.trim();
|
||||
if (tail) {
|
||||
const parsed = parseSseFrame(tail);
|
||||
if (parsed) onEvent(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
export async function addAdminMemoryEntry(
|
||||
userId: string,
|
||||
content: string,
|
||||
|
||||
@@ -5,11 +5,7 @@ import {
|
||||
addAdminV1Fact,
|
||||
deleteAdminMemoryEntry,
|
||||
deleteAdminV1Fact,
|
||||
generateUserLeaderboardReport,
|
||||
getUserMemory,
|
||||
getUserAnalytics,
|
||||
getUserLeaderboard,
|
||||
listUserQa,
|
||||
listUserThreadMessages,
|
||||
listUserThreads,
|
||||
replaceAdminMemoryEntry,
|
||||
@@ -17,56 +13,6 @@ import {
|
||||
updateAdminV1Section,
|
||||
} from "./api";
|
||||
|
||||
export function useUserLeaderboard(
|
||||
range: {
|
||||
since: string;
|
||||
until: string;
|
||||
days?: number;
|
||||
},
|
||||
limit = 20,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
"admin-activity",
|
||||
"leaderboard",
|
||||
range.since,
|
||||
range.until,
|
||||
limit,
|
||||
],
|
||||
queryFn: () => getUserLeaderboard(range, limit),
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status;
|
||||
return status === "running" || status === "refreshing" ? 3000 : false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUserAnalytics(
|
||||
userId: string | null,
|
||||
range: { since: string; until: string; days?: number },
|
||||
limit = 20,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["admin-activity", "analytics", userId, range.since, range.until, limit],
|
||||
queryFn: () => getUserAnalytics(userId!, range, limit),
|
||||
enabled: !!userId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGenerateUserLeaderboardReport() {
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
range,
|
||||
limit,
|
||||
modelName,
|
||||
}: {
|
||||
range: { since: string; until: string; days?: number };
|
||||
limit?: number;
|
||||
modelName?: string;
|
||||
}) => generateUserLeaderboardReport(range, limit ?? 30, modelName),
|
||||
});
|
||||
}
|
||||
|
||||
/** Threads owned by a given user. Disabled until a user is picked. */
|
||||
export function useUserThreads(userId: string | null) {
|
||||
return useQuery({
|
||||
@@ -76,17 +22,6 @@ export function useUserThreads(userId: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUserQa(
|
||||
userId: string | null,
|
||||
params: { page: number; pageSize: number; q?: string },
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["admin-activity", "qa", userId, params.page, params.pageSize, params.q ?? ""],
|
||||
queryFn: () => listUserQa(userId!, params),
|
||||
enabled: !!userId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Messages of a given thread. Disabled until a thread is picked. */
|
||||
export function useUserThreadMessages(
|
||||
userId: string | null,
|
||||
|
||||
@@ -2,13 +2,11 @@ import { getBackendBaseURL } from "@/core/config";
|
||||
import { apiFetch } from "@/core/api";
|
||||
|
||||
import type {
|
||||
Agent,
|
||||
AgentSelectorResponse,
|
||||
AgentSort,
|
||||
CreateAgentRequest,
|
||||
PaginatedAgents,
|
||||
UpdateAgentRequest,
|
||||
} from "./types";
|
||||
Agent,
|
||||
AgentSelectorResponse,
|
||||
CreateAgentRequest,
|
||||
UpdateAgentRequest,
|
||||
} from "./types";
|
||||
|
||||
const BACKEND_UNAVAILABLE_STATUSES = new Set([502, 503, 504]);
|
||||
|
||||
@@ -25,27 +23,13 @@ export class AgentNameCheckError extends Error {
|
||||
export interface ListAgentsParams {
|
||||
search?: string;
|
||||
tagIds?: string[];
|
||||
/** Pagination scope. Omit for the legacy combined-visibility list. */
|
||||
scope?: "mine" | "square";
|
||||
/** 1-based page index. Providing this (or scope) enables server-side pagination. */
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: AgentSort;
|
||||
}
|
||||
}
|
||||
|
||||
function buildAgentsQuery(params: ListAgentsParams): string {
|
||||
export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.search) qs.set("search", params.search);
|
||||
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
|
||||
if (params.scope) qs.set("scope", params.scope);
|
||||
if (params.page != null) qs.set("page", String(params.page));
|
||||
if (params.pageSize != null) qs.set("page_size", String(params.pageSize));
|
||||
if (params.sort) qs.set("sort", params.sort);
|
||||
return qs.toString();
|
||||
}
|
||||
|
||||
export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]> {
|
||||
const query = buildAgentsQuery(params);
|
||||
const query = qs.toString();
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/agents${query ? `?${query}` : ""}`,
|
||||
);
|
||||
@@ -54,29 +38,6 @@ export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]
|
||||
return data.agents;
|
||||
}
|
||||
|
||||
/** Like {@link listAgents} but returns the pagination envelope (total/page). */
|
||||
export async function listAgentsPaginated(
|
||||
params: ListAgentsParams = {},
|
||||
): Promise<PaginatedAgents> {
|
||||
const query = buildAgentsQuery(params);
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/agents${query ? `?${query}` : ""}`,
|
||||
);
|
||||
if (!res.ok) throw new Error(`Failed to load agents: ${res.statusText}`);
|
||||
const data = (await res.json()) as {
|
||||
agents: Agent[];
|
||||
total?: number;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
};
|
||||
return {
|
||||
agents: data.agents,
|
||||
total: data.total ?? data.agents.length,
|
||||
page: data.page ?? params.page ?? 1,
|
||||
pageSize: data.page_size ?? params.pageSize ?? data.agents.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listAllAgentsAdmin(): Promise<Agent[]> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/agents?admin=true`);
|
||||
if (!res.ok) {
|
||||
@@ -129,13 +90,13 @@ export async function deleteAgent(id: string): Promise<void> {
|
||||
if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`);
|
||||
}
|
||||
|
||||
export async function getAgentSelector(): Promise<AgentSelectorResponse> {
|
||||
export async function getAgentSelector(): Promise<AgentSelectorResponse> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/selector`);
|
||||
if (!res.ok) throw new Error(`Failed to load agent selector: ${res.statusText}`);
|
||||
return res.json() as Promise<AgentSelectorResponse>;
|
||||
}
|
||||
|
||||
export async function favoriteAgent(id: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function favoriteAgent(id: string): Promise<void> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/favorite`, {
|
||||
method: "POST",
|
||||
});
|
||||
@@ -145,35 +106,15 @@ export async function favoriteAgent(id: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function unfavoriteAgent(id: string): Promise<void> {
|
||||
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 pinAgent(id: string): Promise<void> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/pin`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to pin agent: ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function unpinAgent(id: string): Promise<void> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/agents/${id}/pin`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to unpin agent: ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function reorderFeaturedAgents(agentIds: string[]): Promise<void> {
|
||||
const res = await apiFetch(
|
||||
@@ -211,54 +152,6 @@ export async function listPrivateAgents(password: string): Promise<Agent[]> {
|
||||
return data.agents;
|
||||
}
|
||||
|
||||
export interface ListPrivateAgentsParams {
|
||||
password: string;
|
||||
search?: string;
|
||||
tagIds?: string[];
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: AgentSort;
|
||||
}
|
||||
|
||||
/** Paginated private-square listing (password-gated). */
|
||||
export async function listPrivateAgentsPaginated(
|
||||
params: ListPrivateAgentsParams,
|
||||
): Promise<PaginatedAgents> {
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/agents/private-square/list`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
password: params.password,
|
||||
search: params.search || undefined,
|
||||
tag_ids: params.tagIds && params.tagIds.length ? params.tagIds : undefined,
|
||||
page: params.page,
|
||||
page_size: params.pageSize,
|
||||
sort: params.sort,
|
||||
}),
|
||||
},
|
||||
);
|
||||
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[];
|
||||
total?: number;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
};
|
||||
return {
|
||||
agents: data.agents,
|
||||
total: data.total ?? data.agents.length,
|
||||
page: data.page ?? params.page ?? 1,
|
||||
pageSize: data.page_size ?? params.pageSize ?? data.agents.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function setAgentPrivate(
|
||||
id: string,
|
||||
isPrivate: boolean,
|
||||
|
||||
@@ -3,55 +3,25 @@ import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
adminSyncAgentSkills,
|
||||
createAgent,
|
||||
deleteAgent,
|
||||
favoriteAgent,
|
||||
getAgent,
|
||||
getAgentSelector,
|
||||
listAgents,
|
||||
listAgentsPaginated,
|
||||
listAllAgentsAdmin,
|
||||
listPrivateAgents,
|
||||
listPrivateAgentsPaginated,
|
||||
pinAgent,
|
||||
reorderFeaturedAgents,
|
||||
setAgentFeatured,
|
||||
setAgentPrivate,
|
||||
unfavoriteAgent,
|
||||
unpinAgent,
|
||||
updateAgent,
|
||||
} from "./api";
|
||||
import type {
|
||||
Agent,
|
||||
AgentSort,
|
||||
CreateAgentRequest,
|
||||
PaginatedAgents,
|
||||
UpdateAgentRequest,
|
||||
} from "./types";
|
||||
|
||||
/** A cached agent listing is either a plain ``Agent[]`` (legacy ``useAgents``)
|
||||
* or the paginated ``{ agents, total, ... }`` envelope. Mutators patch both. */
|
||||
type CachedAgents = Agent[] | PaginatedAgents | undefined;
|
||||
|
||||
function isPaginated(value: CachedAgents): value is PaginatedAgents {
|
||||
return !!value && !Array.isArray(value) && Array.isArray(value.agents);
|
||||
}
|
||||
|
||||
/** Apply ``map`` to every agent inside whatever listing shape is cached. */
|
||||
function mapCachedAgents(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
map: (a: Agent) => Agent,
|
||||
) {
|
||||
queryClient.setQueriesData<CachedAgents>({ queryKey: ["agents"] }, (old) => {
|
||||
if (Array.isArray(old)) return old.map(map);
|
||||
if (isPaginated(old)) return { ...old, agents: old.agents.map(map) };
|
||||
return old;
|
||||
});
|
||||
}
|
||||
listAgents,
|
||||
listAllAgentsAdmin,
|
||||
listPrivateAgents,
|
||||
reorderFeaturedAgents,
|
||||
setAgentFeatured,
|
||||
setAgentPrivate,
|
||||
unfavoriteAgent,
|
||||
updateAgent,
|
||||
} from "./api";
|
||||
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
|
||||
@@ -62,7 +32,13 @@ function applyAgentToCache(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
next: Agent,
|
||||
) {
|
||||
mapCachedAgents(queryClient, (a) => (a.id === next.id ? { ...a, ...next } : a));
|
||||
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[] } = {}) {
|
||||
@@ -76,45 +52,6 @@ export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
|
||||
return { agents: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
/** Server-side paginated agent listing for one gallery tab (scope). Returns
|
||||
* the page of agents plus the total count for the "共 N 个" header. */
|
||||
export function useAgentsPaginated(params: {
|
||||
scope: "mine" | "square";
|
||||
search?: string;
|
||||
tagIds?: string[];
|
||||
sort?: AgentSort;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const { scope, page, pageSize, enabled = true } = params;
|
||||
const search = params.search ?? "";
|
||||
const tagIds = params.tagIds ?? [];
|
||||
const sort = params.sort ?? "time_desc";
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: [
|
||||
"agents",
|
||||
"paginated",
|
||||
scope,
|
||||
search,
|
||||
[...tagIds].sort(),
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
],
|
||||
queryFn: () =>
|
||||
listAgentsPaginated({ scope, search, tagIds, sort, page, pageSize }),
|
||||
enabled,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
return {
|
||||
agents: data?.agents ?? [],
|
||||
total: data?.total ?? 0,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only listing — returns every agent including unpublished agents owned
|
||||
* by other users and private-square entries. Caller must be ``system_role
|
||||
@@ -180,14 +117,20 @@ 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() {
|
||||
export function useToggleAgentFavorite() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, favorite }: { id: string; favorite: boolean }) =>
|
||||
favorite ? favoriteAgent(id) : unfavoriteAgent(id),
|
||||
onMutate: ({ id, favorite }) => {
|
||||
mapCachedAgents(queryClient, (a) =>
|
||||
a.id === id ? { ...a, is_favorite: favorite } : a,
|
||||
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,
|
||||
@@ -196,30 +139,10 @@ export function useToggleAgentFavorite() {
|
||||
onSettled: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleAgentPin() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, pinned }: { id: string; pinned: boolean }) =>
|
||||
pinned ? pinAgent(id) : unpinAgent(id),
|
||||
onMutate: ({ id, pinned }) => {
|
||||
mapCachedAgents(queryClient, (a) =>
|
||||
a.id === id ? { ...a, is_pinned: pinned } : a,
|
||||
);
|
||||
queryClient.setQueryData<Agent | undefined>(["agents", id], (old) =>
|
||||
old ? { ...old, is_pinned: pinned } : old,
|
||||
);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
await queryClient.refetchQueries({ queryKey: ["agents"], type: "active" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAgentSelector() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useAgentSelector() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents", "selector"],
|
||||
queryFn: () => getAgentSelector(),
|
||||
@@ -230,9 +153,9 @@ export function useAgentSelector() {
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
function invalidateAgentQueries(queryClient: ReturnType<typeof useQueryClient>) {
|
||||
}
|
||||
|
||||
function invalidateAgentQueries(queryClient: ReturnType<typeof useQueryClient>) {
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
}
|
||||
|
||||
@@ -273,58 +196,6 @@ export function usePrivateAgents(password: string | null) {
|
||||
return { agents: data ?? [], isLoading, error, refetch };
|
||||
}
|
||||
|
||||
/** Paginated variant of {@link usePrivateAgents} — one page of the
|
||||
* password-gated private square plus the total count. */
|
||||
export function usePrivateAgentsPaginated(
|
||||
password: string | null,
|
||||
params: {
|
||||
search?: string;
|
||||
tagIds?: string[];
|
||||
sort?: AgentSort;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
enabled?: boolean;
|
||||
},
|
||||
) {
|
||||
const { page, pageSize, enabled = true } = params;
|
||||
const search = params.search ?? "";
|
||||
const tagIds = params.tagIds ?? [];
|
||||
const sort = params.sort ?? "time_desc";
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: [
|
||||
"agents",
|
||||
"private-square",
|
||||
password ?? "",
|
||||
"paginated",
|
||||
search,
|
||||
[...tagIds].sort(),
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
],
|
||||
queryFn: () =>
|
||||
listPrivateAgentsPaginated({
|
||||
password: password!,
|
||||
search,
|
||||
tagIds,
|
||||
sort,
|
||||
page,
|
||||
pageSize,
|
||||
}),
|
||||
enabled: !!password && enabled,
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
return {
|
||||
agents: data?.agents ?? [],
|
||||
total: data?.total ?? 0,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSetAgentPrivate() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Tag } from "@/core/tags";
|
||||
|
||||
export interface Agent {
|
||||
export interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -17,22 +17,11 @@ export interface Agent {
|
||||
tags?: Tag[];
|
||||
featured_order?: number | null;
|
||||
/** Whether the current user has favorited (收藏) this agent. */
|
||||
is_favorite?: boolean;
|
||||
is_pinned?: boolean;
|
||||
}
|
||||
|
||||
export type AgentSort = "time_desc" | "time_asc" | "updated_desc" | "updated_asc" | "name_asc" | "name_desc";
|
||||
|
||||
export interface AgentSelectorResponse {
|
||||
agents: Agent[];
|
||||
}
|
||||
is_favorite?: boolean;
|
||||
}
|
||||
|
||||
/** Pagination envelope returned by the server-side paginated agent listings. */
|
||||
export interface PaginatedAgents {
|
||||
export interface AgentSelectorResponse {
|
||||
agents: Agent[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface CreateAgentRequest {
|
||||
|
||||
@@ -2,28 +2,26 @@
|
||||
|
||||
import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
|
||||
|
||||
import { getAuthorizationHeaderValue } from "../auth";
|
||||
import { getLangGraphBaseURL } from "../config";
|
||||
|
||||
import { withCsrfHeaders } from "./csrf";
|
||||
import { sanitizeRunStreamOptions } from "./stream-mode";
|
||||
import { getAuthorizationHeaderValue } from "../auth";
|
||||
import { getLangGraphBaseURL } from "../config";
|
||||
|
||||
import { sanitizeRunStreamOptions } from "./stream-mode";
|
||||
|
||||
function createCompatibleClient(isMock?: boolean): LangGraphClient {
|
||||
const client = new LangGraphClient({
|
||||
apiUrl: getLangGraphBaseURL(isMock),
|
||||
onRequest(_url, init) {
|
||||
const headers = withCsrfHeaders(init.headers);
|
||||
const authHeader = getAuthorizationHeaderValue();
|
||||
if (authHeader && !headers.has("Authorization")) {
|
||||
headers.set("Authorization", authHeader);
|
||||
}
|
||||
return {
|
||||
...init,
|
||||
credentials: init.credentials ?? "include",
|
||||
headers,
|
||||
};
|
||||
},
|
||||
});
|
||||
apiUrl: getLangGraphBaseURL(isMock),
|
||||
onRequest(_url, init) {
|
||||
const headers = new Headers(init.headers ?? {});
|
||||
const authHeader = getAuthorizationHeaderValue();
|
||||
if (authHeader && !headers.has("Authorization")) {
|
||||
headers.set("Authorization", authHeader);
|
||||
}
|
||||
return {
|
||||
...init,
|
||||
headers,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const originalRunStream = client.runs.stream.bind(client.runs);
|
||||
client.runs.stream = ((threadId, assistantId, payload) =>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
|
||||
import { getAuthorizationHeaderValue } from "../auth";
|
||||
import { getCanvasLangGraphBaseURL } from "../config";
|
||||
|
||||
import { withCsrfHeaders } from "./csrf";
|
||||
import { sanitizeRunStreamOptions } from "./stream-mode";
|
||||
|
||||
/**
|
||||
@@ -20,16 +19,12 @@ function createCanvasClient(): LangGraphClient {
|
||||
const client = new LangGraphClient({
|
||||
apiUrl: getCanvasLangGraphBaseURL(),
|
||||
onRequest(_url, init) {
|
||||
const headers = withCsrfHeaders(init.headers);
|
||||
const headers = new Headers(init.headers ?? {});
|
||||
const authHeader = getAuthorizationHeaderValue();
|
||||
if (authHeader && !headers.has("Authorization")) {
|
||||
headers.set("Authorization", authHeader);
|
||||
}
|
||||
return {
|
||||
...init,
|
||||
credentials: init.credentials ?? "include",
|
||||
headers,
|
||||
};
|
||||
return { ...init, headers };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
const CSRF_COOKIE_NAME = "csrf_token";
|
||||
const CSRF_HEADER_NAME = "X-CSRF-Token";
|
||||
|
||||
function readCookie(name: string): string | undefined {
|
||||
if (typeof document === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const cookies = document.cookie.split(";");
|
||||
for (const cookie of cookies) {
|
||||
const [rawName, ...rest] = cookie.trim().split("=");
|
||||
if (rawName !== name) {
|
||||
continue;
|
||||
}
|
||||
const rawValue = rest.join("=");
|
||||
if (!rawValue) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(rawValue);
|
||||
} catch {
|
||||
return rawValue;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getCsrfToken(): string | undefined {
|
||||
const token = readCookie(CSRF_COOKIE_NAME)?.trim();
|
||||
return token ? token : undefined;
|
||||
}
|
||||
|
||||
export function withCsrfHeaders(headers?: HeadersInit): Headers {
|
||||
const next = new Headers(headers ?? {});
|
||||
const csrfToken = getCsrfToken();
|
||||
if (csrfToken && !next.has(CSRF_HEADER_NAME)) {
|
||||
next.set(CSRF_HEADER_NAME, csrfToken);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -1,51 +1,18 @@
|
||||
import {
|
||||
clearStoredAuth,
|
||||
getAuthorizationHeaderValue,
|
||||
getStoredAuth,
|
||||
} from "@/core/auth";
|
||||
import { withCsrfHeaders } from "./csrf";
|
||||
|
||||
function withAuthHeaders(headers?: HeadersInit): Headers {
|
||||
const next = withCsrfHeaders(headers);
|
||||
const authHeader = getAuthorizationHeaderValue();
|
||||
if (authHeader && !next.has("Authorization")) {
|
||||
next.set("Authorization", authHeader);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function requestUrl(input: RequestInfo | URL): string {
|
||||
if (typeof input === "string") return input;
|
||||
if (input instanceof URL) return input.href;
|
||||
return (input as Request).url ?? "";
|
||||
}
|
||||
|
||||
export async function apiFetch(input: RequestInfo | URL, init?: RequestInit) {
|
||||
const response = await fetch(input, {
|
||||
...init,
|
||||
credentials: init?.credentials ?? "include",
|
||||
headers: withAuthHeaders(init?.headers),
|
||||
});
|
||||
|
||||
// Session is considered lost ONLY when the backend actually answers 401.
|
||||
// (Previously a stray localStorage change could log the user out client-side
|
||||
// even though the session was still valid.) Auth endpoints are excluded so a
|
||||
// failed/transient login attempt never wipes state, and we only act when we
|
||||
// believed we were logged in — avoiding redirect loops on the login page.
|
||||
if (response.status === 401) {
|
||||
const url = requestUrl(input);
|
||||
const isAuthEndpoint = url.includes("/api/v1/auth/");
|
||||
if (!isAuthEndpoint && getStoredAuth()) {
|
||||
clearStoredAuth();
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
!window.location.hash.startsWith("#/login")
|
||||
) {
|
||||
window.location.replace("#/login");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
import { getAuthorizationHeaderValue } from "@/core/auth";
|
||||
|
||||
function withAuthHeaders(headers?: HeadersInit): Headers {
|
||||
const next = new Headers(headers ?? {});
|
||||
const authHeader = getAuthorizationHeaderValue();
|
||||
if (authHeader && !next.has("Authorization")) {
|
||||
next.set("Authorization", authHeader);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function apiFetch(input: RequestInfo | URL, init?: RequestInit) {
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers: withAuthHeaders(init?.headers),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
|
||||
function filenameFromContentDisposition(header: string | null): string | null {
|
||||
if (!header) return null;
|
||||
const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header);
|
||||
if (star) {
|
||||
const raw = star[1].replace(/["']/g, "").trim();
|
||||
try {
|
||||
return decodeURIComponent(raw);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
const plain = /filename="?([^";\n]+)"?/i.exec(header);
|
||||
return plain ? plain[1].trim() : null;
|
||||
}
|
||||
|
||||
function sanitizeFallbackFilename(title: string | undefined): string {
|
||||
const base = (title?.trim() || "document").replace(/[<>:"/\\|?*\x00-\x1f]+/g, "_");
|
||||
return base.toLowerCase().endsWith(".docx") ? base : `${base}.docx`;
|
||||
}
|
||||
|
||||
async function downloadBlobResponse(response: Response, fallbackFilename: string) {
|
||||
if (!response.ok) {
|
||||
const error = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "导出 Word 失败" }));
|
||||
const detail = error.detail;
|
||||
if (typeof detail === "string") throw new Error(detail);
|
||||
throw new Error("导出 Word 失败");
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const filename =
|
||||
filenameFromContentDisposition(response.headers.get("Content-Disposition")) ??
|
||||
fallbackFilename;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.rel = "noopener";
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用 Markdown → Word 导出(Gateway `writing` 路由,与 `/api/writing/rewrite` 同级)。
|
||||
*
|
||||
* POST /api/writing/export/docx
|
||||
* Body: { title?: string, markdown: string }
|
||||
*
|
||||
* 返回 `application/vnd.openxmlformats-officedocument.wordprocessingml.document`,
|
||||
* 文件名在 `Content-Disposition` 中。
|
||||
*/
|
||||
export async function downloadMarkdownDocxFromBackend(options: {
|
||||
title?: string;
|
||||
markdown: string;
|
||||
}): Promise<void> {
|
||||
const markdown = options.markdown.trim();
|
||||
if (!markdown) {
|
||||
throw new Error("文档内容为空");
|
||||
}
|
||||
|
||||
const fallbackFilename = sanitizeFallbackFilename(options.title);
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/writing/export/docx`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: options.title?.trim() || undefined,
|
||||
markdown,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
await downloadMarkdownAsDocx(markdown, options.title?.trim() || "document", options.title);
|
||||
return;
|
||||
}
|
||||
|
||||
await downloadBlobResponse(response, fallbackFilename);
|
||||
} catch (err) {
|
||||
if (err instanceof TypeError) {
|
||||
await downloadMarkdownAsDocx(markdown, options.title?.trim() || "document", options.title);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -60,12 +60,7 @@ export async function downloadArtifactFile({
|
||||
filenameFromContentDisposition(cd) ?? getFileName(filepath);
|
||||
|
||||
const blob = await res.blob();
|
||||
const contentType = res.headers.get("Content-Type")?.split(";")[0]?.trim();
|
||||
const downloadBlob =
|
||||
contentType && blob.type !== contentType
|
||||
? new Blob([await blob.arrayBuffer()], { type: contentType })
|
||||
: blob;
|
||||
const objectUrl = URL.createObjectURL(downloadBlob);
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = objectUrl;
|
||||
a.download = filename;
|
||||
|
||||
@@ -3,24 +3,9 @@ import { useMemo } from "react";
|
||||
|
||||
import { useThread } from "@/components/workspace/messages/context";
|
||||
|
||||
import { isImageFile } from "../utils/files";
|
||||
|
||||
import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader";
|
||||
import { updateArtifactContent } from "./api";
|
||||
|
||||
function isWriteFileStreamingUrl(filepath: string): boolean {
|
||||
if (!filepath.startsWith("write-file:")) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const url = new URL(filepath);
|
||||
const path = decodeURIComponent(url.pathname);
|
||||
return !isImageFile(path);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function hasToolResult(thread: ReturnType<typeof useThread>["thread"], toolCallId?: string) {
|
||||
if (!toolCallId) {
|
||||
return false;
|
||||
@@ -85,16 +70,16 @@ export function useArtifactContent({
|
||||
threadId: string;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const useWriteFileStreaming = useMemo(() => {
|
||||
return isWriteFileStreamingUrl(filepath);
|
||||
const isWriteFile = useMemo(() => {
|
||||
return filepath.startsWith("write-file:");
|
||||
}, [filepath]);
|
||||
const { thread, isMock, publicShareCode } = useThread();
|
||||
const content = useMemo(() => {
|
||||
if (useWriteFileStreaming) {
|
||||
if (isWriteFile) {
|
||||
return loadArtifactContentFromToolCall({ url: filepath, thread });
|
||||
}
|
||||
return null;
|
||||
}, [filepath, useWriteFileStreaming, thread]);
|
||||
}, [filepath, isWriteFile, thread]);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["artifact", filepath, threadId, isMock, publicShareCode],
|
||||
@@ -107,7 +92,7 @@ export function useArtifactContent({
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const pendingEdit = useMemo(() => {
|
||||
if (useWriteFileStreaming) {
|
||||
if (isWriteFile) {
|
||||
return null;
|
||||
}
|
||||
return applyPendingTextEdit({
|
||||
@@ -115,13 +100,11 @@ export function useArtifactContent({
|
||||
filepath,
|
||||
thread,
|
||||
});
|
||||
}, [data?.content, filepath, useWriteFileStreaming, thread]);
|
||||
}, [data?.content, filepath, isWriteFile, thread]);
|
||||
|
||||
return {
|
||||
content: useWriteFileStreaming
|
||||
? content
|
||||
: (pendingEdit?.content ?? data?.content),
|
||||
url: useWriteFileStreaming ? undefined : data?.url,
|
||||
content: isWriteFile ? content : (pendingEdit?.content ?? data?.content),
|
||||
url: isWriteFile ? undefined : data?.url,
|
||||
isLoading,
|
||||
isApplyingEdit: pendingEdit?.kind === "editing",
|
||||
error,
|
||||
|
||||
@@ -9,8 +9,6 @@ export interface UserListItem {
|
||||
system_role: "admin" | "user";
|
||||
skill_count: number;
|
||||
agent_count: number;
|
||||
/** 问答次数:用户提问的次数(一次提问算一次)。 */
|
||||
qa_count: number;
|
||||
}
|
||||
|
||||
export async function listAllUsers(): Promise<UserListItem[]> {
|
||||
@@ -19,97 +17,25 @@ export async function listAllUsers(): Promise<UserListItem[]> {
|
||||
return res.json() as Promise<UserListItem[]>;
|
||||
}
|
||||
|
||||
// Normalize a FastAPI error body into a human-readable string. `detail` may be
|
||||
// a plain string, an object like AuthErrorResponse ({ code, message }), or an
|
||||
// array of validation errors — anything but a string would otherwise render as
|
||||
// the dreaded "[object Object]".
|
||||
function extractErrorMessage(body: unknown, fallback = "Login failed."): string {
|
||||
const detail = (body as { detail?: unknown } | null)?.detail ?? body;
|
||||
if (typeof detail === "string") return detail;
|
||||
if (Array.isArray(detail)) {
|
||||
const msg = detail
|
||||
.map((d) => (d && typeof d === "object" ? (d as { msg?: string }).msg : String(d)))
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
return msg || fallback;
|
||||
}
|
||||
if (detail && typeof detail === "object") {
|
||||
const obj = detail as { message?: string; msg?: string };
|
||||
return obj.message ?? obj.msg ?? fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export async function loginByUsername(
|
||||
username: string,
|
||||
password?: string | null,
|
||||
): Promise<LoginByUsernameResponse> {
|
||||
// When the username-login password gate is enabled server-side, the shared
|
||||
// secret must be passed as ?password=. Omitted when no password is provided
|
||||
// so the passwordless flow keeps working unchanged.
|
||||
const base = `${getBackendBaseURL()}/api/v1/auth/login/username`;
|
||||
const url =
|
||||
password != null && password !== ""
|
||||
? `${base}?password=${encodeURIComponent(password)}`
|
||||
: base;
|
||||
const response = await apiFetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => null);
|
||||
throw new Error(extractErrorMessage(error));
|
||||
}
|
||||
return response.json() as Promise<LoginByUsernameResponse>;
|
||||
}
|
||||
|
||||
// The upstream token issuer and the verifier (token_info_url) run on separate
|
||||
// servers whose clocks can drift ~3-4s apart. A token verified right after it
|
||||
// is issued can look "not yet valid / expired" to the verifier, which surfaces
|
||||
// as a 401 here. That window closes on its own once real time advances past the
|
||||
// skew, so we silently retry the exchange a couple of times before giving up —
|
||||
// the LoginPage keeps showing its loading state, so the user never notices.
|
||||
const TOKEN_LOGIN_MAX_ATTEMPTS = 3; // initial try + 2 retries → covers up to ~6s skew
|
||||
const TOKEN_LOGIN_RETRY_DELAY_MS = 3000;
|
||||
|
||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export async function loginByToken(
|
||||
token: string,
|
||||
): Promise<LoginByUsernameResponse> {
|
||||
// Token-exchange login: the gateway resolves the upstream token to a username
|
||||
// and signs the matching local account in.
|
||||
const url = `${getBackendBaseURL()}/api/v1/auth/login/token?token=${encodeURIComponent(token)}`;
|
||||
let lastError = "Login failed.";
|
||||
|
||||
for (let attempt = 1; attempt <= TOKEN_LOGIN_MAX_ATTEMPTS; attempt += 1) {
|
||||
const response = await apiFetch(url, {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/v1/auth/login/username`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (response.ok) {
|
||||
return response.json() as Promise<LoginByUsernameResponse>;
|
||||
}
|
||||
|
||||
const error = await response.json().catch(() => null);
|
||||
lastError = extractErrorMessage(error);
|
||||
|
||||
// Only 401 is plausibly the transient clock-skew case and worth waiting on.
|
||||
// 403 (disabled), 429 (rate-limited), 500 (misconfigured) won't self-heal,
|
||||
// so fail fast instead of stalling the user.
|
||||
const isTransient = response.status === 401;
|
||||
if (!isTransient || attempt === TOKEN_LOGIN_MAX_ATTEMPTS) {
|
||||
throw new Error(lastError);
|
||||
}
|
||||
await wait(TOKEN_LOGIN_RETRY_DELAY_MS);
|
||||
body: JSON.stringify({ username }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const error = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Login failed." }));
|
||||
throw new Error(error.detail ?? "Login failed.");
|
||||
}
|
||||
|
||||
// Unreachable: the loop either returns or throws above.
|
||||
throw new Error(lastError);
|
||||
return response.json() as Promise<LoginByUsernameResponse>;
|
||||
}
|
||||
|
||||
@@ -13,10 +13,6 @@ export const EXTERNAL_LOGIN_STORAGE_KEYS = {
|
||||
ticket: "login.ticket",
|
||||
userId: "login.userId",
|
||||
env: "login.env",
|
||||
/** MaxKey getTokenByOnlineTicket 返回的 token */
|
||||
maxkeyOnlineToken: "login.maxkeyOnlineToken",
|
||||
/** consumer /cop/userDetailToken 返回的 data 整包(JSON) */
|
||||
userDetailToken: "login.userDetailToken",
|
||||
} as const;
|
||||
|
||||
export type ExternalLoginParams = {
|
||||
@@ -55,14 +51,7 @@ export function readExternalLoginParams(): Required<ExternalLoginParams> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把外部(URL query / 无界 props)的 ``theme`` 值映射成 ThemeContext 的
|
||||
* ``'dark' | 'light'``。已知取值:
|
||||
* - ``dark-blue`` → 深色
|
||||
* - ``red`` → 浅色(当前默认红白主题)
|
||||
* - ``blue`` → 暂无该主题,先用浅色代替(命中 else 分支)
|
||||
* 其余/缺省同样回退浅色。将来若新增 blue 主题,在此扩展映射即可。
|
||||
*/
|
||||
/** 把 ``theme`` query 值映射成 ThemeContext 的 ``'dark' | 'light'`` */
|
||||
export function resolveThemeFromQuery(themeParam: string | null | undefined): "dark" | "light" {
|
||||
return themeParam === "dark-blue" ? "dark" : "light";
|
||||
}
|
||||
@@ -1,70 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
|
||||
const AUTH_STORAGE_KEY = "deerflow.auth";
|
||||
const SETTLE_DELAY_MS = 1200;
|
||||
const IDENTITY_TOAST_ID = "auth-identity-changed";
|
||||
|
||||
function readUserId(): string | null {
|
||||
return getStoredAuth()?.user_id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount once at the app root. Watches localStorage auth changes from other
|
||||
* tabs and lets the user decide when to refresh on account switches.
|
||||
* 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(() => {
|
||||
let settleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function evaluateSettledIdentity() {
|
||||
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;
|
||||
}
|
||||
|
||||
toast.warning("检测到账号在其它标签页发生变化", {
|
||||
id: IDENTITY_TOAST_ID,
|
||||
description: "当前标签页仍是原会话。如需与新账号同步,请刷新页面。",
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: "刷新同步",
|
||||
onClick: () => window.location.reload(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleStorage(event: StorageEvent) {
|
||||
// Ignore unrelated keys and localStorage.clear(). Backend session loss is
|
||||
// handled centrally through 401 responses.
|
||||
if (event.key !== AUTH_STORAGE_KEY) {
|
||||
return;
|
||||
}
|
||||
if (settleTimer) {
|
||||
clearTimeout(settleTimer);
|
||||
}
|
||||
settleTimer = setTimeout(evaluateSettledIdentity, SETTLE_DELAY_MS);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
if (settleTimer) {
|
||||
clearTimeout(settleTimer);
|
||||
}
|
||||
};
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { requestCrudService } from "@/service/request-axios-service";
|
||||
|
||||
import {
|
||||
EXTERNAL_LOGIN_STORAGE_KEYS,
|
||||
persistExternalLoginParams,
|
||||
} from "./external-login";
|
||||
|
||||
const AUTH_BASE_URL = (import.meta.env.VITE_AUTH_BASE_URL || "").replace(
|
||||
/\/$/,
|
||||
"",
|
||||
);
|
||||
const CONSUMER_URL = (import.meta.env.VITE_AIPI_CONSUMER_URL || "").replace(
|
||||
/\/$/,
|
||||
"",
|
||||
);
|
||||
|
||||
/** MaxKey onlineTicket 换发的 token(后续请求可能用到) */
|
||||
export const MAXKEY_ONLINE_TOKEN_STORAGE_KEY =
|
||||
EXTERNAL_LOGIN_STORAGE_KEYS.maxkeyOnlineToken;
|
||||
|
||||
/** `/cop/userDetailToken` 返回的 data 整包(JWT 解析后的用户信息) */
|
||||
export const USER_DETAIL_TOKEN_STORAGE_KEY =
|
||||
EXTERNAL_LOGIN_STORAGE_KEYS.userDetailToken;
|
||||
|
||||
export type MaxkeyTokenByTicketResponse = {
|
||||
code: number;
|
||||
data: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type UserDetailTokenPayload = {
|
||||
sub?: string;
|
||||
user_id?: string;
|
||||
exp?: number;
|
||||
expired?: boolean;
|
||||
iat?: number;
|
||||
institution?: string;
|
||||
iss?: string;
|
||||
locale?: string;
|
||||
kid?: string;
|
||||
jti?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type UserDetailTokenResponse = {
|
||||
code?: number;
|
||||
data?: UserDetailTokenPayload;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
function persistMaxkeyOnlineToken(token: string): void {
|
||||
try {
|
||||
localStorage.setItem(MAXKEY_ONLINE_TOKEN_STORAGE_KEY, token);
|
||||
} catch {
|
||||
// 静默失败,不阻断登录
|
||||
}
|
||||
}
|
||||
|
||||
function persistUserDetailToken(data: UserDetailTokenPayload): void {
|
||||
try {
|
||||
localStorage.setItem(USER_DETAIL_TOKEN_STORAGE_KEY, JSON.stringify(data));
|
||||
if (data.user_id) {
|
||||
localStorage.setItem(EXTERNAL_LOGIN_STORAGE_KEYS.userId, String(data.user_id));
|
||||
}
|
||||
} catch {
|
||||
// 静默失败
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 无界 / 外部跳转:用 onlineTicket 换 MaxKey token,并拉取用户详情。
|
||||
* - token 存入 localStorage(`login.maxkeyOnlineToken`)
|
||||
* - userDetailToken 的 data 存入 localStorage(`login.userDetailToken`)
|
||||
* @returns 用于 DeerFlow `loginByUsername` 的用户名(取自 data.sub)
|
||||
*/
|
||||
export async function resolveUsernameByOnlineTicket(
|
||||
ticket: string,
|
||||
): Promise<string> {
|
||||
const onlineTicket = ticket.trim();
|
||||
if (!onlineTicket) {
|
||||
throw new Error("缺少 onlineTicket,无法完成无界登录。");
|
||||
}
|
||||
if (!AUTH_BASE_URL) {
|
||||
throw new Error("未配置 VITE_AUTH_BASE_URL,无法换取 MaxKey token。");
|
||||
}
|
||||
if (!CONSUMER_URL) {
|
||||
throw new Error("未配置 VITE_AIPI_CONSUMER_URL,无法获取用户详情。");
|
||||
}
|
||||
|
||||
persistExternalLoginParams({ ticket: onlineTicket });
|
||||
|
||||
const tokenRes = (await requestCrudService.get(
|
||||
`${AUTH_BASE_URL}/maxkey-mgt-api/sys/getTokenByOnlineTicket`,
|
||||
{ onlineTicket },
|
||||
)) as unknown as MaxkeyTokenByTicketResponse;
|
||||
|
||||
console.log("[wujie] getTokenByOnlineTicket:", tokenRes);
|
||||
|
||||
if (tokenRes.code !== 200 || !tokenRes.data) {
|
||||
throw new Error(
|
||||
tokenRes.message || "getTokenByOnlineTicket 未返回有效 token。",
|
||||
);
|
||||
}
|
||||
persistMaxkeyOnlineToken(tokenRes.data);
|
||||
|
||||
const detailRes = (await requestCrudService.get(
|
||||
`${CONSUMER_URL}/cop/userDetailToken`,
|
||||
{ ticket: onlineTicket },
|
||||
)) as unknown as UserDetailTokenResponse;
|
||||
|
||||
console.log("[wujie] userDetailToken:", detailRes);
|
||||
|
||||
const userData = detailRes.data;
|
||||
if (!userData) {
|
||||
throw new Error("userDetailToken 未返回用户 data。");
|
||||
}
|
||||
persistUserDetailToken(userData);
|
||||
|
||||
const username = (userData.sub ?? "").trim();
|
||||
if (!username) {
|
||||
throw new Error("userDetailToken 中缺少 sub(用户名)。");
|
||||
}
|
||||
return username;
|
||||
}
|
||||
|
||||
export function readMaxkeyOnlineToken(): string {
|
||||
try {
|
||||
return localStorage.getItem(MAXKEY_ONLINE_TOKEN_STORAGE_KEY) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function readUserDetailToken(): UserDetailTokenPayload | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(USER_DETAIL_TOKEN_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as UserDetailTokenPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ const CONSUMER_URL = import.meta.env.VITE_AIPI_CONSUMER_URL || "";
|
||||
export const TOOLS_TOKEN_STORAGE_KEY = "tools_token";
|
||||
export const EXTERNAL_USER_ID_STORAGE_KEY = "userId";
|
||||
export const EXTERNAL_USER_INFO_STORAGE_KEY = "userInfo";
|
||||
export const GENERAL_QA_ROUTE = "/page/workspace/chats/new";
|
||||
export const GENERAL_QA_ROUTE = "/page/strategy/qa/general";
|
||||
|
||||
type UserDetailResponse = {
|
||||
data?: {
|
||||
|
||||
@@ -9,12 +9,6 @@ export interface CustomPrompt {
|
||||
title: string;
|
||||
content: string;
|
||||
sort_order?: number;
|
||||
/**
|
||||
* Optional association to a single agent. ``null``/absent = global (shown for
|
||||
* every agent); otherwise the prompt only surfaces when that agent is
|
||||
* selected.
|
||||
*/
|
||||
agent_id?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
@@ -33,7 +27,6 @@ async function listUserPrompts(): Promise<CustomPrompt[]> {
|
||||
async function createUserPrompt(input: {
|
||||
title: string;
|
||||
content: string;
|
||||
agent_id?: string | null;
|
||||
}): Promise<CustomPrompt> {
|
||||
const res = await apiFetch(baseUrl(), {
|
||||
method: "POST",
|
||||
@@ -49,7 +42,7 @@ async function createUserPrompt(input: {
|
||||
|
||||
async function updateUserPrompt(
|
||||
id: string,
|
||||
input: { title?: string; content?: string; agent_id?: string | null },
|
||||
input: { title?: string; content?: string },
|
||||
): Promise<CustomPrompt> {
|
||||
const res = await apiFetch(`${baseUrl()}/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
@@ -100,7 +93,7 @@ export function useUpdateCustomPrompt() {
|
||||
input,
|
||||
}: {
|
||||
id: string;
|
||||
input: { title?: string; content?: string; agent_id?: string | null };
|
||||
input: { title?: string; content?: string };
|
||||
}) => updateUserPrompt(id, input),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { resolveThemeFromQuery } from "@/core/auth/external-login";
|
||||
|
||||
const DEFAULT_USERNAME = "guest";
|
||||
|
||||
export type EmbedChatParams = {
|
||||
/** Parent correlation id — required for status polling without thread_id. */
|
||||
sessionId: string;
|
||||
message: string;
|
||||
username: string;
|
||||
theme: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve iframe embed params from URL query only (plain iframe, not wujie).
|
||||
*
|
||||
* Parent must generate ``sessionId`` and pass it on the iframe URL so it can
|
||||
* poll ``GET /api/public/embed/sessions/{sessionId}/status`` without reading
|
||||
* the child frame.
|
||||
*/
|
||||
export function resolveEmbedChatParams(search: string): EmbedChatParams {
|
||||
const params = new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
|
||||
|
||||
const sessionId =
|
||||
params.get("sessionId")?.trim() ||
|
||||
params.get("session_id")?.trim() ||
|
||||
"";
|
||||
|
||||
const message =
|
||||
params.get("message")?.trim() ||
|
||||
params.get("question")?.trim() ||
|
||||
params.get("text")?.trim() ||
|
||||
"";
|
||||
|
||||
const username =
|
||||
params.get("username")?.trim() ||
|
||||
params.get("userName")?.trim() ||
|
||||
DEFAULT_USERNAME;
|
||||
|
||||
const theme = params.get("theme")?.trim() || "";
|
||||
|
||||
return { sessionId, message, username, theme };
|
||||
}
|
||||
|
||||
export function resolveEmbedTheme(themeParam: string): "dark" | "light" {
|
||||
return resolveThemeFromQuery(themeParam || null);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export type EmbedSessionStatus = {
|
||||
session_id: string;
|
||||
registered: boolean;
|
||||
thread_id: string | null;
|
||||
finished: boolean;
|
||||
thread_status: string;
|
||||
has_active_run: boolean;
|
||||
latest_run_status: string | null;
|
||||
};
|
||||
|
||||
export type EmbedSessionResult = EmbedSessionStatus & {
|
||||
messages: Record<string, unknown>[];
|
||||
generated_text: string;
|
||||
generated_files: string[];
|
||||
};
|
||||
|
||||
async function parseEmbedError(response: Response): Promise<string> {
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (typeof body.detail === "string") {
|
||||
return body.detail;
|
||||
}
|
||||
return `HTTP ${response.status}`;
|
||||
}
|
||||
|
||||
/** Register session_id → thread_id (iframe calls this; parent never needs thread_id). */
|
||||
export async function registerEmbedSession(
|
||||
sessionId: string,
|
||||
threadId: string,
|
||||
): Promise<{ session_id: string; thread_id: string }> {
|
||||
const base = getBackendBaseURL();
|
||||
const response = await fetch(`${base}/api/public/embed/sessions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "omit",
|
||||
body: JSON.stringify({ session_id: sessionId, thread_id: threadId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await parseEmbedError(response));
|
||||
}
|
||||
return response.json() as Promise<{ session_id: string; thread_id: string }>;
|
||||
}
|
||||
|
||||
/** Poll whether embed Q&A has finished — parent uses sessionId only. */
|
||||
export async function getEmbedSessionStatus(
|
||||
sessionId: string,
|
||||
): Promise<EmbedSessionStatus> {
|
||||
const base = getBackendBaseURL();
|
||||
const response = await fetch(
|
||||
`${base}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/status`,
|
||||
{ credentials: "omit" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(await parseEmbedError(response));
|
||||
}
|
||||
return response.json() as Promise<EmbedSessionStatus>;
|
||||
}
|
||||
|
||||
/** Fetch full messages, generated text, and artifact paths — parent uses sessionId only. */
|
||||
export async function getEmbedSessionResult(
|
||||
sessionId: string,
|
||||
): Promise<EmbedSessionResult> {
|
||||
const base = getBackendBaseURL();
|
||||
const response = await fetch(
|
||||
`${base}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/result`,
|
||||
{ credentials: "omit" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(await parseEmbedError(response));
|
||||
}
|
||||
return response.json() as Promise<EmbedSessionResult>;
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* 无界(wujie)子应用运行时 props 读取。
|
||||
*
|
||||
* 本应用作为子应用被内网门户用无界嵌套时,父级通过
|
||||
* <WujieVue name="subApp" :url="..." :props="{ ticket, userId, theme, env }" />
|
||||
* 注入参数,子应用在 `window.$wujie.props` 上拿到**同一个对象**(无界在子应用
|
||||
* bootstrap 之前就已注入,因此首帧即可读)。
|
||||
*
|
||||
* ticket 在 LoginPage 会走 MaxKey + consumer 双接口换用户名并登录;读出的
|
||||
* ticket/userId/env 复用 external-login 的 localStorage 存储(与 URL 跳转登录
|
||||
* 同一套 key),theme 交给调用方(LoginPage)映射到 ThemeContext。
|
||||
*/
|
||||
import { useEffect, useMemo } from "react";
|
||||
|
||||
import { persistExternalLoginParams } from "@/core/auth/external-login";
|
||||
|
||||
/** 无界父级注入的 props 形状。除已知字段外允许透传其余未知字段便于排查。 */
|
||||
export interface WujieProps {
|
||||
/** 第三方 onlineTicket(LoginPage 用于 MaxKey + userDetailToken 登录)。 */
|
||||
ticket?: string;
|
||||
/** 外部用户 id。 */
|
||||
userId?: string;
|
||||
/** 环境标识。 */
|
||||
env?: string;
|
||||
/** 主题色标识(映射到深/浅色)。 */
|
||||
theme?: string;
|
||||
/** 嵌入问答:首条用户问题(与 question / text 同义)。 */
|
||||
message?: string;
|
||||
question?: string;
|
||||
text?: string;
|
||||
/** 嵌入问答:DeerFlow 本地用户名登录。 */
|
||||
username?: string;
|
||||
userName?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface WujieRuntime {
|
||||
props?: WujieProps;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/** 无界注入的子应用运行时;非无界环境下为 undefined。 */
|
||||
$wujie?: WujieRuntime;
|
||||
/** 无界注入的环境标志(子应用内为 true)。 */
|
||||
__POWERED_BY_WUJIE__?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前是否运行在无界子应用环境中。 */
|
||||
export function isWujieEnv(): boolean {
|
||||
return typeof window !== "undefined" && !!window.__POWERED_BY_WUJIE__;
|
||||
}
|
||||
|
||||
/** 读取无界父级注入的 props;不在无界环境 / 未注入时返回空对象。 */
|
||||
export function readWujieProps(): WujieProps {
|
||||
if (typeof window === "undefined") return {};
|
||||
return window.$wujie?.props ?? {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局读取无界父级注入的 props 的 hook。
|
||||
*
|
||||
* - 首帧从 `window.$wujie.props` 读出(无界已在 bootstrap 前注入,引用稳定);
|
||||
* - 读到后把 ticket/userId/env 持久化到 localStorage(复用 external-login 存储),
|
||||
* 供后续任意页面/请求读取 —— 即「存到全局」;
|
||||
* - 返回 props 供调用方消费(如 LoginPage 取 theme)。
|
||||
*
|
||||
* 非无界环境(直接访问)下返回空对象且不做任何持久化,可安全无条件调用。
|
||||
*/
|
||||
export function useWujieProps(): WujieProps {
|
||||
const props = useMemo(() => readWujieProps(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.ticket && !props.userId && !props.env) return;
|
||||
persistExternalLoginParams({
|
||||
ticket: props.ticket,
|
||||
userId: props.userId,
|
||||
env: props.env,
|
||||
});
|
||||
}, [props.ticket, props.userId, props.env]);
|
||||
|
||||
return props;
|
||||
}
|
||||
@@ -41,7 +41,6 @@ export const enUS: Translations = {
|
||||
code: "Code",
|
||||
preview: "Preview",
|
||||
cancel: "Cancel",
|
||||
confirm: "Confirm",
|
||||
save: "Save",
|
||||
install: "Install",
|
||||
create: "Create",
|
||||
@@ -81,11 +80,6 @@ export const enUS: Translations = {
|
||||
// Input Box
|
||||
inputBox: {
|
||||
placeholder: "How can I assist you today?",
|
||||
editQuestion: "Edit question",
|
||||
deleteMessage: "Delete question and answer",
|
||||
deleteMessageConfirmTitle: "Delete this question and its answer?",
|
||||
deleteMessageConfirmDescription:
|
||||
"The question and its answer will both be removed. This cannot be undone.",
|
||||
createSkillPrompt:
|
||||
"We're going to build a new skill step by step with `skill-creator`. To start, what do you want this skill to do?",
|
||||
addAttachments: "Add attachments",
|
||||
@@ -476,8 +470,6 @@ export const enUS: Translations = {
|
||||
badgeArchivedTitle: "Archived — recoverable from the archive drawer",
|
||||
badgePinned: "Pinned",
|
||||
badgePinnedTitle: "Pinned — the curator will never auto-archive this skill",
|
||||
badgeFeatured: "Top",
|
||||
badgeFeaturedTitle: "Pinned to the top of the list",
|
||||
badgeSourceAgent: "Agent-created",
|
||||
badgeSourceAgentTitle: "Auto-generated by an agent during a conversation",
|
||||
badgeSourceUpload: "Uploaded",
|
||||
@@ -496,10 +488,6 @@ export const enUS: Translations = {
|
||||
unpinAction: "Unpin",
|
||||
pinSuccess: "Pinned",
|
||||
unpinSuccess: "Unpinned",
|
||||
featureAction: "Pin to top",
|
||||
unfeatureAction: "Unpin from top",
|
||||
featureSuccess: "Pinned to top",
|
||||
unfeatureSuccess: "Unpinned from top",
|
||||
deleteAction: "Delete skill",
|
||||
deleteConfirm:
|
||||
"Delete skill \"{name}\"? This cannot be undone and the skill will be removed from any agent that referenced it.",
|
||||
|
||||
@@ -42,7 +42,6 @@ export interface Translations {
|
||||
code: string;
|
||||
preview: string;
|
||||
cancel: string;
|
||||
confirm: string;
|
||||
save: string;
|
||||
install: string;
|
||||
create: string;
|
||||
@@ -78,10 +77,6 @@ export interface Translations {
|
||||
// Input Box
|
||||
inputBox: {
|
||||
placeholder: string;
|
||||
editQuestion: string;
|
||||
deleteMessage: string;
|
||||
deleteMessageConfirmTitle: string;
|
||||
deleteMessageConfirmDescription: string;
|
||||
createSkillPrompt: string;
|
||||
addAttachments: string;
|
||||
mode: string;
|
||||
@@ -409,8 +404,6 @@ export interface Translations {
|
||||
badgeArchivedTitle: string;
|
||||
badgePinned: string;
|
||||
badgePinnedTitle: string;
|
||||
badgeFeatured: string;
|
||||
badgeFeaturedTitle: string;
|
||||
badgeSourceAgent: string;
|
||||
badgeSourceAgentTitle: string;
|
||||
badgeSourceUpload: string;
|
||||
@@ -429,10 +422,6 @@ export interface Translations {
|
||||
unpinAction: string;
|
||||
pinSuccess: string;
|
||||
unpinSuccess: string;
|
||||
featureAction: string;
|
||||
unfeatureAction: string;
|
||||
featureSuccess: string;
|
||||
unfeatureSuccess: string;
|
||||
deleteAction: string;
|
||||
deleteConfirm: string;
|
||||
deleteSuccess: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import {
|
||||
CompassIcon,
|
||||
GraduationCapIcon,
|
||||
ImageIcon,
|
||||
@@ -41,7 +41,6 @@ export const zhCN: Translations = {
|
||||
code: "代码",
|
||||
preview: "预览",
|
||||
cancel: "取消",
|
||||
confirm: "确定",
|
||||
save: "保存",
|
||||
install: "安装",
|
||||
create: "创建",
|
||||
@@ -81,10 +80,6 @@ export const zhCN: Translations = {
|
||||
// Input Box
|
||||
inputBox: {
|
||||
placeholder: "今天我能为你做些什么?",
|
||||
editQuestion: "编辑问题",
|
||||
deleteMessage: "删除提问及回答",
|
||||
deleteMessageConfirmTitle: "删除该提问及其回答?",
|
||||
deleteMessageConfirmDescription: "该提问及对应的回答都会被删除,且无法恢复。",
|
||||
createSkillPrompt:
|
||||
"我们一起用 skill-creator 技能来创建一个技能吧。先问问我希望这个技能能做什么。",
|
||||
addAttachments: "添加附件",
|
||||
@@ -456,8 +451,6 @@ export const zhCN: Translations = {
|
||||
badgeArchivedTitle: "已被归档,可在归档抽屉中恢复",
|
||||
badgePinned: "钉选",
|
||||
badgePinnedTitle: "已钉选,策展程序不会自动归档此技能",
|
||||
badgeFeatured: "置顶",
|
||||
badgeFeaturedTitle: "已置顶,固定显示在列表顶部",
|
||||
badgeSourceAgent: "Agent 创建",
|
||||
badgeSourceAgentTitle: "在对话中由 Agent 自动生成",
|
||||
badgeSourceUpload: "用户上传",
|
||||
@@ -476,10 +469,6 @@ export const zhCN: Translations = {
|
||||
unpinAction: "取消钉选",
|
||||
pinSuccess: "已钉选",
|
||||
unpinSuccess: "已取消钉选",
|
||||
featureAction: "置顶",
|
||||
unfeatureAction: "取消置顶",
|
||||
featureSuccess: "已置顶",
|
||||
unfeatureSuccess: "已取消置顶",
|
||||
deleteAction: "删除技能",
|
||||
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有智能体中自动移除。",
|
||||
deleteSuccess: "技能已删除",
|
||||
|
||||
@@ -20,8 +20,6 @@ interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:cla
|
||||
|
||||
interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {}
|
||||
|
||||
interface AssistantSetupWritingApprovalGroup extends GenericMessageGroup<"assistant:setup-writing-approval"> {}
|
||||
|
||||
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
|
||||
|
||||
type MessageGroup =
|
||||
@@ -31,7 +29,6 @@ type MessageGroup =
|
||||
| AssistantPresentFilesGroup
|
||||
| AssistantClarificationGroup
|
||||
| AssistantSetupAgentApprovalGroup
|
||||
| AssistantSetupWritingApprovalGroup
|
||||
| AssistantSubagentGroup;
|
||||
|
||||
export function groupMessages<T>(
|
||||
@@ -53,8 +50,7 @@ export function groupMessages<T>(
|
||||
last.type !== "human" &&
|
||||
last.type !== "assistant" &&
|
||||
last.type !== "assistant:clarification" &&
|
||||
last.type !== "assistant:setup-agent-approval" &&
|
||||
last.type !== "assistant:setup-writing-approval"
|
||||
last.type !== "assistant:setup-agent-approval"
|
||||
) {
|
||||
return last;
|
||||
}
|
||||
@@ -92,13 +88,6 @@ export function groupMessages<T>(
|
||||
type: "assistant:setup-agent-approval",
|
||||
messages: [message],
|
||||
});
|
||||
} else if (isSetupWritingApprovalToolMessage(message)) {
|
||||
lastOpenGroup()?.messages.push(message);
|
||||
groups.push({
|
||||
id: message.id,
|
||||
type: "assistant:setup-writing-approval",
|
||||
messages: [message],
|
||||
});
|
||||
} else {
|
||||
const open = lastOpenGroup();
|
||||
if (open) {
|
||||
@@ -365,15 +354,6 @@ export function isSetupAgentApprovalToolMessage(message: Message) {
|
||||
);
|
||||
}
|
||||
|
||||
export function isSetupWritingApprovalToolMessage(message: Message) {
|
||||
return (
|
||||
message.type === "tool" &&
|
||||
message.name === "setup_writing" &&
|
||||
typeof message.additional_kwargs?.setup_writing_approval === "object" &&
|
||||
message.additional_kwargs.setup_writing_approval !== null
|
||||
);
|
||||
}
|
||||
|
||||
export function extractPresentFilesFromMessage(message: Message) {
|
||||
if (message.type !== "ai" || !hasPresentFiles(message)) {
|
||||
return [];
|
||||
@@ -482,10 +462,10 @@ export function parseUploadedFiles(content: string): FileInMessage[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
// NOTE: do NOT bail on "(empty)" here. The backend writes "(empty)" only for
|
||||
// the "uploaded in this message" section; files uploaded in previous messages
|
||||
// are still listed below it. Bailing dropped those valid entries. The file
|
||||
// regex below naturally skips the "(empty)" line, so parsing stays correct.
|
||||
// Check if the backend reported no new files were uploaded in this message
|
||||
if (uploadedFilesContent?.includes("(empty)")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Parse file list
|
||||
// Format: - filename (size)\n Path: /path/to/file
|
||||
|
||||
Loaded 100 of 389 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user