列表引导改为手动触发且不跳转创建页;历史 Tab 展示会商步骤/链条与深度研究模板;Step2 增加暂停与内容高度跟随滚动,并修复旧草稿恢复兜底。 Co-authored-by: Cursor <cursoragent@cursor.com>
261 lines
11 KiB
TypeScript
261 lines
11 KiB
TypeScript
/**
|
||
* 大屏绘制智能体「交互区」聊天面板(沙箱左 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 overflow-x-hidden 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"
|
||
/>
|
||
{busy && (
|
||
<button
|
||
type="button"
|
||
onClick={onPause}
|
||
title="中止当前绘制(之后可输入修改要求继续)"
|
||
className="mb-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-amber-100 border border-amber-200/60 text-amber-700 hover:bg-amber-200 transition active:scale-95"
|
||
>
|
||
<Pause className="h-4 w-4" />
|
||
</button>
|
||
)}
|
||
<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>
|
||
);
|
||
}
|