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
|
VITE_MOCK_COP_TASK_DETAIL=true
|
||||||
|
|
||||||
# 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:true 走本地样例假数据(真实接口未就绪时调试用);联调通过后置 false。
|
# 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:true 走本地样例假数据(真实接口未就绪时调试用);联调通过后置 false。
|
||||||
VITE_MOCK_SQ_REPORT=true
|
VITE_MOCK_SQ_REPORT=false
|
||||||
|
|
||||||
# Gateway / DeerFlow API
|
# Gateway / DeerFlow API
|
||||||
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
|
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 };
|
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 {
|
export function readDashboardInputJson(): unknown | null {
|
||||||
|
if (!inputCacheEnabled()) return null;
|
||||||
try {
|
try {
|
||||||
const raw = sessionStorage.getItem(DASHBOARD_INPUT_STORAGE_KEY);
|
const raw = sessionStorage.getItem(DASHBOARD_INPUT_STORAGE_KEY);
|
||||||
return raw ? JSON.parse(raw) : null;
|
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 {
|
export function persistDashboardInputJson(data: unknown): void {
|
||||||
|
if (!inputCacheEnabled()) return;
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem(DASHBOARD_INPUT_STORAGE_KEY, JSON.stringify(data));
|
sessionStorage.setItem(DASHBOARD_INPUT_STORAGE_KEY, JSON.stringify(data));
|
||||||
} catch {
|
} catch {
|
||||||
@@ -168,33 +178,44 @@ function placeholderFromCopTask(taskId: string): unknown | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取本页的输入 JSON:优先暂存 → selectSqReport 接口(提取 contentJson)→ copTaskDetail 占位。
|
* 取本页的输入 JSON:**先请求 selectSqReport,待其返回后再交给模型抽取**。
|
||||||
* 任一步失败都静默退化,最终可能返回 null(调用方据此提示用户/不自动开跑)。
|
|
||||||
*
|
*
|
||||||
* 真实接口未就绪时,`VITE_MOCK_SQ_REPORT=true` 让请求走本地样例假数据(见 utils/request.ts),
|
* - 真实接口模式(`VITE_MOCK_SQ_REPORT=false`):不读缓存、不兜底假数据——
|
||||||
* 此处逻辑对真假数据完全一致——都是「请求 selectSqReport → extractSqReport 提取内容」。
|
* **只用接口真实返回的内容**;请求失败或返回空一律返回 `null`(页面据此提示「请求失败/无数据」,
|
||||||
|
* 绝不拿占位/演示数据去喂模型,避免「没等接口就抽取假数据」)。
|
||||||
|
* - mock 开发模式(true):走本地样例假数据(见 utils/request.ts)并缓存避免反复请求;
|
||||||
|
* 仅此模式在接口不可用时回退 copTaskDetail 占位 / DEMO 演示数据,保证开发能跑通。
|
||||||
*/
|
*/
|
||||||
export async function fetchDashboardInputJson(taskId: string): Promise<unknown | null> {
|
export async function fetchDashboardInputJson(
|
||||||
const cached = readDashboardInputJson();
|
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;
|
if (cached) return cached;
|
||||||
|
|
||||||
const url = sqReportUrl();
|
const url = sqReportUrl();
|
||||||
if (url && taskId) {
|
if (url && taskId) {
|
||||||
try {
|
try {
|
||||||
// service 拦截器统一注入鉴权并返回 response.data(整包 body)。
|
// service 拦截器统一注入鉴权并返回 response.data(整包 body)。
|
||||||
|
// await 在此:必须**等接口返回**后才有 body 可 extract → 再由上层启动模型抽取。
|
||||||
const body = await requestCrudService.get(url, { taskId });
|
const body = await requestCrudService.get(url, { taskId });
|
||||||
const extracted = extractSqReport(body, taskId);
|
const extracted = extractSqReport(body, taskId);
|
||||||
if (extracted) {
|
if (extracted) {
|
||||||
persistDashboardInputJson(extracted);
|
persistDashboardInputJson(extracted); // 真实模式 no-op
|
||||||
return extracted;
|
return extracted;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* 拉取失败 → 落占位 */
|
/* 请求失败:真实模式返回 null(见下),mock 模式落占位/DEMO */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 真实接口模式:**不兜底假数据**。失败/空 → null,让页面提示而不是拿假数据喂模型。
|
||||||
|
if (!mock) return null;
|
||||||
|
|
||||||
|
// 以下兜底**仅 mock/开发模式**:copTaskDetail 占位 / DEMO 演示数据,保证开发能看效果。
|
||||||
const placeholder = placeholderFromCopTask(taskId);
|
const placeholder = placeholderFromCopTask(taskId);
|
||||||
// 占位里有真实任务内容(来自登录详情)才用它;否则回退演示数据,保证页面能跑通看效果。
|
|
||||||
const detail = readCopTaskDetail();
|
const detail = readCopTaskDetail();
|
||||||
const resolved = detail ? placeholder : { ...DEMO_DASHBOARD_INPUT, taskId: taskId || DEMO_DASHBOARD_INPUT.taskId };
|
const resolved = detail ? placeholder : { ...DEMO_DASHBOARD_INPUT, taskId: taskId || DEMO_DASHBOARD_INPUT.taskId };
|
||||||
if (resolved) persistDashboardInputJson(resolved);
|
if (resolved) persistDashboardInputJson(resolved);
|
||||||
|
|||||||
@@ -12,8 +12,9 @@
|
|||||||
* 数据抽取/聊天/沙箱/渲染全部复用 `useStep3Dashboard` + `DashboardChatPanel` +
|
* 数据抽取/聊天/沙箱/渲染全部复用 `useStep3Dashboard` + `DashboardChatPanel` +
|
||||||
* `DashboardApp`,仅把输入从「圆桌研讨成果」换成「任务原始 JSON」。
|
* `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 { useLocation, useSearchParams } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Boxes,
|
Boxes,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
LayoutPanelTop,
|
LayoutPanelTop,
|
||||||
Loader2,
|
Loader2,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
RefreshCw,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -113,6 +115,8 @@ export default function DashboardAgentPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [view, setView] = useState<DashView>("chat");
|
const [view, setView] = useState<DashView>("chat");
|
||||||
const [renderMode, setRenderMode] = useState<RenderMode>("app");
|
const [renderMode, setRenderMode] = useState<RenderMode>("app");
|
||||||
|
// 真实接口请求失败/无数据时的提示(真实模式不拿假数据兜底,改为提示+重试)。
|
||||||
|
const [inputError, setInputError] = useState<string | null>(null);
|
||||||
|
|
||||||
// 默认选中第一个模型。
|
// 默认选中第一个模型。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -131,48 +135,56 @@ export default function DashboardAgentPage() {
|
|||||||
const hydrateTranscriptRef = useRef(hydrateTranscript);
|
const hydrateTranscriptRef = useRef(hydrateTranscript);
|
||||||
hydrateTranscriptRef.current = hydrateTranscript;
|
hydrateTranscriptRef.current = hydrateTranscript;
|
||||||
|
|
||||||
// ── 挂载:按 taskId 取会话 → 决定直显大屏 / 看生成过程(只跑一次) ──
|
// ── 挂载:按 taskId 取会话 → 决定直显大屏 / 看生成过程 ──
|
||||||
const didInitRef = useRef(false);
|
// 抽成可复用函数:首屏 effect 跑一次 + 「重试」按钮可再调(真实接口请求失败后重新拉取)。
|
||||||
useEffect(() => {
|
const initialize = useCallback(async () => {
|
||||||
if (didInitRef.current) return;
|
|
||||||
if (!taskId) {
|
if (!taskId) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
didInitRef.current = true;
|
setLoading(true); // 请求数据期间显示加载效果(「正在请求任务数据…」)
|
||||||
void (async () => {
|
setInputError(null);
|
||||||
try {
|
try {
|
||||||
// 会话 + 输入 JSON 并行取。输入**始终**备好并写进 inputRef —— 否则恢复历史
|
// 会话 + 输入 JSON 并行取,但**都 await 完成后**才决定是否启动模型抽取——
|
||||||
// 后用户点「重新绘制」/续问时 buildInitialPrompt 拿不到源数据,会回退到空的
|
// 即「先请求数据,待接口返回后再交给模型抽取」。输入写进 inputRef 供续问/重画复用。
|
||||||
// 圆桌 prompt,智能体没东西可分析 → 生成不出数据(即「模拟数据不生效」)。
|
const [session, input] = await Promise.all([
|
||||||
const [session, input] = await Promise.all([
|
getDashboardSessionByTask(taskId).catch(() => null),
|
||||||
getDashboardSessionByTask(taskId).catch(() => null),
|
fetchDashboardInputJson(taskId).catch(() => null),
|
||||||
fetchDashboardInputJson(taskId).catch(() => null),
|
]);
|
||||||
]);
|
inputRef.current = input;
|
||||||
inputRef.current = input;
|
const savedDialogues = (session?.transcript as Step2Dialogue[] | undefined) ?? [];
|
||||||
const savedDialogues = (session?.transcript as Step2Dialogue[] | undefined) ?? [];
|
const validReport = session?.reportJson && parseReportJson(session.reportJson);
|
||||||
const validReport = session?.reportJson && parseReportJson(session.reportJson);
|
if (validReport) {
|
||||||
if (validReport) {
|
// 有有效结果 → 恢复并直接展示大屏页面。
|
||||||
// 有有效结果 → 恢复并直接展示大屏页面。
|
hydrateTranscriptRef.current(savedDialogues, session!.reportJson);
|
||||||
hydrateTranscriptRef.current(savedDialogues, session!.reportJson);
|
setView("page");
|
||||||
setView("page");
|
} else if (savedDialogues.length > 0) {
|
||||||
} else if (savedDialogues.length > 0) {
|
// 有历史聊天但无有效结果 → 进生成过程展示历史,不自动重跑(点「重新绘制」可重生成)。
|
||||||
// 有历史聊天但无有效结果 → 进生成过程展示历史,不自动重跑(点「重新绘制」可重生成)。
|
hydrateTranscriptRef.current(savedDialogues, session?.reportJson ?? null);
|
||||||
hydrateTranscriptRef.current(savedDialogues, session?.reportJson ?? null);
|
setView("chat");
|
||||||
setView("chat");
|
} else {
|
||||||
|
// 无任何记录 → 接口返回了真实数据才启动模型抽取;否则提示(真实模式不拿假数据兜底)。
|
||||||
|
setView("chat");
|
||||||
|
if (input) {
|
||||||
|
startRef.current();
|
||||||
} else {
|
} else {
|
||||||
// 无任何记录 → 自动开跑,让用户看生成过程(不自动跳)。
|
setInputError("未获取到任务数据:接口请求失败或未返回内容,请稍后重试。");
|
||||||
setView("chat");
|
|
||||||
if (input) startRef.current();
|
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
})();
|
} finally {
|
||||||
// 只依赖 taskId:start/hydrateTranscript 经 ref 读取,避免其 identity 变化重跑本 effect。
|
setLoading(false);
|
||||||
|
}
|
||||||
|
// 只依赖 taskId:start/hydrateTranscript 经 ref 读取,避免其 identity 变化重建本回调。
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [taskId]);
|
}, [taskId]);
|
||||||
|
|
||||||
|
const didInitRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (didInitRef.current) return;
|
||||||
|
didInitRef.current = true;
|
||||||
|
void initialize();
|
||||||
|
}, [initialize]);
|
||||||
|
|
||||||
// ── 防抖回存聊天记录 + 最新 report-json(按 taskId 共享) ──
|
// ── 防抖回存聊天记录 + 最新 report-json(按 taskId 共享) ──
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!taskId || loading) return;
|
if (!taskId || loading) return;
|
||||||
@@ -221,6 +233,50 @@ export default function DashboardAgentPage() {
|
|||||||
const isStreaming =
|
const isStreaming =
|
||||||
phase === "creating" || phase === "streaming" || dialogues.some((d) => d.streaming === true);
|
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 = () => {
|
const handleOpenNewWindow = () => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
if (renderMode === "html" && dashboardHtml) {
|
if (renderMode === "html" && dashboardHtml) {
|
||||||
@@ -336,9 +392,27 @@ export default function DashboardAgentPage() {
|
|||||||
|
|
||||||
{/* 主体 */}
|
{/* 主体 */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-slate-500">
|
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-sm text-slate-500">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
<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>
|
</div>
|
||||||
) : view === "page" ? (
|
) : view === "page" ? (
|
||||||
renderMode === "html" && htmlBlobUrl ? (
|
renderMode === "html" && htmlBlobUrl ? (
|
||||||
@@ -366,8 +440,8 @@ export default function DashboardAgentPage() {
|
|||||||
dialogues={dialogues}
|
dialogues={dialogues}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
error={dashboard.error}
|
error={dashboard.error}
|
||||||
onAsk={ask}
|
onAsk={handleAsk}
|
||||||
onRedraw={redraw}
|
onRedraw={handleRedraw}
|
||||||
onPause={abort}
|
onPause={abort}
|
||||||
onOpenPreview={openDashboardPreview}
|
onOpenPreview={openDashboardPreview}
|
||||||
hasResult={hasResult}
|
hasResult={hasResult}
|
||||||
|
|||||||
Reference in new issue
Block a user