From d5b0bd4d31231bec3e74511d00693f8d267a44d9 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Wed, 3 Jun 2026 21:44:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(roundtable):=20=E6=96=87=E4=BB=B6=E5=8D=A1?= =?UTF-8?q?=E6=88=AA=E6=96=AD=E3=80=81Step3=20=E5=AE=8C=E6=95=B4=20HTML=20?= =?UTF-8?q?=E4=B8=8E=E8=B7=A8=E6=A0=87=E7=AD=BE=E9=A1=B5=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第二步消息列表长文件名不再溢出卡片;第三步绘制结束后从沙箱回灌完整 HTML(修复 append 续写 stub 被截短),进入第三步时关闭第二步沙箱;HTML 预览与提示词强调 utf-8/中文。跨标签页切换账号改为 toast 提示而非强制刷新;方案总结智能体 SOUL 去掉「AI 自动生成」标签要求。 --- .../artifacts/artifact-file-detail.tsx | 5 +- frontend-web/src/core/auth/sync.tsx | 76 ++++++++++++++++--- .../components/HighFidelityReport.tsx | 8 ++ .../components/Step2Panel.tsx | 10 +-- .../hooks/useStep3Report.ts | 68 +++++++++++++++-- .../roundtable-report/SOUL.md | 3 +- 6 files changed, 143 insertions(+), 27 deletions(-) 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 01800c52..faa635a2 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -715,7 +715,10 @@ export function ArtifactFilePreview({ return; } - const blob = new Blob([content ?? ""], { type: "text/html" }); + // 必须带 charset=utf-8:blob: URL 的 Content-Type 不含 charset 时,iframe 会按 + // 系统默认编码(中文 Windows 常为 GBK/Latin-1)解码,模型生成的 HTML 若没写 + // 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。 + const blob = new Blob([content ?? ""], { type: "text/html;charset=utf-8" }); const url = URL.createObjectURL(blob); setHtmlPreviewUrl(url); diff --git a/frontend-web/src/core/auth/sync.tsx b/frontend-web/src/core/auth/sync.tsx index e9f3b460..8a5fbdb6 100644 --- a/frontend-web/src/core/auth/sync.tsx +++ b/frontend-web/src/core/auth/sync.tsx @@ -1,46 +1,98 @@ "use client"; import { useEffect, useRef } from "react"; +import { toast } from "sonner"; import { getStoredAuth } from "@/core/auth"; const AUTH_STORAGE_KEY = "deerflow.auth"; +// 防抖窗口:跨标签页登录/重写 auth 常常在极短时间内连发多个 storage 事件 +// (例如先写 token、再写用户信息)。等它"落定"再判断,避免抖动期的中间态 +// (短暂 null / 半写状态)误触发动作。 +const SETTLE_DELAY_MS = 1200; + +// 提示 toast 的稳定 id —— 同一次身份变化只保留一条,避免连发事件刷屏。 +const IDENTITY_TOAST_ID = "auth-identity-changed"; + function readUserId(): string | null { return getStoredAuth()?.user_id ?? null; } /** * Mount once at the app root. Watches `localStorage["deerflow.auth"]` for - * cross-tab changes — when another tab logs in as a different user, this tab - * still holds the previous user's cached state (chat list, thread ids, etc.) - * which then collides with the new token and surfaces as a 404 on "新对话". - * Reloading the tab is the simplest way to resync React state with the new - * identity. + * cross-tab changes. + * + * 背景:`deerflow.auth` 在同源所有标签页之间共享。某个标签页登录/重写它时, + * 其它标签页仍持有旧用户的内存态(会话列表、thread id、以及多智能体会商这类 + * 纯内存的页面状态),与新 token 不一致,可能在"新对话"等操作上 404。 + * + * 旧实现:只要检测到 user_id 变化就 `window.location.reload()`。这会把**正在 + * 使用的标签页**(例如正在跑多智能体会商第二步的那个)整页刷新,内存态全丢、 + * 步骤回到第一步 —— 用户复制链接到新标签页打开(新标签页可能以不同/默认用户 + * 重新登录并改写共享 auth)就会触发,体验很差。 + * + * 现实现(方案 1,收敛粗暴刷新): + * - **真·退出登录**(auth 变为 null)→ 仍然跳登录页并刷新(本就该重新登录)。 + * - **出现不同的非空用户**(另一个标签页登录成别的身份)→ **不再自动整页刷新**, + * 改为弹一条常驻提示,把"是否刷新同步"的决定权交给用户,避免冲掉正在进行的工作。 + * - 防抖 + 落定后二次校验,过滤掉同用户重写 / 抖动中间态带来的误触发。 */ export function AuthSyncWatcher() { const lastUserIdRef = useRef(readUserId()); useEffect(() => { - function handleStorage(event: StorageEvent) { - if (event.key !== null && event.key !== AUTH_STORAGE_KEY) { - return; - } + let settleTimer: ReturnType | null = null; + + // storage 事件落定后再判断当前的真实身份并决定动作。 + function evaluateSettledIdentity() { const nextUserId = readUserId(); if (nextUserId === lastUserIdRef.current) { + // 同用户重写,或抖动后又回到原身份 —— 无需任何动作。 return; } - lastUserIdRef.current = nextUserId; + if (nextUserId === null) { + // 另一个标签页退出登录(或清空了 auth)→ 本标签页也该回到登录页。 + lastUserIdRef.current = null; window.location.replace("#/login"); window.location.reload(); return; } - window.location.reload(); + + // 出现了不同的非空用户。共享 auth 已被改成别的身份,继续在本标签页发请求 + // 可能 404 / 鉴权失败,但**不**强制刷新 —— 用户可能正在本标签页干活 + // (如多智能体会商),由用户自己决定何时刷新同步。 + lastUserIdRef.current = nextUserId; + toast.warning("检测到账号在其它标签页发生变化", { + id: IDENTITY_TOAST_ID, + description: "当前标签页仍是原会话。如需与新账号同步,请刷新页面。", + duration: Infinity, + action: { + label: "刷新同步", + onClick: () => window.location.reload(), + }, + }); + } + + function handleStorage(event: StorageEvent) { + // 只关心 auth 键的变化;event.key === null 表示 localStorage 被整体清空。 + if (event.key !== null && event.key !== AUTH_STORAGE_KEY) { + return; + } + if (settleTimer) { + clearTimeout(settleTimer); + } + settleTimer = setTimeout(evaluateSettledIdentity, SETTLE_DELAY_MS); } window.addEventListener("storage", handleStorage); - return () => window.removeEventListener("storage", handleStorage); + return () => { + window.removeEventListener("storage", handleStorage); + if (settleTimer) { + clearTimeout(settleTimer); + } + }; }, []); return null; diff --git a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx index 8cb7a0d0..cd5e88ea 100644 --- a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx +++ b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx @@ -14,6 +14,7 @@ import { Sparkles, } from "lucide-react"; +import { useArtifacts } from "@/components/workspace/artifacts"; import { buildArtifactDownloadUrl, listSessionArtifacts, @@ -118,6 +119,13 @@ export const HighFidelityReport: React.FC = ({ }) => { // 「结果绘制」模式:把现有报告内容收起到左栏,右侧由方案可视化智能体绘制图表 HTML。 const [drawMode, setDrawMode] = useState(false); + + // 进入第三步即关闭并清空第二步遗留的沙箱(artifacts 面板全局共享,第二步打开后 + // 不会自动关)。点「结果绘制」后 Step3ReportMode 会按需开自己的沙箱,互不串扰。 + const { deselect: deselectArtifact } = useArtifacts(); + useEffect(() => { + deselectArtifact(); + }, [deselectArtifact]); // Build the per-seat list (excludes the coordinator). useMemo so the file // fetch effect doesn't re-fire on every parent render. const seatThreadEntries = useMemo(() => { diff --git a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx index c3cc6206..fc077524 100644 --- a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx @@ -521,7 +521,7 @@ function PresentFilesRow({ disabled: boolean; }) { return ( -
+
{files.map((path) => ( -
+
{filename}
-
+
{typeLabel} diff --git a/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts b/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts index 6d2bf725..72f05b81 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts @@ -17,8 +17,10 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useArtifacts } from "@/components/workspace/artifacts"; +import { apiFetch } from "@/core/api"; import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts"; import { + buildArtifactDownloadUrl, initReportThread, listSessionArtifacts, streamMultiAgent, @@ -139,6 +141,11 @@ function buildReportPrompt(opts: { "文字为主图表点睛(全篇穿插 3–5 个有信息量的图、每图配一两句解读)、浅红主题(主色 #c25555);" + "写完文件后只用一两句话总结,不要把 HTML 源码贴进对话。", ); + // 语言 + 编码硬约束:偶发整篇输出英文 / 中文乱码的两个根因都在这里堵住。 + parts.push( + "硬性要求:① 全程使用简体中文输出——HTML 正文、标题、图表标签与图例、按钮、结论摘要等一切文字都必须是中文,禁止输出英文(专有名词可中英并列);" + + '② HTML 必须是完整文档,并在 最前面声明 ,确保中文不乱码。', + ); return parts.join("\n"); } @@ -543,6 +550,51 @@ export function useStep3Report( [setArtifacts, selectArtifact, setArtifactsOpen], ); + // 收尾对账:以沙箱真实文件为准回灌 stub,拿到完整 HTML。 + // 智能体可能分多次写文件 —— 尤其是 write_file 带 `append=true` 续写后半段(截图里 + // 「Append remaining HTML content…」那一步)。后端 append 会把后半段拼到磁盘文件已有 + // 内容后面,磁盘文件是完整的;但前端 stub 按 (name,path) 去重,第二次 write_file 会用 + // 「只含后半段」的 content 覆盖掉前半段(防抹空守卫只挡空内容、挡不住非空的后半段)。 + // 这里流式结束后从 outputs 读真实文件回灌 stub,让预览与存档都拿到完整 HTML。 + // 返回最终用于存草稿的 HTML(拉取失败 / 真实文件更短时退回 stub 内容,不打断收尾)。 + const reconcileStubFromRealFile = useCallback( + async (threadId: string, signal?: AbortSignal): Promise => { + const writeTc = stubRef.current[threadId]?.[0]?.tool_calls.find( + (tc) => tc.name === "write_file", + ); + const stubPath = + typeof (writeTc?.args as { path?: unknown })?.path === "string" + ? ((writeTc!.args as { path?: string }).path as string) + : ""; + const stubContent = + typeof (writeTc?.args as { content?: unknown })?.content === "string" + ? ((writeTc!.args as { content?: string }).content as string) + : ""; + if (!stubPath) return stubContent; + try { + const list = await listSessionArtifacts(threadId, { signal }); + const htmlFile = + list.files.find((f) => /\.html?$/i.test(f.name)) ?? null; + if (!htmlFile) return stubContent; + const res = await apiFetch( + buildArtifactDownloadUrl(threadId, htmlFile.path), + { signal }, + ); + if (!res.ok) return stubContent; + const real = await res.text(); + // 只在真实文件更长(说明 stub 被后半段截短了)时替换,避免偶发空/短响应回退。 + if (real.trim() && real.length > stubContent.length) { + upsertStub(threadId, "write_file", { path: stubPath, content: real }); + return real; + } + } catch { + /* 拉取真实文件失败就退回 stub 内容,不打断主流程 */ + } + return stubContent; + }, + [upsertStub], + ); + const abort = useCallback(() => { aborterRef.current?.abort(); aborterRef.current = null; @@ -604,17 +656,16 @@ export function useStep3Report( const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0; if (!hasStubFile) await openRealArtifactFallback(threadId); + // 以沙箱真实文件回灌 stub(修复 append 续写只剩后半段的问题),并拿到完整 HTML。 + const html = hasStubFile + ? await reconcileStubFromRealFile(threadId, aborter.signal) + : ""; + if (aborter.signal.aborted) return; + finalizeDialogue(bubbleId, res.content, "绘制完成"); setPhase("done"); - // 绘制成功 → 把完整 HTML 等抛给上层存进草稿(step3)。从 stub 取最新 write_file 内容。 - const writeTc = stubRef.current[threadId]?.[0]?.tool_calls.find( - (tc) => tc.name === "write_file", - ); - const html = - typeof (writeTc?.args as { content?: unknown })?.content === "string" - ? ((writeTc!.args as { content?: string }).content as string) - : ""; + // 绘制成功 → 把完整 HTML 等抛给上层存进草稿(step3)。 if (html.trim()) { onReportSavedRef.current?.({ html, @@ -645,6 +696,7 @@ export function useStep3Report( handleArtifactToolPhase, handleUpstreamFrame, openRealArtifactFallback, + reconcileStubFromRealFile, ]); const start = useCallback(() => { diff --git a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-report/SOUL.md b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-report/SOUL.md index 933bc760..30756662 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-report/SOUL.md +++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-report/SOUL.md @@ -30,7 +30,7 @@ | # | 章节 | 内容(文字) | 配图建议 | |---|------|------|----------| -| ① | 报告头 | 标题 + 生成时间占位 + 「AI 自动生成」标签 | — | +| ① | 报告头 | 标题 + 生成时间占位 | — | | ② | 一句话结论 + 关键指标卡 | 用一句话点明最终方案/结论 | 4–6 个 KPI 数字卡(共识度、参与席位数、识别风险数、里程碑数、预估周期、预算规模等) | | ③ | 任务背景与目标 | 2–4 句交代要解决什么、目标与关键约束 | 可选:目标/约束要点列表 | | ④ | 各席位观点与交付 | **每个席位一段提炼文字**(这位专家关注什么、给出了什么关键结论/建议),用小标题+正文,让读者读懂每方的贡献 | 关键席位视情况配小图(如情报席位的对比、环境席位的承载评估) | @@ -73,4 +73,5 @@ - ❌ 写 Python / shell / 任何脚本去生成 HTML,或用 bash 跑脚本产出文件(必须自己把 HTML 文本直接写进 `write_file`) - ❌ 把 HTML 拆成多文件或分多次拼接、或写完后再去读取/校验文件 - ❌ 用 `str_replace` 局部修改 HTML(返工请整文件 `write_file` 重写) +- ❌ 在报告头或任何位置添加「AI 自动生成」「AI generated」之类的标签、徽章或水印 - ❌ 编造与原料明显矛盾的数据