feat(dashboard): 大屏绘制智能体独立页面(按 taskId 存取,不分权)

- 新增带导航+侧边栏的独立页面 DashboardAgentPage:按 taskId 取/存会话,
  有有效 report-json 直显大屏(DashboardApp),否则进生成过程(不自动跳)+输入框可改
- 后端 dashboard_sessions 存储(model/sql/__init__)+路由/by-task(GET/PUT 不分权)
  + deps/app/models 接线;新表靠 create_all 建,无迁移
- 扩展 useStep3Dashboard:buildInitialPrompt(输入任务JSON) + hydrateTranscript(恢复聊天记录)
- 抽出共享 DashboardChatPanel;report-template 增 buildDashboardPromptFromJson
- 输入 seam core/auth/dashboard-input:JSON 接口未就绪先用占位/演示数据兜底
- 路由 StrategyRoutes dashboard-agent + 侧边栏「问答管理→大屏绘制」
- 圆桌业务链卡片「关系图」改横向布局
- 顺带提交:AIWritingContext 登录切换处理、thread-access-error helper

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
董宏凯andClaude Opus 4.8 committed 2026-06-18 14:26:27 +08:00
1 parent 500a562bb6
commit 32772b7448
20 files changed
+1396 -271

No files matched your search

@@ -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>
);
}