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 (
+
+ {/* 面板头:智能体名 + 模型选择 + 暂停/重画 */}
+
+
+
+
+ {busy ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* 聊天记录 */}
+
+
+ {phase === "creating" && dialogues.length === 0 && (
+
+
+ 正在初始化绘制线程…
+
+ )}
+
+ {displayDialogues.map((msg) => (
+
+ ))}
+
+ {busy && (
+
+
+ 右侧沙箱实时显示数据抽取过程
+
+ )}
+
+ {phase === "error" && (
+
+
绘制失败
+
{error}
+
+
+ )}
+
+ {phase === "done" && (
+
+
+ {doneHint}
+ {hasResult && (
+
+ )}
+
+ )}
+
+ {!atBottom &&
}
+
+ {/* 问答修改输入框 */}
+
+
+
+ );
+}
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 &&
}
-
- {/* 问答修改输入框 */}
-
-
-
- );
-}
diff --git a/frontend-web/src/roundtable-planning/hooks/useStep3Dashboard.ts b/frontend-web/src/roundtable-planning/hooks/useStep3Dashboard.ts
index af040adf..c39d9773 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;
}
/** 恢复大屏时可带上的历史生成过程对话(文字 + 步骤条)+ stub —— 整段回显用。 */
@@ -82,6 +87,8 @@ export interface UseStep3DashboardReturn {
hydrateSaved: (html: string, saved?: Step3DashRestore) => void;
/** 从草稿/后台作业恢复(dashboardJson 结构化数据):按**当前主题**渲染后展示。 */
hydrateFromJson: (jsonStr: string, saved?: Step3DashRestore) => void;
+ /** 从持久化恢复**真实聊天记录** + 结构化数据(独立大屏页按 taskId 取回时用)。 */
+ hydrateTranscript: (dialogues: Step2Dialogue[], dashboardJson: string | null) => void;
/** 手动打开大屏沙箱预览(恢复后/用户点气泡)。 */
openDashboardPreview: () => void;
}
@@ -114,6 +121,7 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
themeMode,
savedReport,
onReportSaved,
+ buildInitialPrompt,
} = options;
const onSavedRef = useRef(onReportSaved);
@@ -121,6 +129,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(() => {
@@ -383,7 +396,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;
@@ -513,6 +532,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;
@@ -546,6 +597,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 ? (
+
+ ) : (
+
+
+
+ )
+ ) : (
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
index f4178a69..0fda7901 100644
--- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
+++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
@@ -2564,22 +2564,31 @@ function RoundtableEntryRail({
该业务链暂无智能体席位
) : (
-
- {stageGroups.map((stage, si) => (
-
- {si > 0 && (
-
- )}
- {stage.length > 1 && (
-
- 并行 {stage.length}
-
- )}
-
- {stage.map((seat, sj) => {
+
+ {stageGroups.flatMap((stage, si) => {
+ // 横向编排:阶段从左到右排列,阶段之间用 → 串联;并行阶段的
+ // 席位在该列内竖向堆叠(宽度可控,符合「同层并行·层间串行」示意)。
+ const items: React.ReactNode[] = [];
+ if (si > 0) {
+ items.push(
+
,
+ );
+ }
+ items.push(
+
+ {stage.length > 1 && (
+
+ 并行 {stage.length}
+
+ )}
+
+ {stage.map((seat, sj) => {
const palette =
SEAT_CHIP_PALETTE[
seatColorIndex++ %
@@ -2614,10 +2623,12 @@ function RoundtableEntryRail({
);
- })}
-
-
- ))}
+ })}
+
+
,
+ );
+ return items;
+ })}
)}
diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md
index 440e6ee7..ad7b7c57 100644
--- a/offline-backend-20260512/backend/CLAUDE.md
+++ b/offline-backend-20260512/backend/CLAUDE.md
@@ -255,6 +255,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **Light Apps** (`/api/light-apps`) | Light-app registry (????? / ????), **global/shared** (no user scoping; `created_by`/`updated_by` audit-only). Two kinds: `window_open` (launched in a new tab from the ???? card grid) and `iframe` (embedded under a parent sidebar menu via `mount_location` and dynamically injected into the sidebar). Both carry `route_params` ? a list of `{key, paramType: login\|theme, source, customValue, themeValue}` descriptors the **frontend** resolves at launch time (login params read from `localStorage.userInfo`; theme params use a literal value) and appends to `url` as query string. `GET /` - list (filters `app_type`/`mount_location`/`enabled_only`/`search`); `GET /{app_id}` - read one (both open to any authenticated user); `POST /`, `PUT /{app_id}`, `DELETE /{app_id}` - **admin only**; iframe apps must carry a `mount_location` (400 otherwise); the unique `name` collides as HTTP 409. Backed by `deerflow.persistence.light_apps` (table `light_apps`, auto-created by `create_all`); store wired in `deps.py` as `app.state.light_app_store`. Tests: `tests/test_light_apps.py`. |
| **Menu Overrides** (`/api/menu-overrides`) | Sidebar-menu overrides (菜单管理), **global/shared** (no user scoping; `updated_by` audit-only). The static menu tree (icons/routes/`adminOnly`) lives in the **frontend** code (`core/page-layout/sidebar-menu.ts`); iframe light-apps are injected at render time. This router persists a per-node **overlay** the frontend merges onto that tree: `parent_id` (re-parent, moves the whole subtree; NULL = top level), `sort_order` (reorder among siblings), `label` (rename; NULL keeps code label), `disabled` (停用 — hides the node **and its subtree**). A node with no row renders exactly as code defines it, so new code menus / newly-registered iframe apps appear automatically and are immediately configurable. `GET /` - list all overrides (open to any authenticated user); `PUT /` - **admin only**, atomically **replaces the whole set** (`{overrides: [...]}`); rejects re-parenting that would exceed **3 levels** or form a cycle (`_validate_depth`, HTTP 400). Backed by `deerflow.persistence.menu_overrides` (table `menu_overrides`, auto-created by `create_all`); store wired in `deps.py` as `app.state.menu_override_store`. Frontend: merge logic `core/page-layout/menu-overrides.ts` (`applyMenuOverrides` in `use-sidebar-menu.ts`), client `strategy-components/api/menu-overrides.ts`, admin page `pages/MenuManagementPage.tsx` (@dnd-kit drag-reorder/re-parent + 「移动到」parent selector + ↑↓ arrows + 改名/停用), entry in the bottom-left settings dropdown (`components/page-sidebar.tsx`, admin-only). Tests: `tests/test_menu_overrides.py`, `frontend-web/src/core/page-layout/menu-overrides.test.ts`. |
| **Sensitive Words** (`/api/sensitive-words`) | Sensitive-word / desensitize map (敏感词管理 / 脱敏词), **global/shared** (no user scoping; `updated_by` audit-only). Each row maps an original `word` → `replacement`, with `enabled` (停用 parks a word without deleting) + `sort_order`. The **frontend** ships a seed map in code (`src/lib/desensitize.ts` `SEED_DESENSITIZE_MAP`) used as the default on a fresh deployment; once an admin saves, this table is the source of truth and the frontend loads it at startup (`DesensitizeWordsLoader` → `replaceDesensitizeMap`, **enabled** rows only). `GET /` - list all words (open to any authenticated user — every page desensitizes); `PUT /` - **admin OR the `lqq` account** (`_require_manager`: `system_role == "admin"` or email `@`-prefix `== "lqq"`), atomically **replaces the whole set** (`{words: [...]}`, de-duped by `word`, last wins). Backed by `deerflow.persistence.sensitive_words` (table `sensitive_words`, PK `word` `String(191)`, auto-created by `create_all`); store wired in `deps.py` as `app.state.sensitive_word_store`. Frontend: client `strategy-components/api/sensitive-words.ts`, page `pages/SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, entry in the bottom-left settings dropdown — **gated on username `lqq`, not admin role**, present in both sidebar modes; 「导入种子词」 merges missing seed entries). Tests: `tests/test_sensitive_words.py`. |
+| **Dashboard Sessions** (`/api/dashboard-sessions`) | 「大屏绘制智能体」独立页面会话,**按外部 `task_id` 存取,不按 user 分权**(open read + shared writes;`user_id` audit-only)。一个 task 一行(upsert):`transcript`(聊天记录 JSON)+ `report_json`(最新结构化数据,可解析 = 「有效结果」)。`GET /by-task/{task_id}` - 取该 task 会话(无则 404);`PUT /by-task/{task_id}` - upsert(`{transcript?, report_json?}`,只覆盖传入项)。Backed by `deerflow.persistence.dashboard_sessions`(table `dashboard_sessions`, auto-created by `create_all`,无迁移);store wired in `deps.py` as `app.state.dashboard_session_store`。前端独立页 `roundtable-planning/pages/DashboardAgentPage.tsx`(路由 `/page/strategy/dashboard-agent?taskId=`,侧边栏「问答管理 → 大屏绘制」)让智能体分析登录拉取的业务 JSON → report-json → 渲染 `DashboardApp`;输入 seam `core/auth/dashboard-input.ts`(JSON 接口未就绪时回退 copTaskDetail 占位)。Tests: `tests/test_dashboard_sessions.py`。 |
| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Defaults to `background=true`**: the slow per-thread extraction loop is deferred to a FastAPI background task and the endpoint returns `{queued:true, processed:N}` immediately (each thread's transcript is truncated to `max_input_chars` before the LLM call). Running the loop inline for a large batch outlives the nginx gateway timeout (504), which the frontend's `parseResponse` surfaces as a generic "Request failed"; `background=false` keeps the blocking path (full per-thread `created/skipped/failed` tally) for small batches/scripts. Shared loop: `_run_batch_ingest`. **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` ? `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "???????". The "??????" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector?keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[?]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). `GET/POST /folders` + `PUT/DELETE /folders/{id}` - user-managed directories (notes carry a `folder` path; `GET /notes?folder=` filters a dir + sub-dirs); `GET/POST /extract-templates` + `GET/PUT/DELETE /extract-templates/{id}` - editable extraction-prompt templates (thread/document ingest accept `template_id`; capture/PUT-note accept `folder`). Backed by the `deerflow.knowledge` module (see below). |
Proxied through nginx: `/api/langgraph/*` ? LangGraph, all other `/api/*` ? Gateway.
diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py
index 3ddae9d6..5936e04f 100644
--- a/offline-backend-20260512/backend/app/gateway/app.py
+++ b/offline-backend-20260512/backend/app/gateway/app.py
@@ -37,6 +37,7 @@ from app.gateway.routers import (
channels,
checkpoint_migration,
feedback,
+ dashboard_sessions,
html_page_favorites,
intent,
knowledge,
@@ -1356,6 +1357,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Sensitive-word map (敏感词管理 / 脱敏词) at /api/sensitive-words
app.include_router(sensitive_words.router)
+ # 大屏绘制智能体独立页面会话(按 taskId 存取,不分权)at /api/dashboard-sessions
+ app.include_router(dashboard_sessions.router)
+
# Standalone parallel multi-agent panel: fan-out streaming + saved tasks
app.include_router(parallel_agents.router)
app.include_router(parallel_agent_tasks.router)
diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py
index 7cd7b910..3b087c1a 100644
--- a/offline-backend-20260512/backend/app/gateway/deps.py
+++ b/offline-backend-20260512/backend/app/gateway/deps.py
@@ -252,6 +252,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.sensitive_word_store = make_sensitive_word_store(sf)
+ from deerflow.persistence.dashboard_sessions import make_dashboard_session_store
+
+ app.state.dashboard_session_store = make_dashboard_session_store(sf)
+
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf)
diff --git a/offline-backend-20260512/backend/app/gateway/routers/dashboard_sessions.py b/offline-backend-20260512/backend/app/gateway/routers/dashboard_sessions.py
new file mode 100644
index 00000000..a5276e7c
--- /dev/null
+++ b/offline-backend-20260512/backend/app/gateway/routers/dashboard_sessions.py
@@ -0,0 +1,84 @@
+"""按 taskId 存取的「大屏绘制智能体」独立页面会话存储 API。
+
+独立大屏页(前端 ``DashboardAgentPage``)按外部 ``task_id`` 存取:聊天记录
+(``transcript``)+ 最新结构化数据(``report_json``)。**按 task 共享、不按 user
+分权**(open read + 写放开),镜像 ``roundtable_drafts`` 的 by-task 语义。
+
+Routes (prefix ``/api/dashboard-sessions``):
+ GET /by-task/{task_id} 取该 task 的会话(无则 404)
+ PUT /by-task/{task_id} upsert(body: {transcript?, report_json?})
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import APIRouter, HTTPException, Request
+from pydantic import BaseModel
+
+from deerflow.runtime.user_context import get_effective_user_id
+
+router = APIRouter(prefix="/api/dashboard-sessions", tags=["dashboard-sessions"])
+
+
+class DashboardSessionResponse(BaseModel):
+ task_id: str
+ transcript: Any | None = None
+ report_json: str | None = None
+ created_at: str | None = None
+ updated_at: str | None = None
+
+
+class DashboardSessionUpsertRequest(BaseModel):
+ # 只传需要更新的字段:聊天记录 / 结构化数据,二者皆可单独更新。
+ transcript: Any | None = None
+ report_json: str | None = None
+
+
+def _current_user_id(request: Request) -> str:
+ user = getattr(request.state, "user", None)
+ if user is not None:
+ return str(user.id)
+ return get_effective_user_id()
+
+
+def _get_store(request: Request):
+ store = getattr(request.app.state, "dashboard_session_store", None)
+ if store is None:
+ raise HTTPException(status_code=503, detail="Dashboard session store not available")
+ return store
+
+
+def _project(row: dict[str, Any]) -> DashboardSessionResponse:
+ return DashboardSessionResponse(
+ task_id=row.get("task_id", ""),
+ transcript=row.get("transcript"),
+ report_json=row.get("report_json"),
+ created_at=row.get("created_at"),
+ updated_at=row.get("updated_at"),
+ )
+
+
+@router.get("/by-task/{task_id}", response_model=DashboardSessionResponse)
+async def get_dashboard_session(request: Request, task_id: str) -> DashboardSessionResponse:
+ """取该 task 的大屏会话(**不按 user 分权**);无则 404。"""
+ store = _get_store(request)
+ row = await store.get_by_task(task_id)
+ if row is None:
+ raise HTTPException(status_code=404, detail="No dashboard session for this task")
+ return _project(row)
+
+
+@router.put("/by-task/{task_id}", response_model=DashboardSessionResponse)
+async def upsert_dashboard_session(
+ request: Request, task_id: str, body: DashboardSessionUpsertRequest
+) -> DashboardSessionResponse:
+ """按 task upsert 大屏会话(**不按 user 分权**),返回最新快照。"""
+ store = _get_store(request)
+ row = await store.upsert_by_task(
+ task_id,
+ transcript=body.transcript,
+ report_json=body.report_json,
+ user_id=_current_user_id(request),
+ )
+ return _project(row)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/dashboard_sessions/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/dashboard_sessions/__init__.py
new file mode 100644
index 00000000..4ea30510
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/dashboard_sessions/__init__.py
@@ -0,0 +1,18 @@
+"""大屏绘制智能体 session persistence (按 taskId 存取,不分权)."""
+
+from __future__ import annotations
+
+from deerflow.persistence.dashboard_sessions.model import DashboardSessionRow
+from deerflow.persistence.dashboard_sessions.sql import DashboardSessionRepository
+
+__all__ = [
+ "DashboardSessionRepository",
+ "DashboardSessionRow",
+ "make_dashboard_session_store",
+]
+
+
+def make_dashboard_session_store(session_factory):
+ if session_factory is None:
+ return None
+ return DashboardSessionRepository(session_factory)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/dashboard_sessions/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/dashboard_sessions/model.py
new file mode 100644
index 00000000..0a1560aa
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/dashboard_sessions/model.py
@@ -0,0 +1,49 @@
+"""ORM model for the standalone 大屏绘制智能体 session (按 taskId 存取).
+
+One row per external ``task_id`` — the standalone dashboard page persists its
+chat transcript + the latest structured ``report_json`` so that returning to the
+same task either shows the finished dashboard directly (valid report_json) or
+restores the generation process.
+
+**Global / shared by task** — like ``roundtable_drafts`` task-bound drafts, the
+store keys on ``task_id`` and does **not** scope by ``user_id`` (open read +
+shared writes). ``user_id`` is recorded for audit only.
+
+``transcript`` / ``report_json`` are large JSON blobs (chat history + report
+data) stored as serialized strings in ``PortableLongText`` (MySQL ``LONGTEXT``)
+to dodge MySQL's 64 KB ``TEXT`` cap — same approach as ``roundtable_drafts``.
+"""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+from sqlalchemy import String, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column
+
+from deerflow.persistence.base import Base
+from deerflow.persistence.types import BeijingDateTime, PortableLongText
+
+
+class DashboardSessionRow(Base):
+ __tablename__ = "dashboard_sessions"
+ # One session per task — keep it unique so upsert-by-task is unambiguous.
+ __table_args__ = (UniqueConstraint("task_id", name="uq_dashboard_sessions_task_id"),)
+
+ id: Mapped[str] = mapped_column(String(64), primary_key=True)
+ # External task id (deep-link). The session is shared by task, not by user.
+ task_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
+ # Audit only — who last wrote. Never used for access control.
+ user_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
+ # Chat transcript JSON (the dashboard agent's dialogue bubbles).
+ transcript: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
+ # Latest structured dashboard data (report-json string). Presence of a
+ # parseable value = "有效结果" → the page shows the dashboard directly.
+ report_json: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
+ updated_at: Mapped[datetime] = mapped_column(
+ BeijingDateTime(),
+ nullable=False,
+ default=lambda: datetime.now(UTC),
+ onupdate=lambda: datetime.now(UTC),
+ )
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/dashboard_sessions/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/dashboard_sessions/sql.py
new file mode 100644
index 00000000..e3aa93ec
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/dashboard_sessions/sql.py
@@ -0,0 +1,104 @@
+"""SQLAlchemy-backed 大屏绘制智能体 session store (按 taskId,不分权).
+
+``transcript`` / ``report_json`` are JSON serialized into ``PortableLongText``
+and round-tripped here with ``json.dumps`` / ``json.loads`` — same approach as
+``roundtable_drafts``. All access keys on ``task_id`` and ignores ``user_id``
+(open read + shared writes); ``user_id`` is recorded for audit only.
+"""
+
+from __future__ import annotations
+
+import json
+from datetime import UTC, datetime
+from typing import Any
+from uuid import uuid4
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from deerflow.persistence.dashboard_sessions.model import DashboardSessionRow
+
+
+def _loads(value: Any) -> Any:
+ if not isinstance(value, str):
+ return value
+ if not value:
+ return None
+ try:
+ return json.loads(value)
+ except Exception:
+ return None
+
+
+def _dumps(value: Any) -> str | None:
+ if value is None:
+ return None
+ if isinstance(value, str):
+ return value # already a JSON string (report_json) — store verbatim
+ return json.dumps(value, ensure_ascii=False)
+
+
+class DashboardSessionRepository:
+ def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
+ self._sf = session_factory
+
+ @staticmethod
+ def _to_dict(row: DashboardSessionRow) -> dict[str, Any]:
+ return {
+ "id": row.id,
+ "task_id": row.task_id,
+ "user_id": row.user_id,
+ "transcript": _loads(row.transcript),
+ # report_json is a structured-data JSON string; return it as a string
+ # so the frontend parses it with the same robust parser it uses live.
+ "report_json": row.report_json,
+ "created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
+ "updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
+ }
+
+ async def get_by_task(self, task_id: str) -> dict[str, Any] | None:
+ """取该 task 的会话(**忽略 user_id**,不分权)。无则 None。"""
+ stmt = select(DashboardSessionRow).where(DashboardSessionRow.task_id == task_id).limit(1)
+ async with self._sf() as session:
+ result = await session.execute(stmt)
+ row = result.scalars().first()
+ return self._to_dict(row) if row is not None else None
+
+ async def upsert_by_task(
+ self,
+ task_id: str,
+ *,
+ transcript: Any = None,
+ report_json: Any = None,
+ user_id: str | None = None,
+ ) -> dict[str, Any]:
+ """按 task_id upsert:无则建、有则更新(**不分权**)。
+
+ ``transcript`` / ``report_json`` 仅在传入非 None 时覆盖,便于只更新其中一项。
+ """
+ now = datetime.now(UTC)
+ async with self._sf() as session:
+ stmt = select(DashboardSessionRow).where(DashboardSessionRow.task_id == task_id).limit(1)
+ row = (await session.execute(stmt)).scalars().first()
+ if row is None:
+ row = DashboardSessionRow(
+ id=uuid4().hex,
+ task_id=task_id,
+ user_id=user_id,
+ transcript=_dumps(transcript),
+ report_json=_dumps(report_json),
+ created_at=now,
+ updated_at=now,
+ )
+ session.add(row)
+ else:
+ if transcript is not None:
+ row.transcript = _dumps(transcript)
+ if report_json is not None:
+ row.report_json = _dumps(report_json)
+ if user_id is not None:
+ row.user_id = user_id
+ row.updated_at = now
+ await session.commit()
+ await session.refresh(row)
+ return self._to_dict(row)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py
index 23da5f28..de29be84 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py
@@ -23,6 +23,7 @@ from deerflow.persistence.agents.model import (
)
from deerflow.persistence.admin_stats.model import AdminLeaderboardDailyStatRow
from deerflow.persistence.business_mapping.model import BusinessMappingRow
+from deerflow.persistence.dashboard_sessions.model import DashboardSessionRow
from deerflow.persistence.memory_config.model import UserMemoryConfigRow
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.knowledge.models import (
@@ -82,6 +83,7 @@ __all__ = [
"AgentSkillRow",
"AdminLeaderboardDailyStatRow",
"BusinessMappingRow",
+ "DashboardSessionRow",
"KnowledgeEmbeddingRow",
"KnowledgeEntityRow",
"KnowledgeExtractTemplateRow",