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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user