From 865a0bfaf62762cc130bfd4ceff55a41c383f05c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 12 Jun 2026 20:27:34 +0800 Subject: [PATCH 01/12] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hooks/useStep2Orchestration.ts | 19 ++++++++++++------- .../pages/RoundtablePlanningPage.tsx | 7 +++++++ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts index 72089437..032fa03d 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts @@ -1201,8 +1201,9 @@ export function useStep2Orchestration( if (speakerId !== null && item.id === speakerId) { return { ...item, status: "研讨中" }; } + // 发言结束(不再研讨中)的席位标记为「已完成」,而非回退到「在线」。 if (item.status === "研讨中") { - return { ...item, status: "在线" }; + return { ...item, status: "已完成" }; } return item; }), @@ -1215,7 +1216,7 @@ export function useStep2Orchestration( (speakerId: number) => { // 编排循环已经改成**顺序**派 special(详见 §5.4 / 文件顶部注释), // 任意时刻只有一个子智能体在跑。因此这里既要把当前发言席位置为"研讨中", - // 又要把上一轮 / 历史残留下来的"研讨中"统一清成"在线",保证右侧"参与角色" + // 又要把上一轮 / 历史残留下来的"研讨中"统一标记成"已完成",保证右侧"参与角色" // 面板任何时刻**只有一个** sub agent 显示研讨中,与对话区实际在 stream 的 // 那一个一致。activeSpeakerSet 沿用 Set 形态(虽然实际只放 1 个)以兼容外部 // 消费方,真正的"高亮排他"靠 roles 的 status reset。 @@ -1223,7 +1224,8 @@ export function useStep2Orchestration( setRoles((prev) => prev.map((item) => { if (item.id === speakerId) return { ...item, status: "研讨中" }; - if (item.status === "研讨中") return { ...item, status: "在线" }; + // 上一轮发言已结束的席位标记为「已完成」,而非回退到「在线」。 + if (item.status === "研讨中") return { ...item, status: "已完成" }; return item; }), ); @@ -1241,8 +1243,9 @@ export function useStep2Orchestration( }); setRoles((prev) => prev.map((item) => + // 该席位本轮输出完成 —— 标记为「已完成」(带勾选样式),而非回退到「在线」。 item.id === speakerId && item.status === "研讨中" - ? { ...item, status: "在线" } + ? { ...item, status: "已完成" } : item, ), ); @@ -1254,14 +1257,15 @@ export function useStep2Orchestration( setActiveSpeakerSet(new Set()); setRoles((prev) => prev.map((item) => - item.status === "研讨中" ? { ...item, status: "在线" } : item, + // 研讨收尾:仍在研讨中的席位置为「已完成」,而非回退到「在线」。 + item.status === "研讨中" ? { ...item, status: "已完成" } : item, ), ); }, [setRoles]); /** * DAG 并行批次:把**一整批**席位同时标为「研讨中」(与 markSpeakerStreaming - * 的"排他单发"不同 —— 并行就是要多个 sub 同时高亮)。其余历史残留的"研讨中"清成"在线"。 + * 的"排他单发"不同 —— 并行就是要多个 sub 同时高亮)。其余历史残留的"研讨中"标记成"已完成"。 * activeSpeakerSet 天然是 Set,这里放整批 speakerId。 */ const markParallelSpeakers = useCallback( @@ -1271,7 +1275,8 @@ export function useStep2Orchestration( setRoles((prev) => prev.map((item) => { if (wanted.has(item.id)) return { ...item, status: "研讨中" }; - if (item.status === "研讨中") return { ...item, status: "在线" }; + // 上一并行批次发言已结束的席位标记为「已完成」,而非回退到「在线」。 + if (item.status === "研讨中") return { ...item, status: "已完成" }; return item; }), ); diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index bcf6cd2b..c382ceba 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -2422,6 +2422,7 @@ function RoleAvatarBadge({ onSelect?: () => void; }) { const isOngoing = role.status === "研讨中"; + const isDone = role.status === "已完成"; const animate = showSpeakingAnimation && isOngoing; const clickable = Boolean(onSelect); @@ -2439,6 +2440,12 @@ function RoleAvatarBadge({
{renderRoleAvatar(role, agentNameToRole, size)}
+ {/* 收起态:输出完成的席位在头像右下角叠加「已完成」勾选角标。 */} + {isDone && ( + + + + )} ); From f0c85c4795182fc1d069d351492191b964c597dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 12 Jun 2026 21:38:20 +0800 Subject: [PATCH 02/12] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E6=8C=82=E8=B5=B7?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/Step3Panel.tsx | 5 +- .../roundtable-planning/lib/reasoning.test.ts | 61 +++++++++++++++++++ .../src/roundtable-planning/lib/reasoning.ts | 41 ++++++++++--- .../lib/roundtable-deliverables.test.ts | 12 ++++ .../lib/roundtable-deliverables.ts | 4 +- .../gateway/roundtable_inprocess_gateway.py | 6 +- .../backend/app/gateway/services.py | 6 +- .../deerflow/runtime/scheduler/service.py | 4 +- 8 files changed, 126 insertions(+), 13 deletions(-) create mode 100644 frontend-web/src/roundtable-planning/lib/reasoning.test.ts diff --git a/frontend-web/src/roundtable-planning/components/Step3Panel.tsx b/frontend-web/src/roundtable-planning/components/Step3Panel.tsx index d3e94e98..293fe2a1 100644 --- a/frontend-web/src/roundtable-planning/components/Step3Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step3Panel.tsx @@ -16,6 +16,7 @@ import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMo import type { Step2Dialogue } from "@/roundtable-planning/hooks/useStep2Orchestration"; import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report"; import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent"; +import { stripReasoning } from "@/roundtable-planning/lib/reasoning"; export interface Step3PanelProps { threadIds: ThreadIdMap | null; @@ -76,7 +77,9 @@ export function Step3Panel(props: Step3PanelProps) { .map((d) => ({ sender: String(d.sender ?? ""), avatarType: String(d.avatarType ?? "blue"), - content: String(d.content ?? ""), + // 交付内容只保留模型生成的主体输出,剥离 `...` 推理过程 + // —— 既用于渲染各席位交付卡片,也会喂给总结/报告模型,避免推理噪声污染。 + content: stripReasoning(String(d.content ?? "")), })); const seatNameMap = Object.fromEntries( diff --git a/frontend-web/src/roundtable-planning/lib/reasoning.test.ts b/frontend-web/src/roundtable-planning/lib/reasoning.test.ts new file mode 100644 index 00000000..454865f1 --- /dev/null +++ b/frontend-web/src/roundtable-planning/lib/reasoning.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { splitInlineReasoning, stripReasoning } from "./reasoning"; + +test("空输入返回空正文与 null 推理", () => { + assert.deepEqual(splitInlineReasoning(""), { content: "", reasoning: null }); +}); + +test("无 think 标签时原样返回正文", () => { + assert.deepEqual(splitInlineReasoning("纯正文,无推理"), { + content: "纯正文,无推理", + reasoning: null, + }); +}); + +test("形态 1:完整 … 提取推理,保留正文", () => { + const { content, reasoning } = splitInlineReasoning("先想一下这是答案"); + assert.equal(content, "这是答案"); + assert.equal(reasoning, "先想一下"); +}); + +test("形态 2:多段 think 合并推理", () => { + const { content, reasoning } = splitInlineReasoning( + "第一段正文A第二段正文B", + ); + assert.equal(content, "正文A正文B"); + assert.equal(reasoning, "第一段\n\n第二段"); +}); + +test("形态 3:流式未闭合 尾段算推理", () => { + const { content, reasoning } = splitInlineReasoning("正文还在想…"); + assert.equal(content, "正文"); + assert.equal(reasoning, "还在想…"); +}); + +test("形态 4:模板注入式只吐闭合 ,前导内容算推理", () => { + const { content, reasoning } = splitInlineReasoning( + "我需要分析一下需求这是最终方案", + ); + assert.equal(content, "这是最终方案"); + assert.equal(reasoning, "我需要分析一下需求"); +}); + +test("形态 4 流式:尚未吐出 时全部当正文(后续闭合再归位)", () => { + // 未出现任何标签 → 无从区分,先按正文渲染。 + const { content, reasoning } = splitInlineReasoning("正在推理中,还没收到闭合标签"); + assert.equal(content, "正在推理中,还没收到闭合标签"); + assert.equal(reasoning, null); +}); + +test("安全兜底:正文里绝不残留任何 think 标签字面量", () => { + const { content } = splitInlineReasoning("答案前答案中"); + assert.ok(!content.includes("")); + assert.ok(!content.includes("")); +}); + +test("stripReasoning 只返回正文", () => { + assert.equal(stripReasoning("推理交付正文"), "交付正文"); + assert.equal(stripReasoning("前导推理交付正文"), "交付正文"); +}); diff --git a/frontend-web/src/roundtable-planning/lib/reasoning.ts b/frontend-web/src/roundtable-planning/lib/reasoning.ts index 2567e51d..ecfbe5c1 100644 --- a/frontend-web/src/roundtable-planning/lib/reasoning.ts +++ b/frontend-web/src/roundtable-planning/lib/reasoning.ts @@ -1,15 +1,18 @@ /** * 把模型流式文本里的 `...` 块拆出来,作为「思考过程」单独展示。 * - * 兼容三种形态: + * 兼容四种形态: * 1) **完整** `foobar` —— 提取 `foo` 作 reasoning,正文为 `bar` * 2) **多段** `axby` —— reasoning 合并为 `a\n\nb`,正文为 `xy` * 3) **流式未闭合** `barpartial...` —— reasoning 为 `partial...`(尾段),正文为 `bar` + * 4) **模板注入式(只吐闭合)** `foobar` —— 开标签 `` 被 chat template + * 注入到 prompt 里、模型只生成闭合标签(DeepSeek-R1 / QwQ 等推理模型的典型行为)。 + * 此时首个 `` 之前没有配对的 ``,把它之前的内容整体算 reasoning,正文为 `bar`。 * * 实现思路与主聊天 `src/core/messages/utils.ts::splitInlineReasoning` 对齐,但 - * 这里**额外处理了未闭合的 ``** —— 流式渲染时如果模型还没吐出 ``, - * 主聊天那份实现会把 `` 字面量留在正文里,我们用 lastIndexOf 兜底,把尾段 - * 也算作 reasoning。 + * 这里**额外处理了未闭合的 ``(形态 3)和只吐闭合的 ``(形态 4)** —— + * 主聊天靠后端 `additional_kwargs.reasoning_content` 单列推理,而圆桌多智能体流式只取 + * `content` 文本,推理标签会内联在正文里,故这里需要把这两种内联形态都兜住。 * * Inline `foo` 在文本中间出现(无尾闭合)被认为是流式未闭合的开头: * 假设之后会闭合,把它之后所有内容算 reasoning。这与 DeepSeek-R1 / QwQ 等 @@ -18,6 +21,9 @@ const THINK_TAG_RE = /\s*([\s\S]*?)\s*<\/think>/g; const OPEN_TAG = ""; +const CLOSE_TAG = ""; +// 兜底:正文里绝不应残留任何 think 标签字面量,清掉任何孤立的 `` / ``。 +const ANY_THINK_TAG_RE = /<\/?think>/gi; export interface SplitReasoningResult { /** 正文(去掉所有 `...` 块以及尾段未闭合 `` 之后内容)。 */ @@ -30,14 +36,27 @@ export function splitInlineReasoning(text: string): SplitReasoningResult { if (!text) return { content: "", reasoning: null }; const reasoningParts: string[] = []; - // 先处理所有完整 `...` 闭合块。 - const closed = text.replace(THINK_TAG_RE, (_, reasoning: string) => { + + // 形态 4(模板注入式,只吐闭合):首个 `` 出现在任何 `` 之前(或根本 + // 没有 ``),说明开标签被 chat template 注入、模型只生成了闭合标签。把第一个 + // `` 之前的内容整体算作 reasoning,正文从闭合标签之后继续解析。 + let working = text; + const firstClose = working.indexOf(CLOSE_TAG); + const firstOpen = working.indexOf(OPEN_TAG); + if (firstClose >= 0 && (firstOpen < 0 || firstClose < firstOpen)) { + const lead = working.slice(0, firstClose).trim(); + if (lead) reasoningParts.push(lead); + working = working.slice(firstClose + CLOSE_TAG.length); + } + + // 形态 1/2:处理所有完整 `...` 闭合块。 + const closed = working.replace(THINK_TAG_RE, (_, reasoning: string) => { const normalized = reasoning.trim(); if (normalized) reasoningParts.push(normalized); return ""; }); - // 处理尾段未闭合 `` —— 流式情况。 + // 形态 3:尾段未闭合 `` —— 流式情况。 // 如果 closed 里还残留 `` 字面量,说明这一段还没收到 ``, // 把它之后的所有内容视为 reasoning-in-progress,正文截到 `` 之前。 let finalContent = closed; @@ -48,8 +67,16 @@ export function splitInlineReasoning(text: string): SplitReasoningResult { finalContent = closed.slice(0, openIdx); } + // 安全兜底:无论上面是否完全配对,正文里都不该再出现 think 标签字面量。 + finalContent = finalContent.replace(ANY_THINK_TAG_RE, ""); + return { content: finalContent.trim(), reasoning: reasoningParts.length > 0 ? reasoningParts.join("\n\n").trim() : null, }; } + +/** 只要正文、丢弃推理过程的便捷封装(用于「交付内容」类只需主体输出的场景)。 */ +export function stripReasoning(text: string): string { + return splitInlineReasoning(text).content; +} diff --git a/frontend-web/src/roundtable-planning/lib/roundtable-deliverables.test.ts b/frontend-web/src/roundtable-planning/lib/roundtable-deliverables.test.ts index b8beb631..da613cc5 100644 --- a/frontend-web/src/roundtable-planning/lib/roundtable-deliverables.test.ts +++ b/frontend-web/src/roundtable-planning/lib/roundtable-deliverables.test.ts @@ -79,3 +79,15 @@ test("coordinatorName 为 null 时不剔除任何席位", () => { const result = collectRoundtableDeliverables(dialogues, null); assert.equal(result.length, 2); }); + +test("交付正文剥离 … 推理过程", () => { + const dialogues = [ + bubble({ sender: "方案设计", content: "盘算一下方案正文" }), + bubble({ sender: "风险审查", content: "前导推理风险正文" }), + ]; + const result = collectRoundtableDeliverables(dialogues, null); + assert.deepEqual(result, [ + { agentName: "方案设计", content: "方案正文", threadId: null }, + { agentName: "风险审查", content: "风险正文", threadId: null }, + ]); +}); diff --git a/frontend-web/src/roundtable-planning/lib/roundtable-deliverables.ts b/frontend-web/src/roundtable-planning/lib/roundtable-deliverables.ts index 5219e7ec..8878fae9 100644 --- a/frontend-web/src/roundtable-planning/lib/roundtable-deliverables.ts +++ b/frontend-web/src/roundtable-planning/lib/roundtable-deliverables.ts @@ -1,4 +1,5 @@ import type { Step2Dialogue } from "../hooks/useStep2Orchestration"; +import { stripReasoning } from "./reasoning"; /** * 回传父页面(无界)的单个智能体交付。 @@ -29,7 +30,8 @@ export function collectRoundtableDeliverables( } return Array.from(lastBySeat.values()).map((d) => ({ agentName: d.sender, - content: d.content, + // 回传宿主的交付正文剥离 `...` 推理过程,只留主体输出。 + content: stripReasoning(d.content), threadId: d.agentThreadId ?? null, })); } diff --git a/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py b/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py index a9502170..b8cd96d6 100644 --- a/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py +++ b/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py @@ -3,7 +3,7 @@ 实现 ``RoundtableGateway`` 协议(定义在 harness 的 roundtable_orchestrator.types), 但**进程内**跑真模型:复用 ``services.start_run``(scheduled task 同款)驱动一轮 agent, 跑完从 checkpointer 读最终 messages,用 ``multi_agent`` 的纯解析函数解析派活 / 澄清。 -不走 HTTP、不需要 Request(用 ``SimpleNamespace(app=app)`` 当虚拟 request, +不走 HTTP、不需要 Request(用 ``SimpleNamespace(app=app, state=...)`` 当虚拟 request, ``set_current_user`` 由执行器设好)。 本网关位于 app 层(依赖 app.state + multi_agent helpers),实现 harness 定义的协议—— @@ -192,7 +192,9 @@ async def _run_agent_turn( on_disconnect="continue", multitask_strategy="reject", ) - request = SimpleNamespace(app=app) + # 虚拟 request:带上空 ``state``(auth 中间件在真请求上会写 state.user / state.auth; + # 进程内没有这层,下游凡是读 request.state.X 的代码必须能拿到 None 而不是 AttributeError)。 + request = SimpleNamespace(app=app, state=SimpleNamespace(user=None, auth=None)) record = await start_run(body, thread_id, request) # type: ignore[arg-type] if getattr(record, "task", None) is not None: await record.task # 阻塞到这轮 agent 跑完,checkpoint 已落盘 diff --git a/offline-backend-20260512/backend/app/gateway/services.py b/offline-backend-20260512/backend/app/gateway/services.py index 359dba8b..12c8781b 100644 --- a/offline-backend-20260512/backend/app/gateway/services.py +++ b/offline-backend-20260512/backend/app/gateway/services.py @@ -493,7 +493,11 @@ async def _enforce_agent_access(body: Any, request: Request) -> None: user_id = get_effective_user_id() # Admins see every agent in the admin console listing (?admin=true), so # they may also start a run against any of them. - is_admin = getattr(getattr(request.state, "user", None), "system_role", None) == "admin" + # ``request`` may be an in-process virtual request (SimpleNamespace) from + # the roundtable job gateway / scheduled-task runtime — those carry no + # ``state`` attribute, so the whole chain must be getattr-guarded + # (a bare ``request.state`` raises AttributeError and kills the run). + is_admin = getattr(getattr(getattr(request, "state", None), "user", None), "system_role", None) == "admin" for agent_id in agent_ids: try: normalized = validate_agent_id(agent_id) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/service.py b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/service.py index aef85a5c..fdb6fc59 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/service.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/service.py @@ -1408,7 +1408,9 @@ class ScheduledTaskService: token = set_current_user(_RuntimeUser(id=task["user_id"])) try: - request = SimpleNamespace(app=self.app) + # 虚拟 request:带空 state(真请求由 auth 中间件写 state.user/state.auth; + # 进程内调用没有这层,下游读 request.state.X 必须拿到 None 而非 AttributeError)。 + request = SimpleNamespace(app=self.app, state=SimpleNamespace(user=None, auth=None)) execution_context = dict(task.get("execution_context") or {}) stored_agent_ref = task.get("execution_agent_name") if stored_agent_ref: From 00d6bd85955d3b13b9c26341bc59c697fdf707b4 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Fri, 12 Jun 2026 21:38:38 +0800 Subject: [PATCH 03/12] =?UTF-8?q?=E4=B8=BB=E9=A2=98=E8=89=B2=E4=BF=AE?= =?UTF-8?q?=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-web/src/pages/LightAppIframeView.tsx | 3 +- .../src/pages/LightAppManagementPage.tsx | 143 +++++++++++------- .../src/strategy-components/api/light-apps.ts | 4 +- .../src/strategy-components/lib/light-app.ts | 68 ++++++++- 4 files changed, 157 insertions(+), 61 deletions(-) diff --git a/frontend-web/src/pages/LightAppIframeView.tsx b/frontend-web/src/pages/LightAppIframeView.tsx index 02bd5cbb..c4ce4237 100644 --- a/frontend-web/src/pages/LightAppIframeView.tsx +++ b/frontend-web/src/pages/LightAppIframeView.tsx @@ -17,7 +17,8 @@ export default function LightAppIframeView() { const isDark = theme === "dark"; const { data: app, isLoading, error } = useLightApp(appId); - const src = useMemo(() => (app ? buildLightAppUrl(app) : ""), [app]); + // 依赖 theme:切换主题色时重建 src,把对应主题色重新传给被嵌套应用。 + const src = useMemo(() => (app ? buildLightAppUrl(app) : ""), [app, theme]); const centerClass = `flex h-full w-full items-center justify-center text-sm ${ isDark ? "text-slate-400" : "text-slate-500" diff --git a/frontend-web/src/pages/LightAppManagementPage.tsx b/frontend-web/src/pages/LightAppManagementPage.tsx index 1b54131a..bab529e4 100644 --- a/frontend-web/src/pages/LightAppManagementPage.tsx +++ b/frontend-web/src/pages/LightAppManagementPage.tsx @@ -23,9 +23,12 @@ import { type LightAppType, } from "@/strategy-components/api/light-apps"; import { + APP_THEME_COLORS, LOGIN_SOURCE_OPTIONS, MOUNT_OPTIONS, mountLabel, + parseThemeMap, + stringifyThemeMap, } from "@/strategy-components/lib/light-app"; interface FormState { @@ -53,7 +56,7 @@ const newParam = (): LightAppRouteParam => ({ paramType: "login", source: "accessToken", customValue: "", - themeValue: "dark-blue", + themeValue: "red=red;blue=blue;dark-blue=dark-blue", }); function formFromApp(app: LightApp): FormState { @@ -69,7 +72,7 @@ function formFromApp(app: LightApp): FormState { paramType: p.paramType, source: p.source ?? "accessToken", customValue: p.customValue ?? "", - themeValue: p.themeValue ?? "dark-blue", + themeValue: p.themeValue ?? "", })), }; } @@ -512,10 +515,12 @@ export default function LightAppManagementPage() {
路由参数 - + > + +
- + {/* 主题参数:逐主题色配置传给目标应用的值(左=系统主题色,右=目标应用值,可改) */} + {p.paramType === "theme" && ( +
+

+ 各系统主题色跳转/嵌套时传给该应用的值(默认同名,可按对方命名修改) +

+ {APP_THEME_COLORS.map((c) => ( +
+ + + {c.label} + + + {c.key} = + + + patchParam(idx, { + themeValue: stringifyThemeMap({ + ...themeMap, + [c.key]: e.target.value, + }), + }) + } + placeholder={c.key} + className="h-7 flex-1 rounded-[2px] border border-[#DBDBDB] bg-white px-2 font-mono text-[12px] focus:border-[#F5222D] focus:outline-none focus:ring-px focus:ring-[#F5222D]" + /> +
+ ))} +
+ )} - ))} + ); + })} ) : (
@@ -669,6 +707,7 @@ function cleanParams(params: LightAppRouteParam[]): LightAppRouteParam[] { base.customValue = (p.customValue ?? "").trim(); } } else { + // theme:存映射串(或纯值),运行时按当前主题解析取值 base.themeValue = (p.themeValue ?? "").trim(); } return base; diff --git a/frontend-web/src/strategy-components/api/light-apps.ts b/frontend-web/src/strategy-components/api/light-apps.ts index 4fa88ec9..9f476e7a 100644 --- a/frontend-web/src/strategy-components/api/light-apps.ts +++ b/frontend-web/src/strategy-components/api/light-apps.ts @@ -20,7 +20,7 @@ import { getBackendBaseURL } from "@/core/config"; export type LightAppType = "window_open" | "iframe"; export type RouteParamType = "login" | "theme"; -/** 路由参数项。登录参数运行时从 localStorage.userInfo 解析;主题参数用 themeValue。 */ +/** 路由参数项。登录参数运行时从 localStorage.userInfo 解析;主题参数随当前系统主题自动注入。 */ export interface LightAppRouteParam { key: string; paramType: RouteParamType; @@ -28,7 +28,7 @@ export interface LightAppRouteParam { source?: string | null; /** 登录参数且 source==="other" 时的字面值。 */ customValue?: string | null; - /** 主题参数的值(dark-blue / red / blue,可改)。 */ + /** 主题参数配置:映射串 `light=red;dark=darkBlue`(或纯值);运行时按当前系统主题取对应值(见 resolveThemeParamValue)。 */ themeValue?: string | null; } diff --git a/frontend-web/src/strategy-components/lib/light-app.ts b/frontend-web/src/strategy-components/lib/light-app.ts index b2066f47..5b8e66ac 100644 --- a/frontend-web/src/strategy-components/lib/light-app.ts +++ b/frontend-web/src/strategy-components/lib/light-app.ts @@ -27,13 +27,68 @@ export const LOGIN_SOURCE_OPTIONS: { label: string; value: string }[] = [ { label: "其他(自定义值)", value: "other" }, ]; -/** 主题参数示例值(深蓝 / 红 / 蓝),可改。 */ -export const THEME_EXAMPLE_OPTIONS: { label: string; value: string }[] = [ - { label: "深蓝色 dark-blue", value: "dark-blue" }, - { label: "红色 red", value: "red" }, - { label: "蓝色 blue", value: "blue" }, +/** + * 我方主题色键。当前启用 red(浅色) / dark-blue(深色);blue 为后续将新增的主题。 + * 主题参数的映射以这些键为左侧 key(如 `dark-blue=darkBlue`)。 + */ +export type AppThemeColor = "red" | "blue" | "dark-blue"; + +/** 我方全部主题色(「应用管理」主题参数逐项配置目标值时遍历;dot 为配色小圆点)。 */ +export const APP_THEME_COLORS: { key: AppThemeColor; label: string; dot: string }[] = [ + { key: "red", label: "红色", dot: "#F5222D" }, + { key: "blue", label: "蓝色", dot: "#1677FF" }, + { key: "dark-blue", label: "深蓝色", dot: "#002B5C" }, ]; +/** + * 读取我方当前主题色,以 ThemeContext 写在 上的 `dark` class 为准 + * (含嵌入场景的强制主题)。当前:深色=dark-blue,浅色=red。 + * TODO: 后续新增 blue 主题后,在此区分 blue 与 dark-blue。 + */ +export function getCurrentTheme(): AppThemeColor { + if (typeof document !== "undefined" && document.documentElement.classList.contains("dark")) { + return "dark-blue"; + } + return "red"; +} + +/** 解析主题映射串(`red=red;dark-blue=darkBlue`)为 { 主题色键: 目标值 }。值不做 trim,便于表单回显。 */ +export function parseThemeMap(raw: string | null | undefined): Record { + const map: Record = {}; + for (const pair of (raw ?? "").split(/[;,]/)) { + const idx = pair.search(/[=:]/); + if (idx <= 0) continue; + const k = pair.slice(0, idx).trim().toLowerCase(); + map[k] = pair.slice(idx + 1); + } + return map; +} + +/** 把主题映射对象序列化为映射串,按 APP_THEME_COLORS 顺序输出,缺省走同名(identity)。 */ +export function stringifyThemeMap(map: Record): string { + return APP_THEME_COLORS.map((c) => `${c.key}=${map[c.key] ?? c.key}`).join(";"); +} + +/** + * 按我方当前主题,从配置中取目标轻应用对应的值(跳转 / iframe 嵌套时注入)。 + * + * 配置支持两种写法(管理员在「应用管理」主题参数里配置): + * 1) 映射串:`red=red;blue=blue;dark-blue=darkBlue` —— 按当前主题取对应值; + * 缺当前主题映射时回退到 red,再回退到首个值,最后回退到主题色名本身。 + * 2) 纯值:`red` —— 不区分主题,原样返回。 + */ +export function resolveThemeParamValue( + raw: string | null | undefined, + theme: AppThemeColor = getCurrentTheme(), +): string { + const value = (raw ?? "").trim(); + if (!value) return theme; + // 不含键值分隔符 → 纯值,不区分主题原样返回 + if (!/[=:]/.test(value)) return value; + const map = parseThemeMap(value); + return (map[theme] ?? map.red ?? Object.values(map)[0] ?? theme).trim(); +} + /** 读取并解析 localStorage 中的 userInfo。 */ export function readUserInfo(): Record { try { @@ -52,7 +107,8 @@ export function resolveRouteParamValue( userInfo: Record = readUserInfo(), ): string { if (param.paramType === "theme") { - return param.themeValue ?? ""; + // 主题参数:按我方当前系统主题,从管理员配置的映射中取目标应用对应的值。 + return resolveThemeParamValue(param.themeValue); } // login if (param.source === "other") { From 93243298e11602318b26a06d95b126c4ed9c4185 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 12 Jun 2026 21:45:55 +0800 Subject: [PATCH 04/12] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- offline-backend-20260512/.env | 3 +- .../backend/app/gateway/deps.py | 39 ++++++++++++------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/offline-backend-20260512/.env b/offline-backend-20260512/.env index f5e4b4cf..3e28ef53 100644 --- a/offline-backend-20260512/.env +++ b/offline-backend-20260512/.env @@ -60,5 +60,6 @@ ELEMENT_STATE_FILE=/app/backend/.deer-flow/element-bot-token # PYTHON_TRUSTED_HOST=your-python-mirror MYSQL_DATABASE_URL=mysql+asyncmy://deerflow:deerflow123@192.168.0.123:3306/deerflow?charset=utf8mb4 DEER_FLOW_NOTEBOOK_API_BASE=http://127.0.0.1:8003 -# 圆桌后台作业网关:inprocess=进程内驱动真模型;simulated=模拟(默认) +# 圆桌后台作业网关:缺省即 inprocess(进程内驱动真模型,后台挂起开箱即用); +# 仅前端联调时显式设 simulated(真实进度但不调真模型)。本行可删,留作文档。 ROUNDTABLE_JOB_GATEWAY=inprocess diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py index 03a98168..109d32d2 100644 --- a/offline-backend-20260512/backend/app/gateway/deps.py +++ b/offline-backend-20260512/backend/app/gateway/deps.py @@ -38,6 +38,17 @@ if TYPE_CHECKING: T = TypeVar("T") +def resolve_roundtable_gateway_mode(raw: str | None) -> str: + """解析 ``ROUNDTABLE_JOB_GATEWAY``:**默认 ``inprocess``(真模型后台挂起)**。 + + 只有显式写 ``simulated`` 才用模拟网关(前端联调用:真实进度、不调真模型); + 缺省 / 空串 / 其它值一律落到 ``inprocess`` —— 部署时无需在 .env 里配置任何值, + 后台挂起开箱即用(Phase 3b 已在 Windows 本地与内网 Docker 验证)。 + """ + mode = (raw or "").strip().lower() + return "simulated" if mode == "simulated" else "inprocess" + + def get_config(request: Request) -> AppConfig: """Return the app-scoped ``AppConfig`` stored on ``app.state``.""" config = getattr(request.app.state, "config", None) @@ -225,25 +236,17 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]: _register_roundtable_job_default_store(app.state.roundtable_job_store) # 圆桌后台作业执行器:把编排引擎作为后台任务跑。 - # 网关由环境变量 ``ROUNDTABLE_JOB_GATEWAY`` 选择: - # - "inprocess"(Phase 3b):进程内驱动真模型(leader/seat/report); - # - 其它/缺省(Phase 3a):模拟网关,产生真实进度但不调真模型,供前端联调。 - # 默认 simulated —— 真网关需在能起完整后端栈 + 真模型的环境验证后再开。 + # 网关由环境变量 ``ROUNDTABLE_JOB_GATEWAY`` 选择(见 resolve_roundtable_gateway_mode): + # - 缺省 / "inprocess"(Phase 3b):进程内驱动真模型(leader/seat/report)——**默认**, + # 部署时无需任何配置即可用后台挂起; + # - 显式 "simulated"(Phase 3a):模拟网关,产生真实进度但不调真模型,供前端联调。 if app.state.roundtable_job_store is not None: import os from app.gateway.roundtable_job_executor import RoundtableJobExecutor - gateway_mode = (os.getenv("ROUNDTABLE_JOB_GATEWAY") or "simulated").strip().lower() - if gateway_mode == "inprocess": - from app.gateway.roundtable_inprocess_gateway import InProcessRoundtableGateway - - _app = app # 闭包捕获 app,供网关访问 app.state(checkpointer/thread_store) - gateway_factory = lambda p: InProcessRoundtableGateway( # noqa: E731 - _app, user_id=p.user_id, agents=p.agents, model=p.model - ) - logger.info("[roundtable-jobs] gateway = inprocess (real models)") - else: + gateway_mode = resolve_roundtable_gateway_mode(os.getenv("ROUNDTABLE_JOB_GATEWAY")) + if gateway_mode == "simulated": from deerflow.agents.roundtable_orchestrator import SimulatedRoundtableGateway # delay=3s/步:让模拟后台任务有可观察的进行中时长(切页/重连仍在跑)。 @@ -251,6 +254,14 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]: p.agents, mode=p.mode, delay=3.0 ) logger.info("[roundtable-jobs] gateway = simulated") + else: + from app.gateway.roundtable_inprocess_gateway import InProcessRoundtableGateway + + _app = app # 闭包捕获 app,供网关访问 app.state(checkpointer/thread_store) + gateway_factory = lambda p: InProcessRoundtableGateway( # noqa: E731 + _app, user_id=p.user_id, agents=p.agents, model=p.model + ) + logger.info("[roundtable-jobs] gateway = inprocess (real models, default)") app.state.roundtable_job_executor = RoundtableJobExecutor( app.state.roundtable_job_store, From 74324f4585df19729c0d6e12cf62c0a723a2c50a Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Fri, 12 Jun 2026 22:30:05 +0800 Subject: [PATCH 05/12] =?UTF-8?q?=E8=BD=BB=E5=BA=94=E7=94=A8=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E5=8F=82=E6=95=B0=EF=BC=9A=E9=9A=8F=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E8=89=B2=E6=98=A0=E5=B0=84=E4=BC=A0=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 主题参数改为按系统主题(red/dark-blue)映射取目标应用对应的值 - 应用管理弹窗用结构化映射编辑器(逐主题色配置目标值,默认同名) - iframe 嵌套显式传入当前主题色,切主题时 src 重建 - 读取/保存时规范化映射串;解析支持 light/dark 等别名,取不到回退主题色名而非恒为 red Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend-web/src/pages/LightAppIframeView.tsx | 12 ++++++-- .../src/pages/LightAppManagementPage.tsx | 4 +-- .../src/strategy-components/api/light-apps.ts | 8 ++++- .../src/strategy-components/lib/light-app.ts | 29 +++++++++++++++---- 4 files changed, 41 insertions(+), 12 deletions(-) diff --git a/frontend-web/src/pages/LightAppIframeView.tsx b/frontend-web/src/pages/LightAppIframeView.tsx index c4ce4237..7f6b63ed 100644 --- a/frontend-web/src/pages/LightAppIframeView.tsx +++ b/frontend-web/src/pages/LightAppIframeView.tsx @@ -9,7 +9,7 @@ import { useParams } from "react-router-dom"; import { useTheme } from "@/contexts/ThemeContext"; import { useLightApp } from "@/strategy-components/api/light-apps"; -import { buildLightAppUrl } from "@/strategy-components/lib/light-app"; +import { buildLightAppUrl, type AppThemeColor } from "@/strategy-components/lib/light-app"; export default function LightAppIframeView() { const { appId } = useParams<{ appId: string }>(); @@ -17,8 +17,14 @@ export default function LightAppIframeView() { const isDark = theme === "dark"; const { data: app, isLoading, error } = useLightApp(appId); - // 依赖 theme:切换主题色时重建 src,把对应主题色重新传给被嵌套应用。 - const src = useMemo(() => (app ? buildLightAppUrl(app) : ""), [app, theme]); + // 由 useTheme 的 theme 直接换算主题色并显式传入,切主题时 src 立即重建、 + // 把新主题色传给被嵌套应用(不依赖 的 dark class,避免时序读旧值)。 + // TODO: 后续接入 blue 主题后,这里区分 blue 与 dark-blue。 + const themeColor: AppThemeColor = isDark ? "dark-blue" : "red"; + const src = useMemo( + () => (app ? buildLightAppUrl(app, undefined, themeColor) : ""), + [app, themeColor], + ); const centerClass = `flex h-full w-full items-center justify-center text-sm ${ isDark ? "text-slate-400" : "text-slate-500" diff --git a/frontend-web/src/pages/LightAppManagementPage.tsx b/frontend-web/src/pages/LightAppManagementPage.tsx index bab529e4..fd2b16c5 100644 --- a/frontend-web/src/pages/LightAppManagementPage.tsx +++ b/frontend-web/src/pages/LightAppManagementPage.tsx @@ -707,8 +707,8 @@ function cleanParams(params: LightAppRouteParam[]): LightAppRouteParam[] { base.customValue = (p.customValue ?? "").trim(); } } else { - // theme:存映射串(或纯值),运行时按当前主题解析取值 - base.themeValue = (p.themeValue ?? "").trim(); + // theme:规范化为完整映射串(red=..;blue=..;dark-blue=..),运行时按当前主题取值 + base.themeValue = stringifyThemeMap(parseThemeMap(p.themeValue)); } return base; }); diff --git a/frontend-web/src/strategy-components/api/light-apps.ts b/frontend-web/src/strategy-components/api/light-apps.ts index 9f476e7a..7e16c5a5 100644 --- a/frontend-web/src/strategy-components/api/light-apps.ts +++ b/frontend-web/src/strategy-components/api/light-apps.ts @@ -16,6 +16,7 @@ import { import { apiFetch } from "@/core/api/fetch-client"; import { getBackendBaseURL } from "@/core/config"; +import { parseThemeMap, stringifyThemeMap } from "@/strategy-components/lib/light-app"; export type LightAppType = "window_open" | "iframe"; export type RouteParamType = "login" | "theme"; @@ -104,7 +105,12 @@ function toApp(s: ServerApp): LightApp { paramType: p.paramType, source: p.source ?? null, customValue: p.customValue ?? null, - themeValue: p.themeValue ?? null, + // 主题参数读取时规范化为完整映射串(red=..;blue=..;dark-blue=..), + // 让旧的纯值/部分映射也能随主题切换,无需重新保存。 + themeValue: + p.paramType === "theme" + ? stringifyThemeMap(parseThemeMap(p.themeValue)) + : (p.themeValue ?? null), })), mountLocation: s.mount_location ?? null, icon: s.icon ?? null, diff --git a/frontend-web/src/strategy-components/lib/light-app.ts b/frontend-web/src/strategy-components/lib/light-app.ts index 5b8e66ac..df47982a 100644 --- a/frontend-web/src/strategy-components/lib/light-app.ts +++ b/frontend-web/src/strategy-components/lib/light-app.ts @@ -40,6 +40,16 @@ export const APP_THEME_COLORS: { key: AppThemeColor; label: string; dot: string { key: "dark-blue", label: "深蓝色", dot: "#002B5C" }, ]; +/** + * 主题键别名:兼容历史/手填里用 light/dark 等写法的映射键,归一到当前主题色键。 + * 查映射时按别名顺序尝试,避免「键对不上 → 回退到 red」的恒红 bug。 + */ +const THEME_KEY_ALIASES: Record = { + red: ["red", "light"], + blue: ["blue"], + "dark-blue": ["dark-blue", "darkblue", "dark"], +}; + /** * 读取我方当前主题色,以 ThemeContext 写在 上的 `dark` class 为准 * (含嵌入场景的强制主题)。当前:深色=dark-blue,浅色=red。 @@ -73,8 +83,8 @@ export function stringifyThemeMap(map: Record): string { * 按我方当前主题,从配置中取目标轻应用对应的值(跳转 / iframe 嵌套时注入)。 * * 配置支持两种写法(管理员在「应用管理」主题参数里配置): - * 1) 映射串:`red=red;blue=blue;dark-blue=darkBlue` —— 按当前主题取对应值; - * 缺当前主题映射时回退到 red,再回退到首个值,最后回退到主题色名本身。 + * 1) 映射串:`red=red;blue=blue;dark-blue=darkBlue` —— 按当前主题(含别名)取对应值; + * 取不到时回退到主题色名本身(identity),而非盲目回退首个值(会恒为 red)。 * 2) 纯值:`red` —— 不区分主题,原样返回。 */ export function resolveThemeParamValue( @@ -86,7 +96,11 @@ export function resolveThemeParamValue( // 不含键值分隔符 → 纯值,不区分主题原样返回 if (!/[=:]/.test(value)) return value; const map = parseThemeMap(value); - return (map[theme] ?? map.red ?? Object.values(map)[0] ?? theme).trim(); + for (const key of THEME_KEY_ALIASES[theme] ?? [theme]) { + if (map[key] != null) return map[key].trim(); + } + // 映射里没有当前主题(含别名)→ 退回主题色名本身,避免恒为 red + return theme; } /** 读取并解析 localStorage 中的 userInfo。 */ @@ -105,10 +119,12 @@ export function readUserInfo(): Record { export function resolveRouteParamValue( param: LightAppRouteParam, userInfo: Record = readUserInfo(), + theme?: AppThemeColor, ): string { if (param.paramType === "theme") { - // 主题参数:按我方当前系统主题,从管理员配置的映射中取目标应用对应的值。 - return resolveThemeParamValue(param.themeValue); + // 主题参数:按我方当前主题,从管理员配置的映射中取目标应用对应的值。 + // theme 显式传入时优先(避免切主题瞬间 DOM 的 dark class 还没更新导致读旧值)。 + return resolveThemeParamValue(param.themeValue, theme); } // login if (param.source === "other") { @@ -130,10 +146,11 @@ export function resolveRouteParamValue( export function buildLightAppUrl( app: Pick, userInfo: Record = readUserInfo(), + theme?: AppThemeColor, ): string { const pairs = (app.routeParams ?? []) .filter((p) => p.key) - .map((p) => `${encodeURIComponent(p.key)}=${encodeURIComponent(resolveRouteParamValue(p, userInfo))}`); + .map((p) => `${encodeURIComponent(p.key)}=${encodeURIComponent(resolveRouteParamValue(p, userInfo, theme))}`); if (pairs.length === 0) return app.url; const sep = app.url.includes("?") ? "&" : "?"; return `${app.url}${sep}${pairs.join("&")}`; From d5f319d9cbe5a946595d5d8ebebe2ca556ee70c5 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 10:36:46 +0800 Subject: [PATCH 06/12] =?UTF-8?q?@=20feat(=E5=9C=86=E6=A1=8C):=20Step3=20?= =?UTF-8?q?=E4=B8=89=E4=BA=A7=E7=89=A9=E5=B9=B6=E8=A1=8C=20+=20=E5=A4=A7?= =?UTF-8?q?=E5=B1=8F=E5=A4=9A=E9=A1=B5=E6=A8=A1=E5=BC=8F=20+=20=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E6=8C=82=E8=B5=B7=E6=9F=A5=E7=9C=8B=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后台挂起 Step3: - engine 总结报告/结果绘制/大屏数据 asyncio.gather 三流并行,故障隔离 - request.state 回归修复(进程内虚拟 request 补 state),恢复后台报告 - ROUNDTABLE_JOB_GATEWAY 默认 inprocess;产物自愈重试(截断/缺失纠偏) - 大屏后端只产 report-json(dashboardJson),前端按当前主题渲染 结果绘制大屏多页模式(模式二): - report-template 模板渲染(report-json→大屏HTML),零截断、主题跟随 - 与模式一同款交互(流式+沙箱+可修改);沙箱新窗口打开按钮 最终方案展示:左侧席位栏+总控头像、交付/文件合并、收展动画、内联沙箱 后台挂起查看=恢复实时流程: - handleLoadDraft 返回 Promise,await hydrate 后订阅 streamJob SSE 跟随 - saveDraftNow 强制存(发起前+查看前各一次)防丢 第二步优化:总控判断串/并行(并行单列可写)、席位上下文+交付模板、 peerDeliveries、失败重试、共识标记、流程图小窗(缩放/拖动/并行批) Co-Authored-By: Claude Fable 5 @ --- frontend-web/.tmp-tests/flow-graph.test.mjs | 319 ------- .../docs/ai-writing-内置智能体改造.md | 28 + ...agent-background-run-遗留问题与下次继续.md | 31 +- .../artifacts/artifact-file-detail.tsx | 63 +- .../workspace/messages/message-list.tsx | 3 + .../components/ai-writing/AIWritingForm.tsx | 9 +- .../ai-writing/InterventionCard.tsx | 38 +- .../ai-writing/MaterialsToolbar.tsx | 4 +- .../open-canvas/contexts/AIWritingContext.tsx | 3 +- .../contexts/WritingFormContext.tsx | 13 +- .../open-canvas/hooks/useAIWritingStream.ts | 8 +- .../src/open-canvas/types/ai-writing.ts | 6 +- .../src/roundtable-planning/api/drafts.ts | 6 + .../api/roundtable-jobs.ts | 19 + .../components/DagFlowChart.tsx | 20 +- .../components/DagFlowMiniPanel.tsx | 469 ++++++++++ .../components/DispatchChainModal.tsx | 55 +- .../components/HighFidelityReport.tsx | 843 +++++++++++++----- .../components/Step2Panel.tsx | 127 ++- .../components/Step3DashboardMode.tsx | 312 +++++++ .../components/Step3Panel.tsx | 14 +- .../components/Step3ReportMode.tsx | 103 ++- .../components/Step3SummaryMode.tsx | 52 +- .../hooks/useDraftPersistence.ts | 30 +- .../hooks/useStep2Orchestration.ts | 305 +++++-- .../hooks/useStep3Dashboard.ts | 496 +++++++++++ .../hooks/useStep3Report.ts | 51 +- .../hooks/useStep3Summary.ts | 147 ++- .../lib/flow-graph.test.ts | 11 + .../src/roundtable-planning/lib/flow-graph.ts | 5 + .../src/roundtable-planning/lib/job-live.ts | 129 +++ .../src/roundtable-planning/lib/job-types.ts | 30 +- .../lib/report-template.test.ts | 159 ++++ .../lib/report-template.ts | 722 +++++++++++++++ .../pages/RoundtablePlanningPage.tsx | 373 ++++++-- offline-backend-20260512/backend/CLAUDE.md | 2 + .../gateway/roundtable_inprocess_gateway.py | 211 ++++- .../app/gateway/roundtable_job_executor.py | 24 +- .../app/gateway/routers/_ai_writing_seed.py | 74 ++ .../ai-writing-researcher/config.yaml | 6 +- .../roundtable-summary/SOUL.md | 23 +- .../app/gateway/routers/roundtable_jobs.py | 12 +- .../deerflow/agents/ai_writing/graph.py | 10 +- .../agents/ai_writing/nodes/researcher.py | 515 ++++++----- .../agents/ai_writing/search/skill_search.py | 12 + .../deerflow/agents/ai_writing/state.py | 5 +- .../roundtable_orchestrator/__init__.py | 12 +- .../agents/roundtable_orchestrator/engine.py | 209 ++++- .../roundtable_orchestrator/progress.py | 7 +- .../simulated_gateway.py | 44 +- .../agents/roundtable_orchestrator/types.py | 33 + 51 files changed, 5016 insertions(+), 1186 deletions(-) delete mode 100644 frontend-web/.tmp-tests/flow-graph.test.mjs create mode 100644 frontend-web/src/roundtable-planning/components/DagFlowMiniPanel.tsx create mode 100644 frontend-web/src/roundtable-planning/components/Step3DashboardMode.tsx create mode 100644 frontend-web/src/roundtable-planning/hooks/useStep3Dashboard.ts create mode 100644 frontend-web/src/roundtable-planning/lib/job-live.ts create mode 100644 frontend-web/src/roundtable-planning/lib/report-template.test.ts create mode 100644 frontend-web/src/roundtable-planning/lib/report-template.ts diff --git a/frontend-web/.tmp-tests/flow-graph.test.mjs b/frontend-web/.tmp-tests/flow-graph.test.mjs deleted file mode 100644 index 289bea27..00000000 --- a/frontend-web/.tmp-tests/flow-graph.test.mjs +++ /dev/null @@ -1,319 +0,0 @@ -// src/roundtable-planning/lib/flow-graph.test.ts -import assert from "node:assert/strict"; -import test from "node:test"; - -// src/roundtable-planning/lib/flow-graph.ts -var FLOW_NODE_TYPE_META = { - start: { label: "\u5F00\u59CB", bg: "#f1f5f9", border: "#94a3b8", text: "#334155", dot: "#64748b" }, - end: { label: "\u7ED3\u675F", bg: "#f1f5f9", border: "#94a3b8", text: "#334155", dot: "#64748b" }, - stage: { label: "\u9636\u6BB5", bg: "#eff6ff", border: "#60a5fa", text: "#1d4ed8", dot: "#3b82f6" }, - task: { label: "\u4EFB\u52A1", bg: "#ecfeff", border: "#22d3ee", text: "#0e7490", dot: "#06b6d4" }, - decision: { label: "\u5224\u65AD", bg: "#fffbeb", border: "#fbbf24", text: "#b45309", dot: "#f59e0b" }, - risk: { label: "\u98CE\u9669", bg: "#fef2f2", border: "#f87171", text: "#b91c1c", dot: "#ef4444" }, - deliverable: { label: "\u4EA4\u4ED8\u7269", bg: "#f0fdf4", border: "#4ade80", text: "#15803d", dot: "#22c55e" }, - role: { label: "\u89D2\u8272", bg: "#faf5ff", border: "#c084fc", text: "#7e22ce", dot: "#a855f7" } -}; -var VALID_TYPES = new Set(Object.keys(FLOW_NODE_TYPE_META)); -var FLOW_FENCE_OPEN = "```flow-json"; -function collectObjects(text, key) { - const keyIdx = text.indexOf(key); - if (keyIdx < 0) return []; - const arrStart = text.indexOf("[", keyIdx); - if (arrStart < 0) return []; - const out = []; - let depth = 0; - let objStart = -1; - let inStr = false; - let esc = false; - for (let i = arrStart + 1; i < text.length; i++) { - const ch = text[i]; - if (inStr) { - if (esc) esc = false; - else if (ch === "\\") esc = true; - else if (ch === '"') inStr = false; - continue; - } - if (ch === '"') { - inStr = true; - continue; - } - if (ch === "{") { - if (depth === 0) objStart = i; - depth++; - continue; - } - if (ch === "}") { - depth--; - if (depth === 0 && objStart >= 0) { - try { - const parsed = JSON.parse(text.slice(objStart, i + 1)); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - out.push(parsed); - } - } catch { - } - objStart = -1; - } - continue; - } - if (ch === "]" && depth === 0) break; - } - return out; -} -function asString(v) { - return typeof v === "string" ? v.trim() : ""; -} -function toNode(raw) { - const id = asString(raw.id); - const label = asString(raw.label) || asString(raw.name); - if (!id || !label) return null; - const rawType = asString(raw.type).toLowerCase(); - const type = VALID_TYPES.has(rawType) ? rawType : "task"; - const detail = asString(raw.detail) || asString(raw.description) || void 0; - return { id, label, type, detail }; -} -function edgesFromNode(raw, nodeId) { - const from = raw.from ?? raw.prev; - if (!Array.isArray(from)) return []; - const out = []; - for (const entry of from) { - if (typeof entry === "string" && entry.trim()) { - out.push({ from: entry.trim(), to: nodeId }); - } else if (entry && typeof entry === "object") { - const o = entry; - const fromId = asString(o.id) || asString(o.from); - if (!fromId) continue; - const label = asString(o.label) || void 0; - out.push({ from: fromId, to: nodeId, label }); - } - } - return out; -} -function toEdge(raw) { - const from = asString(raw.from) || asString(raw.source); - const to = asString(raw.to) || asString(raw.target); - if (!from || !to) return null; - const label = asString(raw.label) || void 0; - return { from, to, label }; -} -function extractFlowGraph(text) { - const start = text.lastIndexOf(FLOW_FENCE_OPEN); - if (start < 0) return null; - let body = text.slice(start + FLOW_FENCE_OPEN.length); - const close = body.indexOf("```"); - if (close >= 0) body = body.slice(0, close); - const titleMatch = /"title"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(body); - let title = ""; - if (titleMatch) { - try { - title = JSON.parse(`"${titleMatch[1]}"`); - } catch { - title = titleMatch[1]; - } - } - const rawNodes = collectObjects(body, '"nodes"'); - const nodes = []; - const seen = /* @__PURE__ */ new Set(); - const edges = []; - for (const raw of rawNodes) { - const node = toNode(raw); - if (!node || seen.has(node.id)) continue; - seen.add(node.id); - nodes.push(node); - edges.push(...edgesFromNode(raw, node.id)); - } - for (const raw of collectObjects(body, '"edges"')) { - const edge = toEdge(raw); - if (edge) edges.push(edge); - } - if (nodes.length === 0) return null; - const edgeKeys = /* @__PURE__ */ new Set(); - const validEdges = edges.filter((e) => { - if (!seen.has(e.from) || !seen.has(e.to) || e.from === e.to) return false; - const key = `${e.from}->${e.to}`; - if (edgeKeys.has(key)) return false; - edgeKeys.add(key); - return true; - }); - return { title, nodes, edges: validEdges }; -} -var FLOW_INTENT_PATTERNS = [ - "\u6D41\u7A0B\u56FE", - "\u53EF\u89C6\u5316\u6D41\u7A0B", - "\u7ED3\u6784\u5316\u8F93\u51FA", - "\u7ED3\u6784\u5316\u6570\u636E", - "\u7ED3\u6784\u5316\u6D41\u7A0B", - "\u7ED3\u6784\u5316\u603B\u7ED3", - "\u683C\u5F0F\u5316\u8F93\u51FA", - "\u683C\u5F0F\u5316\u603B\u7ED3", - "flow-json", - "flowchart" -]; -function detectFlowIntent(text) { - if (!text) return false; - const lower = text.toLowerCase(); - return FLOW_INTENT_PATTERNS.some((p) => lower.includes(p)); -} -function stripFlowFence(text, streaming) { - const start = text.lastIndexOf(FLOW_FENCE_OPEN); - if (start < 0) return text; - const after = text.slice(start + FLOW_FENCE_OPEN.length); - const close = after.indexOf("```"); - const tail = close >= 0 ? after.slice(close + 3) : ""; - const placeholder = close < 0 && streaming ? "\n\n> \u{1F4CA} \u6B63\u5728\u8F93\u51FA\u7ED3\u6784\u5316\u6D41\u7A0B\u6570\u636E\uFF0C\u6D41\u7A0B\u56FE\u753B\u5E03\u540C\u6B65\u7ED8\u5236\u4E2D\u2026" : "\n\n> \u{1F4CA} \u5DF2\u751F\u6210\u7ED3\u6784\u5316\u6D41\u7A0B\u6570\u636E\uFF0C\u53EF\u5728\u53F3\u4FA7\u300C\u6D41\u7A0B\u56FE\u300D\u753B\u5E03\u67E5\u770B\uFF08\u70B9\u51FB\u8282\u70B9\u770B\u8BE6\u60C5\uFF09\u3002"; - return `${text.slice(0, start).trimEnd()}${placeholder}${tail}`; -} -var X_GAP = 224; -var Y_GAP = 132; -function layoutFlowGraph(data) { - const preds = /* @__PURE__ */ new Map(); - for (const e of data.edges) { - const list = preds.get(e.to); - if (list) list.push(e.from); - else preds.set(e.to, [e.from]); - } - const memo = /* @__PURE__ */ new Map(); - const visiting = /* @__PURE__ */ new Set(); - const layerOf = (id) => { - const cached = memo.get(id); - if (cached !== void 0) return cached; - if (visiting.has(id)) return 0; - visiting.add(id); - const ps = preds.get(id) ?? []; - const layer = ps.length === 0 ? 0 : Math.max(...ps.map(layerOf)) + 1; - visiting.delete(id); - memo.set(id, layer); - return layer; - }; - const byLayer = /* @__PURE__ */ new Map(); - for (const n of data.nodes) { - const layer = layerOf(n.id); - const list = byLayer.get(layer); - if (list) list.push(n); - else byLayer.set(layer, [n]); - } - const pos = /* @__PURE__ */ new Map(); - for (const [layer, nodes] of byLayer) { - const rowWidth = (nodes.length - 1) * X_GAP; - nodes.forEach((n, i) => { - pos.set(n.id, { x: i * X_GAP - rowWidth / 2, y: layer * Y_GAP }); - }); - } - return data.nodes.map((n) => ({ ...n, ...pos.get(n.id) })); -} - -// src/roundtable-planning/lib/flow-graph.test.ts -var FULL = [ - "\u62A5\u544A\u6B63\u6587\u2026\u2026", - "```flow-json", - '{"title":"\u65B9\u6848\u6267\u884C\u6D41\u7A0B","nodes":[', - '{"id":"n1","label":"\u73AF\u5883\u8BC4\u4F30","type":"stage","detail":"\u8BC4\u4F30\u73B0\u573A\u73AF\u5883"},', - '{"id":"n2","label":"\u7F16\u5236\u65B9\u6848","type":"task","from":["n1"]},', - '{"id":"n3","label":"\u662F\u5426\u53EF\u884C","type":"decision","from":[{"id":"n2","label":"\u8BC4\u5BA1"}]}', - "]}", - "```" -].join("\n"); -test("extractFlowGraph\uFF1A\u5B8C\u6574\u5757\u89E3\u6790\u51FA\u8282\u70B9/\u8FB9/\u6807\u9898", () => { - const g = extractFlowGraph(FULL); - assert.ok(g); - assert.equal(g.title, "\u65B9\u6848\u6267\u884C\u6D41\u7A0B"); - assert.equal(g.nodes.length, 3); - assert.equal(g.nodes[0].type, "stage"); - assert.deepEqual(g.edges, [ - { from: "n1", to: "n2" }, - { from: "n2", to: "n3", label: "\u8BC4\u5BA1" } - ]); -}); -test("extractFlowGraph\uFF1A\u6D41\u5F0F\u534A\u622A\uFF08fence \u672A\u95ED\u5408 + \u5C3E\u5BF9\u8C61\u622A\u65AD\uFF09\u53EA\u53D6\u5B8C\u6574\u8282\u70B9", () => { - const partial = [ - "\u6B63\u6587", - "```flow-json", - '{"title":"T","nodes":[', - '{"id":"a","label":"A","type":"start"},', - '{"id":"b","label":"B","type":"task","from":["a"]},', - '{"id":"c","label":"C","ty' - // 截断 - ].join("\n"); - const g = extractFlowGraph(partial); - assert.ok(g); - assert.equal(g.nodes.length, 2); - assert.deepEqual(g.edges, [{ from: "a", to: "b" }]); -}); -test("extractFlowGraph\uFF1A\u672A\u77E5 type \u843D task\u3001\u91CD\u590D id \u53BB\u91CD\u3001\u60AC\u7A7A\u8FB9\u4E22\u5F03", () => { - const text = [ - "```flow-json", - '{"nodes":[', - '{"id":"x","label":"X","type":"banana"},', - '{"id":"x","label":"X-dup","type":"task"},', - '{"id":"y","label":"Y","type":"risk","from":["x","ghost"]}', - "]}", - "```" - ].join("\n"); - const g = extractFlowGraph(text); - assert.ok(g); - assert.equal(g.nodes.length, 2); - assert.equal(g.nodes[0].type, "task"); - assert.equal(g.nodes[0].label, "X"); - assert.deepEqual(g.edges, [{ from: "x", to: "y" }]); -}); -test("extractFlowGraph\uFF1A\u65E0 fence / \u65E0\u5B8C\u6574\u8282\u70B9\u8FD4\u56DE null", () => { - assert.equal(extractFlowGraph("\u666E\u901A\u56DE\u590D\uFF0C\u6CA1\u6709\u7ED3\u6784\u5316\u6570\u636E"), null); - assert.equal(extractFlowGraph('```flow-json\n{"nodes":[{"id":"a"'), null); -}); -test("extractFlowGraph\uFF1A\u517C\u5BB9\u72EC\u7ACB edges \u6570\u7EC4\uFF08source/target \u522B\u540D\uFF09", () => { - const text = [ - "```flow-json", - '{"nodes":[{"id":"a","label":"A"},{"id":"b","label":"B"}],', - '"edges":[{"source":"a","target":"b","label":"\u8D70"}]}', - "```" - ].join("\n"); - const g = extractFlowGraph(text); - assert.ok(g); - assert.deepEqual(g.edges, [{ from: "a", to: "b", label: "\u8D70" }]); -}); -test("stripFlowFence\uFF1A\u95ED\u5408\u5757\u66FF\u6362\u4E3A\u5F15\u5BFC\u8BED\u5E76\u4FDD\u7559\u524D\u540E\u6587", () => { - const out = stripFlowFence(FULL, false); - assert.ok(out.includes("\u62A5\u544A\u6B63\u6587")); - assert.ok(!out.includes("```flow-json")); - assert.ok(out.includes("\u5DF2\u751F\u6210\u7ED3\u6784\u5316\u6D41\u7A0B\u6570\u636E")); -}); -test("stripFlowFence\uFF1A\u672A\u95ED\u5408\uFF08\u6D41\u5F0F\u4E2D\uFF09\u7528\u300C\u6B63\u5728\u8F93\u51FA\u300D\u63AA\u8F9E", () => { - const out = stripFlowFence('\u6B63\u6587\n```flow-json\n{"nodes":[', true); - assert.ok(out.includes("\u6B63\u5728\u8F93\u51FA\u7ED3\u6784\u5316\u6D41\u7A0B\u6570\u636E")); - assert.ok(!out.includes("```flow-json")); -}); -test("layoutFlowGraph\uFF1A\u6309\u524D\u9A71\u5206\u5C42\uFF08\u6839 0 \u5C42\uFF0C\u5B50\u5C42\u9012\u589E\uFF09\uFF0C\u4FDD\u6301\u539F\u59CB\u987A\u5E8F", () => { - const g = extractFlowGraph(FULL); - const positioned = layoutFlowGraph(g); - assert.equal(positioned[0].y, 0); - assert.ok(positioned[1].y > positioned[0].y); - assert.ok(positioned[2].y > positioned[1].y); - assert.deepEqual(positioned.map((n) => n.id), ["n1", "n2", "n3"]); -}); -test("detectFlowIntent\uFF1A\u660E\u786E\u77ED\u8BED\u547D\u4E2D\uFF08\u7F16\u6392\u63D0\u793A / \u8F93\u5165\u6846\u6D88\u606F\uFF09", () => { - assert.ok(detectFlowIntent("\u6700\u7EC8\u603B\u7ED3\u8BF7\u7528\u7ED3\u6784\u5316\u8F93\u51FA")); - assert.ok(detectFlowIntent("\u603B\u7ED3\u6210\u683C\u5F0F\u5316\u8F93\u51FA")); - assert.ok(detectFlowIntent("\u7ED9\u6211\u753B\u4E00\u5F20\u6D41\u7A0B\u56FE")); - assert.ok(detectFlowIntent("\u8F93\u51FA\u53EF\u89C6\u5316\u6D41\u7A0B")); - assert.ok(detectFlowIntent("\u8BF7\u6309 flow-json \u5951\u7EA6\u8F93\u51FA")); - assert.ok(detectFlowIntent("\u91CD\u70B9\u603B\u7ED3\u65B9\u6848\u8BBE\u8BA1\u5E2D\u4F4D\uFF0C\u6700\u540E\u7ED9\u7ED3\u6784\u5316\u6570\u636E")); -}); -test("detectFlowIntent\uFF1A\u666E\u901A\u63AA\u8F9E\u4E0D\u8BEF\u89E6\u53D1", () => { - assert.equal(detectFlowIntent("\u7528\u8868\u683C\u628A\u8981\u70B9\u7ED3\u6784\u5316\u5448\u73B0"), false); - assert.equal(detectFlowIntent("\u628A\u6267\u884C\u8DEF\u7EBF\u5199\u5F97\u66F4\u7EC6\u4E00\u4E9B"), false); - assert.equal(detectFlowIntent("\u9762\u5411\u9AD8\u7BA1\u6C47\u62A5\uFF0C\u63A7\u5236\u5728\u4E00\u9875\u5185"), false); - assert.equal(detectFlowIntent(""), false); - assert.equal(detectFlowIntent(null), false); - assert.equal(detectFlowIntent(void 0), false); -}); -test("layoutFlowGraph\uFF1A\u73AF\u4E0D\u6B7B\u5FAA\u73AF", () => { - const text = [ - "```flow-json", - '{"nodes":[{"id":"a","label":"A","from":["b"]},{"id":"b","label":"B","from":["a"]}]}', - "```" - ].join("\n"); - const g = extractFlowGraph(text); - const positioned = layoutFlowGraph(g); - assert.equal(positioned.length, 2); - positioned.forEach((n) => assert.ok(Number.isFinite(n.x) && Number.isFinite(n.y))); -}); diff --git a/frontend-web/docs/ai-writing-内置智能体改造.md b/frontend-web/docs/ai-writing-内置智能体改造.md index 2cb9dc0b..96016c25 100644 --- a/frontend-web/docs/ai-writing-内置智能体改造.md +++ b/frontend-web/docs/ai-writing-内置智能体改造.md @@ -223,3 +223,31 @@ result = await run_writing_agent( ### 已知存量测试失败(与本次无关) `test_ai_writing_graph.py`(mock 已删除的 `intent_parser.llm_json`)、`test_ai_writing_word_count.py`(mock 已删除的 `writer_draft.get_model`)、`test_ai_writing_draft_headings.py`(断言三级结构升级前的 `####→###` 行为)、`test_ai_writing_outline_revise.py`(断言旧 prompt 文案),改造前即失败,待另行清理。 + +## 10. Phase 3 收尾:检索类型两项化 + 配置技能驱动检索(2026-06-13 已实施) + +本节落地了 §9 遗留的「researcher 检索改挂载技能」,并按新需求重新设计了检索类型与 PAUSE-1 交互。 + +### 10.1 检索类型(material_source)收敛为两项 + +- 写作表单「素材来源」改名「检索类型」,只剩 `general`(通用检索,默认)/ `notebook`(我的空间);旧值 `knowledge_base` / `skill` 由 researcher 节点按 `general` 兼容(老会话 resume 不受影响)。 +- 前端:`AIWritingForm.tsx`(选项/标签)、`WritingFormContext.tsx`(默认值/类型/applyProposal 旧值映射)、`types/ai-writing.ts`、`message-list.tsx`(审批卡标签兼容旧值)。 + +### 10.2 通用检索 = 素材收集专家配置的技能编排 + +- `ai-writing-researcher` agent 的 `config.yaml → skills` 是权威配置(智能体管理页可增删/替换);种子默认 `[knowledge-base-search]`。 +- 内置技能 **knowledge-base-search(知识库检索)**:把原 web/intranet 关键词并发检索包装成技能。researcher 对它特殊处理——不拼 description,直接对 Step1 生成的检索词做原生并发检索(行为与旧 knowledge_base 模式一致)。其它配置技能按 `意图 + description` 拼定向 query 检索。按配置顺序执行、累积达 `max_materials` 早停(`stop_early`)。 +- 技能配置为空/全部失效 → 内存兜底回退知识库检索(`researcher._load_configured_skills`),检索永不瘫痪。 +- 种子:`_ai_writing_seed_assets/skills/knowledge-base-search/SKILL.md` → `skills/public/`(never-overwrite);旧部署的 researcher `config.yaml` 缺 `skills` 键时就地补默认(显式 `skills: []` 不动)。`_ai_writing_seed.py::ensure_ai_writing_functional_agents()` 一并处理。 + +### 10.3 PAUSE-1 输入框 → 匹配技能检索并**追加** + +- 用户在素材确认卡输入自然语言(如「检索 A 的数据」)→ `re_search + user_query`:配置技能 >1 个时 `SkillPicker(candidates=配置技能)` 让模型按输入匹配排序(流式打字机保留),单技能直接用;用用户原话(非知识库技能附 description)检索。 +- **追加语义**:保留原素材列表与顺序,新素材按 URL 去重后追加在末尾(ID 续号),跳过 LLM 重排与 max_materials 截断(仅 100 条硬上限),summary 显示「追加检索到 X 条新素材,当前共 Y 条」。原 20 条 + 新 20 条 = 40 条。 +- 「技能检索」独立按钮已删除(通用检索本来就走技能编排);后端继续兼容旧 `enable_skill_search` action(按 user_query 追加处理)。无输入的「重新搜索」仍为覆盖式重检。 +- 前端:`InterventionCard.tsx`(按钮/文案,hasQuery 时按钮文案「检索并追加素材」)、`MaterialsToolbar.tsx`(提示语)、`useAIWritingStream.ts`(素材卡回到 researching 一律置 skillSearchPending,让 SkillStepsCard 占位即时挂载)。 + +### 10.4 测试 + +- `tests/test_ai_writing_material_source.py` 重写(9 例):缺省/旧值→通用检索、自定义技能 description query、user_query 追加+URL 去重+多技能 picker 匹配(candidates 断言)、无输入重检覆盖、空配置回退。 +- `tests/test_ai_writing_seed.py` 扩展(+5 例):技能资产存在、researcher 种子含默认 skills、技能种子 never-overwrite、缺 skills 键就地补丁、显式 `[]` 不动。 diff --git a/frontend-web/docs/multi-agent-background-run-遗留问题与下次继续.md b/frontend-web/docs/multi-agent-background-run-遗留问题与下次继续.md index ba2eefde..4bdb11f6 100644 --- a/frontend-web/docs/multi-agent-background-run-遗留问题与下次继续.md +++ b/frontend-web/docs/multi-agent-background-run-遗留问题与下次继续.md @@ -54,21 +54,22 @@ roundtable-report 真调 deepseek、真搜索、跑到 report run success)。 ## 三、遗留问题(下次重点,已按优先级排) -### P0 —— 报告 agent 不写 HTML 文件(结果绘制拿不到产物) -- **现象**:后台报告 run 跑完,日志 `report html: no .html found in ...threads//user-data/outputs`; - `run.step3.html` 回退成报告 agent 的**纯文字**(不含 ``),第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`——确认它默认行为。 +### P0 —— 报告 agent 不写 HTML 文件(结果绘制拿不到产物)✅ 已于 2026-06-12 修复并恢复 +- **根因(2026-06-12 定位)**:`dc8fca6`(2026-06-11 智能体可见性对齐)给 `services._enforce_agent_access` + 加 admin 判断时**裸访问 `request.state`** —— 后台进程内虚拟 request(`SimpleNamespace(app=app)`) + 没有 `state` 属性 → `AttributeError` 杀死整个编排(前端真 HTTP 请求有 auth 中间件写的 `state.user` + 所以手动路径一直正常)。修复:getattr 全链防御 + 虚拟 request 补空 `state`(含 scheduler 同款), + 回归测试 `tests/test_roundtable_inprocess_request.py`。 +- **已恢复**:`ROUNDTABLE_BACKGROUND_REPORT_ENABLED = True`。后台收口后自动跑 Step3 双产物: + ① `roundtable-summary` 写「方案总结报告.md」(新增 `gateway.run_summary` + `engine._run_step3`, + prompt 对齐 `useStep3Summary.buildSummaryPrompt`)→ ② `roundtable-report` 画「方案总览.html」。 + 两段故障隔离(一段失败保留另一段产物);step3 形状对齐前端 `DraftStep3Snapshot` + (`summaryReport` 嵌套),写草稿 `draft.step3` 与手动调用同款。测试 + `tests/test_roundtable_step3_pipeline.py`。 +- **待真机验证**:早期(06-05,回归引入前)曾观察到 deepseek-chat 在后台不调 `write_file`, + 当时根因可能就是更早版本的同类访问崩溃,也可能另有 prompt 层问题——恢复后跑一单真模型确认 + outputs 下两份产物都落盘;若报告仍不写 HTML,按旧排查清单对比前端 run 参数(run policy 现已 + 由 `roundtable_run_policy.py` 单源统一,差异面只剩 prompt/上下文)。 ### P1 —— 内容质量(本次刚加 inline 广播,待真机验证) - **已做**:席位/总控 prompt 注入「任务背景 + 前序交付 + 别提问」。 diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index d582cbb2..d8e4cfdd 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -4,15 +4,16 @@ import { ArrowDownIcon, Code2Icon, CopyIcon, - DownloadIcon, - EyeIcon, - FileType2Icon, - LoaderIcon, - PackageIcon, - SaveIcon, - UploadCloudIcon, - XIcon, -} from "lucide-react"; + DownloadIcon, + ExternalLinkIcon, + EyeIcon, + FileType2Icon, + LoaderIcon, + PackageIcon, + SaveIcon, + UploadCloudIcon, + XIcon, +} from "lucide-react"; import { useParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { AnchorHTMLAttributes, HTMLAttributes, ReactNode } from "react"; @@ -37,11 +38,11 @@ import { import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { CodeEditor } from "@/components/workspace/code-editor"; import { publishArtifactToKnowledge } from "@/core/agents/api"; -import { useAgent } from "@/core/agents/hooks"; -import { apiFetch } from "@/core/api"; -import { - downloadArtifactFile, -} from "@/core/artifacts/download"; +import { useAgent } from "@/core/agents/hooks"; +import { apiFetch } from "@/core/api"; +import { + downloadArtifactFile, +} from "@/core/artifacts/download"; import { useArtifactContent, useUpdateArtifactContent, @@ -512,7 +513,22 @@ export function ArtifactFileDetail({ } }, [displayContent, filepath]); - const handleDownloadWriteFile = useCallback(() => { + /** HTML 产物在新窗口打开(结果绘制汇总/大屏多页都需要全屏看)。 + * blob: URL 带 charset=utf-8(与预览/下载一致,防中文乱码);URL 延迟回收, + * 给新窗口足够的加载时间。 */ + const handleOpenInNewWindow = useCallback(() => { + const source = displayContent ?? ""; + if (!source.trim()) { + toast.error("文件内容为空,暂时无法在新窗口打开"); + return; + } + const blob = new Blob([source], { type: "text/html;charset=utf-8" }); + const url = URL.createObjectURL(blob); + window.open(url, "_blank", "noopener,noreferrer"); + window.setTimeout(() => URL.revokeObjectURL(url), 60_000); + }, [displayContent]); + + const handleDownloadWriteFile = useCallback(() => { const source = displayContent ?? ""; if (!source.trim()) { toast.error("文件内容为空,暂时无法下载"); @@ -544,7 +560,7 @@ export function ArtifactFileDetail({ return ( - +
@@ -574,7 +590,7 @@ export function ArtifactFileDetail({
-
+
{isSupportPreview && (
)}
-
- +
+ {canPublishToKnowledge && ( )} + {language === "html" && ( + + )} = { loose: "可结合通用知识", }; const MATERIAL_SOURCE_LABELS: Record = { + general: "通用检索", + notebook: "我的空间", + // 旧值(历史会话回显兼容) knowledge_base: "知识库", skill: "技能检索", }; diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx index 36ccdee0..fa3a806b 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx @@ -39,12 +39,11 @@ const INPUT_CLASS = const DEFAULT_KEYWORD_COUNT = 1; const DEFAULT_AUDIENCE = '普通读者'; -type MaterialSource = 'knowledge_base' | 'skill' | 'notebook'; +type MaterialSource = 'general' | 'notebook'; const MAX_INTENT_LENGTH = 500; const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [ - { value: 'knowledge_base', label: '知识库', desc: '默认 web/intranet 检索' }, - { value: 'skill', label: '技能检索', desc: '用已启用的技能编排检索' }, + { value: 'general', label: '通用检索', desc: '用素材收集专家配置的技能检索(默认:知识库检索)' }, { value: 'notebook', label: '我的空间', desc: '从空间中检索素材' }, ]; @@ -279,14 +278,14 @@ export function AIWritingForm({
- +