From 25dbeb7a9a445fb9e597ef184d8afba18fd42a9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sat, 13 Jun 2026 18:41:50 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(=E5=9C=86=E6=A1=8C):=20=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E9=93=BE=E6=9D=A1=E9=85=8D=E7=BD=AE=E9=A1=B5=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=E5=9B=BE=E7=82=B9=E9=98=B5=E6=94=BE=E5=A4=A7=20+=20?= =?UTF-8?q?=E4=BE=A7=E8=BE=B9=E6=A0=8F=E8=8F=9C=E5=8D=95=E9=A1=B9=E6=8C=89?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E7=A8=BF=E8=BF=98=E5=8E=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChainFlowDiagram 点阵背景点尺寸/不透明度上调,更接近设计稿颗粒感 - 全局侧边栏菜单项:锐利方角(2px)、选中整高左竖条、图标 hover 微放大、间距微调;保留原菜单内容与红/青配色 Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend-web/src/components/page-sidebar.tsx | 15 +++++++++++---- .../components/ChainFlowDiagram.tsx | 5 +++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index cae1f24f..58e59a72 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -77,8 +77,8 @@ export function PageSidebar({ compact ? "max-w-0 opacity-0" : "max-w-[220px] opacity-100" } ${small ? "text-xs" : "text-sm"} flex-1 font-medium`; const menuButtonClass = (isActive: boolean, depth = 0) => - `flex w-full items-center rounded-lg py-2 text-left transition-all duration-300 ease-out hover:-translate-y-0.5 theme-transition ${ - collapsed && depth === 0 ? "justify-center px-2" : "gap-2 px-3" + `group relative flex w-full items-center rounded-[2px] py-2 text-left transition-all duration-300 ease-out theme-transition ${ + collapsed && depth === 0 ? "justify-center px-2" : "gap-3 px-3" } ${ isActive ? isDark @@ -114,7 +114,14 @@ export function PageSidebar({ onClick={() => item.path && navigate(item.path)} className={menuButtonClass(isActive, depth)} > - + {isActive && ( + + )} + {(!collapsed || depth > 0) && ( 0)}> {item.label} @@ -144,7 +151,7 @@ export function PageSidebar({ className={menuButtonClass(false, depth)} > 0 ? "scale-105" : "" }`} /> diff --git a/frontend-web/src/roundtable-planning/components/ChainFlowDiagram.tsx b/frontend-web/src/roundtable-planning/components/ChainFlowDiagram.tsx index 808c0d44..fae7eace 100644 --- a/frontend-web/src/roundtable-planning/components/ChainFlowDiagram.tsx +++ b/frontend-web/src/roundtable-planning/components/ChainFlowDiagram.tsx @@ -250,9 +250,10 @@ export function ChainFlowDiagram({ stages, onNodeClick, className }: ChainFlowDi grabbing ? "cursor-grabbing" : "cursor-grab", )} style={{ + // 点阵背景:放大点尺寸(参考「业务链条配置器」设计稿,点更明显)。 backgroundImage: - "radial-gradient(circle, rgba(148,163,184,0.18) 1px, transparent 1px)", - backgroundSize: "24px 24px", + "radial-gradient(circle, rgba(148,163,184,0.4) 1.3px, transparent 1.3px)", + backgroundSize: "22px 22px", }} onPointerDown={(e) => { if (e.button !== 0) return; From 391409b6c0ead4ebbee3e949138b5ff42c1d14a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sat, 13 Jun 2026 18:49:19 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat(=E5=9C=86=E6=A1=8C):=20=E6=9F=A5?= =?UTF-8?q?=E7=9C=8B=E8=BF=90=E8=A1=8C=E4=B8=AD=E4=BD=9C=E4=B8=9A=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E6=8E=A5=E7=AE=A1=E5=9B=9E=E5=89=8D=E7=AB=AF=E7=BB=AD?= =?UTF-8?q?=E8=B7=91=20+=20=E5=90=8E=E5=8F=B0=E6=8C=82=E8=B5=B7=E7=BB=AD?= =?UTF-8?q?=E8=B7=91=E6=96=B9=E6=A1=88=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - onView 运行中/待澄清作业改走 takeOverRunningJob:cancelJob 停后端 → handleLoadDraft 恢复作业真实线程/协调名/对话 → 解除只读闸门 → resumeLiveTakeover 在作业线程上由前端继续实时流式编排 - hydrateFromDraft 同步写 threadIdsRef/coordinatorNameRef,使接管同帧可读真实线程 - 新增 docs/multi-agent-后台挂起续跑实现方案.md(仅方案,待实现) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../docs/multi-agent-后台挂起续跑实现方案.md | 198 ++++++++++++++++++ .../hooks/useStep2Orchestration.ts | 39 ++++ .../pages/RoundtablePlanningPage.tsx | 53 ++--- 3 files changed, 259 insertions(+), 31 deletions(-) create mode 100644 frontend-web/docs/multi-agent-后台挂起续跑实现方案.md diff --git a/frontend-web/docs/multi-agent-后台挂起续跑实现方案.md b/frontend-web/docs/multi-agent-后台挂起续跑实现方案.md new file mode 100644 index 00000000..74b3811d --- /dev/null +++ b/frontend-web/docs/multi-agent-后台挂起续跑实现方案.md @@ -0,0 +1,198 @@ +# 后台挂起「在当前进度继续」实现方案 + +> 状态:**待实现**(仅方案,未改代码) +> 关联:[[multi-agent-background-run-dev.md]]、[[multi-agent-frontend-dev.md]]、memory `project-roundtable-background` +> 目标:点「后台挂起」后,后端作业**在前端当前研讨进度上接着跑**,而不是新建空线程从头重跑。 + +--- + +## 1. 现象与根因 + +### 现象 +在第二步研讨进行中点「后台挂起」,转入后台的作业是**从头重新开始执行**(重新初始化席位、从原始 intent 重新派活),而不是接着前端已经讨论到的进度往下走。 + +### 根因 +后端「起作业」路由 `start_job` **从不设 `is_resume`**(默认 `False`),而执行器据此**新建一套全新线程**: + +```python +# offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py (_run, 约 215 行) +init = getattr(gateway, "init_threads", None) +if init is not None and not params.is_resume: + thread_ids, coordinator_name = await init() # ← 建全新空线程 +``` + +加上前端 `startBackgroundJob` **故意不传 `threadIds`**、用 `buildInitialIntent()` 作第一轮 leader prompt,于是后台作业 = 空线程 + 原始意图 = 从头重跑。 + +> 这是**原设计的有意取舍**。`startBackgroundJob` 注释写明:不复用前端线程,因为「那些线程可能有活跃 run,复用会撞 409」。本方案就是要在可控前提下打开「复用前端线程续跑」这条路。 + +### 后端其实已支持续跑 +澄清续跑路由 `resume_job`(`roundtable_jobs.py` 约 293 行)已经在做「在原线程上接着派活」: + +```python +executor.start_job(StartParams( + ..., + seed_message=_RESUME_PREFIX + answer, # 续跑式提示,而非原始 intent + thread_ids=row.get("thread_ids"), # 复用作业自己的线程 + coordinator_name=row.get("coordinator_name") or "roundtable-coordinator", + is_resume=True, # ← 关键:跳过 init_threads,直接复用 + ... +)) +``` + +执行器在 `is_resume=True` 时直接用 `params.thread_ids` + `params.coordinator_name`(不调 `init_threads`),交给 `run_orchestration` 续跑。**所以能力已存在,`start_job` 只是没走这条路。** + +--- + +## 2. 设计目标与关键事实 + +- 「同进度续跑」= 后台作业复用**前端当前会话的真实线程**(总控 + 各席位),这些线程的 checkpoint 里已存着到目前为止的完整研讨上下文,续跑 = 在原线程上 post 一条「继续」消息让总控接着派活。 +- 前端编排是**逐轮**的:每轮一个 `POST /api/multi-agent/run/stream`(leader 或某席位),**轮与轮之间线程空闲**。只有「某一轮在飞时」线程才被占用。 +- 后端复用现成线程是**已验证路径**(`resume_job` 天天在用),唯一新增的不同点是:这次复用的是「前端会话线程」而非「上一个后台作业的线程」——但线程同属一个后端线程库,复用方式完全一致。 + +--- + +## 3. 改造点(最小集) + +### 3.1 后端:让 `start_job` 支持复用线程 + +文件:`offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py` + +1. `StartJobRequest` 增加字段: + ```python + resume: bool = False # true + 携带 threadIds ⇒ 复用这些线程续跑,不新建 + ``` +2. `start_job` 里把它透传给 `StartParams`: + ```python + is_resume = bool(body.resume and body.threadIds) + executor.start_job(StartParams( + ..., + thread_ids=body.threadIds, + coordinator_name=body.coordinatorName, + is_resume=is_resume, # ← 新增 + ... + )) + ``` + 其余执行器逻辑**不动**(它在 `is_resume=True` 时已能复用 `params.thread_ids`)。 + +> 幂等闸门(同草稿未终态作业直接返回)保持不变;续跑挂起前前端应保证该草稿没有在跑的旧作业。 + +### 3.2 前端 API:补 `resume` 入参 + +文件:`frontend-web/src/roundtable-planning/lib/job-types.ts` +- `StartJobPayload` 增加 `resume?: boolean`。 + +文件:`frontend-web/src/roundtable-planning/api/roundtable-jobs.ts` +- `toStartBody` 增加 `resume: p.resume ?? false`。 + +### 3.3 前端:`startBackgroundJob` 改为复用线程续跑 + +文件:`frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx`(`startBackgroundJob` 约 1119 行) + +当前端已有现成会话(`step2.threadIds` 且 `step2.coordinatorName` 非空,说明研讨已 init)时,改传: + +```ts +const canContinue = !!(step2.threadIds && step2.coordinatorName); +await startJob({ + draftId: draftId ?? undefined, + // 续跑:用「继续」式 seed,而不是 buildInitialIntent()(后者会让总控从头理解任务) + intent: buildInitialIntent(), // 仍传,作为无线程回退时的首轮 prompt + seedMessage: canContinue + ? "用户已将本次研讨转入后台,请基于现有进展继续上一轮的协调与派活;若已可收口则直接综合输出结论。" + : undefined, + agents, + model: selectedModel || undefined, + orchestrationMode, + chain: chainMeta, + orchestrationPlan: orchestrationMode === "dag" ? orchestrationPlan : null, + // 关键三件套:复用前端真实线程 + 协调名 + resume 标志 + threadIds: canContinue ? step2.threadIds ?? undefined : undefined, + coordinatorName: canContinue ? step2.coordinatorName ?? undefined : undefined, + resume: canContinue, +}); +``` + +> 没有现成会话(极早期、线程未建)时 `canContinue=false`,自动回退到「新建线程 + 原 intent」的旧行为,安全。 + +### 3.4 前端:挂起前先停本地编排(避免交出去时线程还在飞) + +文件:同上,`handleBackgroundSuspend`(约 1161 行) + +当前顺序是 `startBackgroundJob()` → `resetToStep1()`(reset 里才 `stopOrchestration`)。复用线程时必须**先停再交**: + +```ts +const handleBackgroundSuspend = useCallback(async () => { + if (step2.selectedAgents.length === 0) { /* toast 提示后 return */ } + // 1. 先彻底停掉前端在飞的那一轮,尽量让线程在交给后台前变空闲 + step2.stopOrchestration(); + step2.setIsRoundTableRunning(false); + try { + // 2. 再起后台作业(复用线程续跑) + await startBackgroundJob(); + } catch (err) { /* toast 失败后 return */ } + // 3. 最后重置回第一步(后台作业继续在历史记录里跑) + resetToStep1(); + triggerToast("🚀 已转入后台,将基于当前进度继续研讨…"); + void drafts.refresh(); +}, [...]); +``` + +--- + +## 4. 关键风险:线程占用(409)与三种处理 + +复用前端线程的唯一风险:**在某一轮在飞时挂起**,`stopOrchestration()` 只断了客户端 SSE,服务端那次 run 可能还在跑一小会儿;后台作业首轮 leader 向同一总控线程 post → 撞 409 / rollback。 + +| 方案 | 做法 | 取舍 | +|---|---|---| +| **A(推荐起步)** | 仅前端 stop + 后端 `is_resume` 复用,接受小概率 409 | 实现最简,真正同进度续跑;轮间挂起 100% 安全,仅「某轮在飞时挂起」有小概率 409 —— 走编排循环**现有 rollback/重试容错**兜底 | +| **B(最稳)** | 同 A,但后端在复用线程续跑前,**先 cancel 这些线程上可能残留的 run**(参考 takeover 里 `cancelJob` 的 `task.cancel()` 思路,落到 thread-run 级别)再 post | 几乎无 409;后端要多写「按 thread_id 取消活跃 run」的逻辑 | +| **C(不推荐)** | 不复用线程,新建空线程,但把「目前为止的研讨」塞进 seed_message 让总控续写 | 零 409,但**席位子线程上下文全丢**、不是真正同进度、leader prompt 臃肿 | + +> 建议:先上 **A**,真机验证 409 命中率;若频繁再加 **B** 的后端线程级 cancel。 + +--- + +## 5. 适用范围(哪些挂起入口改续跑) + +三个触发源(见 `autoSuspendRef`): + +1. **手动「后台挂起」按钮**(`handleBackgroundSuspend`)——**改续跑**。能在 start 前干净 stop,最适合复用线程。 +2. **路由卸载自动挂起**(切到其它页面,`fire`)——可改续跑(能走完整异步链);卸载瞬间 stop 不一定彻底,按 A 方案的容错兜底。 +3. **刷新/关标签页 keepalive**(`beforeunload` 的 `beacon` → `startJobKeepalive`)——**建议保持新建线程**。关页时无法保证前端 run 已停,复用线程 409 风险最高;这里走「新建线程 + 原 intent」的安全回退即可(用户可在历史记录里点「查看」再用我们已实现的 takeover 接管回前端续跑)。 + +> 落地顺序建议:先只改 #1,验证稳定后再决定是否推广到 #2。 + +--- + +## 6. 与已实现「查看=接管回前端」的关系 + +二者是一对对称操作,复用同一套「线程同属一个后端库、可跨前后端续跑」的事实: + +- **后台挂起(本方案)**:前端 → 后端。前端 stop,把前端线程交给后台 `is_resume` 续跑。 +- **查看接管(已实现,见 memory)**:后端 → 前端。`cancelJob` 停后台,`hydrateFromDraft` 恢复作业真实线程,`resumeLiveTakeover()` 由前端在那些线程上续跑。 + +实现本方案时可直接参照 takeover 的线程复用与 `is_resume` 经验。 + +--- + +## 7. 测试 + +后端(`make test`): +- 新增/扩展 `tests/test_roundtable_jobs.py`:`start_job` 带 `resume=true + threadIds` ⇒ `StartParams.is_resume=True` 且**不调** `init_threads`、直接用传入线程;`resume=false` 或无 `threadIds` ⇒ 走 `init_threads` 新建(回归旧行为)。 +- 回归:`tests/test_roundtable_inprocess_request.py`、`tests/test_roundtable_step3_pipeline.py`(确认续跑不破坏 Step3 双产物)。 + +前端(`pnpm typecheck`)+ 真机: +- 研讨进行到几轮后点「后台挂起」→ 历史记录里点「查看」→ 确认后台是**接着已有对话**往下派活,而非从第 1 轮重来。 +- 轮间挂起 / 某轮在飞时挂起两种时机各验一次,观察是否出现 409(A 方案下应被 rollback 容错吸收)。 + +--- + +## 8. 落地清单(TL;DR) + +- [ ] 后端 `StartJobRequest.resume` 字段 + `start_job` 透传 `is_resume` +- [ ] 前端 `StartJobPayload.resume` + `toStartBody` +- [ ] 前端 `startBackgroundJob`:有会话时传 `threadIds/coordinatorName/seedMessage/resume` +- [ ] 前端 `handleBackgroundSuspend`:调整为 **先 stop 再 start 再 reset** +- [ ] 后端测试(resume 复用 vs 新建分支) +- [ ] 真机验证「接着进度跑」+ 409 命中率 +- [ ](可选)方案 B:后端复用线程前按 thread_id 取消残留 run diff --git a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts index 38662b94..84f98a36 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts @@ -341,6 +341,13 @@ export interface UseStep2OrchestrationReturn { hasConsensus: boolean; lastLeaderContent: string; }) => void; + /** + * 「后台挂起接管」(2026-06-13):点「查看」一个运行中的后台作业并取消它、 + * `hydrateFromDraft` 恢复作业真实线程 + 完整对话后调用 —— 在那些线程上**由前端 + * 继续实时流式编排**(leader 自由派活续轮,不从头重跑 DAG)。返回是否成功接管 + * (无线程时返回 false,调用方据此回退到只读查看)。 + */ + resumeLiveTakeover: () => boolean; /** 「新建任务」清空 Step 2 数据(threadIds / dialogues / 共识等)。 */ resetStep2State: () => void; /** 当前沙箱预览绑定的席位 thread id。 */ @@ -2634,6 +2641,11 @@ export function useStep2Orchestration( } setThreadIds(snapshot.threadIds); setCoordinatorName(snapshot.coordinatorName); + // 同步把线程/协调名写进 ref(平时由 [threadIds]/[coordinatorName] 的 effect 异步回写, + // 滞后一拍)。后台挂起「接管续跑」会在 hydrate 之后**同帧**调 resumeLiveTakeover, + // 需要 ref 立刻可读到作业的真实线程,否则读到旧值导致接管失败。 + threadIdsRef.current = snapshot.threadIds; + coordinatorNameRef.current = snapshot.coordinatorName; setStep2RoundtableDialogues(snapshot.step2RoundtableDialogues); setLastLeaderContent(snapshot.lastLeaderContent); setHasConsensus(snapshot.hasConsensus); @@ -2671,6 +2683,32 @@ export function useStep2Orchestration( [], ); + // 「后台挂起接管」:取消后端作业 + hydrateFromDraft(恢复作业真实线程/协调名/对话)后, + // 由前端在这些线程上**继续实时流式编排**。续跑走 leader 自由派活循环(把 dagPlanExecuted + // 置 true 避免从头重跑整张 DAG),并清掉残留的取消/编排锁让新一轮立刻起跑—— + // 与 handleInjectToRoundtable 末尾「直接启动新一轮编排」同构。 + const resumeLiveTakeover = useCallback((): boolean => { + if (!threadIdsRef.current || !coordinatorNameRef.current) { + triggerToast("⚠️ 接管失败:未找到研讨线程,已切换为只读查看"); + return false; + } + dagPlanExecutedRef.current = true; + orchestrationCancelRef.current = true; + abortAllInFlight(); + isOrchestratingRef.current = false; + setIsOrchestrating(false); + setPendingClarification(null); + setClarificationDraft(""); + setIsRoundTableRunning(true); + void runActiveOrchestration( + "用户已从后台挂起接管本次研讨,请基于现有进展继续上一轮的协调与派活工作;若研讨已可收口则直接综合输出结论。", + threadIdsRef.current, + coordinatorNameRef.current, + ); + triggerToast("🎮 已接管至前端,继续实时研讨"); + return true; + }, [abortAllInFlight, runActiveOrchestration, triggerToast]); + const resetStep2State = useCallback(() => { orchestrationCancelRef.current = true; abortAllInFlight(); @@ -2749,6 +2787,7 @@ export function useStep2Orchestration( handleSaveAgentAndRecalculate, hydrateFromDraft, applyLiveJobFrame, + resumeLiveTakeover, resetStep2State, activeArtifactThreadId, seatStubMessages, diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 83f5b6bb..a5e78826 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -103,12 +103,11 @@ import { import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types"; import type { IntentSummary } from "@/roundtable-planning/api/intent"; import { + cancelJob, getJobByDraft, startJob, startJobKeepalive, - streamJob, } from "@/roundtable-planning/api/roundtable-jobs"; -import { mapJobToLiveStep2 } from "@/roundtable-planning/lib/job-live"; import { getChain } from "@/roundtable-planning/api/chains"; import { filterRecommendCandidates } from "@/roundtable-planning/api/recommend"; import { listAgents } from "@/core/agents"; @@ -1028,40 +1027,32 @@ export default function RoundtablePlanningPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentStep, drafts.currentDraftId]); - // ── 「查看=恢复流程」(2026-06-13):查看运行中的后台作业 → SSE 实时跟随 ── - // 加载草稿后订阅 streamJob,把作业每帧对话/产物/共识进度灌进第二步画面(像本地 - // 编排一样实时长出气泡),终态时重载草稿衔接最终 run + step3,并停止订阅。 + // ── 「查看=接管回前端」(2026-06-13):查看运行中的后台作业 → 接管续跑 ── + // 取消后端作业 → 加载作业最近写回草稿的快照(含作业真实线程 + 完整对话)→ 解除 + // 「看历史」只读闸门 → 由前端在那些线程上**继续实时流式编排**(leader 自由派活续轮)。 + // 这样「后台挂起 → 查看」会把会商真正搬回前端,按钮/暂停/干预/再挂起全部回归,而不是 + // 停在只读跟随的后台态。 const jobFollowUnsubRef = useRef<(() => void) | null>(null); const stopJobFollow = useCallback(() => { jobFollowUnsubRef.current?.(); jobFollowUnsubRef.current = null; }, []); - // 「查看运行中作业」:先 await 草稿 hydrate(建好席位/线程基础 + 切第二步),**再** - // 订阅 SSE —— 保证实时帧总是最后权威,不被草稿快照覆盖(竞态根因)。强制停在第二步 - // 看研讨流程(作业可能已到 Step3,但用户点查看就是要看实时研讨过程)。 - const followRunningJob = useCallback( + const takeOverRunningJob = useCallback( async (jobId: string, draftId: string) => { stopJobFollow(); - step2.setIsRoundTableRunning(false); step2.stopOrchestration(); - // 点查看前**立刻存一次**当前聊天(若用户在本地还跑着研讨,保住其记录再切去看作业)。 - await drafts.saveDraftNow(2).catch(() => null); + // 1. 取消后端作业:避免前后端双跑/抢同一线程(409),线程随后由前端接管续跑。 + // executor.cancel 会 task.cancel() 中断在跑的 run,DB 同步置 cancelled。 + await cancelJob(jobId).catch(() => null); + // 2. 加载作业最近写回草稿的快照:恢复 threadIds(作业真实线程)+ 协调名 + 完整对话。 + // hydrateFromDraft 已同步写好 threadIds/coordinatorName 的 ref,供下一步接管即时读取。 await drafts.handleLoadDraft(draftId); - setCurrentStep(2); // 覆盖 hydrate 可能切到的 furthestStep,停在第二步看流程 - jobFollowUnsubRef.current = streamJob(jobId, { - onProgress: (j) => { - step2.applyLiveJobFrame(mapJobToLiveStep2(j)); - }, - onDone: (j) => { - stopJobFollow(); - // 终态:作业已把最终 run + step3 写回草稿 → 重载草稿无缝衔接(含报告)。 - if (j.draftId) void drafts.handleLoadDraft(j.draftId); - void drafts.refresh(); - }, - onError: () => { - /* 断流静默:画面保留最后一帧;用户可重新点「查看」再续 */ - }, - }); + setCurrentStep(2); // 覆盖 hydrate 可能切到的 furthestStep,停在第二步继续研讨 + // 3. 关掉「看历史」只读闸门 → 后台挂起/暂停/干预按钮回归,回到前端可交互态。 + drafts.loadedFromDraftRef.current = false; + // 4. 前端接管:在作业线程上继续实时流式编排(无线程则回退为只读查看,不再续跑)。 + step2.resumeLiveTakeover(); + void drafts.refresh(); }, [stopJobFollow, step2, drafts, setCurrentStep], ); @@ -1306,13 +1297,13 @@ export default function RoundtablePlanningPage() { if (!o) setChainModalJobId(null); }} onView={(job) => { - // 「查看」(2026-06-13 恢复流程语义):done → 加载草稿(含 step3)看报告; - // **运行中/待澄清 → 草稿 hydrate 完成后 SSE 实时跟随**——第二步消息列表像 - // 本地编排一样实时长出新气泡/步骤卡,不再是静态聊天记录。 + // 「查看」(2026-06-13 接管语义):done → 加载草稿(含 step3)看报告; + // **运行中/待澄清 → 接管回前端**:取消后端作业、加载聊天记录、解除只读, + // 由前端在作业线程上继续实时流式编排(不再是只读跟随后台)。 setChainModalJobId(null); if (!job.draftId) return; if (job.status === "running" || job.status === "awaiting_input") { - void followRunningJob(job.id, job.draftId); + void takeOverRunningJob(job.id, job.draftId); return; } void drafts.handleLoadDraft(job.draftId); From cd3a71baab3b19e44e74be00ea4c44a3aeca17f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sat, 13 Jun 2026 20:42:26 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat(agents):=20=E5=86=85=E7=BD=AE=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E4=BD=93=E7=AD=9B=E9=80=89(=E4=BB=85=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98)+=20=E5=86=85=E7=BD=AE=20agent=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E7=94=A8=E9=80=94=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 scope=builtin(user_id IS NULL)加入 sql/memory 的 scope 过滤; /api/agents 扩展 scope 正则并加 admin 闸门(非管理员请求 403) - 新增 _builtin_agent_tag_seed:启动时幂等给功能型内置 agent 按用途打标签 (roundtable-* → 多智能体规划,ai-writing-* → AI写作),只追加不覆盖; 无用途的内置 agent 不打标签 - 前端 agent-gallery 新增「内置智能体」Tab(仅 system_role==admin 可见), scope 类型在 api.ts/hooks.ts 同步 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workspace/agents/agent-gallery.tsx | 43 +++++++- frontend-web/src/core/agents/api.ts | 5 +- frontend-web/src/core/agents/hooks.ts | 2 +- .../backend/app/gateway/app.py | 12 +++ .../routers/_builtin_agent_tag_seed.py | 97 +++++++++++++++++++ .../backend/app/gateway/routers/agents.py | 7 +- .../deerflow/persistence/agents/memory.py | 3 + .../deerflow/persistence/agents/sql.py | 3 + 8 files changed, 163 insertions(+), 9 deletions(-) create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_builtin_agent_tag_seed.py diff --git a/frontend-web/src/components/workspace/agents/agent-gallery.tsx b/frontend-web/src/components/workspace/agents/agent-gallery.tsx index f1c11ddb..fa1a21dc 100644 --- a/frontend-web/src/components/workspace/agents/agent-gallery.tsx +++ b/frontend-web/src/components/workspace/agents/agent-gallery.tsx @@ -12,6 +12,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ 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 { getStoredAuth } from "@/core/auth"; import { useAgentsPaginated, usePrivateAgentsPaginated } from "@/core/agents"; import type { Agent, AgentSort } from "@/core/agents"; import { useI18n } from "@/core/i18n/hooks"; @@ -29,6 +30,8 @@ const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password"; export function AgentGallery() { const { t } = useI18n(); const router = useRouter(); + // 「内置智能体」筛选仅管理员可见(后端 scope=builtin 也做了 admin 闸门兜底)。 + const isAdmin = getStoredAuth()?.system_role === "admin"; const [viewMode, setViewMode] = useViewMode("agent"); const [activeTab, setActiveTab] = useState("mine"); const [search, setSearch] = useState(""); @@ -58,15 +61,21 @@ export function AgentGallery() { // If the active square tab disappears (admin removed that square), fall back // to "我的" so the gallery never points at a dead tab. useEffect(() => { + // 非管理员落到「内置」标签(理论上点不到,兜底)→ 回到「我的」。 + if (activeTab === "builtin" && !isAdmin) { + setActiveTab("mine"); + return; + } if ( activeTab !== "mine" && activeTab !== "private" && + activeTab !== "builtin" && squareTabs.length > 0 && !squareTabs.some((sq) => sq.id === activeTab) ) { setActiveTab("mine"); } - }, [activeTab, squareTabs]); + }, [activeTab, squareTabs, isAdmin]); const { data: privateStatus } = usePrivateSquareStatus(); const privateFeatureAvailable = !!privateStatus?.enabled; @@ -80,9 +89,16 @@ 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 isSquareTab = activeTab !== "mine" && activeTab !== "private"; - const mainScope = activeTab === "mine" ? "mine" : "square"; + // (built-in/published, never private), "我的" to "mine" (owned + favorited), + // "内置" to "builtin" (admin-only, user_id IS NULL). + const isSquareTab = + activeTab !== "mine" && activeTab !== "private" && activeTab !== "builtin"; + const mainScope = + activeTab === "mine" + ? "mine" + : activeTab === "builtin" + ? "builtin" + : "square"; const mainPage = pages[activeTab] ?? 1; const { agents: mainAgents, @@ -393,6 +409,12 @@ export function AgentGallery() { 私密广场 )} + {isAdmin && ( + + + 内置智能体 + + )}
@@ -490,6 +512,19 @@ export function AgentGallery() { })} + {isAdmin && ( + + {renderListBody({ + list: mainAgents, + total: mainTotal, + isLoading: mainLoading, + isFetching: mainFetching, + tab: "builtin", + emptyDescription: "暂时没有内置智能体。", + })} + + )} + {privateFeatureAvailable && ( AsyncGenerator[None, None]: # re-run the heavy sync even though we just did it. _services_mark_sync_fresh() logger.info("Legacy folder-backed agents synced at startup") + # 内置功能型 agent(roundtable-* / ai-writing-*)按用途默认打标签, + # 供管理员「内置智能体」筛选按用途归类。幂等、只追加、失败不阻塞启动。 + try: + from app.gateway.routers._builtin_agent_tag_seed import ( + ensure_builtin_agent_purpose_tags, + ) + + tag_store = getattr(app.state, "tag_store", None) + if tag_store is not None: + await ensure_builtin_agent_purpose_tags(agent_store, tag_store) + except Exception: + logger.exception("Built-in agent purpose-tag seeding failed at startup (non-fatal)") except Exception: logger.exception("Legacy agent sync failed at startup (non-fatal)") diff --git a/offline-backend-20260512/backend/app/gateway/routers/_builtin_agent_tag_seed.py b/offline-backend-20260512/backend/app/gateway/routers/_builtin_agent_tag_seed.py new file mode 100644 index 00000000..58891b95 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/_builtin_agent_tag_seed.py @@ -0,0 +1,97 @@ +"""内置智能体「用途」默认标签的种子化。 + +把**功能型内置 agent**(roundtable-* / ai-writing-*)按用途默认打上标签,使管理员在 +「内置智能体」筛选里一眼能按用途归类。没有明确用途的内置 agent(各种测试/用户残留的 +user_id 为空的 agent)**不打标签**。 + +用途映射(可继续补充): +- **多智能体规划**:roundtable-intent(任务理解)/ roundtable-recommender(智能体推荐)/ + roundtable-coordinator(总控协调)/ roundtable-report / roundtable-summary / + roundtable-execution-plan / roundtable-risk-review / roundtable-solution-design +- **AI写作**:ai-writing-researcher / ai-writing-outliner / ai-writing-writer(作家)/ + ai-writing-editor + +幂等:标签缺失则建,assignment 缺失则补;**只做并集追加**,绝不删用户/管理员后加的 +其它标签。在启动 lifespan 里 `_sync_legacy_agents` 之后调用(那时 agent 行已入库)。 +""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + +# 标签按用途分组(tag_type 固定为 "agent",与 target_type 对齐)。id 用稳定 slug, +# 便于幂等;name 是展示名;color 走标签库的命名色。 +_PURPOSE_TAGS: dict[str, dict[str, str]] = { + "多智能体规划": {"id": "builtin-purpose-multi-agent-planning", "color": "blue"}, + "AI写作": {"id": "builtin-purpose-ai-writing", "color": "green"}, +} + +# 内置 agent id → 用途名(必须是 _PURPOSE_TAGS 的 key)。未列出的内置 agent 不打标签。 +_AGENT_PURPOSE: dict[str, str] = { + # 多智能体规划(圆桌全链路) + "roundtable-intent": "多智能体规划", + "roundtable-recommender": "多智能体规划", + "roundtable-coordinator": "多智能体规划", + "roundtable-report": "多智能体规划", + "roundtable-summary": "多智能体规划", + "roundtable-execution-plan": "多智能体规划", + "roundtable-risk-review": "多智能体规划", + "roundtable-solution-design": "多智能体规划", + # AI 写作(杂志社协作写作四阶段) + "ai-writing-researcher": "AI写作", + "ai-writing-outliner": "AI写作", + "ai-writing-writer": "AI写作", + "ai-writing-editor": "AI写作", +} + +_TARGET_TYPE = "agent" + + +async def ensure_builtin_agent_purpose_tags(agent_store, tag_store) -> int: + """确保内置功能型 agent 带上默认「用途」标签。返回新增 assignment 数。 + + 错误只记录不抛——种子化失败绝不能阻塞服务启动。 + """ + try: + # 1) 确保用途标签存在,建立 name -> id 映射。 + existing = {t.get("name"): t for t in await tag_store.list_tags(_TARGET_TYPE)} + name_to_id: dict[str, str] = {} + for name, meta in _PURPOSE_TAGS.items(): + found = existing.get(name) + if found is not None: + name_to_id[name] = found["id"] + continue + try: + created = await tag_store.create_tag( + {"id": meta["id"], "name": name, "tag_type": _TARGET_TYPE, "color": meta.get("color")} + ) + name_to_id[name] = created["id"] + except ValueError: + # 并发/已存在(按 name 唯一)——重查一次取其 id。 + again = {t.get("name"): t for t in await tag_store.list_tags(_TARGET_TYPE)} + if name in again: + name_to_id[name] = again[name]["id"] + + # 2) 给映射里**实际存在**的内置 agent 追加用途标签(并集,不覆盖已有标签)。 + added = 0 + for agent_id, purpose in _AGENT_PURPOSE.items(): + tag_id = name_to_id.get(purpose) + if not tag_id: + continue + agent = await agent_store.get_any(agent_id) + if agent is None: + continue # 该部署未装此内置 agent,跳过 + current = await tag_store.get_target_tags(_TARGET_TYPE, agent_id) + cur_ids = [t["id"] for t in current] + if tag_id in cur_ids: + continue # 已打过该用途标签 + await tag_store.set_target_tags(_TARGET_TYPE, agent_id, [*cur_ids, tag_id]) + added += 1 + if added: + logger.info("Seeded %d built-in agent purpose tag assignment(s)", added) + return added + except Exception: # noqa: BLE001 + logger.exception("Failed to seed built-in agent purpose tags") + return 0 diff --git a/offline-backend-20260512/backend/app/gateway/routers/agents.py b/offline-backend-20260512/backend/app/gateway/routers/agents.py index f84bd5da..40e9764a 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/agents.py +++ b/offline-backend-20260512/backend/app/gateway/routers/agents.py @@ -587,8 +587,8 @@ async def list_agents( tag_ids: list[str] | None = Query(default=None, description="Keep agents carrying any of these tags (OR)"), scope: str | None = Query( default=None, - pattern="^(mine|square)$", - description="Pagination scope. 'mine' = owned + favorited; 'square' = built-in/published public. Omit for the default combined visibility list.", + pattern="^(mine|square|builtin)$", + description="Pagination scope. 'mine' = owned + favorited; 'square' = built-in/published public; 'builtin' = 内置智能体 (user_id IS NULL, **admin only**). Omit for the default combined visibility list.", ), page: int | None = Query(default=None, ge=1, description="1-based page index. Providing this (or 'scope') enables server-side pagination."), page_size: int = Query(default=20, ge=1, le=200, description="Items per page when paginating."), @@ -623,6 +623,9 @@ async def list_agents( user_id = _current_user_id(request) if admin and not _is_admin_user(request): raise HTTPException(status_code=403, detail="Only system admins can list all agents") + # 「内置智能体」筛选仅限管理员(普通用户不应看到全部内置/功能型 agent 目录)。 + if scope == "builtin" and not _is_admin_user(request): + raise HTTPException(status_code=403, detail="Only system admins can list built-in agents") paginate = scope is not None or page is not None square_id = square.strip() if square and square.strip() else None diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py index 51003e8b..ecabf667 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/memory.py @@ -195,6 +195,9 @@ class MemoryAgentStore(AgentStore): return owned or favorited if scope == "private": return bool(agent.get("is_private")) + if scope == "builtin": + # 内置智能体 = user_id IS NULL(仅管理员;路由层 admin 闸门)。 + return agent.get("user_id") is None return self._is_visible(agent, user_id) async def list_paginated( diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py index 2e662401..4eeddb1f 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/agents/sql.py @@ -275,6 +275,9 @@ class AgentRepository(AgentStore): return or_(AgentRow.user_id == user_id, AgentRow.id.in_(fav_subq)) if scope == "private": return AgentRow.is_private.is_(True) + if scope == "builtin": + # 内置智能体 = user_id IS NULL。仅管理员可用此 scope(路由层已 admin 闸门)。 + return AgentRow.user_id.is_(None) return self._listing_filter(user_id) @staticmethod From 30d4f9667b394c702183c7ceaf5b167787397aa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sat, 13 Jun 2026 20:43:18 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E6=A0=B7=E5=BC=8F=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/LightAppManagementPage.tsx | 464 +++++++++++++----- 1 file changed, 343 insertions(+), 121 deletions(-) diff --git a/frontend-web/src/pages/LightAppManagementPage.tsx b/frontend-web/src/pages/LightAppManagementPage.tsx index 21a805b7..1db97376 100644 --- a/frontend-web/src/pages/LightAppManagementPage.tsx +++ b/frontend-web/src/pages/LightAppManagementPage.tsx @@ -1,12 +1,23 @@ import { useMemo, useState } from "react"; import { + AlertCircle, + AlignLeft, + ChevronDown, + ChevronUp, + Copy, ExternalLink, + FolderTree, + Globe, HelpCircle, Info, Layers, + Link2, + Monitor, Pencil, Plus, Search, + Sliders, + Tag, Trash2, X, } from "lucide-react"; @@ -98,6 +109,7 @@ export default function LightAppManagementPage() { const [editing, setEditing] = useState(null); const [form, setForm] = useState(emptyForm); const [activeParamPopover, setActiveParamPopover] = useState(null); + const [viewMode, setViewMode] = useState<"table" | "cards">("table"); const saving = actions.create.isPending || actions.update.isPending; @@ -180,109 +192,164 @@ export default function LightAppManagementPage() { } }; + const handleCopyUrl = (url: string) => { + navigator.clipboard?.writeText(url); + toast.success("已复制地址"); + }; + const tableApps = useMemo(() => apps, [apps]); return ( -
-
-
-
-

应用管理

-

- 注册轻应用。window.open 类型在「应用列表」以卡片展示;iframe 类型挂载到所选菜单下并注入侧栏。 +

+
+
+
+

+ 应用管理 +

+

+ 注册轻应用。 + + window.open + + 类型在「应用列表」以卡片展示; + + iframe + + 类型挂载到所选菜单下并注入侧栏。

-
- - setSearch(e.target.value)} - placeholder="搜索应用名称 / 描述" - className="h-9 w-full rounded-[2px] border border-[#E8E8E8] bg-white pl-10 pr-4 text-[14px] text-[#222222] placeholder-[#C1C1C1] shadow-sm transition-all focus:border-[#F5222D] focus:outline-none focus:ring-1 focus:ring-[#F5222D]" - /> - {search && ( +
+
+ + setSearch(e.target.value)} + placeholder="搜索应用名称 / 描述..." + className="w-full rounded-lg border border-neutral-100 bg-neutral-50/60 py-2 pl-10 pr-10 text-xs text-neutral-800 placeholder-neutral-400 transition-all hover:bg-white focus:border-[#F5222D]/40 focus:outline-none focus:ring-4 focus:ring-[#F5222D]/10" + /> + {search && ( + + )} +
+ +
- )} + +
-
- - - - - - - - - - - - +
+
+ 搜寻到 {tableApps.length}{" "} + 个注册完成的子模块轻应用 +
+ + {viewMode === "table" ? ( +
+
名称类型地址 / 挂载参数启用操作
+ + + + + + + + + + + + {tableApps.map((app) => ( - + + + - @@ -375,17 +451,17 @@ export default function LightAppManagementPage() { {tableApps.length === 0 && ( -
名称类型地址挂载参数启用操作
-
- - {app.name} - - {app.description ? ( - {app.description} - ) : ( - 无描述说明 - )} -
+ + {app.name} +
{app.appType === "window_open" ? ( - - + + 新页跳转 ) : ( - - + + iframe 嵌套 )} -
+
- {app.url} + {app.url} - {app.appType === "iframe" && app.mountLocation && ( -
- - 挂载: {mountLabel(app.mountLocation)} -
- )} +
+ {app.appType === "iframe" && app.mountLocation ? ( + + + {mountLabel(app.mountLocation)} + + ) : ( + — + )} + -
+
{app.routeParams.length > 0 ? ( ) : ( - 0个 + 0个 )} {activeParamPopover === app.appId && app.routeParams.length > 0 && ( @@ -307,23 +376,30 @@ export default function LightAppManagementPage() { className="fixed inset-0 z-40" onClick={() => setActiveParamPopover(null)} /> -
-
- 路由参数明细 - - URL 拼接 +
+
+ + 注入的路由参数 +
-
+
{app.routeParams.map((p, idx) => (
- {p.key} - - ({getParamSourceLabel(p)}) - +
+ {p.key} + + {getParamSourceLabel(p)} + +
))}
@@ -337,36 +413,36 @@ export default function LightAppManagementPage() {
{app.enabled ? ( <> - - - + + + - 已启用 + 已启用 ) : ( <> - - 已禁用 + + 已停用 )}
-
+
+
+
-
+
-

+

{isLoading ? "加载中..." : "暂无匹配的组件应用"}

{!isLoading && ( -

+

{search ? "请确认搜索关键词输入是否正确" : "点击右上角“注册应用”添加第一款轻应用"}

)} @@ -396,25 +472,152 @@ export default function LightAppManagementPage() { )}
+
+ ) : tableApps.length === 0 ? ( +
+
+ +
+

+ {isLoading ? "加载中..." : "暂无匹配的组件应用"} +

+ {!isLoading && ( +

+ {search ? "请确认搜索关键词输入是否正确" : "点击右上角“注册应用”添加第一款轻应用"} +

+ )} +
+ ) : ( +
+ {tableApps.map((app) => ( +
+
+
+
+
+ {app.appType === "window_open" ? ( + + + 新页跳转 + + ) : ( + + + iframe 嵌套 + + )} +

+ {app.name} +

+
+ {app.enabled ? ( + + + 已启用 + + ) : ( + + + 已停用 + + )} +
+ +

+ {app.description || "暂无应用详细描述说明。"} +

+ +
+ + + {app.url} + + +
+ + {app.routeParams.length > 0 && ( +
+ {app.routeParams.map((p, idx) => ( + + {p.key} + + ))} +
+ )} + + {app.appType === "iframe" && app.mountLocation && ( +
+ 挂载分类: + {mountLabel(app.mountLocation)} +
+ )} +
+ +
+ + +
+
+ ))} +
+ )}
-
- -
-

📌 智能多维挂载规则:

-
    -
  • - 当您注册 iframe 嵌套{" "} - 应用时,您需要在菜单一栏指定分类(如“资源管理”),组件将直接作为新导航菜单项加入该分类。 + {viewMode === "table" && ( +
    +
    + +

    智能多维挂载规则:

    +
    +
      +
    • + • + + 当您注册 iframe 嵌套{" "} + 应用时,您需要在菜单一栏指定分类(如“资源管理”),组件将直接作为新导航菜单项加入该分类。 +
    • -
    • - 当路由挂载成功,点击左侧注入的子栏名称可在主区域调起{" "} - 真实 Iframe 预览器 - ,并自动将参数以加密/标准的QueryString传递过去。 +
    • + • + + 当路由挂载成功,点击左侧注入的子栏名称可在主区域调起{" "} + 真实 Iframe 预览器 + ,并自动将参数以加密/标准的QueryString传递过去。 +
    -
+ )}
{dialogOpen && ( @@ -426,9 +629,14 @@ export default function LightAppManagementPage() {
-

- {editing ? "修改轻应用" : "注册应用"} -

+
+ + + +

+ {editing ? "修改轻应用" : "注册应用"} +

+