feat(dashboard-agent): 真实模式先请求数据再抽取 + 重新生成/编辑闸门
- dashboard-input: 缓存仅 mock 开发用,真实模式不读缓存、不兜底假数据, 只用接口真实返回;新增 forceRefresh 跳过缓存强拉最新数据 - DashboardAgentPage: 请求期间「正在请求任务数据…」加载态 + 失败提示/重试; 重新生成(redraw)/编辑(ask)前先 forceRefresh 拉最新数据, guardedGenerate 闸门——拿到数据才生成,拿不到 toast 取消(防并发)
This commit is contained in:
1 parent
31f9838095
commit
d8f32690a3
3 files changed
+145
-50
No files matched your search
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user