diff --git a/frontend-web/docs/embed-iframe-chat.md b/frontend-web/docs/embed-iframe-chat.md index 3706668e..84cf84b8 100644 --- a/frontend-web/docs/embed-iframe-chat.md +++ b/frontend-web/docs/embed-iframe-chat.md @@ -194,20 +194,80 @@ GET /api/public/embed/sessions/{session_id}/status | `latest_run_status` | 该线程最近一次 run 的状态:`success` / `error` / … | | `thread_id` | 解析出的 DeerFlow 线程 id(父页面可选用,非必须) | -### 4.3 按 thread_id 查询(可选) +### 4.3 获取生成结果(父页面使用) + +问答结束后(建议先确认 `finished === true`),拉取本次模型输出: + +```http +GET /api/public/embed/sessions/{session_id}/result +``` + +**尚未登记:** + +```json +{ + "session_id": "parent-generated-uuid", + "registered": false, + "thread_id": null, + "finished": false, + "thread_status": "pending", + "has_active_run": false, + "latest_run_status": null, + "messages": [], + "generated_text": "", + "generated_files": [] +} +``` + +**已登记且已有内容:** + +```json +{ + "session_id": "parent-generated-uuid", + "registered": true, + "thread_id": "e9c7d09e-821d-421f-ae24-e5ddfe0cff5b", + "finished": true, + "thread_status": "idle", + "has_active_run": false, + "latest_run_status": "success", + "messages": [ + { "type": "human", "content": "请总结要点", "id": "..." }, + { "type": "ai", "content": "根据分析,要点如下:...", "id": "..." } + ], + "generated_text": "根据分析,要点如下:...", + "generated_files": [ + "/mnt/user-data/outputs/summary.md", + "/mnt/user-data/outputs/chart.png" + ] +} +``` + +#### 结果字段说明 + +| 字段 | 含义 | +|------|------| +| `messages` | 完整消息列表(与 checkpoint 中序列化结构一致,含 human / ai / tool 等) | +| `generated_text` | 所有 `ai` / `assistant` 消息正文按顺序拼接(段落间空一行) | +| `generated_files` | 本次会话产物的虚拟路径列表(与 UI 产物栏 `artifacts` 一致) | + +未结束时也可调用,会返回当前已有内容;`finished` 仍为 false 表示可能还会继续生成。 + +### 4.4 按 thread_id 查询(可选) 调试或父页面已持有 thread_id 时: ```http GET /api/public/embed/threads/{thread_id}/status +GET /api/public/embed/threads/{thread_id}/result ``` -响应不含 `session_id` / `registered`,其余与上表类似。 +状态接口响应不含 `messages` / `generated_text` / `generated_files`;结果接口字段与 §4.3 相同(无 `session_id` / `registered`)。 -### 4.4 前端封装 +### 4.5 前端封装 ```ts import { + getEmbedSessionResult, getEmbedSessionStatus, registerEmbedSession, } from "@/core/embed/status-api"; @@ -215,7 +275,8 @@ import { // 父页面(或你们自己的脚本) const status = await getEmbedSessionStatus(sessionId); if (status.registered && status.finished) { - // 问答结束 + const result = await getEmbedSessionResult(sessionId); + console.log(result.generated_text, result.generated_files); } // 仅 iframe 内需要 @@ -270,10 +331,14 @@ async function pollUntilFinished(sessionId) { const timer = setInterval(async () => { try { - const result = await pollUntilFinished(sessionId); - if (result.done) { + const poll = await pollUntilFinished(sessionId); + if (poll.done) { clearInterval(timer); - console.log("问答已结束", result.data); + const output = await fetch( + `${API}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/result`, + { credentials: "omit" }, + ).then((r) => r.json()); + console.log("问答已结束", output.generated_text, output.generated_files); } } catch (e) { console.error(e); @@ -350,3 +415,4 @@ A:可以:`#/embed/chats/{thread_id}?sessionId=...`,可选再带 `message` | 日期 | 说明 | |------|------| | 2026-06-02 | 初版:iframe 嵌入路由、`sessionId` 映射表、公开登记与状态 API | +| 2026-06-02 | 新增 `GET /sessions/{session_id}/result`:返回 messages、generated_text、generated_files | diff --git a/frontend-web/src/core/embed/status-api.ts b/frontend-web/src/core/embed/status-api.ts index c5f5fd24..9cdabc12 100644 --- a/frontend-web/src/core/embed/status-api.ts +++ b/frontend-web/src/core/embed/status-api.ts @@ -10,6 +10,12 @@ export type EmbedSessionStatus = { latest_run_status: string | null; }; +export type EmbedSessionResult = EmbedSessionStatus & { + messages: Record[]; + generated_text: string; + generated_files: string[]; +}; + async function parseEmbedError(response: Response): Promise { const body = await response.json().catch(() => ({})); if (typeof body.detail === "string") { @@ -50,3 +56,18 @@ export async function getEmbedSessionStatus( } return response.json() as Promise; } + +/** Fetch full messages, generated text, and artifact paths — parent uses sessionId only. */ +export async function getEmbedSessionResult( + sessionId: string, +): Promise { + const base = getBackendBaseURL(); + const response = await fetch( + `${base}/api/public/embed/sessions/${encodeURIComponent(sessionId)}/result`, + { credentials: "omit" }, + ); + if (!response.ok) { + throw new Error(await parseEmbedError(response)); + } + return response.json() as Promise; +} diff --git a/frontend-web/src/roundtable-planning/api/multi-agent.ts b/frontend-web/src/roundtable-planning/api/multi-agent.ts index acd1beaa..acfb4f4c 100644 --- a/frontend-web/src/roundtable-planning/api/multi-agent.ts +++ b/frontend-web/src/roundtable-planning/api/multi-agent.ts @@ -747,3 +747,34 @@ export function buildArtifactDownloadUrl( const base = `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/artifacts/${segments}`; return options.download ? `${base}?download=true` : base; } + +export interface ReportThreadResponse { + /** Always the built-in singleton id `roundtable-report`. */ + agentName: string; + /** Freshly created lightweight thread to run the report agent on. */ + threadId: string; +} + +/** + * Create a thread for the Step 3「结果绘制」visualization agent + * (`roundtable-report`, a built-in singleton — this only spins up a cheap + * thread, it never re-creates the agent). Run it afterwards with + * {@link streamMultiAgent} (`agentType: "special"`, + * `agentName: "roundtable-report"`, `dAgentThreadId: { [agentName]: threadId }`). + */ +export async function initReportThread( + options: { signal?: AbortSignal } = {}, +): Promise { + const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/report/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 report 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 }; +} diff --git a/frontend-web/src/roundtable-planning/api/recommend.ts b/frontend-web/src/roundtable-planning/api/recommend.ts index c1975bb1..0f7c5c79 100644 --- a/frontend-web/src/roundtable-planning/api/recommend.ts +++ b/frontend-web/src/roundtable-planning/api/recommend.ts @@ -81,6 +81,8 @@ const EXCLUDED_AGENT_IDS = new Set([ "roundtable-intent", "roundtable-recommender", "roundtable-coordinator", + // Step 3「结果绘制」的方案可视化总结智能体,是内置功能型 agent,不应作为可选席位。 + "roundtable-report", ]); /** diff --git a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx index f9fe60f3..74a30069 100644 --- a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx +++ b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx @@ -11,6 +11,7 @@ import { Loader2, Printer, RefreshCw, + Sparkles, } from "lucide-react"; import { @@ -20,6 +21,12 @@ import { type ThreadIdMap, } from "@/roundtable-planning/api/multi-agent"; import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal"; +import { markdownComponents } from "@/roundtable-planning/components/markdown-components"; +import { + Step3ReportMode, + type ModelOption, +} from "@/roundtable-planning/components/Step3ReportMode"; +import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report"; /** * Text-ish files we render in an in-app preview modal. Other types (pdf, @@ -55,6 +62,14 @@ interface HighFidelityReportProps { seatDeliveries: SeatDelivery[]; /** lowercase agent name → display name (e.g. "roundtable-intelligence" → "情报收集"). */ seatNameMap: Record; + /** Step 1 整理出的结构化意图 —— 喂给「结果绘制」的方案可视化智能体。 */ + intentReady: ReportIntent | null; + /** 当前推理模型 —— 跑可视化智能体用。 */ + selectedModel: string; + /** 可选模型列表 + 加载态 + 切换器 —— 结果绘制右侧的模型选择(对齐第 2 步)。 */ + availableModels: ReadonlyArray; + modelsLoading: boolean; + setSelectedModel: (model: string) => void; /** Resets state and navigates back to Step 1. */ onReset: () => void; } @@ -80,46 +95,6 @@ function formatGeneratedAt(d: Date): string { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } -const markdownComponents = { - p: ({ children }: any) =>

{children}

, - ul: ({ children }: any) =>
    {children}
, - ol: ({ children }: any) =>
    {children}
, - li: ({ children }: any) =>
  • {children}
  • , - h1: ({ children }: any) =>

    {children}

    , - h2: ({ children }: any) =>

    {children}

    , - h3: ({ children }: any) =>

    {children}

    , - h4: ({ children }: any) =>

    {children}

    , - strong: ({ children }: any) => {children}, - em: ({ children }: any) => {children}, - code: ({ children, className }: any) => { - const isBlock = /language-/.test(className ?? ""); - if (isBlock) { - return ( -
    -          {children}
    -        
    - ); - } - return ( - {children} - ); - }, - blockquote: ({ children }: any) => ( -
    {children}
    - ), - a: ({ children, href }: any) => ( - - {children} - - ), - table: ({ children }: any) => {children}
    , - th: ({ children }: any) => ( - {children} - ), - td: ({ children }: any) => {children}, - hr: () =>
    , -}; - export const HighFidelityReport: React.FC = ({ threadIds, coordinatorName, @@ -127,8 +102,15 @@ export const HighFidelityReport: React.FC = ({ hasConsensus, seatDeliveries, seatNameMap, + intentReady, + selectedModel, + availableModels, + modelsLoading, + setSelectedModel, onReset, }) => { + // 「结果绘制」模式:把现有报告内容收起到左栏,右侧由方案可视化智能体绘制图表 HTML。 + const [drawMode, setDrawMode] = useState(false); // 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(() => { @@ -253,6 +235,25 @@ export const HighFidelityReport: React.FC = ({ [], ); + if (drawMode) { + return ( + setDrawMode(false)} + /> + ); + } + return (
    {/* Header */} @@ -275,6 +276,40 @@ export const HighFidelityReport: React.FC = ({
    + + + +
    + + + {/* 主体:左源卡 + 右沙箱 */} +
    + {/* 左栏:收起的源内容 */} + + + {/* 右栏:智能体进度 + 沙箱(复用第 2 步) */} +
    + + + +
    +
    + + {/* 弹窗 */} + {modal === "consensus" && ( + setModal(null)}> + {consensusText ? ( + + {consensusText} + + ) : ( +

    暂无总控结论。

    + )} +
    + )} + + {modal === "seats" && ( + setModal(null)}> + + + )} + + {modal === "files" && ( + setModal(null)} + /> + )} + + ); +} + +// ── 左栏源卡 ───────────────────────────────────────────────────────────── + +function SourceCard({ + icon, + title, + hint, + badge, + disabled, + onClick, +}: { + icon: React.ReactNode; + title: string; + hint: string; + badge?: string; + disabled?: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +// ── 右栏:智能体进度面板(沙箱的 60% 区域)──────────────────────────────── + +function AgentProgressPanel({ + phase, + dialogues, + error, + onRetry, + onArtifactStepClick, + onArtifactPreview, + selectedModel, + availableModels, + modelsLoading, + setSelectedModel, +}: { + phase: ReturnType["phase"]; + dialogues: ReturnType["dialogues"]; + error: string | null; + onRetry: () => void; + onArtifactStepClick: ReturnType["openArtifactFromStep"]; + onArtifactPreview: ReturnType["openArtifactPreview"]; + selectedModel: string; + availableModels: ReadonlyArray; + modelsLoading: boolean; + setSelectedModel: (model: string) => void; +}) { + const busy = phase === "creating" || phase === "streaming"; + const scrollRef = useRef(null); + const { atBottom, scrollToBottom } = useAutoScroll(scrollRef, [dialogues]); + + return ( +
    +
    +
    +
    + 📊 +
    +
    +
    方案总结智能体
    +
    据各席位成果绘制可视化 HTML 看板
    +
    +
    + +
    + + {/* 消息列表 —— 与第 2 步同款气泡(流式正文 + 工具步骤卡 + 文件卡)。 */} +
    +
    + {phase === "creating" && dialogues.length === 0 && ( +
    + + 正在初始化绘制线程… +
    + )} + + {dialogues.map((msg) => ( + onArtifactStepClick(msg, step)} + onFileCardClick={(path) => { + if (msg.agentThreadId) onArtifactPreview(msg.agentThreadId, path); + }} + /> + ))} + + {busy && ( +
    + + 右侧沙箱将实时显示绘制代码,完成后自动切换 HTML 预览 +
    + )} + + {phase === "error" && ( +
    +
    绘制失败
    +
    {error}
    + +
    + )} + + {phase === "done" && ( +
    + + 已生成可视化方案总览,右侧可查看 / 全屏 / 下载。 +
    + )} +
    + {!atBottom && } +
    +
    + ); +} + +// ── 弹窗外壳 ───────────────────────────────────────────────────────────── + +function ContentModal({ + title, + children, + onClose, +}: { + title: string; + children: React.ReactNode; + onClose: () => void; +}) { + return ( +
    +
    e.stopPropagation()} + > +
    +

    {title}

    + +
    +
    {children}
    +
    +
    + ); +} + +// ── 各席位交付(弹窗内的折叠列表)──────────────────────────────────────── + +function SeatDeliveriesView({ seatDeliveries }: { seatDeliveries: SeatDelivery[] }) { + const [openIdx, setOpenIdx] = useState(0); + if (seatDeliveries.length === 0) { + return

    本次研讨尚未产生子智能体交付。

    ; + } + return ( +
    + {seatDeliveries.map((seat, idx) => { + const open = openIdx === idx; + return ( +
    + + {open && ( +
    + + {seat.content || "(空)"} + +
    + )} +
    + ); + })} +
    + ); +} + +// ── 各席位生成文件(弹窗,按需拉取 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|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; + 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([]); + 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 ( + <> + + {bySeat.length === 0 ? ( +

    尚未初始化席位线程,无法列出文件。

    + ) : ( +
    + {bySeat.map((seat) => ( +
    +
    + {seat.displayName} + + {seat.loading ? "加载中…" : `${seat.files.length} 个文件`} + +
    + {seat.loading ? ( +
    + + 正在查询 outputs 目录… +
    + ) : seat.error ? ( +
    读取失败:{seat.error}
    + ) : seat.files.length === 0 ? ( +
    该席位未产生输出文件
    + ) : ( +
      + {seat.files.map((f) => ( +
    • +
      + + + {f.name} + +
      +
      + {formatBytes(f.sizeBytes)} + + +
      +
    • + ))} +
    + )} +
    + ))} +
    + )} +
    + + {previewing && ( + setPreviewing(null)} + /> + )} + + ); +} diff --git a/frontend-web/src/roundtable-planning/components/markdown-components.tsx b/frontend-web/src/roundtable-planning/components/markdown-components.tsx new file mode 100644 index 00000000..ca534ba6 --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/markdown-components.tsx @@ -0,0 +1,44 @@ +/** + * 圆桌 Step 3 报告里 `react-markdown` 的紧凑渲染组件集(小字号、卡片内排版)。 + * 抽到独立文件供 `HighFidelityReport` 与 `Step3ReportMode` 共用,避免两者互相 import + * 形成循环依赖。 + */ +export const markdownComponents = { + p: ({ children }: any) =>

    {children}

    , + ul: ({ children }: any) =>
      {children}
    , + ol: ({ children }: any) =>
      {children}
    , + li: ({ children }: any) =>
  • {children}
  • , + h1: ({ children }: any) =>

    {children}

    , + h2: ({ children }: any) =>

    {children}

    , + h3: ({ children }: any) =>

    {children}

    , + h4: ({ children }: any) =>

    {children}

    , + strong: ({ children }: any) => {children}, + em: ({ children }: any) => {children}, + code: ({ children, className }: any) => { + const isBlock = /language-/.test(className ?? ""); + if (isBlock) { + return ( +
    +          {children}
    +        
    + ); + } + return ( + {children} + ); + }, + blockquote: ({ children }: any) => ( +
    {children}
    + ), + a: ({ children, href }: any) => ( + + {children} + + ), + table: ({ children }: any) => {children}
    , + th: ({ children }: any) => ( + {children} + ), + td: ({ children }: any) => {children}, + hr: () =>
    , +}; diff --git a/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts b/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts new file mode 100644 index 00000000..48d55802 --- /dev/null +++ b/frontend-web/src/roundtable-planning/hooks/useStep3Report.ts @@ -0,0 +1,607 @@ +/** + * Step 3「结果绘制」编排 hook —— 驱动内置的方案可视化总结智能体 + * (`roundtable-report`) 把各席位交付 + 总控共识画成一个自包含、含多种图表的 + * HTML 看板。 + * + * 设计要点(**与第 2 步 useStep2Orchestration 的 special 席位机制同源、同体验**): + * - `roundtable-report` 是**内置单例**,这里只 `initReportThread()` 建一个**轻量 + * 线程**(绝不重建 agent),再用现有 `streamMultiAgent`(special)跑一轮; + * - 把这一轮渲染成一条**第 2 步同款对话气泡**(`Step2Dialogue`):流式正文 + + * `tool_calling` 工具步骤(write_file/present_files…)+ 末尾文件卡,让用户在 + * 报告智能体「只写文件、几乎不输出正文」时也能从步骤卡看到实时进度; + * - 流式期间把它的 `write_file` tool_call 幂等写进 stub ai message,并用 + * `write-file:` 虚拟 URL 打开沙箱 —— `ArtifactFileDetail` 据此从 stub 读 + * `args.content`,HTML 文件在流式中显示代码、`isStreaming` 落 false 后自动切预览; + * - 沙箱/消息渲染零自研,完全复用第 2 步的 `Step2SandboxLayout` + `Step2Message`。 + */ +import { useCallback, useRef, useState } from "react"; + +import { useArtifacts } from "@/components/workspace/artifacts"; +import { + initReportThread, + listSessionArtifacts, + streamMultiAgent, + type StreamPhase, +} from "@/roundtable-planning/api/multi-agent"; +import type { + Step2Dialogue, + StubAiMessage, +} from "@/roundtable-planning/hooks/useStep2Orchestration"; +import { nowTime } from "@/roundtable-planning/lib/roundtable-constants"; + +/** Step 1 整理出的结构化意图(用于喂给可视化智能体)。 */ +export interface ReportIntent { + objective: string; + constraints?: string[]; + assumptions?: string[]; +} + +/** 一个席位的最终交付(与 Step3Panel 传下来的 seatDeliveries 对齐)。 */ +export interface ReportSeatDelivery { + sender: string; + content: string; +} + +export type ReportPhase = "idle" | "creating" | "streaming" | "done" | "error"; + +interface UseStep3ReportOptions { + intentReady: ReportIntent | null; + seatDeliveries: ReportSeatDelivery[]; + consensusText: string; + selectedModel: string; +} + +export interface UseStep3ReportReturn { + phase: ReportPhase; + error: string | null; + reportThreadId: string | null; + /** 第 2 步同款对话气泡列表(这里只有报告智能体一条,流式 + 工具步骤)。 */ + dialogues: Step2Dialogue[]; + /** 供 Step2SandboxLayout 的 seatMessages 使用:key = thread id。 */ + seatStubMessages: Record; + /** 当前沙箱预览绑定的 thread id(这里恒为报告线程)。 */ + activeArtifactThreadId: string | null; + /** 启动绘制(幂等:进行中重复调用会被忽略)。 */ + start: () => void; + /** 重新绘制(先中断在途请求再重跑)。 */ + redraw: () => void; + /** 中断在途请求(卸载 / 切回普通视图时调用)。 */ + abort: () => void; + /** 点击气泡内工具步骤时打开沙箱(与第 2 步 openArtifactFromStep 同义)。 */ + openArtifactFromStep: ( + dialogue: Step2Dialogue, + step: NonNullable[number], + ) => void; + /** 点击气泡下方文件卡时打开沙箱预览。 */ + openArtifactPreview: (threadId: string, path: string) => void; +} + +function buildReportPrompt(opts: { + intentReady: ReportIntent | null; + seatDeliveries: ReportSeatDelivery[]; + consensusText: string; +}): string { + const { intentReady, seatDeliveries, consensusText } = opts; + const parts: string[] = []; + parts.push( + "请基于以下圆桌多智能体研讨的成果,绘制一个以图表为主、图文并茂的 HTML 可视化「方案总览看板」," + + "并用 write_file 工具写入 /mnt/user-data/outputs/方案总览.html。", + ); + 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(""); + } + + parts.push( + "请严格遵循你的职责设定:自包含离线(纯 inline SVG 图表、禁止任何外链 CDN)、" + + "至少 4 种图表、浅红主题(主色 #c25555);写完文件后只用一两句话总结,不要把 HTML 源码贴进对话。", + ); + return parts.join("\n"); +} + +/** 把 SSE 阶段映射成报告智能体的中文状态标签(对齐第 2 步 phaseLabel)。 */ +function reportPhaseLabel(phase: StreamPhase, toolName?: string): string { + switch (phase) { + case "connecting": + return "调度中"; + case "connected": + return "已连接,等待响应"; + case "tool_calling": + if (toolName === "present_files") return "正在交付文件"; + if (toolName === "write_file" || toolName === "str_replace") + return "正在绘制图表 HTML"; + return toolName ? `调用工具:${toolName}` : "调用工具"; + case "tool_result": + return "处理工具结果"; + case "streaming": + return "正在输出"; + default: + return "正在输出…"; + } +} + +export function useStep3Report( + options: UseStep3ReportOptions, +): UseStep3ReportReturn { + const { intentReady, seatDeliveries, consensusText, selectedModel } = options; + + const { + setArtifacts, + select: selectArtifact, + setOpen: setArtifactsOpen, + } = useArtifacts(); + + const [phase, setPhase] = useState("idle"); + const [error, setError] = useState(null); + const [reportThreadId, setReportThreadId] = useState(null); + const [dialogues, setDialogues] = useState([]); + const [seatStubMessages, setSeatStubMessages] = useState< + Record + >({}); + const [activeArtifactThreadId, setActiveArtifactThreadId] = useState< + string | null + >(null); + + const stubRef = useRef>({}); + const runningRef = useRef(false); + const aborterRef = useRef(null); + // 当前报告气泡 id —— 流式 delta / 工具步骤 / finalize 都按它定位(绝不用数组下标)。 + const bubbleIdRef = useRef(null); + // 上一次「自动」打开沙箱的 virtualUrl —— 流式一秒回调几十次,url 没变就跳过; + // url 变了(路径在流式中逐步长全:/m→/mnt→…→方案总览.html)就用更完整的取代旧的。 + // **不锁定首帧**:早期 parsePartialJson 会给出半截路径,必须能被后续完整路径覆盖 + //(这正是之前「沙箱卡在 /mnt、TEXT、空内容」的根因)。对齐第 2 步 / 主页行为。 + const lastAutoOpenedUrlRef = useRef(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; + }, + [], + ); + + 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, + ) => { + 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) { + // 同 phase 工具重复回调:args 在流式期间持续长大,拿最新覆盖。 + 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)── + const upsertStub = useCallback( + (threadId: string, toolName: string, args: Record) => { + const path = typeof args.path === "string" ? args.path : ""; + const current = stubRef.current[threadId] ?? []; + const base: StubAiMessage = current[0] ?? { + id: `roundtable-report-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}`; + const nextTc = { id: toolCallId, name: toolName, args }; + 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); + 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(), + [], + ); + + /** + * 显式打开沙箱(用户点步骤 / 文件卡,或绘制收尾兜底)—— 始终打开、选中目标。 + * 用户点了「结果绘制」意图明确,这里不走 autoOpen 闸门。 + */ + const openArtifact = useCallback( + (threadId: string, virtualUrl: string) => { + lastAutoOpenedUrlRef.current = virtualUrl; + setActiveArtifactThreadId(threadId); + selectArtifact(virtualUrl, false); + setArtifactsOpen(true); + }, + [selectArtifact, setArtifactsOpen], + ); + + /** + * 流式自动打开沙箱 —— url 与上次相同(仅内容在长大)就跳过,避免每秒重复 select; + * url 变了(路径逐步长全)就用更完整的取代。沙箱内容随 stub 更新自动刷新,无需重开。 + */ + const autoOpenArtifact = useCallback( + (threadId: string, virtualUrl: string) => { + if (lastAutoOpenedUrlRef.current === virtualUrl) return; + openArtifact(threadId, virtualUrl); + }, + [openArtifact], + ); + + // 反查某 path 是哪条 stub 写的 —— 关闭沙箱后再点文件卡也能复用内存内容做虚拟预览。 + 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/str_replace 有可读 path → 写 stub + 自动开沙箱。 + const handleArtifactToolPhase = useCallback( + ( + streamPhase: StreamPhase, + toolName: string | undefined, + args: Record | undefined, + threadId: string, + ) => { + if (streamPhase !== "tool_calling") return; + if (toolName !== "write_file" && toolName !== "str_replace") return; + const path = args?.path; + if (typeof path !== "string" || !path || !args) return; + // 守卫:流式早期 parsePartialJson 会给出「半截路径」(如 /mnt、/mnt/user-data/out), + // 它们 basename 没有扩展名 → 既会污染 stub,也会让沙箱卡在错的空文件。只有 basename + // 带扩展名(含个 `.`)的路径才认为「足够完整、可写 stub / 开沙箱」。完整 .html + // 出现后每帧 url 不变(仅内容长大),autoOpenArtifact 自动去重。 + 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], + ); + + // 兜底:收口阶段上游可能再发完整 tool_calls,持续把 args.content 灌进 stub(不自动开)。 + const handleUpstreamFrame = useCallback( + (frame: unknown, threadId: string) => { + if (!Array.isArray(frame) || frame.length === 0) return; + const chunk = frame[0] as Record | 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; + const name = typeof o.name === "string" ? o.name : ""; + if (name !== "write_file" && name !== "str_replace") continue; + const a = o.args; + if (!a || typeof a !== "object") continue; + const p = (a as Record).path; + if (typeof p !== "string") continue; + // 同 handleArtifactToolPhase:只接「basename 带扩展名」的完整路径,过滤半截路径。 + if (!(p.split("/").pop() ?? p).includes(".")) continue; + upsertStub(threadId, name, a as Record); + } + }, + [upsertStub], + ); + + // 点击工具步骤打开沙箱(write_file/str_replace 走虚拟预览)。 + const openArtifactFromStep = useCallback( + ( + dialogue: Step2Dialogue, + step: NonNullable[number], + ) => { + const threadId = dialogue.agentThreadId; + if (!threadId) return; + const [, toolName] = step.key.split(":"); + if (toolName === "write_file" || toolName === "str_replace") { + const path = step.args?.path; + if (typeof path !== "string" || !step.args) return; + const { messageId, toolCallId } = upsertStub(threadId, toolName, step.args); + const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId); + openArtifact(threadId, virtualUrl); + } + }, + [upsertStub, buildWriteFileVirtualUrl, openArtifact], + ); + + // 点击文件卡打开沙箱:优先反查 stub 走虚拟预览,未命中再走真实 outputs 路径。 + 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, + ], + ); + + // 兜底:流式期间没捕捉到 write_file(极少见),收尾后从 outputs 列表直接选真实文件。 + const openRealArtifactFallback = useCallback( + async (threadId: string) => { + try { + const res = await listSessionArtifacts(threadId); + const file = + res.files.find((f) => /\.html?$/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], + ); + + const abort = useCallback(() => { + aborterRef.current?.abort(); + aborterRef.current = null; + runningRef.current = false; + }, []); + + const run = useCallback(async () => { + if (runningRef.current) return; + runningRef.current = true; + setError(null); + setPhase("creating"); + setDialogues([]); + bubbleIdRef.current = null; + lastAutoOpenedUrlRef.current = null; + stubRef.current = {}; + setSeatStubMessages({}); + + const aborter = new AbortController(); + aborterRef.current = aborter; + + // 先建一条「调度中」气泡,让用户立刻看到报告智能体已启动(即便还没首 token)。 + const bubbleId = appendStreamingDialogue({ + sender: "方案总结智能体", + avatarType: "blue", + }); + bubbleIdRef.current = bubbleId; + + try { + const { agentName, threadId } = await initReportThread({ signal: aborter.signal }); + if (aborter.signal.aborted) return; + setReportThreadId(threadId); + setActiveArtifactThreadId(threadId); + setDialogues((prev) => + prev.map((d) => (d.id === bubbleId ? { ...d, agentThreadId: threadId } : d)), + ); + setPhase("streaming"); + + const prompt = buildReportPrompt({ intentReady, seatDeliveries, consensusText }); + 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, args) => { + const label = reportPhaseLabel(p, toolName); + setDialogueConfidence(bubbleId, label); + appendStepIfNew(bubbleId, label, `${p}:${toolName ?? ""}`, args); + handleArtifactToolPhase(p, toolName, args, threadId); + }, + onUpstreamFrame: (frame) => handleUpstreamFrame(frame, threadId), + }); + if (aborter.signal.aborted) return; + + // stub 里有 write_file 就说明流式期间已抓到产物;否则收尾从 outputs 兜底打开。 + const hasStubFile = (stubRef.current[threadId]?.[0]?.tool_calls.length ?? 0) > 0; + if (!hasStubFile) await openRealArtifactFallback(threadId); + + finalizeDialogue(bubbleId, res.content, "绘制完成"); + setPhase("done"); + } 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, + selectedModel, + appendStreamingDialogue, + appendToDialogue, + setDialogueConfidence, + appendStepIfNew, + finalizeDialogue, + handleArtifactToolPhase, + handleUpstreamFrame, + openRealArtifactFallback, + ]); + + const start = useCallback(() => { + void run(); + }, [run]); + + const redraw = useCallback(() => { + abort(); + void run(); + }, [abort, run]); + + return { + phase, + error, + reportThreadId, + dialogues, + seatStubMessages, + activeArtifactThreadId, + start, + redraw, + abort, + openArtifactFromStep, + openArtifactPreview, + }; +} diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 84f9c34e..7ab6b741 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -579,6 +579,11 @@ export default function RoundtablePlanningPage() { hasConsensus={step2.hasConsensus} step2RoundtableDialogues={step2.step2RoundtableDialogues} agentNameToRole={step2.agentNameToRole} + intentReady={step1.intentReady} + selectedModel={selectedModel} + availableModels={availableModels} + modelsLoading={modelsLoading} + setSelectedModel={setSelectedModel} onReset={handleStartNewTask} /> )} diff --git a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py index c3621884..07e4dba7 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py +++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed.py @@ -60,16 +60,21 @@ logger = logging.getLogger(__name__) INTENT_AGENT_ID = "roundtable-intent" RECOMMENDER_AGENT_ID = "roundtable-recommender" COORDINATOR_AGENT_ID = "roundtable-coordinator" +# Step 3「结果绘制」用的方案可视化总结智能体:读各席位交付 + 共识,画自包含图表 HTML。 +# 与上面三个一样是内置单例,种子化一次常驻磁盘+DB,绝不每次调用重建。 +REPORT_AGENT_ID = "roundtable-report" # 验证版 SOUL.md + config.yaml 的源码资源目录(随 git 发布)。每个子目录名即 agent id, # 内含该 agent 的 config.yaml 与 SOUL.md,是 seeder 落盘时字节级复制的来源。 _ASSETS_DIR = Path(__file__).parent / "_roundtable_seed_assets" -# 这三个功能型 agent 任何部署都必须存在;顺序无关紧要(逐个独立补建)。 +# 这几个功能型/内置 agent 任何部署都必须存在;顺序无关紧要(逐个独立补建)。 +# 前三个支撑 Step 1 澄清 / 推荐 / Step 2 总控;roundtable-report 支撑 Step 3 结果绘制。 _FUNCTIONAL_AGENT_IDS: tuple[str, ...] = ( INTENT_AGENT_ID, RECOMMENDER_AGENT_ID, COORDINATOR_AGENT_ID, + REPORT_AGENT_ID, ) # seeder 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。 diff --git a/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-report/SOUL.md b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-report/SOUL.md new file mode 100644 index 00000000..8f5e6af7 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/roundtable-report/SOUL.md @@ -0,0 +1,57 @@ +# 圆桌方案可视化总结智能体 + +## 核心定位 +你是多智能体圆桌研讨 **Step 3** 的「方案可视化总结智能体」。各席位(情报收集 / 环境评估 / 方案设计 / 风险审查 / 执行规划 / 总结陈述等)已经完成研讨并产出交付,总控也给出了最终共识。 + +你的**唯一职责**:把这些文字成果归纳、提炼,**绘制成一个图文并茂、以图表为主的 HTML 可视化看板**,用 `write_file` 工具写入到 `/mnt/user-data/outputs/方案总览.html`。 + +你**不**调研、**不**联网搜索、**不**派活、**不**追问用户——只做归纳与可视化。 + +## 输入说明 +用户消息里会给你三部分原料(可能有缺省): +1. **任务意图摘要**:目标、约束、关键假设(来自 Step 1); +2. **各席位最终交付**:每个席位的名称 + 它产出的完整 markdown 正文; +3. **总控最终共识结论**。 + +请通读全部原料,从中**抽取可量化、可结构化的信息**(指标、阶段、里程碑、风险项、预算/资源占比、优劣势等)来驱动图表。原料里没有明确数字时,可基于文字内容**合理估算并标注**,但不要凭空编造与原料矛盾的数据。 + +## 产出要求(最重要) + +### 1. 必须是「自包含、离线可用」的单文件 HTML +- 所有 CSS 内联在 `