# Conflicts:
#	frontend-web/src/roundtable-planning/hooks/useStep3Dashboard.ts
This commit is contained in:
fengxiangdebaozi committed 2026-06-18 15:18:41 +08:00
commit b04b830039
20 files changed
+1396 -271

No files matched your search

@@ -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<string, unknown>).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<unknown | null> {
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;
}
@@ -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",
@@ -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 <uuid> 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);
}
}
@@ -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,
+11
View File
@@ -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 给沙箱预览) */}
<Route
path="dashboard-agent"
element={
<ChatRuntime>
<DashboardAgentPage />
</ChatRuntime>
}
/>
{/* 需求 0616 #3/#4:zzJC / zzXD 「按类型聚合」模块页(重做,独立路由)。 */}
<Route path="qa/zzjc/*" element={<AgentModuleRoutes module={AGENT_MODULES.zzjc} />} />
<Route path="qa/zzxd/*" element={<AgentModuleRoutes module={AGENT_MODULES.zzxd} />} />
@@ -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<DashboardSession | null> {
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<DashboardSession | null> {
const body: Record<string, unknown> = {};
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);
}
@@ -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<ModelOption>;
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<HTMLDivElement>(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<HTMLFormElement>) => {
e.preventDefault();
const t = text.trim();
if (!t || inputLocked) return;
setText("");
onAsk(t);
};
return (
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">
{/* 面板头:智能体名 + 模型选择 + 暂停/重画 */}
<div className="shrink-0 flex items-center justify-between gap-2 border-b border-slate-100 bg-white px-4 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 shrink-0 rounded-lg bg-gradient-to-tr from-primary to-[var(--brand-200)] flex items-center justify-center text-white text-xs shadow-sm">
📊
</div>
<div className="leading-tight min-w-0">
<div className="text-xs font-bold text-slate-800 truncate">大屏绘制智能体</div>
<div className="text-[10px] text-slate-400 truncate">{subtitle}</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<select
id="step3-dashboard-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={busy || modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: busy
? "绘制中不可切换;完成后或重新绘制时生效"
: "切换绘制使用的推理模型(下次绘制生效)"
}
className="max-w-[160px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[11px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
>
{availableModels.length === 0 && (
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name && m.display_name !== m.name ? `${m.display_name} (${m.name})` : m.name}
</option>
))}
</select>
{busy ? (
<button
type="button"
onClick={onPause}
title="中止当前绘制"
className="bg-white border border-amber-200 text-amber-700 hover:bg-amber-50 text-[10.5px] font-bold px-2.5 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<Pause className="w-3.5 h-3.5" />
中止
</button>
) : (
<button
type="button"
onClick={onRedraw}
title="丢弃上下文,从头重新绘制"
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-2.5 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
重新绘制
</button>
)}
</div>
</div>
{/* 聊天记录 */}
<div className="relative flex-1 min-h-0 flex flex-col">
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto p-4 space-y-4 bg-white">
{phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
正在初始化绘制线程…
</div>
)}
{displayDialogues.map((msg) => (
<Step2Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
/>
))}
{busy && (
<div className="inline-flex items-center gap-2 rounded-full border bg-white px-2.5 py-1 text-[11px] text-slate-500">
<span className="bg-primary size-1.5 animate-pulse rounded-full" />
右侧沙箱实时显示数据抽取过程
</div>
)}
{phase === "error" && (
<div className="rounded-xl border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">
<div className="font-bold mb-1">绘制失败</div>
<div className="leading-relaxed break-all">{error}</div>
<button
type="button"
onClick={onRedraw}
className="mt-2 inline-flex items-center gap-1 text-[11px] font-bold text-rose-700 underline hover:text-rose-800"
>
<RefreshCw className="w-3 h-3" />
重试
</button>
</div>
)}
{phase === "done" && (
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
<CheckCircle className="w-4 h-4" />
{doneHint}
{hasResult && (
<button
type="button"
onClick={onOpenPreview}
className="ml-1 inline-flex items-center gap-1 text-[11px] font-bold text-primary underline hover:opacity-80"
>
在沙箱打开 HTML 预览
</button>
)}
</div>
)}
</div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
{/* 问答修改输入框 */}
<form onSubmit={handleSubmit} className="shrink-0 border-t border-slate-100 bg-white px-3 py-3">
<div className="flex items-end gap-2 rounded-2xl border border-slate-200 bg-slate-50/70 px-3 py-2 focus-within:border-primary/60 focus-within:bg-white focus-within:ring-2 focus-within:ring-[var(--brand-200)]/40">
<AutoGrowTextarea
maxHeight={224}
value={text}
onChange={(e) => 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"
/>
<button
type="submit"
disabled={inputLocked || text.trim().length === 0}
title={inputLocked ? "正在初始化线程,请稍候" : "发送修改要求"}
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-primary text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50"
>
{inputLocked ? <Loader2 className="h-4 w-4 animate-spin" /> : <SendHorizontal className="h-4 w-4" />}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -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({
</div>
);
}
// ── 交互区聊天面板(沙箱的左 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<ModelOption>;
modelsLoading: boolean;
setSelectedModel: (model: string) => void;
}) {
const inputLocked = phase === "creating";
const scrollRef = useRef<HTMLDivElement>(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<HTMLFormElement>) => {
e.preventDefault();
const t = text.trim();
if (!t || inputLocked) return;
setText("");
onAsk(t);
};
return (
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">
{/* 面板头:智能体名 + 模型选择 + 暂停/重画 */}
<div className="shrink-0 flex items-center justify-between gap-2 border-b border-slate-100 bg-white px-4 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<div className="w-6 h-6 shrink-0 rounded-lg bg-gradient-to-tr from-primary to-[var(--brand-200)] flex items-center justify-center text-white text-xs shadow-sm">
📊
</div>
<div className="leading-tight min-w-0">
<div className="text-xs font-bold text-slate-800 truncate">大屏绘制智能体</div>
<div className="text-[10px] text-slate-400 truncate">提炼研讨成果为大屏结构化数据(右侧实时显示抽取过程)</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<select
id="step3-dashboard-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={busy || modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: busy
? "绘制中不可切换;完成后或重新绘制时生效"
: "切换绘制使用的推理模型(下次绘制生效)"
}
className="max-w-[160px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[11px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
>
{availableModels.length === 0 && (
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name && m.display_name !== m.name ? `${m.display_name} (${m.name})` : m.name}
</option>
))}
</select>
{busy ? (
<button
type="button"
onClick={onPause}
title="中止当前绘制"
className="bg-white border border-amber-200 text-amber-700 hover:bg-amber-50 text-[10.5px] font-bold px-2.5 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<Pause className="w-3.5 h-3.5" />
中止
</button>
) : (
<button
type="button"
onClick={onRedraw}
title="丢弃上下文,从头重新绘制"
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-2.5 py-1.5 rounded-xl flex items-center gap-1 transition"
>
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
重新绘制
</button>
)}
</div>
</div>
{/* 聊天记录 */}
<div className="relative flex-1 min-h-0 flex flex-col">
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto p-4 space-y-4 bg-white">
{phase === "creating" && dialogues.length === 0 && (
<div className="flex items-center gap-2 text-xs text-slate-500">
<Loader2 className="w-3.5 h-3.5 animate-spin text-primary" />
正在初始化绘制线程…
</div>
)}
{displayDialogues.map((msg) => (
<Step2Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
/>
))}
{busy && (
<div className="inline-flex items-center gap-2 rounded-full border bg-white px-2.5 py-1 text-[11px] text-slate-500">
<span className="bg-primary size-1.5 animate-pulse rounded-full" />
右侧沙箱实时显示数据抽取过程,完成后自动切到大屏页面
</div>
)}
{phase === "error" && (
<div className="rounded-xl border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">
<div className="font-bold mb-1">绘制失败</div>
<div className="leading-relaxed break-all">{error}</div>
<button
type="button"
onClick={onRedraw}
className="mt-2 inline-flex items-center gap-1 text-[11px] font-bold text-rose-700 underline hover:text-rose-800"
>
<RefreshCw className="w-3 h-3" />
重试
</button>
</div>
)}
{phase === "done" && (
<div className="flex items-center gap-2 text-xs font-bold text-emerald-600">
<CheckCircle className="w-4 h-4" />
数据已就绪,已切换到大屏页面。
{hasResult && (
<button
type="button"
onClick={onOpenPreview}
className="ml-1 inline-flex items-center gap-1 text-[11px] font-bold text-primary underline hover:opacity-80"
>
在沙箱打开 HTML 预览
</button>
)}
</div>
)}
</div>
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
{/* 问答修改输入框 */}
<form onSubmit={handleSubmit} className="shrink-0 border-t border-slate-100 bg-white px-3 py-3">
<div className="flex items-end gap-2 rounded-2xl border border-slate-200 bg-slate-50/70 px-3 py-2 focus-within:border-primary/60 focus-within:bg-white focus-within:ring-2 focus-within:ring-[var(--brand-200)]/40">
<AutoGrowTextarea
maxHeight={224}
value={text}
onChange={(e) => 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"
/>
<button
type="submit"
disabled={inputLocked || text.trim().length === 0}
title={inputLocked ? "正在初始化线程,请稍候" : "发送修改要求"}
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-primary text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50"
>
{inputLocked ? <Loader2 className="h-4 w-4 animate-spin" /> : <SendHorizontal className="h-4 w-4" />}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -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,
};
}
@@ -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;
@@ -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<unknown>(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<DashView>("chat");
const [renderMode, setRenderMode] = useState<RenderMode>("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<string | null>(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 (
<div className="flex h-full min-h-0 items-center justify-center p-8 text-center text-sm text-slate-500">
<div>
<Sparkles className="mx-auto mb-3 h-8 w-8 text-primary/40" />
缺少任务标识(taskId)。请通过带 <code className="px-1">?taskId=</code> 的链接进入大屏绘制页。
</div>
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col text-slate-800">
{/* 顶栏 */}
<div className="shrink-0 flex flex-wrap items-center justify-between gap-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-3">
<div className="flex items-center gap-2.5">
<Sparkles className="h-4.5 w-4.5 text-primary" />
<h3 className="text-sm font-black tracking-tight text-slate-900">大屏绘制智能体</h3>
<span className="inline-flex items-center gap-1.5 rounded-full border border-red-200/50 bg-[var(--brand-200)]/60 px-2 py-0.5 text-[10px] font-bold text-primary">
{(phase === "creating" || phase === "streaming") && <Loader2 className="h-3 w-3 animate-spin" />}
{view === "chat" ? "生成过程" : "大屏页面"}
</span>
</div>
<div className="flex items-center gap-2">
{view === "page" ? (
<>
<div className="inline-flex items-center gap-0.5 rounded-xl border border-slate-200 bg-slate-50 p-0.5">
<button
type="button"
onClick={() => setRenderMode("app")}
title="用应用内 React 组件渲染(Tab 切换、动画、席位钻取)"
className={`flex items-center gap-1 rounded-lg px-2.5 py-1 text-[10.5px] font-bold transition ${
renderMode === "app"
? "border border-slate-200 bg-white text-primary shadow-sm"
: "text-slate-500 hover:text-slate-700"
}`}
>
<Boxes className="h-3.5 w-3.5" />
应用渲染
</button>
<button
type="button"
onClick={() => setRenderMode("html")}
disabled={!dashboardHtml}
title={dashboardHtml ? "用自包含 HTML 大屏渲染(与新窗口/下载一致)" : "尚无已生成的 HTML,先完成一次绘制"}
className={`flex items-center gap-1 rounded-lg px-2.5 py-1 text-[10.5px] font-bold transition disabled:cursor-not-allowed disabled:opacity-40 ${
renderMode === "html"
? "border border-slate-200 bg-white text-primary shadow-sm"
: "text-slate-500 hover:text-slate-700"
}`}
>
<Code2 className="h-3.5 w-3.5" />
网页渲染
</button>
</div>
<button
type="button"
onClick={() => setView("chat")}
title="回到生成过程,加载聊天记录继续问答修改"
className="flex items-center gap-1 rounded-xl border border-slate-200 bg-white px-3 py-1.5 text-[10.5px] font-bold text-slate-700 transition hover:bg-slate-50"
>
<Pencil className="h-3.5 w-3.5 text-slate-500" />
编辑
</button>
<button
type="button"
onClick={handleOpenNewWindow}
title={renderMode === "html" ? "新标签页打开自包含 HTML 大屏" : "新标签页打开应用内 React 大屏(全屏)"}
className="flex items-center gap-1 rounded-xl border border-slate-200 bg-white px-3 py-1.5 text-[10.5px] font-bold text-slate-700 transition hover:bg-slate-50"
>
<ExternalLink className="h-3.5 w-3.5 text-slate-500" />
新窗口打开
</button>
</>
) : (
hasResult && (
<button
type="button"
onClick={() => setView("page")}
title="查看已生成的大屏页面"
className="flex items-center gap-1 rounded-xl border border-slate-200 bg-white px-3 py-1.5 text-[10.5px] font-bold text-slate-700 transition hover:bg-slate-50"
>
<LayoutPanelTop className="h-3.5 w-3.5 text-slate-500" />
查看大屏
</button>
)
)}
</div>
</div>
{/* 主体 */}
{loading ? (
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-slate-500">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
正在加载任务大屏…
</div>
) : view === "page" ? (
renderMode === "html" && htmlBlobUrl ? (
<iframe
title="任务研讨大屏"
src={htmlBlobUrl}
className="min-h-0 w-full flex-1 border-0 bg-white"
sandbox="allow-scripts allow-popups allow-downloads allow-same-origin"
/>
) : (
<div className="min-h-0 flex-1 overflow-auto">
<DashboardApp data={dashboardData} />
</div>
)
) : (
<div className="min-h-0 flex-1 p-3">
<section className="h-full min-h-0 overflow-hidden rounded-2xl border border-slate-200/60 bg-white">
<Step2SandboxLayout
activeThreadId={activeArtifactThreadId}
isStreaming={isStreaming}
seatMessages={activeArtifactThreadId ? seatStubMessages[activeArtifactThreadId] : undefined}
>
<DashboardChatPanel
phase={phase}
dialogues={dialogues}
busy={busy}
error={dashboard.error}
onAsk={ask}
onRedraw={redraw}
onPause={abort}
onOpenPreview={openDashboardPreview}
hasResult={hasResult}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
setSelectedModel={setSelectedModel}
subtitle="分析任务原始数据,提炼为大屏结构化数据(右侧实时显示抽取过程)"
doneHint="数据已就绪,点右上「查看大屏」查看。"
/>
</Step2SandboxLayout>
</section>
</div>
)}
</div>
);
}
@@ -2564,22 +2564,31 @@ function RoundtableEntryRail({
该业务链暂无智能体席位
</p>
) : (
<div className="flex flex-col items-center gap-1.5">
{stageGroups.map((stage, si) => (
<div
key={si}
className="flex flex-col items-center gap-1"
>
{si > 0 && (
<ChevronDown className="h-3 w-3 text-neutral-300" />
)}
{stage.length > 1 && (
<span className="rounded-full bg-primary/5 px-1.5 text-[8.5px] font-semibold text-primary">
并行 {stage.length}
</span>
)}
<div className="flex flex-wrap items-center justify-center gap-1.5">
{stage.map((seat, sj) => {
<div className="flex flex-row flex-wrap items-center justify-center gap-1">
{stageGroups.flatMap((stage, si) => {
// 横向编排:阶段从左到右排列,阶段之间用 → 串联;并行阶段的
// 席位在该列内竖向堆叠(宽度可控,符合「同层并行·层间串行」示意)。
const items: React.ReactNode[] = [];
if (si > 0) {
items.push(
<ChevronRight
key={`arrow-${si}`}
className="h-3 w-3 shrink-0 text-neutral-300"
/>,
);
}
items.push(
<div
key={`stage-${si}`}
className="flex flex-col items-center gap-1"
>
{stage.length > 1 && (
<span className="rounded-full bg-primary/5 px-1.5 text-[8.5px] font-semibold text-primary">
并行 {stage.length}
</span>
)}
<div className="flex flex-col items-center gap-1.5">
{stage.map((seat, sj) => {
const palette =
SEAT_CHIP_PALETTE[
seatColorIndex++ %
@@ -2614,10 +2623,12 @@ function RoundtableEntryRail({
</div>
</div>
);
})}
</div>
</div>
))}
})}
</div>
</div>,
);
return items;
})}
</div>
)}
</div>