diff --git a/frontend-web/src/core/auth/dashboard-input.ts b/frontend-web/src/core/auth/dashboard-input.ts new file mode 100644 index 00000000..c04e59de --- /dev/null +++ b/frontend-web/src/core/auth/dashboard-input.ts @@ -0,0 +1,155 @@ +/** + * 大屏绘制智能体「独立页面」的**输入 JSON** 适配 seam。 + * + * 设计意图:外部系统登录时**后续会**提供一个接口返回一份业务 JSON,本页让 + * 智能体分析这份 JSON → 产出 report-json → 绘制大屏。 + * + * ⚠️ 该接口现在还没开发好。本模块先做**占位适配**让页面尽快可用: + * - 配了 `VITE_DASHBOARD_INPUT_URL` → 按 taskId 拉取真实 JSON; + * - 没配 → 回退用登录深链already 暂存的任务详情(`login.copTaskDetail`)拼一份占位输入, + * 让智能体至少能基于任务名/内容产出一版可看的大屏。 + * + * 接口就绪后:把 `fetchDashboardInputJson` 换成真实拉取;再按规划迁到「后台转换格式 + * + 智能体校验」(校验仍由智能体做,不在后台硬抽)。 + */ + +import { requestCrudService } from "@/service/request-axios-service"; +import { readCopTaskDetail } from "@/core/auth/task-deeplink"; + +/** 本页输入 JSON 暂存的 sessionStorage key(拉取一次后复用,避免重复请求)。 */ +export const DASHBOARD_INPUT_STORAGE_KEY = "dashboard.inputJson"; + +/** + * 🧪 演示用模拟业务 JSON —— JSON 接口未就绪、也没有登录任务详情时的兜底输入。 + * 让大屏页可立即跑通:智能体分析这份数据 → report-json → 渲染大屏。 + * ⚠️ 接口就绪后删除本常量及其兜底引用。 + */ +export const DEMO_DASHBOARD_INPUT = { + taskId: "DEMO-涉台舆情", + taskName: "2026 第一季度涉台舆情态势研判", + taskContent: + "汇总境内外多平台涉台舆情数据,研判热点叙事走势、关键行为体立场与传播渠道分布,识别风险并给出处置建议。", + overview: + "本季涉台舆情整体平稳但局部波动,'两岸交流''和平发展'类正向叙事占比上升,少数账号借军事议题制造紧张;境外智库与媒体联动放大特定框架。", + period: "2026-01-01 ~ 2026-03-31", + kpis: { + 总信息量: 1284000, + 日均增速: "12%", + 正向占比: "58%", + 负向占比: "19%", + 识别风险点: 7, + 覆盖平台: 12, + }, + channels: [ + { platform: "微博", share: 34 }, + { platform: "境外社媒(X/FB)", share: 27 }, + { platform: "短视频", share: 18 }, + { platform: "新闻门户", share: 13 }, + { platform: "论坛/贴吧", share: 8 }, + ], + narratives: [ + { theme: "两岸交流与融合发展", sentiment: "正向", volume: 42, trend: "up" }, + { theme: "和平稳定·避战", sentiment: "正向", volume: 26, trend: "up" }, + { theme: "军事演训与安全", sentiment: "中性偏负", volume: 21, trend: "flat" }, + { theme: "外部势力介入", sentiment: "负向", volume: 11, trend: "down" }, + ], + actors: [ + { name: "主流官媒", stance: "正面引导", influence: 88, note: "议程设置与权威发布" }, + { name: "岛内民间团体", stance: "中性多元", influence: 64, note: "交流合作类内容活跃" }, + { name: "境外智库", stance: "负面框架", influence: 57, note: "联动媒体放大紧张叙事" }, + { name: "自媒体大V", stance: "立场分化", influence: 49, note: "情绪化传播、易被借势" }, + ], + risks: [ + { label: "军事议题被恶意剪辑放大", level: "high" }, + { label: "境外智库联动制造紧张框架", level: "high" }, + { label: "个别大V情绪化带节奏", level: "mid" }, + { label: "短视频谣言二次传播", level: "mid" }, + { label: "节假日期间信息量骤增", level: "low" }, + ], + timeline: [ + { phase: "数据汇聚", desc: "12 平台多源采集与清洗去重" }, + { phase: "叙事识别", desc: "聚类提炼核心叙事与情感倾向" }, + { phase: "行为体画像", desc: "关键账号立场与影响力建模" }, + { phase: "风险研判", desc: "识别高危议题与传播链路" }, + { phase: "处置建议", desc: "分级响应与正向引导预案" }, + ], + weeklyVolume: [ + { week: "W1", value: 86 }, + { week: "W2", value: 92 }, + { week: "W3", value: 110 }, + { week: "W4", value: 134 }, + { week: "W5", value: 121 }, + { week: "W6", value: 158 }, + ], +}; + +function inputBaseUrl(): string { + // TODO: 接口就绪后确认环境变量名/路径。占位:未配则走 copTaskDetail 回退。 + const value = (import.meta.env as Record).VITE_DASHBOARD_INPUT_URL; + return typeof value === "string" ? value.replace(/\/+$/, "") : ""; +} + +/** 读取暂存的输入 JSON。 */ +export function readDashboardInputJson(): unknown | null { + try { + const raw = sessionStorage.getItem(DASHBOARD_INPUT_STORAGE_KEY); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +/** 暂存输入 JSON(隐私模式/quota 异常静默失败)。 */ +export function persistDashboardInputJson(data: unknown): void { + try { + sessionStorage.setItem(DASHBOARD_INPUT_STORAGE_KEY, JSON.stringify(data)); + } catch { + /* noop */ + } +} + +/** 用登录暂存的任务详情拼一份占位输入(接口未就绪时的回退)。 */ +function placeholderFromCopTask(taskId: string): unknown | null { + const detail = readCopTaskDetail(); + if (!detail) return taskId ? { taskId } : null; + return { + taskId, + taskName: detail.taskName, + taskContent: detail.taskContent, + overview: detail.overview, + taskDirection: detail.taskDirection, + _note: "占位输入(真实业务 JSON 接口未就绪)", + }; +} + +/** + * 取本页的输入 JSON:优先暂存 → 真实接口(配了地址)→ copTaskDetail 占位。 + * 任一步失败都静默退化,最终可能返回 null(调用方据此提示用户/不自动开跑)。 + */ +export async function fetchDashboardInputJson(taskId: string): Promise { + const cached = readDashboardInputJson(); + if (cached) return cached; + + const base = inputBaseUrl(); + if (base && taskId) { + try { + // service 拦截器统一注入鉴权并返回 response.data(整包 body)。 + const body = await requestCrudService.get(`${base}`, { taskId }); + const data = body && typeof body === "object" ? (body as { data?: unknown }).data ?? body : body; + if (data) { + persistDashboardInputJson(data); + return data; + } + } catch { + /* 拉取失败 → 落占位 */ + } + } + + const placeholder = placeholderFromCopTask(taskId); + // 占位里有真实任务内容(来自登录详情)才用它;否则回退演示数据,保证页面能跑通看效果。 + // ⚠️ 接口就绪后删除 DEMO 兜底。 + const detail = readCopTaskDetail(); + const resolved = detail ? placeholder : { ...DEMO_DASHBOARD_INPUT, taskId: taskId || DEMO_DASHBOARD_INPUT.taskId }; + if (resolved) persistDashboardInputJson(resolved); + return resolved; +} diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts index 3c953561..247bdb07 100644 --- a/frontend-web/src/core/page-layout/sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/sidebar-menu.ts @@ -65,6 +65,12 @@ export const sidebarMenuItems: MenuItem[] = [ icon: Users, path: "/page/strategy/qa/multi-agent-roundtable", }, + { + id: "dashboard-agent", + label: "大屏绘制", + icon: Gauge, + path: "/page/strategy/dashboard-agent", + }, // { id: "expert-qa", label: "专家问答", icon: Brain, path: "/page/strategy/qa/expert-qa" }, { id: "qa-zzjc", diff --git a/frontend-web/src/core/threads/thread-access-error.ts b/frontend-web/src/core/threads/thread-access-error.ts new file mode 100644 index 00000000..dd803081 --- /dev/null +++ b/frontend-web/src/core/threads/thread-access-error.ts @@ -0,0 +1,98 @@ +import { toast } from "sonner"; + +import { getStoredAuth } from "@/core/auth"; + +/** + * Shared helpers for re-framing LangGraph "thread not accessible" 404s around + * the current login identity. + * + * The most common cause in practice is a login session change: a thread is + * owned by the previously logged-in account, so after switching accounts the + * backend (``app/gateway/authz.py``) correctly returns + * ``HTTP 404: {"detail":"Thread not accessible"}``. Raw-toasting that + * 404 just leaves the user staring at a scary error. Both the chat stream + * (``core/threads/hooks.ts``) and the AI-writing stream + * (``open-canvas/contexts/AIWritingContext.tsx``) funnel their stream errors + * through here so the UX is identical: detect the login-change 404 and prompt + * the user to refresh against the current identity instead of erroring out. + */ + +/** Normalize an unknown stream error into a human-readable message string. */ +export function getStreamErrorMessage(error: unknown): string { + if (typeof error === "string" && error.trim()) { + return error; + } + if (error instanceof Error && error.message.trim()) { + return error.message; + } + if (typeof error === "object" && error !== null) { + const message = Reflect.get(error, "message"); + if (typeof message === "string" && message.trim()) { + return message; + } + const nestedError = Reflect.get(error, "error"); + if (nestedError instanceof Error && nestedError.message.trim()) { + return nestedError.message; + } + if (typeof nestedError === "string" && nestedError.trim()) { + return nestedError; + } + } + return "Request failed."; +} + +/** + * Detect "thread not found" / forbidden errors from the LangGraph SDK. + * + * Backend returns 404 both when a thread truly doesn't exist and when the + * currently authenticated user doesn't own it ("not found" = no row + no + * checkpoint, "not accessible" = row owned by another user / stale login + * session). Both mean the current user can't use this thread. + */ +export function isThreadNotAccessibleError(message: string): boolean { + const lowered = message.toLowerCase(); + if (/thread\s+[\w-]{8,}\s+not\s+(found|accessible)/i.test(message)) { + return true; + } + // Fallback: a bare 404/403 from the threads endpoint when the SDK has + // wrapped the detail differently. + return ( + (lowered.includes("http 404") || lowered.includes("http 403")) && + lowered.includes("thread") + ); +} + +/** Build a label for the *current* logged-in identity (email → user_id → 未知). */ +export function buildCurrentUserLabel(): string { + const auth = getStoredAuth(); + if (!auth) { + return "未登录"; + } + const email = auth.email?.trim(); + if (email) { + return email; + } + const userId = auth.user_id?.trim(); + return userId ? userId : "未知用户"; +} + +/** + * Re-frame a login-change 404 around the current identity and reload the page. + * + * Shows a friendly toast naming the current login, then full-reloads after a + * short delay so the toast is visible and so TanStack Query cache / context + * providers / any state captured under the previous user get rebuilt against + * the current login. Used by streams whose session id lives in component state + * (not the URL) — a plain reload lands them back on a fresh, valid page. + */ +export function notifyThreadAccessChangedAndReload(): void { + const userLabel = buildCurrentUserLabel(); + toast.info( + `当前登录用户为 ${userLabel},原会话不存在或无权访问,正在为你刷新页面…`, + ); + if (typeof window !== "undefined") { + window.setTimeout(() => { + window.location.reload(); + }, 900); + } +} diff --git a/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx b/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx index 11c0ffcb..31c8cdd4 100644 --- a/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx +++ b/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx @@ -15,6 +15,11 @@ import { import { useStream } from '@langchain/langgraph-sdk/react'; import { getAPIClient } from '@/core/api'; +import { + getStreamErrorMessage, + isThreadNotAccessibleError, + notifyThreadAccessChangedAndReload, +} from '@/core/threads/thread-access-error'; import { useCanvasGraphContext } from '../contexts/CanvasGraphContext'; import { saveAIWritingTranscript } from '../api/ai-writing-sessions'; @@ -186,10 +191,18 @@ const AIWritingStreamHost = memo(function AIWritingStreamHost({ }, onError(err: unknown) { eventBatcherRef.current?.cancel(); - dispatchRef.current?.({ - type: 'set_error', - message: err instanceof Error ? err.message : String(err), - }); + const message = getStreamErrorMessage(err); + // A "thread not accessible/not found" 404 almost always means the writing + // thread belongs to a previous login session — the user switched accounts + // (or this tab outlived a re-login), so the current account legitimately + // can't access the old thread. Instead of dumping the raw HTTP 404 into + // the panel, re-frame it around the current identity and reload — same UX + // the chat stream gives (see core/threads/hooks.ts). + if (isThreadNotAccessibleError(message)) { + notifyThreadAccessChangedAndReload(); + return; + } + dispatchRef.current?.({ type: 'set_error', message }); }, onFinish(finishState) { eventBatcherRef.current?.cancel(); @@ -291,7 +304,12 @@ export function AIWritingProvider({ children }: { children: ReactNode }) { }, ); } catch (err) { - dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) }); + const message = getStreamErrorMessage(err); + if (isThreadNotAccessibleError(message)) { + notifyThreadAccessChangedAndReload(); + return; + } + dispatch({ type: 'set_error', message }); } }, [state.status]); @@ -372,7 +390,12 @@ export function AIWritingProvider({ children }: { children: ReactNode }) { streamMode: ['values', 'updates', 'custom', 'messages-tuple'], }); } catch (err) { - dispatch({ type: 'set_error', message: err instanceof Error ? err.message : String(err) }); + const message = getStreamErrorMessage(err); + if (isThreadNotAccessibleError(message)) { + notifyThreadAccessChangedAndReload(); + return; + } + dispatch({ type: 'set_error', message }); } }, [state.sessionId, state.currentPause, state.materials, state.outline, state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult, diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index 27816fa0..306cc25a 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -20,6 +20,7 @@ import { ActionAnalyzes } from "@/strategy-components/components/task-components import KnowledgeBaseResourceManagement from "@/strategy-components/components/resource-management/KnowledgeBaseResourceManagement"; import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement"; import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage"; +import DashboardAgentPage from "@/roundtable-planning/pages/DashboardAgentPage"; import BusinessChainListPage from "@/roundtable-planning/pages/BusinessChainListPage"; import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage"; import "@/roundtable-planning/styles/roundtable-planning.css"; @@ -99,6 +100,16 @@ export default function StrategyRoutes() { } /> + {/* 大屏绘制智能体独立页面(按 taskId 存取;ChatRuntime 提供 ArtifactsProvider 给沙箱预览) */} + + + + } + /> + {/* 需求 0616 #3/#4:zzJC / zzXD 「按类型聚合」模块页(重做,独立路由)。 */} } /> } /> diff --git a/frontend-web/src/roundtable-planning/api/dashboard-sessions.ts b/frontend-web/src/roundtable-planning/api/dashboard-sessions.ts new file mode 100644 index 00000000..d7ddd688 --- /dev/null +++ b/frontend-web/src/roundtable-planning/api/dashboard-sessions.ts @@ -0,0 +1,68 @@ +/** + * 大屏绘制智能体「独立页面」会话持久化 —— 按外部 taskId 存取(后端不按 user 分权)。 + * + * 一个 taskId 一条会话:聊天记录(transcript)+ 最新结构化数据(reportJson)。 + * 后端 `/api/dashboard-sessions/by-task/{taskId}`(GET 取 / PUT upsert)。 + * 详见后端 `app/gateway/routers/dashboard_sessions.py`。 + */ +import { apiFetch } from "@/core/api/fetch-client"; +import { getBackendBaseURL } from "@/core/config"; + +export interface DashboardSession { + taskId: string; + /** 聊天记录(大屏绘制智能体对话气泡数组;形状与 Step2Dialogue 一致,宽松存取)。 */ + transcript: unknown[] | null; + /** 最新结构化数据(report-json 字符串);可被 parseReportJson 解析则视为「有效结果」。 */ + reportJson: string | null; + updatedAt: string | null; +} + +interface ServerDashboardSession { + task_id: string; + transcript: unknown[] | null; + report_json: string | null; + created_at?: string | null; + updated_at?: string | null; +} + +const BASE = () => `${getBackendBaseURL()}/api/dashboard-sessions`; + +function toClient(s: ServerDashboardSession): DashboardSession { + return { + taskId: s.task_id, + transcript: Array.isArray(s.transcript) ? s.transcript : null, + reportJson: s.report_json ?? null, + updatedAt: s.updated_at ?? null, + }; +} + +/** 取该 taskId 的会话;无(404)返回 null。 */ +export async function getDashboardSessionByTask(taskId: string): Promise { + const res = await apiFetch(`${BASE()}/by-task/${encodeURIComponent(taskId)}`, { method: "GET" }); + if (res.status === 404) return null; + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`get dashboard session failed (${res.status}): ${text}`); + } + return toClient((await res.json()) as ServerDashboardSession); +} + +/** upsert 该 taskId 的会话(只传需要更新的字段)。 */ +export async function putDashboardSessionByTask( + taskId: string, + patch: { transcript?: unknown[]; reportJson?: string | null }, +): Promise { + const body: Record = {}; + if (patch.transcript !== undefined) body.transcript = patch.transcript; + if (patch.reportJson !== undefined) body.report_json = patch.reportJson; + const res = await apiFetch(`${BASE()}/by-task/${encodeURIComponent(taskId)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`put dashboard session failed (${res.status}): ${text}`); + } + return toClient((await res.json()) as ServerDashboardSession); +} diff --git a/frontend-web/src/roundtable-planning/components/DashboardChatPanel.tsx b/frontend-web/src/roundtable-planning/components/DashboardChatPanel.tsx new file mode 100644 index 00000000..b590c90e --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/DashboardChatPanel.tsx @@ -0,0 +1,250 @@ +/** + * 大屏绘制智能体「交互区」聊天面板(沙箱左 60%;右 40% 是抽取过程代码预览)。 + * + * 从 `Step3DashboardMode` 抽出共享:圆桌 Step3「结果绘制·大屏」与独立大屏页 + * (`DashboardAgentPage`)都复用同一套聊天 UI(流式气泡 + 模型选择 + 暂停/重画 + + * 修改输入框)。消息里不展示 report-json 本体(已在右侧沙箱实时渲染),用 + * `stripReportFence` 替换成一句提示。 + */ +import { useMemo, useRef, useState, type FormEvent } from "react"; +import { + CheckCircle, + Loader2, + Pause, + RefreshCw, + SendHorizontal, +} from "lucide-react"; + +import { useLocalSettings } from "@/core/settings"; +import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea"; +import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode"; +import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; +import { Step2Message } from "@/roundtable-planning/components/Step2Panel"; +import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll"; +import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics"; +import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard"; +import { stripReportFence } from "@/roundtable-planning/lib/report-template"; + +export interface DashboardChatPanelProps { + phase: UseStep3DashboardReturn["phase"]; + dialogues: UseStep3DashboardReturn["dialogues"]; + busy: boolean; + error: string | null; + onAsk: (message: string) => void; + onRedraw: () => void; + onPause: () => void; + onOpenPreview: () => void; + hasResult: boolean; + selectedModel: string; + availableModels: ReadonlyArray; + modelsLoading: boolean; + setSelectedModel: (model: string) => void; + /** 面板副标题(默认对齐圆桌 Step3;独立大屏页可自定义说明)。 */ + subtitle?: string; + /** 完成提示语(默认「已切换到大屏页面」;独立页不自动跳,可改文案)。 */ + doneHint?: string; +} + +export function DashboardChatPanel({ + phase, + dialogues, + busy, + error, + onAsk, + onRedraw, + onPause, + onOpenPreview, + hasResult, + selectedModel, + availableModels, + modelsLoading, + setSelectedModel, + subtitle = "提炼研讨成果为大屏结构化数据(右侧实时显示抽取过程)", + doneHint = "数据已就绪,已切换到大屏页面。", +}: DashboardChatPanelProps) { + const inputLocked = phase === "creating"; + const scrollRef = useRef(null); + const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]); + const [localSettings] = useLocalSettings(); + const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled; + const dialogueMetrics = useDialogueSpeedMetrics(dialogues); + const [text, setText] = useState(""); + + // 消息列表里不展示 report-json 结构化数据本体(它已在右侧沙箱实时渲染), + // 把数据块替换成一句「看右侧沙箱」的提示,避免左右两边重复刷整段 JSON。 + const displayDialogues = useMemo( + () => + dialogues.map((d) => { + const content = d.content ?? ""; + if (!content) return d; + const stripped = stripReportFence(content, d.streaming === true); + return stripped === content ? d : { ...d, content: stripped }; + }), + [dialogues], + ); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + const t = text.trim(); + if (!t || inputLocked) return; + setText(""); + onAsk(t); + }; + + return ( +
+ {/* 面板头:智能体名 + 模型选择 + 暂停/重画 */} +
+
+
+ 📊 +
+
+
大屏绘制智能体
+
{subtitle}
+
+
+
+ + {busy ? ( + + ) : ( + + )} +
+
+ + {/* 聊天记录 */} +
+
+ {phase === "creating" && dialogues.length === 0 && ( +
+ + 正在初始化绘制线程… +
+ )} + + {displayDialogues.map((msg) => ( + + ))} + + {busy && ( +
+ + 右侧沙箱实时显示数据抽取过程 +
+ )} + + {phase === "error" && ( +
+
绘制失败
+
{error}
+ +
+ )} + + {phase === "done" && ( +
+ + {doneHint} + {hasResult && ( + + )} +
+ )} +
+ {!atBottom && } + + {/* 问答修改输入框 */} +
+
+ setText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + e.currentTarget.form?.requestSubmit(); + } + }} + disabled={inputLocked} + rows={1} + placeholder={ + busy + ? "随时输入修改要求——发送会打断当前绘制并按新要求重出数据" + : "输入修改要求,例如:把风险分布换成柱状图,新增一个执行路线席位" + } + className="min-h-8 max-h-56 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60" + /> + +
+
+
+
+ ); +} diff --git a/frontend-web/src/roundtable-planning/components/Step3DashboardMode.tsx b/frontend-web/src/roundtable-planning/components/Step3DashboardMode.tsx index 69fe7b5a..084d93ed 100644 --- a/frontend-web/src/roundtable-planning/components/Step3DashboardMode.tsx +++ b/frontend-web/src/roundtable-planning/components/Step3DashboardMode.tsx @@ -15,9 +15,7 @@ import { useEffect, useMemo, - useRef, useState, - type FormEvent, } from "react"; import { ArrowLeft, @@ -27,31 +25,22 @@ import { ExternalLink, LayoutPanelTop, Loader2, - Pause, Pencil, - RefreshCw, - SendHorizontal, Sparkles, } from "lucide-react"; -import { useLocalSettings } from "@/core/settings"; -import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea"; import { desensitizeDeep } from "@/lib/desensitize"; import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts"; import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp"; import { adaptReportData } from "@/roundtable-planning/dashboard/data-context"; import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode"; -import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; -import { Step2Message } from "@/roundtable-planning/components/Step2Panel"; +import { DashboardChatPanel } from "@/roundtable-planning/components/DashboardChatPanel"; import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout"; import { DASHBOARD_VIEW_STORAGE_PREFIX } from "@/roundtable-planning/pages/DashboardStandalonePage"; -import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll"; -import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics"; import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard"; import { parseReportJson, renderDashboardHtml, - stripReportFence, type DashboardTheme, } from "@/roundtable-planning/lib/report-template"; import { useTheme } from "@/contexts/ThemeContext"; @@ -361,221 +350,3 @@ export function Step3DashboardMode({ ); } - -// ── 交互区聊天面板(沙箱的左 60%;右 40% 是抽取过程代码预览) ────────────────────────── - -function DashboardChatPanel({ - phase, - dialogues, - busy, - error, - onAsk, - onRedraw, - onPause, - onOpenPreview, - hasResult, - selectedModel, - availableModels, - modelsLoading, - setSelectedModel, -}: { - phase: UseStep3DashboardReturn["phase"]; - dialogues: UseStep3DashboardReturn["dialogues"]; - busy: boolean; - error: string | null; - onAsk: (message: string) => void; - onRedraw: () => void; - onPause: () => void; - onOpenPreview: () => void; - hasResult: boolean; - selectedModel: string; - availableModels: ReadonlyArray; - modelsLoading: boolean; - setSelectedModel: (model: string) => void; -}) { - const inputLocked = phase === "creating"; - const scrollRef = useRef(null); - const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]); - const [localSettings] = useLocalSettings(); - const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled; - const dialogueMetrics = useDialogueSpeedMetrics(dialogues); - const [text, setText] = useState(""); - - // 消息列表里不展示 report-json 结构化数据本体(它已在右侧沙箱实时渲染), - // 把数据块替换成一句「看右侧沙箱」的提示,避免左右两边重复刷整段 JSON。 - const displayDialogues = useMemo( - () => - dialogues.map((d) => { - const content = d.content ?? ""; - if (!content) return d; - const stripped = stripReportFence(content, d.streaming === true); - return stripped === content ? d : { ...d, content: stripped }; - }), - [dialogues], - ); - - const handleSubmit = (e: FormEvent) => { - e.preventDefault(); - const t = text.trim(); - if (!t || inputLocked) return; - setText(""); - onAsk(t); - }; - - return ( -
- {/* 面板头:智能体名 + 模型选择 + 暂停/重画 */} -
-
-
- 📊 -
-
-
大屏绘制智能体
-
提炼研讨成果为大屏结构化数据(右侧实时显示抽取过程)
-
-
-
- - {busy ? ( - - ) : ( - - )} -
-
- - {/* 聊天记录 */} -
-
- {phase === "creating" && dialogues.length === 0 && ( -
- - 正在初始化绘制线程… -
- )} - - {displayDialogues.map((msg) => ( - - ))} - - {busy && ( -
- - 右侧沙箱实时显示数据抽取过程,完成后自动切到大屏页面 -
- )} - - {phase === "error" && ( -
-
绘制失败
-
{error}
- -
- )} - - {phase === "done" && ( -
- - 数据已就绪,已切换到大屏页面。 - {hasResult && ( - - )} -
- )} -
- {!atBottom && } - - {/* 问答修改输入框 */} -
-
- setText(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - e.currentTarget.form?.requestSubmit(); - } - }} - disabled={inputLocked} - rows={1} - placeholder={ - busy - ? "随时输入修改要求——发送会打断当前绘制并按新要求重出数据" - : "输入修改要求,例如:把风险分布换成柱状图,新增一个执行路线席位" - } - className="min-h-8 max-h-56 flex-1 resize-none bg-transparent py-1.5 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60" - /> - -
-
-
-
- ); -} diff --git a/frontend-web/src/roundtable-planning/hooks/useStep3Dashboard.ts b/frontend-web/src/roundtable-planning/hooks/useStep3Dashboard.ts index b96ebadf..d0019f3c 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep3Dashboard.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep3Dashboard.ts @@ -48,6 +48,11 @@ interface UseStep3DashboardOptions { themeMode: "light" | "dark"; savedReport?: DraftStep3Snapshot | null; onReportSaved?: (snapshot: DraftStep3Snapshot) => void; + /** + * 自定义初始 prompt 构造(独立大屏页用:输入是任务原始 JSON,让智能体分析转换)。 + * 返回空/未提供时回退默认的 buildDashboardPrompt(圆桌研讨成果模式)。 + */ + buildInitialPrompt?: () => string | null | undefined; } export interface UseStep3DashboardReturn { @@ -73,6 +78,8 @@ export interface UseStep3DashboardReturn { hydrateSaved: (html: string) => void; /** 从草稿/后台作业恢复(dashboardJson 结构化数据):按**当前主题**渲染后展示。 */ hydrateFromJson: (jsonStr: string) => void; + /** 从持久化恢复**真实聊天记录** + 结构化数据(独立大屏页按 taskId 取回时用)。 */ + hydrateTranscript: (dialogues: Step2Dialogue[], dashboardJson: string | null) => void; /** 手动打开大屏沙箱预览(恢复后/用户点气泡)。 */ openDashboardPreview: () => void; } @@ -105,6 +112,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da themeMode, savedReport, onReportSaved, + buildInitialPrompt, } = options; const onSavedRef = useRef(onReportSaved); @@ -112,6 +120,11 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da onSavedRef.current = onReportSaved; }, [onReportSaved]); + const buildInitialPromptRef = useRef(buildInitialPrompt); + useEffect(() => { + buildInitialPromptRef.current = buildInitialPrompt; + }, [buildInitialPrompt]); + const { select: selectArtifact, setOpen: setArtifactsOpen, open: artifactsOpen } = useArtifacts(); const artifactsOpenRef = useRef(artifactsOpen); useEffect(() => { @@ -374,7 +387,13 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da ] .filter(Boolean) .join("\n"); - const prompt = buildDashboardPrompt({ intentText, seatDeliveries, consensusText }); + // 独立大屏页:用自定义 prompt(输入任务原始 JSON,让智能体分析转换); + // 否则回退默认的圆桌研讨成果 prompt。 + const custom = buildInitialPromptRef.current?.(); + const prompt = + custom && custom.trim() + ? custom + : buildDashboardPrompt({ intentText, seatDeliveries, consensusText }); await streamOnce({ bubbleId, message: prompt, aborter }); } catch (err) { if (aborter.signal.aborted) return; @@ -486,6 +505,38 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da [themeMode, hydrateSaved], ); + /** 从持久化恢复真实聊天记录 + 结构化数据(独立大屏页按 taskId 取回时用)。 + * 与 hydrateFromJson 的区别:保留**用户真实的历史对话气泡**,而非造一条合成提示。 */ + const hydrateTranscript = useCallback( + (savedDialogues: Step2Dialogue[], dashboardJson: string | null) => { + abort(); + runningRef.current = false; + const synthThreadId = "roundtable-dashboard-restored"; + liveThreadRef.current = null; // 合成线程非真线程:续问会新建线程重新生成 + setDashThreadId(synthThreadId); + setActiveArtifactThreadId(synthThreadId); + const parsed = dashboardJson ? parseReportJson(dashboardJson) : null; + if (parsed) { + const rendered = renderDashboardHtml(parsed, { generatedAt: formatNow(), theme: themeMode }); + htmlRef.current = rendered; + setHtml(rendered); + setData(parsed); + dataRef.current = parsed; // 续轮修改的基线 + const { messageId, toolCallId } = upsertDashStub(synthThreadId, rendered); + lastAutoOpenRef.current = { threadId: synthThreadId, messageId, toolCallId }; + } else { + htmlRef.current = null; + setHtml(null); + setData(null); + dataRef.current = null; + } + setDialogues(Array.isArray(savedDialogues) ? savedDialogues : []); + setError(null); + setPhase(parsed ? "done" : "idle"); + }, + [abort, themeMode, upsertDashStub], + ); + // 恢复路径辅助:savedReport 由组件/页面按字段调用对应 hydrate;这里只消除未用告警。 void savedReport; void isDashboardHtml; @@ -519,6 +570,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da setPreviewEnabled, hydrateSaved, hydrateFromJson, + hydrateTranscript, openDashboardPreview, }; } diff --git a/frontend-web/src/roundtable-planning/lib/report-template.ts b/frontend-web/src/roundtable-planning/lib/report-template.ts index 8b7c1854..ac2f62d6 100644 --- a/frontend-web/src/roundtable-planning/lib/report-template.ts +++ b/frontend-web/src/roundtable-planning/lib/report-template.ts @@ -351,6 +351,22 @@ export function coerceReportData(raw: unknown): ReportData | null { // ── 模型 prompt(只要 report-json,不调工具、不写文件) ─────────────────────────── +/** ```report-json 契约说明(buildDashboardPrompt / buildDashboardPromptFromJson 共用)。 */ +const DASHBOARD_REPORT_JSON_CONTRACT = [ + "```report-json 契约(严格 JSON,全程简体中文):", + '{"title":"大屏主标题(≤20字)","subtitle":"一句话定位",', + ' "kpis":[{"label":"指标名","value":"数值","unit":"单位","trend":"up|down|flat"}], // 3-6 个核心指标,从内容提取或合理估算', + ' "consensus":{"headline":"一句话最终结论","points":["结论要点,3-6 条"]},', + ' "charts":[{"type":"line|bar|donut|radar","title":"图表标题","items":[{"label":"维度","value":数字}]}], // 1-3 个总览图表:line=趋势(销量/渗透率随时间)、donut=占比(份额/构成)、radar=多维对比(竞争力)、bar=排名;3-8 项/图', + ' "timeline":[{"label":"阶段名","desc":"一句话说明"}], // 执行路线 3-6 阶段', + ' "seats":[{"id":"seat-1","name":"分区/角色名","summary":"该分区结论一两句",', + ' "sections":[{"heading":"小节标题","bullets":["要点,每条≤40字"]}], // 2-4 节', + ' "metrics":[{"label":"量化指标名","value":0到100}], // 0-3 个', + ' "risks":[{"label":"风险点","level":"high|mid|low"}], // 0-4 个', + ' "dependsOn":["它承接哪些分区的产物(分区id)"]}]} // 依据内容判断依赖关系', + "硬性要求:id 用 seat-1..seat-N;dependsOn 只引用本清单内的 id;JSON 必须可被 JSON.parse 解析(无注释、无尾逗号)。", +].join("\n"); + export function buildDashboardPrompt(opts: { intentText: string; seatDeliveries: Array<{ sender: string; content: string }>; @@ -374,24 +390,45 @@ export function buildDashboardPrompt(opts: { parts.push((opts.consensusText || "").trim() || "(未提供)"); parts.push(""); parts.push( - [ - "```report-json 契约(严格 JSON,全程简体中文):", - '{"title":"大屏主标题(≤20字)","subtitle":"一句话定位",', - ' "kpis":[{"label":"指标名","value":"数值","unit":"单位","trend":"up|down|flat"}], // 3-6 个核心指标,从研讨内容提取或合理估算', - ' "consensus":{"headline":"一句话最终结论","points":["结论要点,3-6 条"]},', - ' "charts":[{"type":"line|bar|donut|radar","title":"图表标题","items":[{"label":"维度","value":数字}]}], // 1-3 个总览图表:line=趋势(销量/渗透率随时间)、donut=占比(份额/构成)、radar=多维对比(竞争力)、bar=排名;3-8 项/图', - ' "timeline":[{"label":"阶段名","desc":"一句话说明"}], // 执行路线 3-6 阶段', - ' "seats":[{"id":"seat-1","name":"席位名(与交付席位一致)","summary":"该席位结论一两句",', - ' "sections":[{"heading":"小节标题","bullets":["要点,每条≤40字"]}], // 2-4 节,提炼该席位交付', - ' "metrics":[{"label":"量化指标名","value":0到100}], // 0-3 个', - ' "risks":[{"label":"风险点","level":"high|mid|low"}], // 0-4 个', - ' "dependsOn":["它承接哪些席位的产物(席位id)"]}]} // 每个交付席位一项;依据交付内容判断依赖关系', - "硬性要求:seats 覆盖**每一个**有交付的席位;id 用 seat-1..seat-N;dependsOn 只引用本清单内的 id;JSON 必须可被 JSON.parse 解析(无注释、无尾逗号)。", - ].join("\n"), + DASHBOARD_REPORT_JSON_CONTRACT.replace( + "硬性要求:id", + "硬性要求:seats 覆盖**每一个**有交付的席位;id", + ), ); return parts.join("\n"); } +/** + * 大屏绘制智能体(独立页面)的初始 prompt:输入是外部系统给的**任意结构 JSON**, + * 让智能体**分析/转换**成大屏所需的 report-json 结构。区别于 buildDashboardPrompt + * (输入是圆桌研讨成果),这里输入是登录时拉取的任务原始数据。 + * + * 注:现在 JSON 接口未就绪,调用方传的是占位数据(任务名/内容等);接口就绪后改为 + * 后台转换格式 + 智能体校验,仍由本智能体产出/校验 report-json(不后台硬抽)。 + */ +export function buildDashboardPromptFromJson(inputJson: unknown): string { + let inputText: string; + try { + inputText = + typeof inputJson === "string" ? inputJson : JSON.stringify(inputJson, null, 2); + } catch { + inputText = String(inputJson ?? ""); + } + return [ + "【结构化数据模式】本轮**不要调用任何工具、不要写任何文件、不要输出 HTML**。", + "下面是本任务的**原始数据(JSON)**。请你**分析并转换**它,提炼成一份用于「研讨大屏」展示的结构化数据:", + "理解原始数据的业务含义,归纳关键指标、结论、图表维度、分阶段路线,以及若干主题分区(seats,按业务维度/模块拆分,不必对应真实席位)。", + "在回复**最末尾**输出一个完整的 ```report-json 代码块(契约见下),代码块之外只允许一两句简短说明。", + "", + "# 任务原始数据(JSON)", + "```json", + inputText.trim() || "(未提供)", + "```", + "", + DASHBOARD_REPORT_JSON_CONTRACT, + ].join("\n"); +} + // ── 主题(2026-06-13):跟随应用当前主题绘制 ───────────────────────────────────── // light = 《浅红色 UI 规范》(docs/浅红色UI规范):白/浅灰大底、#F5222D 品牌红强调、 // 卡片 4px 圆角、语义色 成功#189D72/警告#FF9900/错误#F5222D/信息#0079FE; diff --git a/frontend-web/src/roundtable-planning/pages/DashboardAgentPage.tsx b/frontend-web/src/roundtable-planning/pages/DashboardAgentPage.tsx new file mode 100644 index 00000000..897bf4e3 --- /dev/null +++ b/frontend-web/src/roundtable-planning/pages/DashboardAgentPage.tsx @@ -0,0 +1,377 @@ +/** + * 「大屏绘制智能体」独立页面(带导航 + 侧边栏,挂在 StrategyRoutes 下)。 + * + * 按外部 `taskId`(URL `?taskId=` / 路由 state / 登录暂存的任务详情 id)存取: + * - 进入时按 taskId 拉取已存会话(`/api/dashboard-sessions/by-task`,**不按 user 分权**)。 + * - **已有有效结果(可解析的 report-json)** → 直接展示大屏页面(应用内 `DashboardApp`)。 + * - **没有有效结果** → 进入「生成过程」视图:自动让智能体分析登录拉取的业务 JSON + * (现为占位适配,接口就绪后改后台转换 + 智能体校验)→ 边抽边在右侧沙箱显示, + * **完成不自动跳**;底部输入框可随时改。 + * - 聊天记录 + 最新 report-json 防抖回存(按 taskId 共享,跨用户可见)。 + * + * 数据抽取/聊天/沙箱/渲染全部复用 `useStep3Dashboard` + `DashboardChatPanel` + + * `DashboardApp`,仅把输入从「圆桌研讨成果」换成「任务原始 JSON」。 + */ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useLocation, useSearchParams } from "react-router-dom"; +import { + ArrowLeft, + Boxes, + Code2, + ExternalLink, + LayoutPanelTop, + Loader2, + Pencil, + Sparkles, +} from "lucide-react"; + +import { fetchDashboardInputJson } from "@/core/auth/dashboard-input"; +import { readCopTaskDetail } from "@/core/auth/task-deeplink"; +import { desensitizeDeep } from "@/lib/desensitize"; +import { useModels } from "@/core/models/hooks"; +import { useTheme } from "@/contexts/ThemeContext"; +import { DashboardApp } from "@/roundtable-planning/dashboard/DashboardApp"; +import { adaptReportData } from "@/roundtable-planning/dashboard/data-context"; +import { DashboardChatPanel } from "@/roundtable-planning/components/DashboardChatPanel"; +import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout"; +import { DASHBOARD_VIEW_STORAGE_PREFIX } from "@/roundtable-planning/pages/DashboardStandalonePage"; +import { useStep3Dashboard } from "@/roundtable-planning/hooks/useStep3Dashboard"; +import type { Step2Dialogue } from "@/roundtable-planning/hooks/useStep2Orchestration"; +import { + buildDashboardPromptFromJson, + parseReportJson, + renderDashboardHtml, + type DashboardTheme, +} from "@/roundtable-planning/lib/report-template"; +import { + getDashboardSessionByTask, + putDashboardSessionByTask, +} from "@/roundtable-planning/api/dashboard-sessions"; + +function formatNow(): string { + const d = new Date(); + const pad = (n: number) => n.toString().padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +type DashView = "chat" | "page"; +type RenderMode = "app" | "html"; + +/** 从 URL / 路由 state / 登录暂存任务详情解析 taskId。 */ +function useTaskId(): string { + const [searchParams] = useSearchParams(); + const location = useLocation(); + return useMemo(() => { + const fromQuery = searchParams.get("taskId")?.trim(); + if (fromQuery) return fromQuery; + const fromState = (location.state as { taskId?: string } | null)?.taskId; + if (fromState && String(fromState).trim()) return String(fromState).trim(); + const detail = readCopTaskDetail(); + if (detail?.id != null && String(detail.id).trim()) return String(detail.id).trim(); + return ""; + }, [searchParams, location.state]); +} + +export default function DashboardAgentPage() { + const taskId = useTaskId(); + const { models: availableModels, isLoading: modelsLoading } = useModels(); + const [selectedModel, setSelectedModel] = useState(""); + const { theme } = useTheme(); + const themeMode: DashboardTheme = theme === "dark" ? "dark" : "light"; + + // 输入 JSON(登录拉取/占位)——auto-start 时由 buildInitialPrompt 闭包读取。 + const inputRef = useRef(null); + + const dashboard = useStep3Dashboard({ + intentReady: null, + seatDeliveries: [], + consensusText: "", + selectedModel, + themeMode, + buildInitialPrompt: () => { + const inp = inputRef.current; + return inp ? buildDashboardPromptFromJson(inp) : null; + }, + }); + + const { + phase, + dialogues, + data, + seatStubMessages, + activeArtifactThreadId, + busy, + redraw, + ask, + abort, + setPreviewEnabled, + openDashboardPreview, + start, + hydrateTranscript, + } = dashboard; + + const [loading, setLoading] = useState(true); + const [view, setView] = useState("chat"); + const [renderMode, setRenderMode] = useState("app"); + + // 默认选中第一个模型。 + useEffect(() => { + if (availableModels.length === 0) return; + if (!availableModels.some((m) => m.name === selectedModel)) { + setSelectedModel(availableModels[0].name); + } + }, [availableModels, selectedModel]); + + // ── 挂载:按 taskId 取会话 → 决定直显大屏 / 看生成过程(只跑一次) ── + const didInitRef = useRef(false); + useEffect(() => { + if (didInitRef.current) return; + if (!taskId) { + setLoading(false); + return; + } + didInitRef.current = true; + let cancelled = false; + void (async () => { + try { + const session = await getDashboardSessionByTask(taskId).catch(() => null); + if (cancelled) return; + const savedDialogues = (session?.transcript as Step2Dialogue[] | undefined) ?? []; + const validReport = session?.reportJson && parseReportJson(session.reportJson); + if (validReport) { + // 有有效结果 → 恢复并直接展示大屏页面。 + hydrateTranscript(savedDialogues, session!.reportJson); + setView("page"); + } else if (savedDialogues.length > 0) { + // 有历史聊天但无有效结果 → 进生成过程展示历史,不自动重跑。 + hydrateTranscript(savedDialogues, session?.reportJson ?? null); + setView("chat"); + } else { + // 无任何记录 → 拉输入 JSON 自动开跑,让用户看生成过程(不自动跳)。 + const input = await fetchDashboardInputJson(taskId).catch(() => null); + if (cancelled) return; + inputRef.current = input; + setView("chat"); + if (input) start(); + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [taskId, hydrateTranscript, start]); + + // ── 防抖回存聊天记录 + 最新 report-json(按 taskId 共享) ── + useEffect(() => { + if (!taskId || loading) return; + if (dialogues.length === 0) return; + const t = window.setTimeout(() => { + void putDashboardSessionByTask(taskId, { + transcript: dialogues, + reportJson: data ? JSON.stringify(data) : undefined, + }).catch(() => { + /* 回存失败不打扰用户 */ + }); + }, 800); + return () => window.clearTimeout(t); + }, [taskId, loading, dialogues, data]); + + // 仅在交互区允许本流自动接管沙箱(并行流防抢);离开/卸载时让出。 + useEffect(() => { + setPreviewEnabled(view === "chat"); + return () => setPreviewEnabled(false); + }, [view, setPreviewEnabled]); + + // ── 大屏渲染数据(深度脱敏后渲染,覆盖大屏所有展示文本) ── + const report = useMemo(() => (data ? desensitizeDeep(data) : null), [data]); + const dashboardData = useMemo( + () => (report ? adaptReportData(report, formatNow()) : undefined), + [report], + ); + const dashboardHtml = useMemo( + () => (report ? renderDashboardHtml(report, { generatedAt: formatNow(), theme: themeMode }) : null), + [report, themeMode], + ); + const hasResult = !!dashboardData || !!dashboardHtml; + + // 网页渲染:blob URL 作 iframe src(:target 导航需独立文档 URL,见 Step3DashboardMode 注释)。 + const [htmlBlobUrl, setHtmlBlobUrl] = useState(null); + useEffect(() => { + if (typeof window === "undefined" || !dashboardHtml) { + setHtmlBlobUrl(null); + return; + } + const url = URL.createObjectURL(new Blob([dashboardHtml], { type: "text/html;charset=utf-8" })); + setHtmlBlobUrl(url); + return () => URL.revokeObjectURL(url); + }, [dashboardHtml]); + + const isStreaming = + phase === "creating" || phase === "streaming" || dialogues.some((d) => d.streaming === true); + + const handleOpenNewWindow = () => { + if (typeof window === "undefined") return; + if (renderMode === "html" && dashboardHtml) { + const blobUrl = URL.createObjectURL(new Blob([dashboardHtml], { type: "text/html;charset=utf-8" })); + window.open(blobUrl, "_blank", "noopener,noreferrer"); + window.setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000); + return; + } + const key = `${DASHBOARD_VIEW_STORAGE_PREFIX}${Date.now()}`; + try { + for (let i = window.localStorage.length - 1; i >= 0; i -= 1) { + const k = window.localStorage.key(i); + if (k && k.startsWith(DASHBOARD_VIEW_STORAGE_PREFIX)) window.localStorage.removeItem(k); + } + if (dashboardData) window.localStorage.setItem(key, JSON.stringify(dashboardData)); + } catch { + /* localStorage 不可用:新窗口回落 DEMO */ + } + const base = `${window.location.origin}${window.location.pathname}`; + window.open(`${base}#/roundtable-dashboard?k=${encodeURIComponent(key)}`, "_blank", "noopener,noreferrer"); + }; + + if (!taskId) { + return ( +
+
+ + 缺少任务标识(taskId)。请通过带 ?taskId= 的链接进入大屏绘制页。 +
+
+ ); + } + + return ( +
+ {/* 顶栏 */} +
+
+ +

大屏绘制智能体

+ + {(phase === "creating" || phase === "streaming") && } + {view === "chat" ? "生成过程" : "大屏页面"} + +
+ +
+ {view === "page" ? ( + <> +
+ + +
+ + + + ) : ( + hasResult && ( + + ) + )} +
+
+ + {/* 主体 */} + {loading ? ( +
+ + 正在加载任务大屏… +
+ ) : view === "page" ? ( + renderMode === "html" && htmlBlobUrl ? ( +