feat(dashboard-agent): 真实模式先请求数据再抽取 + 重新生成/编辑闸门

- dashboard-input: 缓存仅 mock 开发用,真实模式不读缓存、不兜底假数据,
  只用接口真实返回;新增 forceRefresh 跳过缓存强拉最新数据
- DashboardAgentPage: 请求期间「正在请求任务数据…」加载态 + 失败提示/重试;
  重新生成(redraw)/编辑(ask)前先 forceRefresh 拉最新数据,
  guardedGenerate 闸门——拿到数据才生成,拿不到 toast 取消(防并发)
This commit is contained in:
董宏凯 committed 2026-06-19 14:04:42 +08:00
1 parent 31f9838095
commit d8f32690a3
3 files changed
+145 -50

No files matched your search

+1 -1
View File
@@ -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
+32 -11
View File
@@ -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<string, unknown>).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<unknown | null> {
const cached = readDashboardInputJson();
export async function fetchDashboardInputJson(
taskId: string,
opts?: { forceRefresh?: boolean },
): Promise<unknown | null> {
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);
@@ -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<DashView>("chat");
const [renderMode, setRenderMode] = useState<RenderMode>("app");
// 真实接口请求失败/无数据时的提示(真实模式不拿假数据兜底,改为提示+重试)。
const [inputError, setInputError] = useState<string | null>(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<unknown | null> => {
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 ? (
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-slate-500">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
正在加载任务大屏…
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-sm text-slate-500">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
<div className="text-center">
<div className="font-semibold text-slate-700">正在请求任务数据…</div>
<div className="mt-1 text-xs text-slate-400">拉取接口返回后将自动开始智能体抽取绘制</div>
</div>
</div>
) : inputError && dialogues.length === 0 ? (
<div className="flex flex-1 items-center justify-center p-8 text-center text-sm text-slate-500">
<div className="max-w-sm">
<Sparkles className="mx-auto mb-3 h-8 w-8 text-primary/40" />
<div className="mb-4 text-slate-600">{inputError}</div>
<button
type="button"
onClick={() => void initialize()}
className="inline-flex items-center gap-1.5 rounded-xl border border-slate-200 bg-white px-4 py-2 text-xs font-bold text-slate-700 transition hover:bg-slate-50"
>
<RefreshCw className="h-3.5 w-3.5 text-slate-500" />
重新请求
</button>
</div>
</div>
) : 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}