Step3 总结报告与顶栏主题背景图
新增 roundtable-summary 智能体及前后端 Step3 Markdown 总结流程;顶栏在浅/深主题下分别使用红/深蓝横幅背景。
This commit is contained in:
1 parent
10047de745
commit
2d74f8883e
14 files changed
+2060
-18
No files matched your search
@@ -103,14 +103,44 @@ export interface DraftStep2RunSnapshot {
|
||||
* thread. Optional — drafts saved before this feature (or never drawn) won't carry it.
|
||||
*/
|
||||
export interface DraftStep3Snapshot {
|
||||
/** The full self-contained HTML written to outputs/方案总览.html. */
|
||||
html: string;
|
||||
/** ISO timestamp when the report was generated. */
|
||||
generatedAt: string;
|
||||
/** The inference model used to draw it. */
|
||||
model: string;
|
||||
/** The agent's closing one-liner (rendered as the report message bubble). */
|
||||
/**
|
||||
* The full self-contained HTML written to outputs/方案总览.html by the
|
||||
* `roundtable-report` (HTML 看板) agent. Optional — a draft may only carry the
|
||||
* Markdown 总结报告 (`summaryReport`) and never draw the HTML 看板.
|
||||
*/
|
||||
html?: string;
|
||||
/** ISO timestamp when the HTML report was generated. */
|
||||
generatedAt?: string;
|
||||
/** The inference model used to draw the HTML report. */
|
||||
model?: string;
|
||||
/** The HTML agent's closing one-liner (rendered as the report message bubble). */
|
||||
summary?: string;
|
||||
/**
|
||||
* Step 3「总结报告」snapshot — output of the `roundtable-summary` agent
|
||||
* (Markdown report). Stored alongside (not instead of) the HTML report so the
|
||||
* two Step-3 surfaces persist independently. Nested in the free-form step3 JSON
|
||||
* blob, so the backend needs no schema change. Optional.
|
||||
*/
|
||||
summaryReport?: DraftStep3SummarySnapshot | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3「总结报告」snapshot — the Markdown report written by `roundtable-summary`,
|
||||
* stored inline so a reloaded draft can show it without depending on the ephemeral
|
||||
* summary thread. `threadId` keeps the real summary thread id so a reloaded draft
|
||||
* can continue Q&A / refinement on the same thread (the agent keeps its context).
|
||||
*/
|
||||
export interface DraftStep3SummarySnapshot {
|
||||
/** The full Markdown written to outputs/方案总结报告.md. */
|
||||
md: string;
|
||||
/** The real summary thread id — reused for follow-up Q&A / refine after reload. */
|
||||
threadId?: string;
|
||||
/** ISO timestamp when the summary was generated. */
|
||||
generatedAt: string;
|
||||
/** The inference model used to write it. */
|
||||
model: string;
|
||||
/** The agent's closing chat text (the report body it echoed into the conversation). */
|
||||
closing?: string;
|
||||
}
|
||||
|
||||
/** Lightweight metadata row returned by the list endpoint. */
|
||||
|
||||
@@ -818,3 +818,28 @@ export async function initReportThread(
|
||||
const json = (await res.json()) as { agent_name: string; thread_id: string };
|
||||
return { agentName: json.agent_name, threadId: json.thread_id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a thread for the Step 3「总结报告」summary agent (`roundtable-summary`,
|
||||
* a built-in singleton — distinct from `roundtable-report`; this only spins up a
|
||||
* cheap thread, it never re-creates the agent). Run it afterwards with
|
||||
* {@link streamMultiAgent} (`agentType: "special"`, `agentName: "roundtable-summary"`,
|
||||
* `dAgentThreadId: { [agentName]: threadId }`). Follow-up Q&A / refinements reuse
|
||||
* the SAME thread (do not re-init) so the agent keeps the report context.
|
||||
*/
|
||||
export async function initSummaryThread(
|
||||
options: { signal?: AbortSignal } = {},
|
||||
): Promise<ReportThreadResponse> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/summary/init`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
signal: options.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`init summary thread failed (${res.status}): ${text}`);
|
||||
}
|
||||
const json = (await res.json()) as { agent_name: string; thread_id: string };
|
||||
return { agentName: json.agent_name, threadId: json.thread_id };
|
||||
}
|
||||
@@ -27,7 +27,11 @@ import {
|
||||
Step3ReportMode,
|
||||
type ModelOption,
|
||||
} from "@/roundtable-planning/components/Step3ReportMode";
|
||||
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
|
||||
import { Step3SummaryMode } from "@/roundtable-planning/components/Step3SummaryMode";
|
||||
import type {
|
||||
DraftStep3Snapshot,
|
||||
DraftStep3SummarySnapshot,
|
||||
} from "@/roundtable-planning/api/drafts";
|
||||
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
|
||||
|
||||
interface SeatDelivery {
|
||||
@@ -62,6 +66,12 @@ interface HighFidelityReportProps {
|
||||
savedReport?: DraftStep3Snapshot | null;
|
||||
/** 一次绘制完成时回调,把结果交给上层存进草稿 step3。 */
|
||||
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
|
||||
/** 业务链写给总控的编排提示 —— 注入「总结报告」智能体作为最高优先级口径。 */
|
||||
coordinatorPrompt?: string | null;
|
||||
/** 草稿里已存的上次总结结果 —— 透传给「总结报告」恢复展示。 */
|
||||
savedSummary?: DraftStep3SummarySnapshot | null;
|
||||
/** 一次总结完成时回调,把结果交给上层存进草稿 step3.summaryReport。 */
|
||||
onSummarySaved?: (snapshot: DraftStep3SummarySnapshot) => void;
|
||||
/** Resets state and navigates back to Step 1. */
|
||||
onReset: () => void;
|
||||
openArtifactPreview: (threadId: string, path: string) => void;
|
||||
@@ -103,6 +113,9 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
setSelectedModel,
|
||||
savedReport,
|
||||
onReportSaved,
|
||||
coordinatorPrompt,
|
||||
savedSummary,
|
||||
onSummarySaved,
|
||||
onReset,
|
||||
openArtifactPreview,
|
||||
seatStubMessages,
|
||||
@@ -115,6 +128,9 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
return `圆桌总控结论-${short}`;
|
||||
}, [intentReady?.objective]);
|
||||
|
||||
// 第 3 步**主页** = 总结报告(进第三步默认自动跑「方案总结报告智能体」)。
|
||||
// 用户点「最终方案展示」切到静态页(summaryMode=false),再从静态页可进「结果绘制」HTML。
|
||||
const [summaryMode, setSummaryMode] = useState(true);
|
||||
const [drawMode, setDrawMode] = useState(false);
|
||||
const [fileSandboxMode, setFileSandboxMode] = useState(false);
|
||||
const [fileSandboxTarget, setFileSandboxTarget] = useState<{
|
||||
@@ -126,8 +142,11 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
// 不会自动关)。点「结果绘制」后 Step3ReportMode 会按需开自己的沙箱,互不串扰。
|
||||
const { deselect: deselectArtifact } = useArtifacts();
|
||||
useEffect(() => {
|
||||
// 总结报告主页(summaryMode)由 Step3SummaryMode 自管沙箱——这里不 deselect,
|
||||
// 否则会盖掉它在恢复草稿时同步打开的 md 预览。切到静态页时才清空第二步遗留沙箱。
|
||||
if (summaryMode) return;
|
||||
deselectArtifact();
|
||||
}, [deselectArtifact]);
|
||||
}, [deselectArtifact, summaryMode]);
|
||||
// Build the per-seat list (excludes the coordinator). useMemo so the file
|
||||
// fetch effect doesn't re-fire on every parent render.
|
||||
const seatThreadEntries = useMemo(() => {
|
||||
@@ -248,6 +267,28 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
[],
|
||||
);
|
||||
|
||||
if (summaryMode) {
|
||||
return (
|
||||
<Step3SummaryMode
|
||||
threadIds={threadIds}
|
||||
coordinatorName={coordinatorName}
|
||||
consensusText={consensusText}
|
||||
hasConsensus={hasConsensus}
|
||||
seatDeliveries={seatDeliveries}
|
||||
seatNameMap={seatNameMap}
|
||||
intentReady={intentReady}
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
modelsLoading={modelsLoading}
|
||||
setSelectedModel={setSelectedModel}
|
||||
coordinatorPrompt={coordinatorPrompt}
|
||||
savedSummary={savedSummary}
|
||||
onSummarySaved={onSummarySaved}
|
||||
onViewStatic={() => setSummaryMode(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (fileSandboxMode && fileSandboxTarget) {
|
||||
return (
|
||||
<Step3FileBrowseMode
|
||||
@@ -332,10 +373,19 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSummaryMode(true)}
|
||||
title="返回「方案总结报告」(Markdown 总结 + 问答/修改)"
|
||||
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 cursor-pointer"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5 text-slate-500" />
|
||||
总结报告
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrawMode(true)}
|
||||
title="把研讨成果交给方案总结智能体,绘制带图表的可视化看板"
|
||||
title="把研讨成果交给方案可视化智能体,绘制带图表的可视化看板"
|
||||
className="bg-primary text-white hover:bg-[var(--brand-900)] text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer shadow-sm shadow-primary/10"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
* 拉取 / 文本类预览 / window.open 下载 / 「重新起草」按钮等。
|
||||
*/
|
||||
import { HighFidelityReport } from "@/roundtable-planning/components/HighFidelityReport";
|
||||
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
|
||||
import type {
|
||||
DraftStep3Snapshot,
|
||||
DraftStep3SummarySnapshot,
|
||||
} from "@/roundtable-planning/api/drafts";
|
||||
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
|
||||
import type { Step2Dialogue } from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
|
||||
@@ -34,6 +37,11 @@ export interface Step3PanelProps {
|
||||
/** 草稿里已存的上次绘制结果 + 完成回调 —— 透传给「结果绘制」做持久化。 */
|
||||
savedReport?: DraftStep3Snapshot | null;
|
||||
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
|
||||
/** 业务链写给总控的编排提示 —— 注入「总结报告」智能体。 */
|
||||
coordinatorPrompt?: string | null;
|
||||
/** 草稿里已存的上次总结结果 + 完成回调 —— 透传给「总结报告」做持久化。 */
|
||||
savedSummary?: DraftStep3SummarySnapshot | null;
|
||||
onSummarySaved?: (snapshot: DraftStep3SummarySnapshot) => void;
|
||||
onReset: () => void;
|
||||
/** Step 2 沙箱预览产出文件(与第二步相同)。 */
|
||||
openArtifactPreview: (threadId: string, path: string) => void;
|
||||
@@ -55,6 +63,9 @@ export function Step3Panel(props: Step3PanelProps) {
|
||||
setSelectedModel,
|
||||
savedReport,
|
||||
onReportSaved,
|
||||
coordinatorPrompt,
|
||||
savedSummary,
|
||||
onSummarySaved,
|
||||
onReset,
|
||||
openArtifactPreview,
|
||||
seatStubMessages,
|
||||
@@ -87,6 +98,9 @@ export function Step3Panel(props: Step3PanelProps) {
|
||||
setSelectedModel={setSelectedModel}
|
||||
savedReport={savedReport}
|
||||
onReportSaved={onReportSaved}
|
||||
coordinatorPrompt={coordinatorPrompt}
|
||||
savedSummary={savedSummary}
|
||||
onSummarySaved={onSummarySaved}
|
||||
onReset={onReset}
|
||||
openArtifactPreview={openArtifactPreview}
|
||||
seatStubMessages={seatStubMessages}
|
||||
|
||||
@@ -0,0 +1,843 @@
|
||||
/**
|
||||
* Step 3「总结报告」模式 —— 第 3 步**主页**(进第三步默认自动跑这个)。
|
||||
*
|
||||
* 由内置的「方案总结报告智能体」(`roundtable-summary`) 把各席位交付 + 总控共识
|
||||
* 总结成一份 **Markdown 报告**(写入 outputs/方案总结报告.md),报告正文同时流式
|
||||
* 输出到左侧对话区;右侧沙箱预览 md 文件。底部输入框让用户继续对话——智能体自行
|
||||
* 判断是「增量改报告」还是「问答」。
|
||||
*
|
||||
* 布局对齐 Step3ReportMode(HTML 绘制):左栏研讨原料源卡 + 右栏沙箱。两者并存、
|
||||
* 互不影响(report 画 HTML 看板,summary 写 Markdown 报告 + 问答)。
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle,
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
Loader2,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
RefreshCw,
|
||||
SendHorizontal,
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
buildArtifactDownloadUrl,
|
||||
listSessionArtifacts,
|
||||
type SessionArtifactFile,
|
||||
type ThreadIdMap,
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
|
||||
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
|
||||
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
||||
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
|
||||
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
|
||||
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
|
||||
import type { ModelOption } from "@/roundtable-planning/components/Step3ReportMode";
|
||||
import type { DraftStep3SummarySnapshot } from "@/roundtable-planning/api/drafts";
|
||||
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||||
import { useStep3Summary } from "@/roundtable-planning/hooks/useStep3Summary";
|
||||
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
|
||||
|
||||
interface SeatDelivery {
|
||||
sender: string;
|
||||
avatarType: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface Step3SummaryModeProps {
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
consensusText: string;
|
||||
hasConsensus: boolean;
|
||||
seatDeliveries: SeatDelivery[];
|
||||
seatNameMap: Record<string, string>;
|
||||
intentReady: ReportIntent | null;
|
||||
selectedModel: string;
|
||||
availableModels: ReadonlyArray<ModelOption>;
|
||||
modelsLoading: boolean;
|
||||
setSelectedModel: (model: string) => void;
|
||||
/** 业务链写给总控的编排提示 —— 注入总结智能体作为最高优先级口径。 */
|
||||
coordinatorPrompt?: string | null;
|
||||
/** 草稿里已存的上次总结结果 —— 有则进入时恢复展示,不重新生成。 */
|
||||
savedSummary?: DraftStep3SummarySnapshot | null;
|
||||
/** 一次总结完成时回调,把结果交给上层存进草稿 step3.summaryReport。 */
|
||||
onSummarySaved?: (snapshot: DraftStep3SummarySnapshot) => void;
|
||||
/** 切到「最终方案展示」静态页(次要入口)。 */
|
||||
onViewStatic: () => void;
|
||||
}
|
||||
|
||||
type SourceModal = "consensus" | "seats" | "files" | null;
|
||||
|
||||
export function Step3SummaryMode({
|
||||
threadIds,
|
||||
coordinatorName,
|
||||
consensusText,
|
||||
hasConsensus,
|
||||
seatDeliveries,
|
||||
seatNameMap,
|
||||
intentReady,
|
||||
selectedModel,
|
||||
availableModels,
|
||||
modelsLoading,
|
||||
setSelectedModel,
|
||||
coordinatorPrompt,
|
||||
savedSummary,
|
||||
onSummarySaved,
|
||||
onViewStatic,
|
||||
}: Step3SummaryModeProps) {
|
||||
const summarySeatDeliveries = useMemo(
|
||||
() => seatDeliveries.map((d) => ({ sender: d.sender, content: d.content })),
|
||||
[seatDeliveries],
|
||||
);
|
||||
|
||||
const consensusDocTitle = useMemo(() => {
|
||||
const objective = intentReady?.objective?.trim();
|
||||
if (!objective) return "圆桌总控最终结论";
|
||||
const short = objective.length > 40 ? `${objective.slice(0, 40)}…` : objective;
|
||||
return `圆桌总控结论-${short}`;
|
||||
}, [intentReady?.objective]);
|
||||
|
||||
const {
|
||||
phase,
|
||||
error,
|
||||
summaryThreadId,
|
||||
dialogues,
|
||||
seatStubMessages,
|
||||
activeArtifactThreadId,
|
||||
canAsk,
|
||||
start,
|
||||
hydrateSaved,
|
||||
redraw,
|
||||
ask,
|
||||
abort,
|
||||
openArtifactFromStep,
|
||||
openArtifactPreview,
|
||||
} = useStep3Summary({
|
||||
intentReady,
|
||||
seatDeliveries: summarySeatDeliveries,
|
||||
consensusText,
|
||||
selectedModel,
|
||||
coordinatorPrompt,
|
||||
savedSummary,
|
||||
onSummarySaved,
|
||||
});
|
||||
|
||||
const [modal, setModal] = useState<SourceModal>(null);
|
||||
|
||||
const { open: artifactsOpen } = useArtifacts();
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||
const prevArtifactsOpenRef = useRef(artifactsOpen);
|
||||
useEffect(() => {
|
||||
if (artifactsOpen !== prevArtifactsOpenRef.current) {
|
||||
prevArtifactsOpenRef.current = artifactsOpen;
|
||||
setLeftCollapsed(artifactsOpen);
|
||||
}
|
||||
}, [artifactsOpen]);
|
||||
|
||||
// 进入即自动开跑;草稿里已存结果则恢复展示。卸载中断在途请求(理由同 Step3ReportMode)。
|
||||
useEffect(() => {
|
||||
if (savedSummary?.md) {
|
||||
hydrateSaved(savedSummary);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
return () => abort();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const isStreaming =
|
||||
phase === "creating" ||
|
||||
phase === "streaming" ||
|
||||
dialogues.some((d) => d.streaming === true);
|
||||
const activeSeatMessages = activeArtifactThreadId
|
||||
? seatStubMessages[activeArtifactThreadId]
|
||||
: undefined;
|
||||
|
||||
const statusLabel =
|
||||
phase === "creating"
|
||||
? "正在初始化总结线程…"
|
||||
: phase === "streaming"
|
||||
? "方案总结报告智能体撰写中…"
|
||||
: phase === "done"
|
||||
? "总结完成"
|
||||
: phase === "error"
|
||||
? "总结失败"
|
||||
: "待总结";
|
||||
|
||||
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="flex items-center gap-2.5">
|
||||
<Sparkles className="w-5 h-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 === "done" && <CheckCircle className="w-3 h-3 text-emerald-600" />}
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => redraw()}
|
||||
disabled={phase === "creating" || phase === "streaming"}
|
||||
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={onViewStatic}
|
||||
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"
|
||||
>
|
||||
最终方案展示
|
||||
<ArrowRight className="w-3.5 h-3.5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主体:左源卡 + 右沙箱 */}
|
||||
<div className="flex-1 min-h-0 flex gap-3 p-3">
|
||||
<aside
|
||||
className={cn(
|
||||
"shrink-0 flex flex-col gap-3 overflow-y-auto pr-0.5 transition-[width] duration-200 ease-out",
|
||||
leftCollapsed ? "w-[52px]" : "w-[280px]",
|
||||
)}
|
||||
>
|
||||
{leftCollapsed ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLeftCollapsed(false)}
|
||||
title="展开研讨原料"
|
||||
aria-expanded={false}
|
||||
className="w-full shrink-0 flex h-10 items-center justify-center rounded-2xl border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</button>
|
||||
<CollapsedSourceCard
|
||||
icon={<CheckCircle className="w-4 h-4 text-emerald-500" />}
|
||||
title="总控最终结论"
|
||||
disabled={!consensusText}
|
||||
onClick={() => setModal("consensus")}
|
||||
/>
|
||||
<CollapsedSourceCard
|
||||
icon={<FileText className="w-4 h-4 text-primary" />}
|
||||
title="各席位最终交付"
|
||||
disabled={seatDeliveries.length === 0}
|
||||
onClick={() => setModal("seats")}
|
||||
/>
|
||||
<CollapsedSourceCard
|
||||
icon={<Download className="w-4 h-4 text-primary" />}
|
||||
title="各席位生成文件"
|
||||
onClick={() => setModal("files")}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="shrink-0 flex items-center justify-between gap-2 px-1">
|
||||
<span className="text-[11px] font-bold text-slate-500 uppercase tracking-widest">
|
||||
研讨原料
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLeftCollapsed(true)}
|
||||
title="收起研讨原料"
|
||||
aria-expanded
|
||||
className="shrink-0 flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors"
|
||||
>
|
||||
<PanelLeftClose className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<SourceCard
|
||||
icon={<CheckCircle className="w-4 h-4 text-emerald-500" />}
|
||||
title="总控最终结论"
|
||||
hint={consensusText ? "点击查看结论全文" : "暂无结论"}
|
||||
badge={hasConsensus ? "已共识" : undefined}
|
||||
disabled={!consensusText}
|
||||
onClick={() => setModal("consensus")}
|
||||
/>
|
||||
<SourceCard
|
||||
icon={<FileText className="w-4 h-4 text-primary" />}
|
||||
title="各席位最终交付"
|
||||
hint="点击逐席位查看交付"
|
||||
badge={String(seatDeliveries.length)}
|
||||
disabled={seatDeliveries.length === 0}
|
||||
onClick={() => setModal("seats")}
|
||||
/>
|
||||
<SourceCard
|
||||
icon={<Download className="w-4 h-4 text-primary" />}
|
||||
title="各席位生成文件"
|
||||
hint="点击查看/预览产出文件"
|
||||
onClick={() => setModal("files")}
|
||||
/>
|
||||
|
||||
<div className="mt-1 rounded-2xl border border-dashed border-slate-200 bg-slate-50/60 p-3 text-[11px] leading-relaxed text-slate-400">
|
||||
右侧由「方案总结报告智能体」据这些原料撰写 Markdown 总结报告,可在下方输入框继续提修改意见或提问。
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* 右栏:智能体进度 + 沙箱(复用第 2 步) */}
|
||||
<section className="flex-1 min-w-0 rounded-2xl border border-slate-200/60 bg-white overflow-hidden">
|
||||
<Step2SandboxLayout
|
||||
activeThreadId={activeArtifactThreadId ?? summaryThreadId}
|
||||
isStreaming={isStreaming}
|
||||
seatMessages={activeSeatMessages}
|
||||
>
|
||||
<AgentProgressPanel
|
||||
phase={phase}
|
||||
dialogues={dialogues}
|
||||
error={error}
|
||||
canAsk={canAsk}
|
||||
onRetry={redraw}
|
||||
onAsk={ask}
|
||||
onArtifactStepClick={openArtifactFromStep}
|
||||
onArtifactPreview={openArtifactPreview}
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
modelsLoading={modelsLoading}
|
||||
setSelectedModel={setSelectedModel}
|
||||
/>
|
||||
</Step2SandboxLayout>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* 弹窗 */}
|
||||
{modal === "consensus" && (
|
||||
<ContentModal
|
||||
title="总控最终结论"
|
||||
onClose={() => setModal(null)}
|
||||
headerAction={
|
||||
<ConsensusDocxDownloadButton markdown={consensusText} title={consensusDocTitle} />
|
||||
}
|
||||
>
|
||||
{consensusText ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{consensusText}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400 italic">暂无总控结论。</p>
|
||||
)}
|
||||
</ContentModal>
|
||||
)}
|
||||
|
||||
{modal === "seats" && (
|
||||
<ContentModal title={`各席位最终交付(${seatDeliveries.length})`} onClose={() => setModal(null)}>
|
||||
<SeatDeliveriesView seatDeliveries={seatDeliveries} />
|
||||
</ContentModal>
|
||||
)}
|
||||
|
||||
{modal === "files" && (
|
||||
<SeatFilesModal
|
||||
threadIds={threadIds}
|
||||
coordinatorName={coordinatorName}
|
||||
seatNameMap={seatNameMap}
|
||||
onClose={() => setModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 左栏源卡 ─────────────────────────────────────────────────────────────
|
||||
|
||||
function SourceCard({
|
||||
icon,
|
||||
title,
|
||||
hint,
|
||||
badge,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
hint: string;
|
||||
badge?: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="text-left bg-white border border-slate-200/60 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:border-primary/40 hover:shadow-[0_4px_14px_rgba(0,0,0,0.04)] disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-slate-200/60"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
{icon}
|
||||
<h4 className="text-xs font-bold text-slate-900 tracking-wide flex-1 truncate">{title}</h4>
|
||||
{badge && (
|
||||
<span className="text-[10px] font-mono font-semibold text-primary bg-[var(--brand-200)]/50 px-1.5 py-0.5 rounded">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 leading-normal">{hint}</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsedSourceCard({
|
||||
icon,
|
||||
title,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className="w-full shrink-0 flex items-center justify-center rounded-2xl border border-slate-200/50 bg-white p-2.5 text-slate-500 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:text-primary hover:border-primary/30 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 右栏:智能体进度面板(沙箱的 60% 区域)+ 底部对话输入框 ──────────────────
|
||||
|
||||
function AgentProgressPanel({
|
||||
phase,
|
||||
dialogues,
|
||||
error,
|
||||
canAsk,
|
||||
onRetry,
|
||||
onAsk,
|
||||
onArtifactStepClick,
|
||||
onArtifactPreview,
|
||||
selectedModel,
|
||||
availableModels,
|
||||
modelsLoading,
|
||||
setSelectedModel,
|
||||
}: {
|
||||
phase: ReturnType<typeof useStep3Summary>["phase"];
|
||||
dialogues: ReturnType<typeof useStep3Summary>["dialogues"];
|
||||
error: string | null;
|
||||
canAsk: boolean;
|
||||
onRetry: () => void;
|
||||
onAsk: (message: string) => void;
|
||||
onArtifactStepClick: ReturnType<typeof useStep3Summary>["openArtifactFromStep"];
|
||||
onArtifactPreview: ReturnType<typeof useStep3Summary>["openArtifactPreview"];
|
||||
selectedModel: string;
|
||||
availableModels: ReadonlyArray<ModelOption>;
|
||||
modelsLoading: boolean;
|
||||
setSelectedModel: (model: string) => void;
|
||||
}) {
|
||||
const busy = phase === "creating" || phase === "streaming";
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]);
|
||||
const [askText, setAskText] = useState("");
|
||||
|
||||
const handleAskSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const text = askText.trim();
|
||||
if (!text || !canAsk) return;
|
||||
setAskText("");
|
||||
onAsk(text);
|
||||
};
|
||||
|
||||
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">据各席位成果撰写 Markdown 总结报告</div>
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
id="step3-summary-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="shrink-0 max-w-[180px] 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>
|
||||
</div>
|
||||
|
||||
{/* 消息列表 —— 与第 2 步同款气泡(流式正文 + 工具步骤卡 + 文件卡)。 */}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{dialogues.map((msg) => (
|
||||
<Step2Message
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
onArtifactStepClick={(step) => onArtifactStepClick(msg, step)}
|
||||
onFileCardClick={(path) => {
|
||||
if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{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" />
|
||||
报告正文实时输出,右侧沙箱同步预览 Markdown
|
||||
</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={onRetry}
|
||||
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" />
|
||||
已生成方案总结报告,右侧可预览 / 下载 .md;可在下方继续提修改意见或提问。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!atBottom && <ScrollToBottomButton onClick={scrollToBottom} />}
|
||||
<form onSubmit={handleAskSubmit} 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={askText}
|
||||
onChange={(e) => setAskText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
disabled={!canAsk}
|
||||
rows={1}
|
||||
placeholder="继续对话:提修改意见(如「执行路线写细些」)或就报告提问"
|
||||
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={!canAsk || askText.trim().length === 0}
|
||||
title={busy ? "正在撰写,请稍后发送" : "发送(智能体自行判断改报告 or 回答)"}
|
||||
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"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<SendHorizontal className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 弹窗外壳 ─────────────────────────────────────────────────────────────
|
||||
|
||||
function ContentModal({
|
||||
title,
|
||||
children,
|
||||
onClose,
|
||||
headerAction,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
onClose: () => void;
|
||||
headerAction?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-[1px] p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-white border border-slate-200 rounded-2xl shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="shrink-0 flex items-center justify-between gap-2 px-5 py-3.5 border-b border-slate-100 bg-slate-50">
|
||||
<h3 className="text-sm font-bold text-slate-900 min-w-0 truncate">{title}</h3>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{headerAction}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1 text-slate-400 hover:text-slate-600 rounded-md hover:bg-slate-100"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-5">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 各席位交付(弹窗内的折叠列表)────────────────────────────────────────
|
||||
|
||||
function SeatDeliveriesView({ seatDeliveries }: { seatDeliveries: SeatDelivery[] }) {
|
||||
const [openIdx, setOpenIdx] = useState<number | null>(0);
|
||||
if (seatDeliveries.length === 0) {
|
||||
return <p className="text-xs text-slate-400 italic">本次研讨尚未产生子智能体交付。</p>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{seatDeliveries.map((seat, idx) => {
|
||||
const open = openIdx === idx;
|
||||
return (
|
||||
<div key={`${seat.sender}-${idx}`} className="border border-slate-150 rounded-xl overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenIdx(open ? null : idx)}
|
||||
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-slate-50 hover:bg-slate-100 transition text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.sender}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-mono shrink-0">{seat.content.length} 字</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{seat.content || "(空)"}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 各席位生成文件(弹窗,按需拉取 outputs 列表)──────────────────────────
|
||||
|
||||
interface SeatArtifacts {
|
||||
agentName: string;
|
||||
displayName: string;
|
||||
threadId: string;
|
||||
files: SessionArtifactFile[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function shouldPreviewInModal(file: { name: string; mimeType: string | null }): boolean {
|
||||
const mime = file.mimeType ?? "";
|
||||
if (mime.startsWith("text/")) return true;
|
||||
if (["application/json"].includes(mime)) return true;
|
||||
return /\.(md|markdown|mmd|txt|json|csv|log|yml|yaml|xml|html?)$/i.test(file.name);
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function SeatFilesModal({
|
||||
threadIds,
|
||||
coordinatorName,
|
||||
seatNameMap,
|
||||
onClose,
|
||||
}: {
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
seatNameMap: Record<string, string>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const seatEntries = useMemo(() => {
|
||||
if (!threadIds) return [] as Array<{ agentName: string; threadId: string; displayName: string }>;
|
||||
return Object.entries(threadIds)
|
||||
.filter(([name]) => !coordinatorName || name !== coordinatorName)
|
||||
.map(([agentName, threadId]) => ({
|
||||
agentName,
|
||||
threadId,
|
||||
displayName: seatNameMap[agentName.toLowerCase()] ?? agentName,
|
||||
}));
|
||||
}, [threadIds, coordinatorName, seatNameMap]);
|
||||
|
||||
const [bySeat, setBySeat] = useState<SeatArtifacts[]>([]);
|
||||
const [previewing, setPreviewing] = useState<{ threadId: string; file: SessionArtifactFile } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (seatEntries.length === 0) {
|
||||
setBySeat([]);
|
||||
return;
|
||||
}
|
||||
setBySeat(seatEntries.map((s) => ({ ...s, files: [], loading: true, error: null })));
|
||||
const abort = new AbortController();
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const results = await Promise.all(
|
||||
seatEntries.map(async (seat) => {
|
||||
try {
|
||||
const res = await listSessionArtifacts(seat.threadId, { signal: abort.signal });
|
||||
return { seat, files: res.files, error: null as string | null };
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") {
|
||||
return { seat, files: [] as SessionArtifactFile[], error: null };
|
||||
}
|
||||
return { seat, files: [] as SessionArtifactFile[], error: (err as Error).message };
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (cancelled) return;
|
||||
setBySeat(results.map(({ seat, files, error }) => ({ ...seat, files, loading: false, error })));
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
abort.abort();
|
||||
};
|
||||
}, [seatEntries]);
|
||||
|
||||
const handlePreview = (threadId: string, file: SessionArtifactFile) => {
|
||||
if (shouldPreviewInModal({ name: file.name, mimeType: file.mimeType })) {
|
||||
setPreviewing({ threadId, file });
|
||||
return;
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
window.open(buildArtifactDownloadUrl(threadId, file.path), "_blank", "noopener,noreferrer");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (threadId: string, file: SessionArtifactFile) => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.open(
|
||||
buildArtifactDownloadUrl(threadId, file.path, { download: true }),
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentModal title="各席位生成文件" onClose={onClose}>
|
||||
{bySeat.length === 0 ? (
|
||||
<p className="text-xs text-slate-400 italic">尚未初始化席位线程,无法列出文件。</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{bySeat.map((seat) => (
|
||||
<div key={seat.agentName} className="border border-slate-150 rounded-xl">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 bg-slate-50 border-b border-slate-150 rounded-t-xl">
|
||||
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.displayName}</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono shrink-0">
|
||||
{seat.loading ? "加载中…" : `${seat.files.length} 个文件`}
|
||||
</span>
|
||||
</div>
|
||||
{seat.loading ? (
|
||||
<div className="flex items-center gap-2 px-3 py-3 text-[11px] text-slate-500">
|
||||
<Loader2 className="w-3 h-3 animate-spin text-primary" />
|
||||
正在查询 outputs 目录…
|
||||
</div>
|
||||
) : seat.error ? (
|
||||
<div className="px-3 py-3 text-[11px] text-rose-600">读取失败:{seat.error}</div>
|
||||
) : seat.files.length === 0 ? (
|
||||
<div className="px-3 py-3 text-[11px] text-slate-400 italic">该席位未产生输出文件</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100">
|
||||
{seat.files.map((f) => (
|
||||
<li key={f.path} className="px-3 py-2 flex items-center justify-between gap-2 hover:bg-slate-50/60">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
<span className="text-[11.5px] font-medium text-slate-800 truncate" title={f.name}>
|
||||
{f.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[10px] font-mono text-slate-400">{formatBytes(f.sizeBytes)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handlePreview(seat.threadId, f)}
|
||||
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
|
||||
title="预览"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDownload(seat.threadId, f)}
|
||||
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
|
||||
title="下载"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ContentModal>
|
||||
|
||||
{previewing && (
|
||||
<ArtifactPreviewModal
|
||||
threadId={previewing.threadId}
|
||||
path={previewing.file.path}
|
||||
name={previewing.file.name}
|
||||
mimeType={previewing.file.mimeType}
|
||||
onClose={() => setPreviewing(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,933 @@
|
||||
/**
|
||||
* Step 3「总结报告」编排 hook —— 驱动内置的方案总结报告智能体
|
||||
* (`roundtable-summary`,与画 HTML 的 `roundtable-report` 并存的另一个内置单例)
|
||||
* 把各席位交付 + 总控共识总结成一份 **Markdown 报告**(写入
|
||||
* `/mnt/user-data/outputs/方案总结报告.md`),并把报告正文同时输出到对话里。
|
||||
*
|
||||
* 与 `useStep3Report` 的关键差异:
|
||||
* - 产物是 **Markdown 文件**(非 HTML 看板),路径/标签/匹配都用 `.md`;
|
||||
* - 注入 **业务链总控编排提示**(coordinatorPrompt)作为最高优先级口径;
|
||||
* - 多了 **`ask(message)`**:用户在输入框里继续对话——**复用同一条真线程**续问,
|
||||
* 让智能体自行判断是「增量改报告(整文件 write_file 重写)」还是「问答(只回正文)」。
|
||||
*
|
||||
* 其余(流式三段气泡、write_file stub→虚拟 URL→沙箱预览、收尾对账回灌)全部沿用
|
||||
* `useStep3Report` 的同款机制,与第 2 步 `Step2SandboxLayout` + `Step2Message` 同源。
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import type {
|
||||
DraftStep3SummarySnapshot,
|
||||
} from "@/roundtable-planning/api/drafts";
|
||||
import {
|
||||
buildArtifactDownloadUrl,
|
||||
initSummaryThread,
|
||||
listSessionArtifacts,
|
||||
streamMultiAgent,
|
||||
type StreamPhase,
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import type {
|
||||
ReportIntent,
|
||||
ReportSeatDelivery,
|
||||
} from "@/roundtable-planning/hooks/useStep3Report";
|
||||
import type {
|
||||
Step2Dialogue,
|
||||
StubAiMessage,
|
||||
} from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import { nowTime } from "@/roundtable-planning/lib/roundtable-constants";
|
||||
|
||||
/** 报告输出文件的固定虚拟路径(恢复历史快照时用它重建 stub)。 */
|
||||
const SUMMARY_OUTPUT_PATH = "/mnt/user-data/outputs/方案总结报告.md";
|
||||
/** 内置单例 id —— 恢复草稿后续问时无 init 返回值,用它兜底拼 dAgentThreadId。 */
|
||||
const SUMMARY_AGENT_NAME = "roundtable-summary";
|
||||
|
||||
export type SummaryPhase = "idle" | "creating" | "streaming" | "done" | "error";
|
||||
|
||||
interface UseStep3SummaryOptions {
|
||||
intentReady: ReportIntent | null;
|
||||
seatDeliveries: ReportSeatDelivery[];
|
||||
consensusText: string;
|
||||
selectedModel: string;
|
||||
/** 业务链写给总控的编排提示 —— 作为最高优先级口径注入总结智能体。 */
|
||||
coordinatorPrompt?: string | null;
|
||||
/** 草稿里已存的上次总结结果 —— 有则进入时恢复展示,不重新生成。 */
|
||||
savedSummary?: DraftStep3SummarySnapshot | null;
|
||||
/** 一次总结成功完成时回调(传出 md/thread/model/时间/收尾语),供上层存草稿。 */
|
||||
onSummarySaved?: (snapshot: DraftStep3SummarySnapshot) => void;
|
||||
}
|
||||
|
||||
export interface UseStep3SummaryReturn {
|
||||
phase: SummaryPhase;
|
||||
error: string | null;
|
||||
summaryThreadId: string | null;
|
||||
dialogues: Step2Dialogue[];
|
||||
seatStubMessages: Record<string, StubAiMessage[]>;
|
||||
activeArtifactThreadId: string | null;
|
||||
/** 是否可发送追问/修改(非流式中、且已有线程或可新建)。 */
|
||||
canAsk: boolean;
|
||||
/** 启动总结(幂等:进行中重复调用会被忽略)。 */
|
||||
start: () => void;
|
||||
/** 从草稿快照恢复展示(不重新生成;保留 threadId 以便继续问答)。 */
|
||||
hydrateSaved: (snapshot: DraftStep3SummarySnapshot) => void;
|
||||
/** 重新生成(先中断在途请求再重跑)。 */
|
||||
redraw: () => void;
|
||||
/** 用户在输入框里继续对话:续同一线程,智能体自判改报告 or 问答。 */
|
||||
ask: (message: string) => void;
|
||||
/** 中断在途请求(卸载 / 切回普通视图时调用)。 */
|
||||
abort: () => void;
|
||||
openArtifactFromStep: (
|
||||
dialogue: Step2Dialogue,
|
||||
step: NonNullable<Step2Dialogue["steps"]>[number],
|
||||
) => void;
|
||||
openArtifactPreview: (threadId: string, path: string) => void;
|
||||
}
|
||||
|
||||
/** 把研讨原料拼成一段(首轮 prompt 和「恢复后无线程续问」都用得到)。 */
|
||||
function buildMaterials(opts: {
|
||||
intentReady: ReportIntent | null;
|
||||
seatDeliveries: ReportSeatDelivery[];
|
||||
consensusText: string;
|
||||
coordinatorPrompt?: string | null;
|
||||
}): string {
|
||||
const { intentReady, seatDeliveries, consensusText, coordinatorPrompt } = opts;
|
||||
const parts: string[] = [];
|
||||
|
||||
const cp = coordinatorPrompt?.trim();
|
||||
if (cp) {
|
||||
parts.push("# 业务链总控编排提示(最高优先级,按它的关注点/结构/口径来组织报告)");
|
||||
parts.push(cp);
|
||||
parts.push("");
|
||||
}
|
||||
|
||||
if (intentReady?.objective) {
|
||||
parts.push("# 一、任务意图");
|
||||
parts.push(`目标:${intentReady.objective}`);
|
||||
if (intentReady.constraints?.length) {
|
||||
parts.push("约束:");
|
||||
for (const c of intentReady.constraints) parts.push(`- ${c}`);
|
||||
}
|
||||
if (intentReady.assumptions?.length) {
|
||||
parts.push("关键假设:");
|
||||
for (const a of intentReady.assumptions) parts.push(`- ${a}`);
|
||||
}
|
||||
parts.push("");
|
||||
}
|
||||
|
||||
if (seatDeliveries.length) {
|
||||
parts.push("# 二、各席位最终交付");
|
||||
seatDeliveries.forEach((s, i) => {
|
||||
parts.push(`## ${i + 1}. ${s.sender}`);
|
||||
parts.push(s.content?.trim() || "(无内容)");
|
||||
parts.push("");
|
||||
});
|
||||
}
|
||||
|
||||
if (consensusText?.trim()) {
|
||||
parts.push("# 三、总控最终共识结论");
|
||||
parts.push(consensusText.trim());
|
||||
parts.push("");
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/** 首轮总结 prompt。 */
|
||||
function buildSummaryPrompt(opts: {
|
||||
intentReady: ReportIntent | null;
|
||||
seatDeliveries: ReportSeatDelivery[];
|
||||
consensusText: string;
|
||||
coordinatorPrompt?: string | null;
|
||||
}): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(
|
||||
"请基于以下圆桌多智能体研讨的成果,撰写一份以文字叙述为主线的 Markdown「方案总结报告」," +
|
||||
"用 write_file 工具写入 /mnt/user-data/outputs/方案总结报告.md," +
|
||||
"并把报告完整正文同时输出到对话回复里供用户直接阅读。",
|
||||
);
|
||||
parts.push("");
|
||||
parts.push(buildMaterials(opts));
|
||||
parts.push(
|
||||
"请严格遵循你的职责设定:纯 Markdown 文本(不画 HTML / 图表)、叙事型结构" +
|
||||
"(一句话结论 → 背景目标 → 各席位观点 → 综合分析 → 最终共识方案 → 执行路线)、全程简体中文;" +
|
||||
"若上面给了「业务链总控编排提示」,以它的要求优先组织报告。",
|
||||
);
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/** 恢复草稿后线程已不在内存(无 live thread)时,新建线程续问用的 prompt:带上旧报告做底。 */
|
||||
function buildResumeAskPrompt(opts: {
|
||||
intentReady: ReportIntent | null;
|
||||
seatDeliveries: ReportSeatDelivery[];
|
||||
consensusText: string;
|
||||
coordinatorPrompt?: string | null;
|
||||
previousMd: string;
|
||||
userMessage: string;
|
||||
}): string {
|
||||
const { previousMd, userMessage, ...materials } = opts;
|
||||
const parts: string[] = [];
|
||||
parts.push(
|
||||
"这是一次对已有「方案总结报告」的继续对话。下面先给出研讨原料与你之前已生成的报告," +
|
||||
"然后是用户的新消息——请自行判断:若是修改意见,就在旧报告基础上增量修改并用 write_file " +
|
||||
"整文件重写 /mnt/user-data/outputs/方案总结报告.md,再把更新后的报告正文输出到对话;" +
|
||||
"若只是提问,就只用文字回答、不要改写文件。",
|
||||
);
|
||||
parts.push("");
|
||||
parts.push(buildMaterials(materials));
|
||||
if (previousMd.trim()) {
|
||||
parts.push("# 你之前已生成的报告(方案总结报告.md 当前内容)");
|
||||
parts.push("```markdown");
|
||||
parts.push(previousMd.trim());
|
||||
parts.push("```");
|
||||
parts.push("");
|
||||
}
|
||||
parts.push("# 用户的新消息");
|
||||
parts.push(userMessage.trim());
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/** 把 SSE 阶段映射成中文状态标签。 */
|
||||
function summaryPhaseLabel(phase: StreamPhase, toolName?: string): string {
|
||||
switch (phase) {
|
||||
case "connecting":
|
||||
return "调度中";
|
||||
case "connected":
|
||||
return "已连接,等待响应";
|
||||
case "tool_calling":
|
||||
if (toolName === "write_file" || toolName === "str_replace")
|
||||
return "正在撰写报告";
|
||||
return toolName ? `调用工具:${toolName}` : "调用工具";
|
||||
case "tool_result":
|
||||
return "处理工具结果";
|
||||
case "streaming":
|
||||
return "正在输出";
|
||||
default:
|
||||
return "正在输出…";
|
||||
}
|
||||
}
|
||||
|
||||
export function useStep3Summary(
|
||||
options: UseStep3SummaryOptions,
|
||||
): UseStep3SummaryReturn {
|
||||
const {
|
||||
intentReady,
|
||||
seatDeliveries,
|
||||
consensusText,
|
||||
selectedModel,
|
||||
coordinatorPrompt,
|
||||
savedSummary,
|
||||
onSummarySaved,
|
||||
} = options;
|
||||
|
||||
const onSummarySavedRef = useRef(onSummarySaved);
|
||||
useEffect(() => {
|
||||
onSummarySavedRef.current = onSummarySaved;
|
||||
}, [onSummarySaved]);
|
||||
|
||||
const {
|
||||
setArtifacts,
|
||||
select: selectArtifact,
|
||||
setOpen: setArtifactsOpen,
|
||||
} = useArtifacts();
|
||||
|
||||
const [phase, setPhase] = useState<SummaryPhase>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [summaryThreadId, setSummaryThreadId] = useState<string | null>(null);
|
||||
const [dialogues, setDialogues] = useState<Step2Dialogue[]>([]);
|
||||
const [seatStubMessages, setSeatStubMessages] = useState<
|
||||
Record<string, StubAiMessage[]>
|
||||
>({});
|
||||
const [activeArtifactThreadId, setActiveArtifactThreadId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const stubRef = useRef<Record<string, StubAiMessage[]>>({});
|
||||
const runningRef = useRef(false);
|
||||
const aborterRef = useRef<AbortController | null>(null);
|
||||
// 真线程(init 后或恢复时已知的后端 thread id)—— ask() 续问时复用它,保留报告上下文。
|
||||
const liveThreadRef = useRef<string | null>(null);
|
||||
// 当前报告 md 全文(续问写底 / 存草稿用)。
|
||||
const currentMdRef = useRef<string>("");
|
||||
const lastAutoOpenedUrlRef = useRef<string | null>(null);
|
||||
|
||||
// ── 对话气泡三段式(append → delta → finalize)+ 工具步骤(与第 2 步同款)──
|
||||
const appendStreamingDialogue = useCallback(
|
||||
(card: { sender: string; avatarType: string; agentThreadId?: string }): number => {
|
||||
const id = Date.now() + Math.random();
|
||||
setDialogues((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
sender: card.sender,
|
||||
avatarType: card.avatarType,
|
||||
content: "",
|
||||
time: nowTime(),
|
||||
confidence: "调度中",
|
||||
streaming: true,
|
||||
steps: [],
|
||||
agentThreadId: card.agentThreadId,
|
||||
},
|
||||
]);
|
||||
return id;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/** 追加一条用户(指挥官)气泡 —— ask() 时先回显用户输入。 */
|
||||
const appendUserDialogue = useCallback((text: string) => {
|
||||
const id = Date.now() + Math.random();
|
||||
setDialogues((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
sender: "指挥官(追问)",
|
||||
avatarType: "human",
|
||||
content: text,
|
||||
time: nowTime(),
|
||||
confidence: "已发送",
|
||||
streaming: false,
|
||||
steps: [],
|
||||
},
|
||||
]);
|
||||
}, []);
|
||||
|
||||
const appendToDialogue = useCallback((id: number, deltaText: string) => {
|
||||
if (!deltaText) return;
|
||||
setDialogues((prev) =>
|
||||
prev.map((d) =>
|
||||
d.id === id ? { ...d, content: (d.content ?? "") + deltaText } : d,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const setDialogueConfidence = useCallback((id: number, confidence: string) => {
|
||||
setDialogues((prev) =>
|
||||
prev.map((d) => (d.id === id ? { ...d, confidence } : d)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const appendStepIfNew = useCallback(
|
||||
(
|
||||
bubbleId: number,
|
||||
label: string,
|
||||
key: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => {
|
||||
setDialogues((prev) =>
|
||||
prev.map((d) => {
|
||||
if (d.id !== bubbleId) return d;
|
||||
const steps: Step2Dialogue["steps"] = d.steps ?? [];
|
||||
const last = steps[steps.length - 1];
|
||||
if (last && last.key === key) {
|
||||
if (args && args !== last.args) {
|
||||
const next = [...steps];
|
||||
next[next.length - 1] = { ...last, args };
|
||||
return { ...d, steps: next };
|
||||
}
|
||||
return d;
|
||||
}
|
||||
return {
|
||||
...d,
|
||||
steps: [
|
||||
...steps,
|
||||
{ id: `${bubbleId}-${steps.length}`, key, label, ts: nowTime(), args },
|
||||
],
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const finalizeDialogue = useCallback(
|
||||
(id: number, finalContent: string, confidence?: string) => {
|
||||
setDialogues((prev) =>
|
||||
prev.map((d) => {
|
||||
if (d.id !== id) return d;
|
||||
const next =
|
||||
finalContent && finalContent.trim().length > 0
|
||||
? finalContent
|
||||
: d.content || "(已写入报告文件,无额外正文)";
|
||||
return {
|
||||
...d,
|
||||
content: next,
|
||||
confidence: confidence ?? d.confidence,
|
||||
streaming: false,
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// ── stub 写入(幂等,按 (toolName, path) 去重,后续帧覆盖 args)—— 仅 write_file ──
|
||||
const upsertStub = useCallback(
|
||||
(threadId: string, toolName: string, args: Record<string, unknown>) => {
|
||||
const path = typeof args.path === "string" ? args.path : "";
|
||||
const current = stubRef.current[threadId] ?? [];
|
||||
const base: StubAiMessage = current[0] ?? {
|
||||
id: `roundtable-summary-stub-${threadId}`,
|
||||
type: "ai",
|
||||
content: "",
|
||||
tool_calls: [],
|
||||
};
|
||||
const idx = base.tool_calls.findIndex(
|
||||
(tc) =>
|
||||
tc.name === toolName &&
|
||||
String((tc.args as { path?: unknown })?.path ?? "") === path,
|
||||
);
|
||||
const toolCallId =
|
||||
idx >= 0 ? base.tool_calls[idx].id : `tc-${threadId}-${base.tool_calls.length}`;
|
||||
// 防抹空:重写早期帧 content 可能为空,保留旧非空内容直到吐出新非空内容。
|
||||
let storeArgs = args;
|
||||
if (idx >= 0) {
|
||||
const newContent = typeof args.content === "string" ? args.content : "";
|
||||
const prevContent =
|
||||
typeof (base.tool_calls[idx].args as { content?: unknown })?.content === "string"
|
||||
? ((base.tool_calls[idx].args as { content?: string }).content as string)
|
||||
: "";
|
||||
if (!newContent.trim() && prevContent.trim()) {
|
||||
storeArgs = { ...args, content: prevContent };
|
||||
}
|
||||
}
|
||||
const nextTc = { id: toolCallId, name: toolName, args: storeArgs };
|
||||
const nextToolCalls =
|
||||
idx >= 0
|
||||
? base.tool_calls.map((tc, i) => (i === idx ? nextTc : tc))
|
||||
: [...base.tool_calls, nextTc];
|
||||
const nextMsg: StubAiMessage = { ...base, tool_calls: nextToolCalls };
|
||||
const nextMap = { ...stubRef.current, [threadId]: [nextMsg] };
|
||||
stubRef.current = nextMap;
|
||||
setSeatStubMessages(nextMap);
|
||||
// 记住当前报告全文(用于续问写底 / 存草稿)。
|
||||
if (typeof storeArgs.content === "string" && storeArgs.content.trim()) {
|
||||
currentMdRef.current = storeArgs.content;
|
||||
}
|
||||
return { messageId: nextMsg.id, toolCallId };
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const buildWriteFileVirtualUrl = useCallback(
|
||||
(path: string, messageId: string, toolCallId: string) =>
|
||||
new URL(
|
||||
`write-file:${path}?message_id=${encodeURIComponent(
|
||||
messageId,
|
||||
)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
|
||||
).toString(),
|
||||
[],
|
||||
);
|
||||
|
||||
const openArtifact = useCallback(
|
||||
(threadId: string, virtualUrl: string) => {
|
||||
lastAutoOpenedUrlRef.current = virtualUrl;
|
||||
setActiveArtifactThreadId(threadId);
|
||||
selectArtifact(virtualUrl, false);
|
||||
setArtifactsOpen(true);
|
||||
},
|
||||
[selectArtifact, setArtifactsOpen],
|
||||
);
|
||||
|
||||
const autoOpenArtifact = useCallback(
|
||||
(threadId: string, virtualUrl: string) => {
|
||||
if (lastAutoOpenedUrlRef.current === virtualUrl) return;
|
||||
openArtifact(threadId, virtualUrl);
|
||||
},
|
||||
[openArtifact],
|
||||
);
|
||||
|
||||
const findStubSource = useCallback(
|
||||
(
|
||||
rawPath: string,
|
||||
): { threadId: string; messageId: string; toolCallId: string } | null => {
|
||||
const target = rawPath.trim();
|
||||
if (!target) return null;
|
||||
const targetBase = target.split("/").pop() ?? target;
|
||||
let basenameMatch:
|
||||
| { threadId: string; messageId: string; toolCallId: string }
|
||||
| null = null;
|
||||
for (const [threadId, msgs] of Object.entries(stubRef.current)) {
|
||||
const msg = msgs?.[0];
|
||||
if (!msg) continue;
|
||||
for (const tc of msg.tool_calls) {
|
||||
if (tc.name !== "write_file" && tc.name !== "str_replace") continue;
|
||||
const p =
|
||||
typeof (tc.args as { path?: unknown })?.path === "string"
|
||||
? ((tc.args as { path?: string }).path as string)
|
||||
: "";
|
||||
if (!p) continue;
|
||||
if (p === target) return { threadId, messageId: msg.id, toolCallId: tc.id };
|
||||
if (!basenameMatch && (p.split("/").pop() ?? p) === targetBase) {
|
||||
basenameMatch = { threadId, messageId: msg.id, toolCallId: tc.id };
|
||||
}
|
||||
}
|
||||
}
|
||||
return basenameMatch;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 流式期间:phase==="tool_calling" 且 write_file 有可读 path → 写 stub + 自动开沙箱。
|
||||
const handleArtifactToolPhase = useCallback(
|
||||
(
|
||||
streamPhase: StreamPhase,
|
||||
toolName: string | undefined,
|
||||
args: Record<string, unknown> | undefined,
|
||||
threadId: string,
|
||||
) => {
|
||||
if (streamPhase !== "tool_calling") return;
|
||||
if (toolName !== "write_file") return; // str_replace 无 content,不驱动预览
|
||||
const path = args?.path;
|
||||
if (typeof path !== "string" || !path || !args) return;
|
||||
const basename = path.split("/").pop() ?? path;
|
||||
if (!basename.includes(".")) return; // 半截路径(无扩展名)忽略
|
||||
const { messageId, toolCallId } = upsertStub(threadId, toolName, args);
|
||||
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
|
||||
autoOpenArtifact(threadId, virtualUrl);
|
||||
},
|
||||
[upsertStub, buildWriteFileVirtualUrl, autoOpenArtifact],
|
||||
);
|
||||
|
||||
const handleUpstreamFrame = useCallback(
|
||||
(frame: unknown, threadId: string) => {
|
||||
if (!Array.isArray(frame) || frame.length === 0) return;
|
||||
const chunk = frame[0] as Record<string, unknown> | undefined;
|
||||
const toolCalls = chunk?.tool_calls;
|
||||
if (!Array.isArray(toolCalls)) return;
|
||||
for (const tc of toolCalls) {
|
||||
if (!tc || typeof tc !== "object") continue;
|
||||
const o = tc as Record<string, unknown>;
|
||||
const name = typeof o.name === "string" ? o.name : "";
|
||||
if (name !== "write_file") continue;
|
||||
const a = o.args;
|
||||
if (!a || typeof a !== "object") continue;
|
||||
const p = (a as Record<string, unknown>).path;
|
||||
if (typeof p !== "string") continue;
|
||||
if (!(p.split("/").pop() ?? p).includes(".")) continue;
|
||||
upsertStub(threadId, name, a as Record<string, unknown>);
|
||||
}
|
||||
},
|
||||
[upsertStub],
|
||||
);
|
||||
|
||||
const openArtifactPreview = useCallback(
|
||||
(threadId: string, path: string) => {
|
||||
const normalized = path.trim();
|
||||
if (!normalized) return;
|
||||
const source = findStubSource(normalized);
|
||||
if (source) {
|
||||
const virtualUrl = buildWriteFileVirtualUrl(
|
||||
normalized,
|
||||
source.messageId,
|
||||
source.toolCallId,
|
||||
);
|
||||
openArtifact(source.threadId, virtualUrl);
|
||||
return;
|
||||
}
|
||||
if (!threadId) return;
|
||||
setActiveArtifactThreadId(threadId);
|
||||
setArtifacts([normalized]);
|
||||
selectArtifact(normalized, false);
|
||||
setArtifactsOpen(true);
|
||||
},
|
||||
[
|
||||
findStubSource,
|
||||
buildWriteFileVirtualUrl,
|
||||
openArtifact,
|
||||
setArtifacts,
|
||||
selectArtifact,
|
||||
setArtifactsOpen,
|
||||
],
|
||||
);
|
||||
|
||||
const openArtifactFromStep = useCallback(
|
||||
(
|
||||
dialogue: Step2Dialogue,
|
||||
step: NonNullable<Step2Dialogue["steps"]>[number],
|
||||
) => {
|
||||
const threadId = dialogue.agentThreadId;
|
||||
if (!threadId) return;
|
||||
const [, toolName] = step.key.split(":");
|
||||
const path = step.args?.path;
|
||||
if (typeof path !== "string" || !path) return;
|
||||
if (toolName === "write_file") {
|
||||
const { messageId, toolCallId } = upsertStub(threadId, toolName, step.args!);
|
||||
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
|
||||
openArtifact(threadId, virtualUrl);
|
||||
} else if (toolName === "str_replace") {
|
||||
openArtifactPreview(threadId, path);
|
||||
}
|
||||
},
|
||||
[upsertStub, buildWriteFileVirtualUrl, openArtifact, openArtifactPreview],
|
||||
);
|
||||
|
||||
const openRealArtifactFallback = useCallback(
|
||||
async (threadId: string) => {
|
||||
try {
|
||||
const res = await listSessionArtifacts(threadId);
|
||||
const file = res.files.find((f) => /\.md$/i.test(f.name)) ?? res.files[0];
|
||||
if (!file) return;
|
||||
setActiveArtifactThreadId(threadId);
|
||||
setArtifacts([file.path]);
|
||||
selectArtifact(file.path, false);
|
||||
setArtifactsOpen(true);
|
||||
} catch {
|
||||
/* 列文件失败就保持当前状态 */
|
||||
}
|
||||
},
|
||||
[setArtifacts, selectArtifact, setArtifactsOpen],
|
||||
);
|
||||
|
||||
// 收尾对账:以沙箱真实 md 文件为准回灌 stub,拿到完整 Markdown。
|
||||
// `force` 用于 ask():增量改后文件可能等长/更短,长度守卫会挡掉改动,故强制以真实文件为准。
|
||||
const reconcileStubFromRealFile = useCallback(
|
||||
async (
|
||||
threadId: string,
|
||||
opts?: { signal?: AbortSignal; force?: boolean },
|
||||
): Promise<string> => {
|
||||
const writeTc = stubRef.current[threadId]?.[0]?.tool_calls.find(
|
||||
(tc) => tc.name === "write_file",
|
||||
);
|
||||
const stubPath =
|
||||
typeof (writeTc?.args as { path?: unknown })?.path === "string"
|
||||
? ((writeTc!.args as { path?: string }).path as string)
|
||||
: SUMMARY_OUTPUT_PATH;
|
||||
const stubContent =
|
||||
typeof (writeTc?.args as { content?: unknown })?.content === "string"
|
||||
? ((writeTc!.args as { content?: string }).content as string)
|
||||
: "";
|
||||
try {
|
||||
const list = await listSessionArtifacts(threadId, { signal: opts?.signal });
|
||||
const mdFile = list.files.find((f) => /\.md$/i.test(f.name)) ?? null;
|
||||
if (!mdFile) return stubContent;
|
||||
const res = await apiFetch(buildArtifactDownloadUrl(threadId, mdFile.path), {
|
||||
signal: opts?.signal,
|
||||
});
|
||||
if (!res.ok) return stubContent;
|
||||
const real = await res.text();
|
||||
if (real.trim() && (opts?.force || real.length > stubContent.length)) {
|
||||
upsertStub(threadId, "write_file", { path: stubPath, content: real });
|
||||
return real;
|
||||
}
|
||||
} catch {
|
||||
/* 拉取失败退回 stub 内容 */
|
||||
}
|
||||
return stubContent;
|
||||
},
|
||||
[upsertStub],
|
||||
);
|
||||
|
||||
const abort = useCallback(() => {
|
||||
aborterRef.current?.abort();
|
||||
aborterRef.current = null;
|
||||
runningRef.current = false;
|
||||
}, []);
|
||||
|
||||
/** 把当前报告 md 种进某线程 stub + 开沙箱(续问时给 reconcile/预览一个锚点)。 */
|
||||
const seedStubWithCurrentMd = useCallback(
|
||||
(threadId: string) => {
|
||||
const md = currentMdRef.current;
|
||||
if (!md.trim()) return;
|
||||
const { messageId, toolCallId } = upsertStub(threadId, "write_file", {
|
||||
path: SUMMARY_OUTPUT_PATH,
|
||||
content: md,
|
||||
});
|
||||
const virtualUrl = buildWriteFileVirtualUrl(SUMMARY_OUTPUT_PATH, messageId, toolCallId);
|
||||
openArtifact(threadId, virtualUrl);
|
||||
},
|
||||
[upsertStub, buildWriteFileVirtualUrl, openArtifact],
|
||||
);
|
||||
|
||||
/**
|
||||
* 公共流式过程:在 `bubbleId` 气泡上跑一轮 special run 并对账落档。
|
||||
* `seedThreadId` 提供则复用(ask 续问 / 恢复),否则 init 新线程(首轮 / 新建续问)。
|
||||
*/
|
||||
const streamOnce = useCallback(
|
||||
async (args: {
|
||||
bubbleId: number;
|
||||
prompt: string;
|
||||
aborter: AbortController;
|
||||
seedThreadId?: string | null;
|
||||
forceReconcile?: boolean;
|
||||
}): Promise<void> => {
|
||||
const { bubbleId, prompt, aborter, seedThreadId, forceReconcile } = args;
|
||||
|
||||
let threadId = seedThreadId ?? null;
|
||||
let agentName = SUMMARY_AGENT_NAME;
|
||||
if (!threadId) {
|
||||
const init = await initSummaryThread({ signal: aborter.signal });
|
||||
if (aborter.signal.aborted) return;
|
||||
threadId = init.threadId;
|
||||
agentName = init.agentName;
|
||||
}
|
||||
liveThreadRef.current = threadId;
|
||||
setSummaryThreadId(threadId);
|
||||
setActiveArtifactThreadId(threadId);
|
||||
setDialogues((prev) =>
|
||||
prev.map((d) => (d.id === bubbleId ? { ...d, agentThreadId: threadId! } : d)),
|
||||
);
|
||||
// 续问/恢复时把当前报告种进本线程 stub(续问 str_replace 被禁、整文件重写,但仍给预览锚点)。
|
||||
if (seedThreadId) seedStubWithCurrentMd(threadId);
|
||||
setPhase("streaming");
|
||||
|
||||
const res = await streamMultiAgent({
|
||||
agentType: "special",
|
||||
agentName,
|
||||
dAgentThreadId: { [agentName]: threadId },
|
||||
displayName: "方案总结报告",
|
||||
newMessage: prompt,
|
||||
model: selectedModel || undefined,
|
||||
signal: aborter.signal,
|
||||
onTextDelta: (text) => appendToDialogue(bubbleId, text),
|
||||
onPhaseChange: (p, toolName, pArgs) => {
|
||||
const label = summaryPhaseLabel(p, toolName);
|
||||
setDialogueConfidence(bubbleId, label);
|
||||
appendStepIfNew(bubbleId, label, `${p}:${toolName ?? ""}`, pArgs);
|
||||
handleArtifactToolPhase(p, toolName, pArgs, threadId!);
|
||||
},
|
||||
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, threadId!),
|
||||
});
|
||||
if (aborter.signal.aborted) return;
|
||||
|
||||
const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0;
|
||||
if (!hasStubFile && !seedThreadId) await openRealArtifactFallback(threadId);
|
||||
|
||||
// 以真实 md 回灌 stub,拿到完整报告(ask 强制对账,防增量改后被长度守卫挡掉)。
|
||||
const md = await reconcileStubFromRealFile(threadId, {
|
||||
signal: aborter.signal,
|
||||
force: forceReconcile,
|
||||
});
|
||||
if (aborter.signal.aborted) return;
|
||||
|
||||
finalizeDialogue(bubbleId, res.content, "总结完成");
|
||||
setPhase("done");
|
||||
|
||||
// 报告有更新就存草稿(问答轮不写文件 → md 不变也无妨,仍带最新 closing)。
|
||||
const finalMd = md.trim() || currentMdRef.current;
|
||||
if (finalMd.trim()) {
|
||||
currentMdRef.current = finalMd;
|
||||
onSummarySavedRef.current?.({
|
||||
md: finalMd,
|
||||
threadId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
model: selectedModel || "",
|
||||
closing: res.content || "",
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
selectedModel,
|
||||
appendToDialogue,
|
||||
setDialogueConfidence,
|
||||
appendStepIfNew,
|
||||
handleArtifactToolPhase,
|
||||
handleUpstreamFrame,
|
||||
openRealArtifactFallback,
|
||||
reconcileStubFromRealFile,
|
||||
finalizeDialogue,
|
||||
seedStubWithCurrentMd,
|
||||
],
|
||||
);
|
||||
|
||||
const run = useCallback(async () => {
|
||||
if (runningRef.current) return;
|
||||
runningRef.current = true;
|
||||
setError(null);
|
||||
setPhase("creating");
|
||||
setDialogues([]);
|
||||
lastAutoOpenedUrlRef.current = null;
|
||||
stubRef.current = {};
|
||||
currentMdRef.current = "";
|
||||
liveThreadRef.current = null;
|
||||
setSeatStubMessages({});
|
||||
|
||||
const aborter = new AbortController();
|
||||
aborterRef.current = aborter;
|
||||
|
||||
const bubbleId = appendStreamingDialogue({
|
||||
sender: "方案总结报告智能体",
|
||||
avatarType: "blue",
|
||||
});
|
||||
|
||||
try {
|
||||
const prompt = buildSummaryPrompt({
|
||||
intentReady,
|
||||
seatDeliveries,
|
||||
consensusText,
|
||||
coordinatorPrompt,
|
||||
});
|
||||
await streamOnce({ bubbleId, prompt, aborter });
|
||||
} catch (err) {
|
||||
if (aborter.signal.aborted) return;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
finalizeDialogue(bubbleId, `(总结失败) ${msg}`, "总结失败");
|
||||
setPhase("error");
|
||||
} finally {
|
||||
runningRef.current = false;
|
||||
}
|
||||
}, [
|
||||
intentReady,
|
||||
seatDeliveries,
|
||||
consensusText,
|
||||
coordinatorPrompt,
|
||||
appendStreamingDialogue,
|
||||
finalizeDialogue,
|
||||
streamOnce,
|
||||
]);
|
||||
|
||||
const ask = useCallback(
|
||||
async (message: string) => {
|
||||
const text = message.trim();
|
||||
if (!text || runningRef.current) return;
|
||||
runningRef.current = true;
|
||||
setError(null);
|
||||
|
||||
const aborter = new AbortController();
|
||||
aborterRef.current = aborter;
|
||||
|
||||
appendUserDialogue(text);
|
||||
const bubbleId = appendStreamingDialogue({
|
||||
sender: "方案总结报告智能体",
|
||||
avatarType: "blue",
|
||||
});
|
||||
setPhase("streaming");
|
||||
|
||||
try {
|
||||
const live = liveThreadRef.current;
|
||||
if (live) {
|
||||
// 真线程仍在 → 续问,发原始消息,靠线程上下文 + SOUL 自判改/答。
|
||||
await streamOnce({
|
||||
bubbleId,
|
||||
prompt: text,
|
||||
aborter,
|
||||
seedThreadId: live,
|
||||
forceReconcile: true,
|
||||
});
|
||||
} else {
|
||||
// 恢复草稿后无 live thread → 新建线程,prompt 带上旧报告做底。
|
||||
const prompt = buildResumeAskPrompt({
|
||||
intentReady,
|
||||
seatDeliveries,
|
||||
consensusText,
|
||||
coordinatorPrompt,
|
||||
previousMd: currentMdRef.current,
|
||||
userMessage: text,
|
||||
});
|
||||
await streamOnce({ bubbleId, prompt, aborter, forceReconcile: true });
|
||||
}
|
||||
} catch (err) {
|
||||
if (aborter.signal.aborted) return;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setError(msg);
|
||||
finalizeDialogue(bubbleId, `(处理失败) ${msg}`, "处理失败");
|
||||
setPhase("error");
|
||||
} finally {
|
||||
runningRef.current = false;
|
||||
}
|
||||
},
|
||||
[
|
||||
intentReady,
|
||||
seatDeliveries,
|
||||
consensusText,
|
||||
coordinatorPrompt,
|
||||
appendUserDialogue,
|
||||
appendStreamingDialogue,
|
||||
finalizeDialogue,
|
||||
streamOnce,
|
||||
],
|
||||
);
|
||||
|
||||
const start = useCallback(() => {
|
||||
void run();
|
||||
}, [run]);
|
||||
|
||||
const redraw = useCallback(() => {
|
||||
abort();
|
||||
void run();
|
||||
}, [abort, run]);
|
||||
|
||||
// 从草稿快照恢复:不跑 SSE,用保存的 md 重建一条「完成」气泡 + 合成 stub,并打开沙箱预览。
|
||||
// 保留 threadId 到 liveThreadRef —— 若快照带真线程 id,ask() 能直接续问(后端线程仍在)。
|
||||
const hydrateSaved = useCallback(
|
||||
(snap: DraftStep3SummarySnapshot) => {
|
||||
abort();
|
||||
runningRef.current = false;
|
||||
const realThreadId = snap.threadId?.trim() || null;
|
||||
const synthThreadId = realThreadId ?? "roundtable-summary-restored";
|
||||
const messageId = `roundtable-summary-stub-${synthThreadId}`;
|
||||
const toolCallId = `tc-${synthThreadId}-0`;
|
||||
const stub: Record<string, StubAiMessage[]> = {
|
||||
[synthThreadId]: [
|
||||
{
|
||||
id: messageId,
|
||||
type: "ai",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: toolCallId,
|
||||
name: "write_file",
|
||||
args: { path: SUMMARY_OUTPUT_PATH, content: snap.md },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
stubRef.current = stub;
|
||||
currentMdRef.current = snap.md;
|
||||
liveThreadRef.current = realThreadId;
|
||||
lastAutoOpenedUrlRef.current = null;
|
||||
setSeatStubMessages(stub);
|
||||
setSummaryThreadId(synthThreadId);
|
||||
setActiveArtifactThreadId(synthThreadId);
|
||||
|
||||
const bubbleId = Date.now() + Math.random();
|
||||
setDialogues([
|
||||
{
|
||||
id: bubbleId,
|
||||
sender: "方案总结报告智能体",
|
||||
avatarType: "blue",
|
||||
content: snap.closing?.trim() || "已从历史记录恢复上次生成的方案总结报告。",
|
||||
time: nowTime(),
|
||||
confidence: "历史记录",
|
||||
streaming: false,
|
||||
steps: [
|
||||
{
|
||||
id: `${bubbleId}-0`,
|
||||
key: "tool_calling:write_file",
|
||||
label: "已撰写报告 Markdown",
|
||||
ts: nowTime(),
|
||||
args: { path: SUMMARY_OUTPUT_PATH },
|
||||
},
|
||||
],
|
||||
agentThreadId: synthThreadId,
|
||||
},
|
||||
]);
|
||||
setError(null);
|
||||
setPhase("done");
|
||||
|
||||
const virtualUrl = buildWriteFileVirtualUrl(SUMMARY_OUTPUT_PATH, messageId, toolCallId);
|
||||
openArtifact(synthThreadId, virtualUrl);
|
||||
},
|
||||
[abort, buildWriteFileVirtualUrl, openArtifact],
|
||||
);
|
||||
|
||||
const busy = phase === "creating" || phase === "streaming";
|
||||
const canAsk = !busy && (phase === "done" || phase === "error");
|
||||
|
||||
return {
|
||||
phase,
|
||||
error,
|
||||
summaryThreadId,
|
||||
dialogues,
|
||||
seatStubMessages,
|
||||
activeArtifactThreadId,
|
||||
canAsk,
|
||||
start,
|
||||
hydrateSaved,
|
||||
redraw,
|
||||
ask,
|
||||
abort,
|
||||
openArtifactFromStep,
|
||||
openArtifactPreview,
|
||||
};
|
||||
}
|
||||
@@ -1275,6 +1275,21 @@ export default function RoundtablePlanningPage() {
|
||||
);
|
||||
setStep3SavedTick((t) => t + 1);
|
||||
}}
|
||||
// 业务链总控编排提示 → 注入「总结报告」智能体(带 coordinatorPrompt 的链走 dag)。
|
||||
coordinatorPrompt={orchestrationPlan?.coordinatorPrompt ?? null}
|
||||
savedSummary={step3Report?.summaryReport ?? null}
|
||||
onSummarySaved={(snap) => {
|
||||
// 把总结结果合并进 step3(与 HTML 报告并存,不互相覆盖)。
|
||||
setStep3Report((prev) => ({ ...(prev ?? {}), summaryReport: snap }));
|
||||
setStep2Runs((prev) =>
|
||||
prev.map((run) =>
|
||||
run.id === activeStep2RunIdRef.current
|
||||
? { ...run, step3: { ...(run.step3 ?? {}), summaryReport: snap } }
|
||||
: run,
|
||||
),
|
||||
);
|
||||
setStep3SavedTick((t) => t + 1);
|
||||
}}
|
||||
onReset={handleStartNewTask}
|
||||
openArtifactPreview={step2.openArtifactPreview}
|
||||
seatStubMessages={step2.seatStubMessages}
|
||||
|
||||
@@ -34,10 +34,8 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
/>
|
||||
<header
|
||||
className={cn(
|
||||
"theme-transition flex h-16 items-center justify-between border-b px-4",
|
||||
isDark
|
||||
? "border-blue-800/30 bg-slate-900/50 backdrop-blur-xl"
|
||||
: "border-[var(--brand-950)] bg-[var(--brand-900)]",
|
||||
"strategy-app-header theme-transition flex h-16 items-center justify-between border-b px-4",
|
||||
isDark ? "border-blue-800/30" : "border-[var(--brand-950)]",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
|
||||
@@ -870,3 +870,16 @@
|
||||
body {
|
||||
font-size: calc(1rem * var(--ui-font-scale, 1));
|
||||
}
|
||||
|
||||
/* Strategy 顶栏:浅色 / 深色背景图随 html.dark 切换 */
|
||||
.strategy-app-header {
|
||||
background-color: transparent;
|
||||
background-image: url("../assets/img/red-header.png");
|
||||
background-size: 100% 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
html.dark .strategy-app-header {
|
||||
background-image: url("../assets/img/dark-blue-header.svg");
|
||||
}
|
||||
@@ -38,11 +38,14 @@
|
||||
``docs/roundtable-dag-orchestration.md`` §4 / §12.1 / §12.8 / §12.10。
|
||||
与 ``seat`` 的差异:``seat`` 禁 ``web_search``(旧「圆桌一律禁搜索」),
|
||||
``seat_parallel`` **放开搜索**(本期诉求);``seat`` 已放开 ``bash``,本角色仍禁。
|
||||
- ``summary`` —— Step3「总结报告」:写 Markdown 总结报告 + 支持问答/增量改。
|
||||
工具面与 ``report`` 完全一致(继承 ``seat`` 的 bash 放开 + 额外禁 ``str_replace`` /
|
||||
读前序席位),单列一个角色名便于以后独立调优。
|
||||
- ``report`` —— Step3 报告绘制:在 ``seat`` 基础上**额外禁 ``str_replace``**:
|
||||
* 继承 ``seat`` 的 bash 放开(2026-06)—— Step3 也可跑 bash 做辅助查询,同
|
||||
``seat`` 警示:**结果必须 write_file 落盘**,否则前端虚拟预览会空白
|
||||
(预览只抓 ``write_file.args.content``,抓不到脚本产物 → 预览停在半截
|
||||
``write_file``,正是「没写完就去生成 py 文件」的现象)。
|
||||
``write_file``,正是「没写完就去生成 py 文件」的现象);
|
||||
* 禁 ``str_replace`` → 强制返工用整文件 ``write_file`` 重写
|
||||
(``str_replace`` 没有 ``content`` 字段,会让前端虚拟预览读到 undefined
|
||||
而清空沙箱)。
|
||||
@@ -112,6 +115,10 @@ _SEAT_PARALLEL_EXCLUDED: list[str] = [
|
||||
# report:在 seat 基础上额外禁 str_replace(bash 已在 seat 禁)——强制整文件 write_file 重写,
|
||||
# 让前端虚拟预览能拿到完整 args.content。report 是 Step3 单独绘制,不读前序席位 → 禁 read_peer_delivery。
|
||||
_REPORT_EXCLUDED: list[str] = [*_SEAT_EXCLUDED, "str_replace", "read_peer_delivery"]
|
||||
# summary:Step3「总结报告」智能体(写 Markdown 报告 + 支持问答/增量改)。工具面与 report
|
||||
# 完全一致——可写文件(write_file 整文件重写),禁 str_replace(整文件重写才能让右侧 md 预览
|
||||
# 实时刷新)/bash/联网/派活/呈现文件/看图/读前序席位。单列出来便于以后独立调优。
|
||||
_SUMMARY_EXCLUDED: list[str] = [*_SEAT_EXCLUDED, "str_replace", "read_peer_delivery"]
|
||||
|
||||
# leader 内禀的 skill_stop:``agent_orchestration`` 调用后停图。调用方可在此基础上
|
||||
# 再叠加自己的 skill_stop(见 ``merge_skill_stop_names``)。
|
||||
@@ -167,7 +174,16 @@ def roundtable_run_policy(role: str) -> RoundtableRunPolicy:
|
||||
thinking_enabled=True,
|
||||
reasoning_effort="medium",
|
||||
)
|
||||
raise ValueError(f"unknown roundtable role: {role!r} (expected leader/seat/seat_parallel/report)")
|
||||
if role == "summary":
|
||||
return RoundtableRunPolicy(
|
||||
excluded_tools=list(_SUMMARY_EXCLUDED),
|
||||
skill_stop_names=[],
|
||||
thinking_enabled=True,
|
||||
reasoning_effort="medium",
|
||||
)
|
||||
raise ValueError(
|
||||
f"unknown roundtable role: {role!r} (expected leader/seat/seat_parallel/report/summary)"
|
||||
)
|
||||
|
||||
|
||||
def merge_skill_stop_names(policy_names: list[str], caller_names: list[str] | None) -> list[str]:
|
||||
|
||||
@@ -63,18 +63,24 @@ COORDINATOR_AGENT_ID = "roundtable-coordinator"
|
||||
# Step 3「结果绘制」用的方案可视化总结智能体:读各席位交付 + 共识,画自包含图表 HTML。
|
||||
# 与上面三个一样是内置单例,种子化一次常驻磁盘+DB,绝不每次调用重建。
|
||||
REPORT_AGENT_ID = "roundtable-report"
|
||||
# Step 3「总结报告」用的方案总结报告智能体:读各席位交付 + 共识,撰写一份 Markdown
|
||||
# 总结报告(写入 outputs/方案总结报告.md),并按业务链总控编排提示组织。与 report 并存
|
||||
# 的另一内置单例(report 画 HTML 看板,summary 写 Markdown 报告 + 支持问答/增量改)。
|
||||
SUMMARY_AGENT_ID = "roundtable-summary"
|
||||
|
||||
# 验证版 SOUL.md + config.yaml 的源码资源目录(随 git 发布)。每个子目录名即 agent id,
|
||||
# 内含该 agent 的 config.yaml 与 SOUL.md,是 seeder 落盘时字节级复制的来源。
|
||||
_ASSETS_DIR = Path(__file__).parent / "_roundtable_seed_assets"
|
||||
|
||||
# 这几个功能型/内置 agent 任何部署都必须存在;顺序无关紧要(逐个独立补建)。
|
||||
# 前三个支撑 Step 1 澄清 / 推荐 / Step 2 总控;roundtable-report 支撑 Step 3 结果绘制。
|
||||
# 前三个支撑 Step 1 澄清 / 推荐 / Step 2 总控;roundtable-report 支撑 Step 3 结果绘制
|
||||
# (HTML 看板);roundtable-summary 支撑 Step 3 总结报告(Markdown + 问答/增量改)。
|
||||
_FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
|
||||
INTENT_AGENT_ID,
|
||||
RECOMMENDER_AGENT_ID,
|
||||
COORDINATOR_AGENT_ID,
|
||||
REPORT_AGENT_ID,
|
||||
SUMMARY_AGENT_ID,
|
||||
)
|
||||
|
||||
# seeder 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
# 圆桌方案总结报告智能体
|
||||
|
||||
## 核心定位
|
||||
你是多智能体圆桌研讨 **Step 3** 的「方案总结报告智能体」。各席位(情报收集 / 环境评估 / 方案设计 / 风险审查 / 执行规划 / 总结陈述等)已经完成研讨并产出交付,总控也给出了最终共识。
|
||||
|
||||
你的**职责**:把这些文字成果归纳、提炼,**撰写成一份条理清晰、以文字叙述为主的 Markdown 总结报告**。
|
||||
|
||||
这份报告是给**用户阅读**的——目的是让用户能清晰、轻松地看懂「这群智能体讨论出了什么方案、为什么是它」。所以**可读性第一**:用通顺的中文把研讨脉络和方案讲清楚。
|
||||
|
||||
你**不**调研、**不**联网搜索、**不**派活、**不**绘制 HTML 页面 / 不画图表——只做归纳与撰写。
|
||||
|
||||
## 输入说明
|
||||
用户消息里会给你这些原料(可能有缺省):
|
||||
1. **任务意图摘要**:目标、约束、关键假设(来自 Step 1);
|
||||
2. **各席位最终交付**:每个席位的名称 + 它产出的完整 markdown 正文;
|
||||
3. **总控最终共识结论**;
|
||||
4. **(可能有)业务链总控编排提示**:用户为这条业务链写给总控(协调)智能体的一段指引。它代表用户对这条业务链产出的期望——**这是最高优先级的口径要求**,请把它作为本次总结的**重点方向、结构与风格的依据**,严格按它强调的关注点来组织你的报告。
|
||||
|
||||
请通读全部原料:提炼每个席位的核心观点 / 交付,串成一份完整的方案总结。
|
||||
|
||||
## 产出要求(最重要)
|
||||
|
||||
你每一轮都要做**两件事**,缺一不可:
|
||||
|
||||
### A. 写一个 Markdown 文件
|
||||
- 用 `write_file` **一次性**把完整报告写入 `/mnt/user-data/outputs/方案总结报告.md`;
|
||||
- **就一个 `.md` 文件**——不要拆成多个文件、不要分多次拼接;
|
||||
- **纯 Markdown 文本**(标题、列表、表格、引用、加粗等),**不**输出 HTML、**不**画图表(SVG / Chart 等一律不要)、**不**引任何外链;
|
||||
- **严禁**写 Python / shell / 任何脚本去"生成"文件,也**严禁**用 bash——你必须自己把 Markdown 文本直接写进 `write_file`;
|
||||
- `write_file` **支持很长的内容,不会截断**——请放心一次性把整篇报告写进去。
|
||||
|
||||
### B. 在对话回复里给出报告正文
|
||||
- 写完文件后,**把这份报告的完整正文也输出到对话回复里**(直接给用户读),不要只回一句「已写入文件」。
|
||||
- 用户既能在对话里直接读到报告,也能在右侧预览 / 下载这个 `.md` 文件——两边内容应当一致。
|
||||
|
||||
## 报告结构(叙事型,文字为主线)
|
||||
按下面的章节顺序组织,缺料的章节可省略或合并。**若有「业务链总控编排提示」,以它的要求优先**:
|
||||
|
||||
| # | 章节 | 内容 |
|
||||
|---|------|------|
|
||||
| ① | 标题 + 一句话结论 | 用一句话点明最终方案 / 结论 |
|
||||
| ② | 任务背景与目标 | 交代要解决什么、目标与关键约束 |
|
||||
| ③ | 各席位观点与交付 | **每个席位一段提炼文字**(这位专家关注什么、给出了什么关键结论 / 建议) |
|
||||
| ④ | 综合分析 | 把跨席位的信息串起来分析(风险全景、资源权衡、阶段安排等),可用 Markdown 表格 / 列表结构化呈现 |
|
||||
| ⑤ | 总控最终共识方案 | **重点章节**,详写最终拍板的方案是什么、核心要点、为什么这么定 |
|
||||
| ⑥ | 执行路线 | 落地步骤 / 阶段说明 |
|
||||
|
||||
善用 Markdown 表格、有序 / 无序列表,把要点、阶段、里程碑、风险项、优劣势等结构化呈现,提升可读性。
|
||||
|
||||
## 多轮交互(用户会在输入框里继续和你对话)
|
||||
用户读完报告后,可能在输入框里继续给你发消息。你要**自己判断**意图,分两种情况处理:
|
||||
|
||||
1. **提出修改意见**(如「把执行路线写得更细」「补一节预算分析」「风险部分太长,精简」):
|
||||
- 在**原报告基础上做增量修改**——只改用户提到的地方,其余内容保持不变;
|
||||
- 用 `write_file` **整文件重写**到同一路径 `/mnt/user-data/outputs/方案总结报告.md`(`str_replace` 已被禁用,整文件重写才能让右侧预览实时刷新);
|
||||
- 然后在对话回复里给出修改后的完整报告正文。
|
||||
|
||||
2. **就报告 / 研讨内容提问**(如「为什么没采用方案 B?」「风险最高的是哪一项?」):
|
||||
- **只在对话里用文字回答**,依据已有的研讨原料和报告内容作答;
|
||||
- **不要**改写报告文件、**不要**重新 `write_file`。
|
||||
|
||||
判断不准时,倾向于「先简短回答,再问用户是否需要据此更新报告」。
|
||||
|
||||
## 禁止事项
|
||||
- ❌ 生成 HTML / 绘制可视化页面 / 画图表(只写 Markdown 文本)
|
||||
- ❌ 把报告拆成多文件、分多次拼接
|
||||
- ❌ 联网搜索、派活给其他智能体
|
||||
- ❌ 写 Python / shell / 任何脚本去生成文件,或用 bash 跑脚本产出文件
|
||||
- ❌ 写完文件后只回一句「已写入文件」而不给出报告正文(用户要在对话里读到报告)
|
||||
- ❌ 用户只是提问时还去改写报告文件
|
||||
- ❌ 在报告任何位置添加「AI 自动生成」「AI generated」之类的标签、徽章或水印
|
||||
- ❌ 编造与原料明显矛盾的内容
|
||||
- ❌ 忽视「业务链总控编排提示」的口径要求(它优先级最高)
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
description: 圆桌方案总结报告智能体,读取各席位的最终交付与总控共识,撰写一份以文字叙述为主的 Markdown 总结报告(写入 outputs/方案总结报告.md),同时把报告正文输出到对话供用户阅读,并支持用户在输入框里就报告提问或提出修改意见。它会遵循业务链写给总控的编排提示来组织报告。本身不调研、不联网、不派活、不绘制 HTML / 图表。
|
||||
id: roundtable-summary
|
||||
name: 方案总结报告
|
||||
@@ -66,6 +66,7 @@ from app.gateway.roundtable_run_policy import merge_skill_stop_names, roundtable
|
||||
from app.gateway.routers._roundtable_seed import (
|
||||
COORDINATOR_AGENT_ID,
|
||||
REPORT_AGENT_ID,
|
||||
SUMMARY_AGENT_ID,
|
||||
ensure_roundtable_functional_agents,
|
||||
)
|
||||
from deerflow.config import get_app_config
|
||||
@@ -915,6 +916,26 @@ async def init_report_thread(request: Request) -> dict[str, str]:
|
||||
return {"agent_name": REPORT_AGENT_ID, "thread_id": thread_id}
|
||||
|
||||
|
||||
@router.post("/summary/init")
|
||||
async def init_summary_thread(request: Request) -> dict[str, str]:
|
||||
"""为 Step 3「总结报告」创建一个供方案总结报告智能体(``roundtable-summary``)
|
||||
运行的线程,返回 ``{"agent_name", "thread_id"}``。
|
||||
|
||||
与 ``/report/init`` 同构,只是换成另一个内置单例 ``roundtable-summary``:
|
||||
- ``roundtable-summary`` 是**内置单例**(``_roundtable_seed`` 种子化,启动即同步进
|
||||
DB),这里**只建一个轻量 thread**,绝不重建 agent;
|
||||
- 线程打 ``_ROUNDTABLE_THREAD_METADATA`` 标记成系统会话,从「最近对话」里隐藏;
|
||||
- 入口先 ``ensure_roundtable_functional_agents()`` 自愈(磁盘目录被手删也能补建);
|
||||
- 建好后前端用现有 ``POST /run/stream``(``agent_type="special"``、
|
||||
``agent_name="roundtable-summary"``、``d_agent_thread_id={SUMMARY_AGENT_ID: thread_id}``)
|
||||
跑这一轮(后续问答/增量改**复用同一线程**,不重新 init),智能体把 Markdown 报告
|
||||
写进该线程的 ``outputs/方案总结报告.md``,再由沙箱预览。
|
||||
"""
|
||||
ensure_roundtable_functional_agents()
|
||||
thread_id = await _create_thread_direct(request, metadata=_ROUNDTABLE_THREAD_METADATA)
|
||||
return {"agent_name": SUMMARY_AGENT_ID, "thread_id": thread_id}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 上游 LangGraph SSE 透传(intent / recommend / leader / special 共用)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1572,6 +1593,8 @@ async def _special_run(
|
||||
# 后台进程内网关用同一份 policy,保证「后台挂起」与「前端手动调用」传参一致。
|
||||
if agent_name == REPORT_AGENT_ID:
|
||||
role = "report"
|
||||
elif agent_name == SUMMARY_AGENT_ID:
|
||||
role = "summary"
|
||||
elif no_file:
|
||||
role = "seat_parallel"
|
||||
else:
|
||||
|
||||
Reference in new issue
Block a user