From d8f32690a3d25e94eec14471606bdbf315c12cd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 19 Jun 2026 14:04:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(dashboard-agent):=20=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E5=85=88=E8=AF=B7=E6=B1=82=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=86=8D=E6=8A=BD=E5=8F=96=20+=20=E9=87=8D=E6=96=B0=E7=94=9F?= =?UTF-8?q?=E6=88=90/=E7=BC=96=E8=BE=91=E9=97=B8=E9=97=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dashboard-input: 缓存仅 mock 开发用,真实模式不读缓存、不兜底假数据, 只用接口真实返回;新增 forceRefresh 跳过缓存强拉最新数据 - DashboardAgentPage: 请求期间「正在请求任务数据…」加载态 + 失败提示/重试; 重新生成(redraw)/编辑(ask)前先 forceRefresh 拉最新数据, guardedGenerate 闸门——拿到数据才生成,拿不到 toast 取消(防并发) --- frontend-web/.env.development | 2 +- frontend-web/src/core/auth/dashboard-input.ts | 43 +++-- .../pages/DashboardAgentPage.tsx | 150 +++++++++++++----- 3 files changed, 145 insertions(+), 50 deletions(-) diff --git a/frontend-web/.env.development b/frontend-web/.env.development index cca4033a..67b77a36 100644 --- a/frontend-web/.env.development +++ b/frontend-web/.env.development @@ -8,7 +8,7 @@ VITE_USE_REAL_DATA=true VITE_MOCK_COP_TASK_DETAIL=true # 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:true 走本地样例假数据(真实接口未就绪时调试用);联调通过后置 false。 -VITE_MOCK_SQ_REPORT=true +VITE_MOCK_SQ_REPORT=false # Gateway / DeerFlow API VITE_BACKEND_BASE_URL=http://127.0.0.1:8001 diff --git a/frontend-web/src/core/auth/dashboard-input.ts b/frontend-web/src/core/auth/dashboard-input.ts index 7e1142da..145259b4 100644 --- a/frontend-web/src/core/auth/dashboard-input.ts +++ b/frontend-web/src/core/auth/dashboard-input.ts @@ -134,8 +134,17 @@ export function extractSqReport(body: unknown, taskId: string): unknown | null { return { taskId, source: "cop/selectSqReport", categories }; } -/** 读取暂存的输入 JSON。 */ +/** + * 输入 JSON 缓存**仅供开发(mock)使用**:`VITE_MOCK_SQ_REPORT==='true'` 时才读/写缓存, + * 避免开发反复请求样例假数据。真实接口模式(false)下不读不写缓存,**始终拉取真实数据**。 + */ +function inputCacheEnabled(): boolean { + return (import.meta.env as Record).VITE_MOCK_SQ_REPORT === "true"; +} + +/** 读取暂存的输入 JSON(仅 mock 开发模式生效,真实模式恒返回 null 以强制重新拉取)。 */ export function readDashboardInputJson(): unknown | null { + if (!inputCacheEnabled()) return null; try { const raw = sessionStorage.getItem(DASHBOARD_INPUT_STORAGE_KEY); return raw ? JSON.parse(raw) : null; @@ -144,8 +153,9 @@ export function readDashboardInputJson(): unknown | null { } } -/** 暂存输入 JSON(隐私模式/quota 异常静默失败)。 */ +/** 暂存输入 JSON(仅 mock 开发模式生效;隐私模式/quota 异常静默失败)。 */ export function persistDashboardInputJson(data: unknown): void { + if (!inputCacheEnabled()) return; try { sessionStorage.setItem(DASHBOARD_INPUT_STORAGE_KEY, JSON.stringify(data)); } catch { @@ -168,33 +178,44 @@ function placeholderFromCopTask(taskId: string): unknown | null { } /** - * 取本页的输入 JSON:优先暂存 → selectSqReport 接口(提取 contentJson)→ copTaskDetail 占位。 - * 任一步失败都静默退化,最终可能返回 null(调用方据此提示用户/不自动开跑)。 + * 取本页的输入 JSON:**先请求 selectSqReport,待其返回后再交给模型抽取**。 * - * 真实接口未就绪时,`VITE_MOCK_SQ_REPORT=true` 让请求走本地样例假数据(见 utils/request.ts), - * 此处逻辑对真假数据完全一致——都是「请求 selectSqReport → extractSqReport 提取内容」。 + * - 真实接口模式(`VITE_MOCK_SQ_REPORT=false`):不读缓存、不兜底假数据—— + * **只用接口真实返回的内容**;请求失败或返回空一律返回 `null`(页面据此提示「请求失败/无数据」, + * 绝不拿占位/演示数据去喂模型,避免「没等接口就抽取假数据」)。 + * - mock 开发模式(true):走本地样例假数据(见 utils/request.ts)并缓存避免反复请求; + * 仅此模式在接口不可用时回退 copTaskDetail 占位 / DEMO 演示数据,保证开发能跑通。 */ -export async function fetchDashboardInputJson(taskId: string): Promise { - const cached = readDashboardInputJson(); +export async function fetchDashboardInputJson( + taskId: string, + opts?: { forceRefresh?: boolean }, +): Promise { + const mock = inputCacheEnabled(); // true=mock/开发;false=真实接口 + // forceRefresh(重新分析/修改时):跳过缓存,强制重新请求接口拿**最新数据**(数据可能已变化)。 + const cached = opts?.forceRefresh ? null : readDashboardInputJson(); // 真实模式恒 null(缓存仅开发用) if (cached) return cached; const url = sqReportUrl(); if (url && taskId) { try { // service 拦截器统一注入鉴权并返回 response.data(整包 body)。 + // await 在此:必须**等接口返回**后才有 body 可 extract → 再由上层启动模型抽取。 const body = await requestCrudService.get(url, { taskId }); const extracted = extractSqReport(body, taskId); if (extracted) { - persistDashboardInputJson(extracted); + persistDashboardInputJson(extracted); // 真实模式 no-op return extracted; } } catch { - /* 拉取失败 → 落占位 */ + /* 请求失败:真实模式返回 null(见下),mock 模式落占位/DEMO */ } } + // 真实接口模式:**不兜底假数据**。失败/空 → null,让页面提示而不是拿假数据喂模型。 + if (!mock) return null; + + // 以下兜底**仅 mock/开发模式**:copTaskDetail 占位 / DEMO 演示数据,保证开发能看效果。 const placeholder = placeholderFromCopTask(taskId); - // 占位里有真实任务内容(来自登录详情)才用它;否则回退演示数据,保证页面能跑通看效果。 const detail = readCopTaskDetail(); const resolved = detail ? placeholder : { ...DEMO_DASHBOARD_INPUT, taskId: taskId || DEMO_DASHBOARD_INPUT.taskId }; if (resolved) persistDashboardInputJson(resolved); diff --git a/frontend-web/src/roundtable-planning/pages/DashboardAgentPage.tsx b/frontend-web/src/roundtable-planning/pages/DashboardAgentPage.tsx index 6ce2ccb1..ce7a1fd5 100644 --- a/frontend-web/src/roundtable-planning/pages/DashboardAgentPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/DashboardAgentPage.tsx @@ -12,8 +12,9 @@ * 数据抽取/聊天/沙箱/渲染全部复用 `useStep3Dashboard` + `DashboardChatPanel` + * `DashboardApp`,仅把输入从「圆桌研讨成果」换成「任务原始 JSON」。 */ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useLocation, useSearchParams } from "react-router-dom"; +import { toast } from "sonner"; import { ArrowLeft, Boxes, @@ -22,6 +23,7 @@ import { LayoutPanelTop, Loader2, Pencil, + RefreshCw, Sparkles, } from "lucide-react"; @@ -113,6 +115,8 @@ export default function DashboardAgentPage() { const [loading, setLoading] = useState(true); const [view, setView] = useState("chat"); const [renderMode, setRenderMode] = useState("app"); + // 真实接口请求失败/无数据时的提示(真实模式不拿假数据兜底,改为提示+重试)。 + const [inputError, setInputError] = useState(null); // 默认选中第一个模型。 useEffect(() => { @@ -131,48 +135,56 @@ export default function DashboardAgentPage() { const hydrateTranscriptRef = useRef(hydrateTranscript); hydrateTranscriptRef.current = hydrateTranscript; - // ── 挂载:按 taskId 取会话 → 决定直显大屏 / 看生成过程(只跑一次) ── - const didInitRef = useRef(false); - useEffect(() => { - if (didInitRef.current) return; + // ── 挂载:按 taskId 取会话 → 决定直显大屏 / 看生成过程 ── + // 抽成可复用函数:首屏 effect 跑一次 + 「重试」按钮可再调(真实接口请求失败后重新拉取)。 + const initialize = useCallback(async () => { if (!taskId) { setLoading(false); return; } - didInitRef.current = true; - void (async () => { - try { - // 会话 + 输入 JSON 并行取。输入**始终**备好并写进 inputRef —— 否则恢复历史 - // 后用户点「重新绘制」/续问时 buildInitialPrompt 拿不到源数据,会回退到空的 - // 圆桌 prompt,智能体没东西可分析 → 生成不出数据(即「模拟数据不生效」)。 - const [session, input] = await Promise.all([ - getDashboardSessionByTask(taskId).catch(() => null), - fetchDashboardInputJson(taskId).catch(() => null), - ]); - inputRef.current = input; - const savedDialogues = (session?.transcript as Step2Dialogue[] | undefined) ?? []; - const validReport = session?.reportJson && parseReportJson(session.reportJson); - if (validReport) { - // 有有效结果 → 恢复并直接展示大屏页面。 - hydrateTranscriptRef.current(savedDialogues, session!.reportJson); - setView("page"); - } else if (savedDialogues.length > 0) { - // 有历史聊天但无有效结果 → 进生成过程展示历史,不自动重跑(点「重新绘制」可重生成)。 - hydrateTranscriptRef.current(savedDialogues, session?.reportJson ?? null); - setView("chat"); + setLoading(true); // 请求数据期间显示加载效果(「正在请求任务数据…」) + setInputError(null); + try { + // 会话 + 输入 JSON 并行取,但**都 await 完成后**才决定是否启动模型抽取—— + // 即「先请求数据,待接口返回后再交给模型抽取」。输入写进 inputRef 供续问/重画复用。 + const [session, input] = await Promise.all([ + getDashboardSessionByTask(taskId).catch(() => null), + fetchDashboardInputJson(taskId).catch(() => null), + ]); + inputRef.current = input; + const savedDialogues = (session?.transcript as Step2Dialogue[] | undefined) ?? []; + const validReport = session?.reportJson && parseReportJson(session.reportJson); + if (validReport) { + // 有有效结果 → 恢复并直接展示大屏页面。 + hydrateTranscriptRef.current(savedDialogues, session!.reportJson); + setView("page"); + } else if (savedDialogues.length > 0) { + // 有历史聊天但无有效结果 → 进生成过程展示历史,不自动重跑(点「重新绘制」可重生成)。 + hydrateTranscriptRef.current(savedDialogues, session?.reportJson ?? null); + setView("chat"); + } else { + // 无任何记录 → 接口返回了真实数据才启动模型抽取;否则提示(真实模式不拿假数据兜底)。 + setView("chat"); + if (input) { + startRef.current(); } else { - // 无任何记录 → 自动开跑,让用户看生成过程(不自动跳)。 - setView("chat"); - if (input) startRef.current(); + setInputError("未获取到任务数据:接口请求失败或未返回内容,请稍后重试。"); } - } finally { - setLoading(false); } - })(); - // 只依赖 taskId:start/hydrateTranscript 经 ref 读取,避免其 identity 变化重跑本 effect。 + } finally { + setLoading(false); + } + // 只依赖 taskId:start/hydrateTranscript 经 ref 读取,避免其 identity 变化重建本回调。 // eslint-disable-next-line react-hooks/exhaustive-deps }, [taskId]); + const didInitRef = useRef(false); + useEffect(() => { + if (didInitRef.current) return; + didInitRef.current = true; + void initialize(); + }, [initialize]); + // ── 防抖回存聊天记录 + 最新 report-json(按 taskId 共享) ── useEffect(() => { if (!taskId || loading) return; @@ -221,6 +233,50 @@ export default function DashboardAgentPage() { const isStreaming = phase === "creating" || phase === "streaming" || dialogues.some((d) => d.streaming === true); + // 「重新分析 / 修改」前都**重新请求一次最新数据**(数据可能已变化);返回拉到的最新输入或 null。 + const refreshInput = useCallback(async (): Promise => { + if (!taskId) return null; + const fresh = await fetchDashboardInputJson(taskId, { forceRefresh: true }).catch(() => null); + if (fresh) inputRef.current = fresh; // 拉到才覆盖;失败保留上一次,避免清空可用数据 + return fresh; + }, [taskId]); + + // 防并发:刷新+生成进行中时再次点击直接忽略。 + const refreshingRef = useRef(false); + + // 重新生成/编辑统一闸门:**只有请求到最新数据才继续生成**,拿不到则提示并取消(不拿旧/空数据跑模型)。 + const guardedGenerate = useCallback( + (run: () => void, cancelHint: string) => { + if (refreshingRef.current) return; + refreshingRef.current = true; + void (async () => { + const loadingId = toast.loading("正在请求最新任务数据…"); + try { + const fresh = await refreshInput(); + if (!fresh) { + toast.error(cancelHint, { id: loadingId }); + return; + } + toast.dismiss(loadingId); + run(); + } finally { + refreshingRef.current = false; + } + })(); + }, + [refreshInput], + ); + + const handleRedraw = useCallback( + () => guardedGenerate(redraw, "未获取到最新任务数据,已取消重新生成,请稍后重试"), + [guardedGenerate, redraw], + ); + + const handleAsk = useCallback( + (message: string) => guardedGenerate(() => ask(message), "未获取到最新任务数据,已取消修改,请稍后重试"), + [guardedGenerate, ask], + ); + const handleOpenNewWindow = () => { if (typeof window === "undefined") return; if (renderMode === "html" && dashboardHtml) { @@ -336,9 +392,27 @@ export default function DashboardAgentPage() { {/* 主体 */} {loading ? ( -
- - 正在加载任务大屏… +
+ +
+
正在请求任务数据…
+
拉取接口返回后将自动开始智能体抽取绘制
+
+
+ ) : inputError && dialogues.length === 0 ? ( +
+
+ +
{inputError}
+ +
) : view === "page" ? ( renderMode === "html" && htmlBlobUrl ? ( @@ -366,8 +440,8 @@ export default function DashboardAgentPage() { dialogues={dialogues} busy={busy} error={dashboard.error} - onAsk={ask} - onRedraw={redraw} + onAsk={handleAsk} + onRedraw={handleRedraw} onPause={abort} onOpenPreview={openDashboardPreview} hasResult={hasResult}