@
feat(圆桌): Step3 大屏多页模板重构 + 独立 dashboard 组件化 - 新增 roundtable-planning/dashboard 模块化大屏(DashboardApp + 各分析卡片 组件 + data-context + 类型/样式) - Step3DashboardMode 接入新 dashboard,report-template 大屏 HTML 模板重写 - useStep3Dashboard 配套调整 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> @
This commit is contained in:
1 parent
146c8e76f0
commit
432c12fbac
16 files changed
+2642
-321
No files matched your search
@@ -1,20 +1,29 @@
|
||||
/**
|
||||
* Step3「结果绘制 · 大屏多页模式(模式二)」—— 与模式一同款交互(2026-06-13 重构)。
|
||||
* Step3「结果绘制 · 大屏多页」—— 两视图:
|
||||
*
|
||||
* 布局/交互对齐模式一(Step3ReportMode):左侧流式对话气泡 + 右侧沙箱(复用
|
||||
* `Step2SandboxLayout`,大屏 HTML 以 write_file 虚拟 stub 进沙箱 → 代码/预览切换、
|
||||
* 下载、新窗口打开全部可用)+ 底部修改要求输入框(同线程续轮修订全量 report-json)。
|
||||
* ① 交互区 (view="chat"):左侧大屏绘制智能体的聊天记录 + 输入框(模型问答改页面),
|
||||
* 右侧复用 `Step2SandboxLayout` 沙箱「代码预览」实时展示数据抽取过程(report-json
|
||||
* 边出边以代码态显示)。生成完成自动切到 ②。
|
||||
* ② 大屏页面 (view="page"):应用内直接渲染参考大屏 React 组件(`DashboardApp`,真实
|
||||
* 数据经 `adaptReportData` 注入)。顶部按钮:重新绘制(回交互区并重画)、新窗口打开
|
||||
* (把自包含 HTML 大屏在新标签页打开)、单页看板、返回报告。
|
||||
*
|
||||
* 与模式一的差别只在产物管线:模型输出 1-2KB ```report-json,前端模板渲染
|
||||
* (lib/report-template)—— 快一个数量级、零截断;链路(initReportThread +
|
||||
* streamMultiAgent)与模式一完全同源。
|
||||
* 数据抽取/聊天/沙箱链路全部复用既有 `useStep3Dashboard`(流式问答、stub 沙箱、续看),
|
||||
* 只是把「完成后的产物」从 iframe HTML 预览换成应用内 React 大屏;HTML 沙箱那条路保留,
|
||||
* 供「新窗口打开」与并行的经典模式使用。
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
Eye,
|
||||
LayoutDashboard,
|
||||
ExternalLink,
|
||||
LayoutPanelTop,
|
||||
Loader2,
|
||||
Pause,
|
||||
RefreshCw,
|
||||
@@ -22,15 +31,18 @@ import {
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
|
||||
import { getAvatar } from "@/roundtable-planning/components/Avatars";
|
||||
import { MarkdownContent } from "@/roundtable-planning/components/MessageBubble";
|
||||
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
|
||||
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 { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
|
||||
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||||
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
|
||||
import type { UseStep3DashboardReturn } from "@/roundtable-planning/hooks/useStep3Dashboard";
|
||||
import { isDashboardHtml, stripReportFence } from "@/roundtable-planning/lib/report-template";
|
||||
import { parseReportJson } from "@/roundtable-planning/lib/report-template";
|
||||
|
||||
interface Step3DashboardModeProps {
|
||||
selectedModel: string;
|
||||
@@ -38,7 +50,6 @@ interface Step3DashboardModeProps {
|
||||
modelsLoading: boolean;
|
||||
setSelectedModel: (model: string) => void;
|
||||
savedReport?: DraftStep3Snapshot | null;
|
||||
/** 页面层常驻的大屏生成流(2026-06-13 提升:切走不断流,进入续看)。 */
|
||||
dashboard: UseStep3DashboardReturn;
|
||||
/** 切回经典「单页看板」绘制模式。 */
|
||||
onSwitchClassic: () => void;
|
||||
@@ -46,6 +57,14 @@ interface Step3DashboardModeProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
export function Step3DashboardMode({
|
||||
selectedModel,
|
||||
availableModels,
|
||||
@@ -56,154 +75,115 @@ export function Step3DashboardMode({
|
||||
onSwitchClassic,
|
||||
onBack,
|
||||
}: Step3DashboardModeProps) {
|
||||
// 生成流来自页面层常驻 hook(切走不断流,进入本视图续看进行中过程/结果)。
|
||||
const {
|
||||
phase,
|
||||
error,
|
||||
dashThreadId,
|
||||
dialogues,
|
||||
seatStubMessages,
|
||||
activeArtifactThreadId,
|
||||
busy,
|
||||
html,
|
||||
start,
|
||||
busy,
|
||||
redraw,
|
||||
ask,
|
||||
abort,
|
||||
hydrateSaved,
|
||||
setPreviewEnabled,
|
||||
openDashboardPreview,
|
||||
} = dashboard;
|
||||
|
||||
// 预览开关:本视图在前台时本流才允许自动打开沙箱(并行流防抢);进入时若已有
|
||||
// 产物/进行中会立即接管沙箱续看。
|
||||
// 真实数据 → 大屏数据:优先本轮实时生成,其次草稿快照;都没有则 undefined(用 DEMO)。
|
||||
const dashboardData = useMemo(() => {
|
||||
const report = dashboard.data ?? parseReportJson(savedReport?.dashboardJson ?? null);
|
||||
return report ? adaptReportData(report, formatNow()) : undefined;
|
||||
}, [dashboard.data, savedReport?.dashboardJson]);
|
||||
|
||||
const hasResult = !!dashboardData || !!html;
|
||||
|
||||
// 视图:有产物默认看「大屏页面」,否则进「交互区」看抽取过程。
|
||||
const [view, setView] = useState<DashView>(() => (hasResult ? "page" : "chat"));
|
||||
|
||||
// 完成即自动切到大屏页面(含问答修订后的再次完成)。
|
||||
const prevPhaseRef = useRef(phase);
|
||||
useEffect(() => {
|
||||
dashboard.setPreviewEnabled(true);
|
||||
return () => dashboard.setPreviewEnabled(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
if (phase === "done" && prevPhaseRef.current !== "done") setView("page");
|
||||
prevPhaseRef.current = phase;
|
||||
}, [phase]);
|
||||
|
||||
// 续看语义:只兜底"从未启动过"(idle);**卸载不再 abort**(切走不断流)。
|
||||
// 优先 dashboardJson(结构化数据,按当前主题渲染);旧版 marker html 兜底。
|
||||
// 仅在交互区允许本流自动接管沙箱(并行流防抢);离开/卸载时让出。
|
||||
useEffect(() => {
|
||||
if (phase !== "idle") return;
|
||||
if (savedReport?.dashboardJson) {
|
||||
dashboard.hydrateFromJson(savedReport.dashboardJson);
|
||||
} else if (savedReport?.html && isDashboardHtml(savedReport.html)) {
|
||||
hydrateSaved(savedReport.html);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
setPreviewEnabled(view === "chat");
|
||||
return () => setPreviewEnabled(false);
|
||||
}, [view, setPreviewEnabled]);
|
||||
|
||||
const [revision, setRevision] = useState("");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
useAutoScroll(scrollRef, [dialogues]);
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const text = revision.trim();
|
||||
if (!text) return;
|
||||
setRevision("");
|
||||
ask(text);
|
||||
// 新窗口打开:把自包含 HTML 大屏(renderDashboardHtml 产物)在新标签页打开。
|
||||
const handleOpenNewWindow = () => {
|
||||
if (!html || typeof window === "undefined") return;
|
||||
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
};
|
||||
|
||||
// 气泡显示:report-json 生 JSON 替换成一句人话(对齐 flow-json 的处理)。
|
||||
const displayDialogues = useMemo(
|
||||
() =>
|
||||
dialogues.map((d) => {
|
||||
const content = d.content ?? "";
|
||||
if (!content.includes("```report-json")) return d;
|
||||
return { ...d, content: stripReportFence(content, d.streaming === true) };
|
||||
}),
|
||||
[dialogues],
|
||||
);
|
||||
const handleRedraw = () => {
|
||||
setView("chat");
|
||||
redraw();
|
||||
};
|
||||
|
||||
const isStreaming = busy || dialogues.some((d) => d.streaming === true);
|
||||
const activeSeatMessages = activeArtifactThreadId
|
||||
? seatStubMessages[activeArtifactThreadId]
|
||||
: undefined;
|
||||
|
||||
const statusLabel =
|
||||
phase === "creating"
|
||||
? "正在初始化绘制线程…"
|
||||
: phase === "streaming"
|
||||
? "大屏数据提炼中…"
|
||||
: phase === "done"
|
||||
? "绘制完成"
|
||||
: phase === "error"
|
||||
? "绘制失败"
|
||||
: "待绘制";
|
||||
const isStreaming =
|
||||
phase === "creating" || phase === "streaming" || dialogues.some((d) => d.streaming === true);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col text-slate-800">
|
||||
{/* 顶栏(对齐模式一样式) */}
|
||||
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-3.5 flex-wrap">
|
||||
{/* 顶栏 */}
|
||||
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-3 flex-wrap">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<LayoutDashboard className="w-5 h-5 text-primary" />
|
||||
<Sparkles className="w-4.5 h-4.5 text-primary" />
|
||||
<h3 className="text-sm font-black text-slate-900 tracking-tight">结果绘制 · 大屏多页</h3>
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-bold text-primary bg-[var(--brand-200)]/60 border border-red-200/50 px-2 py-0.5 rounded-full">
|
||||
{(phase === "creating" || phase === "streaming") && (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
)}
|
||||
{(phase === "creating" || phase === "streaming") && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{phase === "done" && <CheckCircle className="w-3 h-3 text-emerald-600" />}
|
||||
{statusLabel}
|
||||
{view === "chat" ? "交互绘制中" : "大屏页面"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
disabled={modelsLoading || availableModels.length === 0}
|
||||
title="切换大屏绘制使用的推理模型"
|
||||
className="shrink-0 max-w-[180px] bg-slate-50/50 border border-slate-200 rounded-xl px-2.5 py-1.5 text-[10.5px] font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary disabled:opacity-50"
|
||||
>
|
||||
{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={abort}
|
||||
title="暂停生成"
|
||||
className="bg-white border border-amber-200 text-amber-700 hover:bg-amber-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
|
||||
>
|
||||
<Pause className="w-3.5 h-3.5" />
|
||||
暂停
|
||||
</button>
|
||||
{view === "page" ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRedraw}
|
||||
title="回到交互区,重新绘制(可在那里通过问答修改大屏)"
|
||||
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
|
||||
重新绘制
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenNewWindow}
|
||||
disabled={!html}
|
||||
title={html ? "在新标签页打开自包含 HTML 大屏" : "暂无可打开的大屏"}
|
||||
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5 text-slate-500" />
|
||||
新窗口打开
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
hasResult && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView("page")}
|
||||
title="查看已生成的大屏页面"
|
||||
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
|
||||
>
|
||||
<LayoutPanelTop className="w-3.5 h-3.5 text-slate-500" />
|
||||
查看大屏
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{html && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openDashboardPreview}
|
||||
title="在右侧沙箱打开大屏预览"
|
||||
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5 text-slate-500" />
|
||||
打开大屏预览
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => redraw()}
|
||||
disabled={busy}
|
||||
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5 text-slate-500" />
|
||||
重新绘制
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSwitchClassic}
|
||||
title="切回经典单页看板绘制(模型自由发挥整页 HTML)"
|
||||
title="切回经典单页看板绘制"
|
||||
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-slate-500" />
|
||||
@@ -220,92 +200,241 @@ export function Step3DashboardMode({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主体:对话 + 沙箱(复用第 2 步分栏,与模式一一致) */}
|
||||
<div className="flex-1 min-h-0 p-3">
|
||||
<section className="flex h-full min-w-0 flex-col rounded-2xl border border-slate-200/60 bg-white overflow-hidden">
|
||||
<Step2SandboxLayout
|
||||
activeThreadId={activeArtifactThreadId ?? dashThreadId}
|
||||
isStreaming={isStreaming}
|
||||
seatMessages={activeSeatMessages}
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* 对话流 */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="custom-scrollbar flex-1 min-h-0 overflow-y-auto px-5 py-4 space-y-4"
|
||||
>
|
||||
{displayDialogues.length === 0 && (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-slate-400">
|
||||
<Loader2 className="w-7 h-7 animate-spin text-primary" />
|
||||
<span className="text-xs font-medium">正在初始化大屏绘制…</span>
|
||||
</div>
|
||||
)}
|
||||
{displayDialogues.map((d) => (
|
||||
<div key={d.id} className="flex gap-2.5">
|
||||
<div className="shrink-0">{getAvatar(d.avatarType ?? "blue", "h-7 w-7")}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-0.5 flex items-center gap-2 text-xs font-semibold text-slate-700">
|
||||
{d.sender}
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-1.5 py-px text-[9.5px] font-bold",
|
||||
d.streaming
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-slate-100 text-slate-400",
|
||||
)}
|
||||
>
|
||||
{d.confidence}
|
||||
</span>
|
||||
<span className="text-[9.5px] font-mono text-slate-300">{d.time}</span>
|
||||
</div>
|
||||
<div className="prose prose-sm max-w-none break-words text-[12.5px] leading-relaxed text-slate-700">
|
||||
<MarkdownContent>{d.content || "…"}</MarkdownContent>
|
||||
{d.streaming && (
|
||||
<span className="ml-0.5 inline-block h-3.5 w-0.5 animate-pulse rounded bg-primary align-middle" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{error && phase === "error" && (
|
||||
<div className="rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-[11px] text-rose-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 主体:交互区(聊天 + 抽取沙箱) / 大屏页面(React) */}
|
||||
{view === "page" ? (
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
<DashboardApp data={dashboardData} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 p-3">
|
||||
<section className="h-full min-h-0 rounded-2xl border border-slate-200/60 bg-white overflow-hidden">
|
||||
<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}
|
||||
/>
|
||||
</Step2SandboxLayout>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* 修改要求输入框(与模式一同款交互:随时可发,busy 时先打断再续) */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="shrink-0 border-t border-slate-100 bg-slate-50/60 px-4 py-3"
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={revision}
|
||||
onChange={(e) => setRevision(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
rows={2}
|
||||
placeholder="输入修改要求,例如:把 KPI 换成成本相关指标 / 风险图改成雷达图 / 增加一个验收阶段…"
|
||||
className="flex-1 resize-none rounded-xl border border-slate-200 bg-white px-3 py-2 text-xs outline-none transition focus:border-primary focus:ring-1 focus:ring-primary/30"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!revision.trim()}
|
||||
className="shrink-0 flex h-9 items-center gap-1.5 rounded-xl bg-primary px-4 text-xs font-bold text-white transition hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<SendHorizontal className="size-3.5" />
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
// ── 交互区聊天面板(沙箱的左 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("");
|
||||
|
||||
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>
|
||||
</Step2SandboxLayout>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{dialogues.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">
|
||||
<textarea
|
||||
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-28 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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user