From 05815d94757938851a8154814c417eeb47a5cf75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sun, 24 May 2026 21:13:19 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=9A=E6=99=BA=E8=83=BD=E9=97=AE=E7=AD=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/roundtable-planning/api/intent.ts | 64 ++ .../roundtable-planning/api/multi-agent.ts | 212 ++-- .../components/ArtifactPreviewModal.tsx | 184 +++ .../components/HighFidelityReport.tsx | 869 +++++++------- .../components/ScrollToBottomButton.tsx | 35 + .../hooks/useAutoScroll.ts | 74 ++ .../pages/RoundtablePlanningPage.tsx | 1012 ++++++++++++++--- .../src/roundtable-planning/utils/drafts.ts | 195 ++++ .../backend/app/gateway/routers/artifacts.py | 58 + .../backend/app/gateway/routers/intent.py | 57 +- .../app/gateway/routers/multi_agent.py | 12 +- 11 files changed, 2045 insertions(+), 727 deletions(-) create mode 100644 frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx create mode 100644 frontend-web/src/roundtable-planning/components/ScrollToBottomButton.tsx create mode 100644 frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts create mode 100644 frontend-web/src/roundtable-planning/utils/drafts.ts diff --git a/frontend-web/src/roundtable-planning/api/intent.ts b/frontend-web/src/roundtable-planning/api/intent.ts index 8bb76c18..481b9d00 100644 --- a/frontend-web/src/roundtable-planning/api/intent.ts +++ b/frontend-web/src/roundtable-planning/api/intent.ts @@ -47,6 +47,15 @@ export interface IntentSummary { assumptions?: string[]; } +/** + * Same phases as streamMultiAgent so the UI can reuse one phase-label mapper. + * `connecting` ≈ HTTP sent, no frames yet; `connected` ≈ got `event: metadata`; + * `streaming` ≈ first AI text chunk; `tool_calling` ≈ ask_clarification or + * similar tool call (which usually means the run is about to interrupt and + * the user-visible text won't grow further this turn). + */ +export type IntentStreamPhase = "connecting" | "connected" | "streaming" | "tool_calling"; + export interface StreamIntentRequest { threadId: string; message: string; @@ -54,6 +63,13 @@ export interface StreamIntentRequest { signal?: AbortSignal; /** Fires for every AI text chunk parsed out of LangGraph messages-tuple frames. */ onTextDelta?: (text: string, messageId: string) => void; + /** + * Fires whenever the inferred SSE phase changes. The UI uses this to flip + * the placeholder bubble's caption from "调度中" → "已连接,等待响应" → + * "正在输出" so users see what's happening during the inevitable LLM + * first-token wait. + */ + onPhaseChange?: (phase: IntentStreamPhase, toolName?: string) => void; } export interface StreamIntentResult { @@ -139,6 +155,17 @@ export async function streamIntent(req: StreamIntentRequest): Promise { + if (next === currentPhase && !toolName) return; + currentPhase = next; + req.onPhaseChange?.(next, toolName); + }; + const result: StreamIntentResult = { status: "asking", content: "", summary: null }; while (true) { @@ -153,6 +180,12 @@ export async function streamIntent(req: StreamIntentRequest): Promise 0) { + const chunk = parsed[0]; + if (chunk && typeof chunk === "object") { + const chunkObj = chunk as Record; + const chunkType = String(chunkObj.type ?? "").toLowerCase(); + const toolCalls = chunkObj.tool_calls; + const toolCallChunks = chunkObj.tool_call_chunks; + const content = chunkObj.content; + const hasToolCall = + (Array.isArray(toolCalls) && toolCalls.length > 0) || + (Array.isArray(toolCallChunks) && toolCallChunks.length > 0); + if (hasToolCall) { + const firstCall = Array.isArray(toolCalls) && toolCalls.length > 0 + ? (toolCalls[0] as Record) + : Array.isArray(toolCallChunks) && toolCallChunks.length > 0 + ? (toolCallChunks[0] as Record) + : null; + const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : ""; + setPhase("tool_calling", toolName); + } else if ( + (chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") && + ((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0)) + ) { + setPhase("streaming"); + } + } + } + const delta = extractAiTextDelta(parsed); if (delta) req.onTextDelta?.(delta.text, delta.id); } diff --git a/frontend-web/src/roundtable-planning/api/multi-agent.ts b/frontend-web/src/roundtable-planning/api/multi-agent.ts index ebe98422..29f28ba1 100644 --- a/frontend-web/src/roundtable-planning/api/multi-agent.ts +++ b/frontend-web/src/roundtable-planning/api/multi-agent.ts @@ -41,6 +41,24 @@ export interface InitMultiAgentResponse { export async function initMultiAgent( payload: InitMultiAgentRequest, ): Promise { + // Timing telemetry for /api/multi-agent/init — enabled by default so we can + // see where time goes when users report "Step 2 init feels slow". Disable + // with `window.__MULTI_AGENT_INIT_TIMING__ = false` in devtools. + const timingEnabled = + typeof window === "undefined" + ? false + : (window as unknown as { __MULTI_AGENT_INIT_TIMING__?: boolean }).__MULTI_AGENT_INIT_TIMING__ !== false; + const t0 = performance.now(); + let tFirstByte = 0; + let tCoord = 0; + let seatCount = 0; + const tlog = (label: string) => { + if (!timingEnabled) return; + const elapsed = (performance.now() - t0).toFixed(0); + console.debug(`[init-timing] +${elapsed}ms ${label}`); + }; + tlog("fetch.start"); + const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/init`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -51,6 +69,7 @@ export async function initMultiAgent( }), signal: payload.signal, }); + tlog(`fetch.headers status=${res.status}`); if (!res.ok || !res.body) { const text = await res.text().catch(() => ""); throw new Error(`init multi-agent failed (${res.status}): ${text}`); @@ -68,6 +87,10 @@ export async function initMultiAgent( while (true) { const { done, value } = await reader.read(); if (done) break; + if (tFirstByte === 0) { + tFirstByte = performance.now(); + tlog(`first-byte (TTFB-after-headers=${(tFirstByte - t0).toFixed(0)}ms)`); + } buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; @@ -88,6 +111,8 @@ export async function initMultiAgent( const event = typeof obj.event === "string" ? obj.event : ""; switch (event) { case "seat_ready": { + seatCount += 1; + tlog(`seat_ready #${seatCount} ${String(obj.agent_name ?? "?")}`); payload.onSeatReady?.({ agentName: String(obj.agent_name ?? ""), threadId: String(obj.thread_id ?? ""), @@ -97,6 +122,8 @@ export async function initMultiAgent( break; } case "coordinator_ready": { + tCoord = performance.now(); + tlog(`coordinator_ready (since-first-seat=${(tCoord - tFirstByte).toFixed(0)}ms)`); payload.onCoordinatorReady?.({ mainAgentName: String(obj.main_agent_name ?? ""), threadId: String(obj.thread_id ?? ""), @@ -104,6 +131,7 @@ export async function initMultiAgent( break; } case "init_done": { + tlog(`init_done seats=${seatCount}`); mainAgentName = String(obj.main_agent_name ?? ""); threadIds = (obj.thread_ids ?? {}) as ThreadIdMap; break; @@ -123,6 +151,8 @@ export async function initMultiAgent( if (!aborted) throw err; } + tlog(`total ${(performance.now() - t0).toFixed(0)}ms (seats=${seatCount})`); + if (errorDetail) { throw new Error(`init multi-agent failed: ${errorDetail}`); } @@ -276,44 +306,6 @@ function extractAiTextDelta(parsed: unknown): { text: string; id: string } | nul return { text, id }; } -/** - * Trigger a browser download for a text blob. No-op outside the browser. - * Used by streamMultiAgent's dump-to-file debug mode. - */ -function downloadTextBlob(filename: string, text: string): void { - if (typeof window === "undefined" || typeof document === "undefined") return; - try { - const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.style.display = "none"; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - // Defer revoke so the browser has a chance to start the download. - setTimeout(() => URL.revokeObjectURL(url), 60_000); - } catch (err) { - // Downloads must never break the actual stream — just log and continue. - console.warn("[stream] downloadTextBlob failed:", err); - } -} - -/** - * Whether streamMultiAgent should auto-download the raw SSE transcript to a - * .txt when each call ends. Default ON for now because we're debugging the - * "sub-agent stream looks empty" issue and the browser console truncates - * large message logs. Toggle from devtools: - * window.__MULTI_AGENT_DUMP__ = false - */ -function isDumpEnabled(): boolean { - if (typeof window === "undefined") return false; - const flag = (window as unknown as { __MULTI_AGENT_DUMP__?: boolean }).__MULTI_AGENT_DUMP__; - // Default: enabled. Only disabled if explicitly set to false. - return flag !== false; -} - /** * Open an SSE connection to /api/multi-agent/run/stream and resolve once the * stream completes. The final frame (`{status: ...}`) is interpreted; every @@ -357,24 +349,6 @@ export async function streamMultiAgent( typeof window !== "undefined" && (window as unknown as { __MULTI_AGENT_DEBUG__?: boolean }).__MULTI_AGENT_DEBUG__ !== false; - // Raw SSE transcript buffer. Every raw line read from the stream (event:, - // data:, blank framing lines, anything) is appended verbatim so the dumped - // .txt is byte-for-byte what the frontend received off the socket. This is - // independent of parsing: even malformed/non-JSON frames end up in the file. - const dumpEnabled = isDumpEnabled(); - const transcript: string[] = dumpEnabled - ? [ - `# multi-agent stream transcript`, - `# agent_type: ${req.agentType}`, - `# agent_name: ${req.agentName}`, - `# started_at: ${new Date().toISOString()}`, - `# new_message: ${req.newMessage.replace(/\n/g, " ⏎ ")}`, - `# model: ${req.model ?? "(default)"}`, - `# ---- raw SSE follows ----`, - "", - ] - : []; - const result: StreamMultiAgentResult = { dispatched: [], message: null, @@ -393,38 +367,16 @@ export async function streamMultiAgent( req.onPhaseChange?.(next, name); }; - // Wrap the read loop so we always flush the transcript to disk, even on - // abort / network failure. Otherwise an interrupted run would lose the very - // evidence we want to inspect. - const finishDump = (reason: string) => { - if (!dumpEnabled) return; - transcript.push(""); - transcript.push(`# ---- stream ended (${reason}) ----`); - transcript.push(`# ended_at: ${new Date().toISOString()}`); - transcript.push(`# frames_parsed: ${frameSeq}`); - transcript.push(`# final_content_length: ${result.content.length}`); - transcript.push(`# dispatched_count: ${result.dispatched.length}`); - const tsSafe = new Date().toISOString().replace(/[:.]/g, "-"); - const nameSafe = req.agentName.replace(/[^a-zA-Z0-9_-]/g, "_"); - const filename = `multi-agent_${req.agentType}_${nameSafe}_${tsSafe}.txt`; - downloadTextBlob(filename, transcript.join("\n")); - }; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = decoder.decode(value, { stream: true }); + buffer += chunk; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - const chunk = decoder.decode(value, { stream: true }); - buffer += chunk; - - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - - for (const rawLine of lines) { - // Record EVERY raw line — including blank framing lines and event: - // headers — so the dumped transcript matches what the socket sent. - if (dumpEnabled) transcript.push(rawLine); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const rawLine of lines) { const line = rawLine.trimEnd(); if (!line) continue; if (line.startsWith("event:")) { @@ -525,16 +477,82 @@ export async function streamMultiAgent( } } - if (debugEnabled) { - console.debug(debugLabel, `stream ended after ${frameSeq} frames; final content length=${result.content.length}`); - } - finishDump("normal"); - return result; - } catch (err) { - // Flush whatever we got so the user can still inspect the transcript on - // abort / network drop. Then rethrow so callers' own handling runs. - const aborted = (err as Error)?.name === "AbortError" || req.signal?.aborted === true; - finishDump(aborted ? "aborted" : `error: ${(err as Error)?.message ?? String(err)}`); - throw err; + if (debugEnabled) { + console.debug(debugLabel, `stream ended after ${frameSeq} frames; final content length=${result.content.length}`); } + return result; +} + +export interface SessionArtifactFile { + /** Virtual path under the sandbox, e.g. `mnt/user-data/outputs/foo.md`. */ + path: string; + /** Bare filename (basename of `path`). */ + name: string; + sizeBytes: number; + /** MIME type guessed from the extension, or null when unknown. */ + mimeType: string | null; + /** ISO 8601 UTC timestamp of last modification. */ + modifiedAt: string; +} + +export interface SessionArtifactsResponse { + threadId: string; + files: SessionArtifactFile[]; +} + +/** + * List every file written to `/mnt/user-data/outputs/` for a thread. Used by + * the Step 3 final-report view to surface a thread's produced artifacts so the + * user can preview/download them via {@link buildArtifactDownloadUrl}. + * + * Returns an empty `files` array when the thread produced no artifacts (the + * backend treats a missing outputs directory as empty, not 404). + */ +export async function listSessionArtifacts( + threadId: string, + options: { signal?: AbortSignal } = {}, +): Promise { + const res = await apiFetch( + `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/artifacts`, + { method: "GET", signal: options.signal }, + ); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`list artifacts failed (${res.status}): ${text}`); + } + const json = (await res.json()) as { + thread_id: string; + files: Array<{ + path: string; + name: string; + size_bytes: number; + mime_type: string | null; + modified_at: string; + }>; + }; + return { + threadId: json.thread_id, + files: (json.files ?? []).map((f) => ({ + path: f.path, + name: f.name, + sizeBytes: f.size_bytes, + mimeType: f.mime_type, + modifiedAt: f.modified_at, + })), + }; +} + +/** + * Build a URL that previews (inline) or downloads (attachment) an artifact + * file. The `path` must be a virtual path returned by + * {@link listSessionArtifacts} (e.g. `mnt/user-data/outputs/foo.md`). + */ +export function buildArtifactDownloadUrl( + threadId: string, + virtualPath: string, + options: { download?: boolean } = {}, +): string { + const segments = virtualPath.split("/").map((s) => encodeURIComponent(s)).join("/"); + const base = `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/artifacts/${segments}`; + return options.download ? `${base}?download=true` : base; } diff --git a/frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx b/frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx new file mode 100644 index 00000000..2b5cdc95 --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx @@ -0,0 +1,184 @@ +import React, { useCallback, useEffect, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { Download, FileText, Loader2, X } from "lucide-react"; + +import { apiFetch } from "@/core/api/fetch-client"; +import { buildArtifactDownloadUrl } from "@/roundtable-planning/api/multi-agent"; + +interface ArtifactPreviewModalProps { + /** The thread that owns the artifact. Both fetch and download URLs use it. */ + threadId: string; + /** Virtual path under the sandbox (e.g. `mnt/user-data/outputs/foo.md`). */ + path: string; + /** Bare filename — shown in the modal header. */ + name: string; + /** Mime type from the listing; drives the renderer choice. */ + mimeType: string | null; + onClose: () => void; +} + +const MARKDOWN_COMPONENTS = { + 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: () =>
    , +}; + +function isMarkdown(mimeType: string | null, name: string): boolean { + if (mimeType === "text/markdown") return true; + return /\.(md|markdown)$/i.test(name); +} + +export const ArtifactPreviewModal: React.FC = ({ + threadId, + path, + name, + mimeType, + onClose, +}) => { + const [content, setContent] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Esc closes the modal. Attached once per mount. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [onClose]); + + // Fetch the artifact body via the same URL the original download link used. + // The GET endpoint already returns text/* as decoded UTF-8, so res.text() + // gives us a renderable string directly. + useEffect(() => { + let cancelled = false; + const ctrl = new AbortController(); + setLoading(true); + setError(null); + setContent(null); + + (async () => { + try { + const url = buildArtifactDownloadUrl(threadId, path); + const res = await apiFetch(url, { method: "GET", signal: ctrl.signal }); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const text = await res.text(); + if (!cancelled) setContent(text); + } catch (err) { + if (cancelled || (err as Error).name === "AbortError") return; + setError((err as Error).message); + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { + cancelled = true; + ctrl.abort(); + }; + }, [threadId, path]); + + const handleDownload = useCallback(() => { + if (typeof window === "undefined") return; + const url = buildArtifactDownloadUrl(threadId, path, { download: true }); + window.open(url, "_blank", "noopener,noreferrer"); + }, [threadId, path]); + + const renderAsMarkdown = isMarkdown(mimeType, name); + + return ( +
    { + // Only close on outer-overlay click — clicks inside the modal panel + // bubble up here too, so check the target. + if (e.target === e.currentTarget) onClose(); + }} + > +
    + {/* Header */} +
    +
    + + {name} +
    +
    + + +
    +
    + + {/* Body */} +
    + {loading ? ( +
    + + 正在加载文件内容… +
    + ) : error ? ( +
    + 加载失败:{error} +
    + ) : content === null ? null : renderAsMarkdown ? ( + + {content} + + ) : ( +
    {content}
    + )} +
    +
    +
    + ); +}; diff --git a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx index 12ac31e3..6c658f21 100644 --- a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx +++ b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx @@ -1,498 +1,477 @@ -import React from "react"; -import { - ShieldCheck, - Layers, - Clipboard, - Star, - CheckCircle, - Download, - Share2, - Sparkles +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { + CheckCircle, + ChevronDown, + ChevronRight, + Download, + Eye, + FileText, + Loader2, + Printer, + RefreshCw, } from "lucide-react"; -interface HighFidelityReportProps { - budgetLimit: number; - consensusPercentage: number; - setCurrentStep: (step: number) => void; - setStep1MessageHistory: (history: any) => void; - setStep2RoundtableDialogues: (dialogues: any) => void; - triggerToast: (msg: string) => void; +import { + buildArtifactDownloadUrl, + listSessionArtifacts, + type SessionArtifactFile, + type ThreadIdMap, +} from "@/roundtable-planning/api/multi-agent"; +import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal"; + +/** + * Text-ish files we render in an in-app preview modal. Other types (pdf, + * images, html) keep the original window.open behaviour so the browser's + * native viewer handles them. + */ +function shouldPreviewInModal(file: { name: string; mimeType: string | null }): boolean { + const mime = file.mimeType ?? ""; + if (mime === "text/markdown" || mime === "text/plain" || mime === "text/csv" || mime === "application/json") { + return true; + } + if (mime.startsWith("text/")) return true; + return /\.(md|markdown|txt|json|csv|log|yml|yaml|xml)$/i.test(file.name); } +interface SeatDelivery { + /** Display name of the seat (e.g. "情报收集"). */ + sender: string; + /** Avatar color hint inherited from the dialogue card. Unused for layout right now. */ + avatarType: string; + /** The seat's final markdown delivery from Step 2. */ + content: string; +} + +interface HighFidelityReportProps { + threadIds: ThreadIdMap | null; + coordinatorName: string | null; + /** The most recent leader-agent text — rendered as the final consensus. */ + consensusText: string; + /** True once leader.dispatched === [] was observed (natural convergence). */ + hasConsensus: boolean; + /** Each sub-agent's final delivery, filtered out of step2 dialogues. */ + seatDeliveries: SeatDelivery[]; + /** lowercase agent name → display name (e.g. "roundtable-intelligence" → "情报收集"). */ + seatNameMap: Record; + /** Resets state and navigates back to Step 1. */ + onReset: () => void; +} + +interface SeatArtifacts { + agentName: string; + displayName: string; + threadId: string; + files: SessionArtifactFile[]; + loading: boolean; + error: string | null; +} + +function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`; + return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + +function formatGeneratedAt(d: Date): string { + const pad = (n: number) => n.toString().padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${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 = ({ - budgetLimit, - consensusPercentage, - setCurrentStep, - setStep1MessageHistory, - setStep2RoundtableDialogues, - triggerToast + threadIds, + coordinatorName, + consensusText, + hasConsensus, + seatDeliveries, + seatNameMap, + onReset, }) => { + // 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(() => { + 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]); + + // Map sender display name → its delivery content for the accordion rows. + // Built once per seatDeliveries change; later entries with the same sender + // win so the *latest* delivery wins. + const deliveryByDisplay = useMemo(() => { + const m = new Map(); + for (const d of seatDeliveries) m.set(d.sender, d.content); + return m; + }, [seatDeliveries]); + + const [artifactsBySeat, setArtifactsBySeat] = useState([]); + const [expandedSeats, setExpandedSeats] = useState>(new Set()); + const [reloadKey, setReloadKey] = useState(0); + // Active preview target — non-null means the modal is open. We carry the + // owning threadId so the modal can fetch + build download URLs without + // needing a second prop drill. + const [previewing, setPreviewing] = useState<{ threadId: string; file: SessionArtifactFile } | null>(null); + const generatedAt = useMemo(() => formatGeneratedAt(new Date()), []); + + // Fetch artifact listings for every seat thread in parallel. Re-runs when + // the user clicks "刷新文件清单" (reloadKey ticks) or when the thread set + // changes (rare — only on a true re-init). + useEffect(() => { + if (seatThreadEntries.length === 0) { + setArtifactsBySeat([]); + return; + } + + const initial: SeatArtifacts[] = seatThreadEntries.map((s) => ({ + ...s, + files: [], + loading: true, + error: null, + })); + setArtifactsBySeat(initial); + + const abort = new AbortController(); + let cancelled = false; + + (async () => { + const results = await Promise.all( + seatThreadEntries.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; + setArtifactsBySeat( + results.map(({ seat, files, error }) => ({ + ...seat, + files, + loading: false, + error, + })), + ); + })(); + + return () => { + cancelled = true; + abort.abort(); + }; + }, [seatThreadEntries, reloadKey]); + + const totalFileCount = useMemo( + () => artifactsBySeat.reduce((sum, s) => sum + s.files.length, 0), + [artifactsBySeat], + ); + + const toggleSeat = useCallback((key: string) => { + setExpandedSeats((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); + + const handlePreview = useCallback( + (threadId: string, file: SessionArtifactFile) => { + // Text-ish artifacts (markdown / txt / json / csv / yaml / ...) open + // in our in-app modal so the user can read the content without leaving + // the planning page. Binary or active content (pdf / png / html) still + // hands off to the browser's native viewer via the original tab. + if (shouldPreviewInModal({ name: file.name, mimeType: file.mimeType })) { + setPreviewing({ threadId, file }); + return; + } + if (typeof window !== "undefined") { + const url = buildArtifactDownloadUrl(threadId, file.path); + window.open(url, "_blank", "noopener,noreferrer"); + } + }, + [], + ); + + const handleDownload = useCallback( + (threadId: string, file: SessionArtifactFile) => { + const url = buildArtifactDownloadUrl(threadId, file.path, { download: true }); + if (typeof window !== "undefined") { + window.open(url, "_blank", "noopener,noreferrer"); + } + }, + [], + ); + return (
    - - {/* Output title block */} + {/* Header */}
    -

    - 最终架构规划方案 (成果展示大屏) -

    +

    最终方案展示

    - - ● 已达成共识 - + {hasConsensus && ( + + ● 已达成共识 + + )} - 生成时间:2026-05-21 11:01:12 + 生成时间:{generatedAt}
    - - -
    - {/* Comprehensive high fidelity report details */}
    - - {/* Dashboard Metric Overview Rows */} -
    - {/* Metric 1 */} -
    - 设计全要素通过率 -
    - - {budgetLimit >= 500 ? "99.4%" : budgetLimit > 400 ? "98.2%" : "96.5%"} - - 合规通过 + {/* Section 1: Coordinator consensus conclusion */} +
    +

    + + 总控最终结论 +

    + {consensusText ? ( +
    + + {consensusText} +
    -

    安全拦截红线保障 0 出圈、0 超限

    -
    - - {/* Metric 2 */} -
    - 安全冗余评估能级 -
    - - {budgetLimit >= 540 ? "Tier III+" : budgetLimit > 410 ? "Tier II" : "Tier I"} - - 异地容灾 -
    -

    多路容错算力热备与热切换防线

    -
    - - {/* Metric 3 */} -
    - 研讨总核销资金预算 -
    - - {Math.round(budgetLimit * 0.925 * 10) / 10}万 - - - 省 {Math.round(budgetLimit * 0.075 * 10) / 10}w - -
    -

    包含逻辑容器、极端微物理灾灾备购置

    -
    - - {/* Metric 4 */} -
    - 终审共识度评分 -
    - 42 轮研商 - - 共识率 {consensusPercentage}% - -
    -

    多智能体委员会滚动合议完美收敛

    -
    -
    - - {/* Section I: 执行摘要 (Executive Summary) */} -
    -
    - -

    - 执行摘要 / Executive Summary -

    -
    -
    -

    - • - 本路线规划方案在总核销支出预算限制 ≤ {budgetLimit}万(主备案并轨共耗约 {Math.round(budgetLimit * 0.925 * 10) / 10}万元)、核心编制队伍限制 ≤ 30人、绝对周转上线规定 72小时 内交付的目标约束下制定。最终裁决收敛并采纳包含【信息补全】、【主干推进】与【风险收敛】三阶段非对称推进的稳健路径。 + ) : ( +

    + 暂无总控结论。返回 Step 2 完成至少一轮研讨后再查看此页。

    -

    - • - 经多角色 AI 官群及人类指挥官深度澄清和在沙盒拓扑中演算测试,采用本规划方案,在安全拦截(合规绿码)、运行负载及高灾备级余度三大维度上均符合最苛刻的可落地标准。 + )} + + + {/* Section 2: Per-seat deliveries (collapsible) */} +

    +

    + + 各席位最终交付 + + {seatDeliveries.length} + +

    + + {seatDeliveries.length === 0 ? ( +

    + 本次研讨尚未产生子智能体交付。Leader 派活并收到子智能体回复后,该区块会自动填充。

    -

    - • - 项目具有明确的关键里程碑控制链、主备熔断备份计划,可执行确凿性最优,推荐自批复时点起立即启动第一阶段工作。 -

    -
    -
    - - {/* Section II: 方案详细大纲 (Detailed Core Outline) */} -
    -
    - -

    - 一、最终规划方案内容大纲 -

    -
    - -
    - - {/* Item 1 */} -
    -
    - 1 - 任务背景与目标 / Target Scope -
    -

    - 围绕给定预算上限(限额:{budgetLimit}万)与资源约束(人数:≤30人,时限:72小时),建立一套主备、高容错、防崩溃的多模态适应性部署方案。通过多智能体委员会多轮博弈对抗演炼,彻底击碎不确定性风险,平滑过渡并投产运行。 -

    + ) : ( +
    + {seatDeliveries.map((seat, idx) => { + const key = `${seat.sender}-${idx}`; + const expanded = expandedSeats.has(key); + return ( +
    + + {expanded && ( +
    + + {seat.content || "(空)"} + +
    + )} +
    + ); + })}
    + )} + - {/* Item 2 */} -
    -
    - 2 - 约束条件与关键假设 / Ground Hypotheses -
    -
    -
    - 📌 指向性硬约束: -
      -
    • 预算开销指示极限: ≤ {budgetLimit}万
    • -
    • 人力池最大可用总额: ≤ 30人
    • -
    • 全投产周转交付极限: ≤ 72小时
    • -
    -
    -
    - 🔮 关键假设依据: -
      -
    • 阶段一能够在24h内快捷、高配合度、无阻碍补全关键参数
    • -
    • 灾灾备系统对逻辑双微高可用(云原生 K3s)具有高度适配性
    • -
    • 极端环境历史数据具有一定参照性,各微服务配合维持稳定
    • -
    -
    -
    -
    + {/* Section 3: Generated files per seat */} +
    +

    + + 生成的报告文件 + + {totalFileCount} + +

    - {/* Item 3 (Interactive roadmap timeline pipeline mockup) */} + {artifactsBySeat.length === 0 ? ( +

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

    + ) : (
    -
    - 3 - 三阶段推进方案路线图 / Three-Phase Integration Roadmap -
    -

    - 项目设计采用分段非对称递进开发,首尾相扣,利用熔断和回滚阀门最大化减少交付风险: -

    - -
    - - {/* Phase 1 card */} -
    -
    - 0 - 24h -
    -
    - - 阶段一:系统信息补全 + {artifactsBySeat.map((seat) => ( +
    +
    + {seat.displayName} + + {seat.loading ? "加载中…" : `${seat.files.length} 个文件`} -
      -
    • - • - 补缺所有缺失现状对齐指标 -
    • -
    • - • - 拉网厘清核心需求物理缺失 -
    • -
    • - • - 与一线用户对齐约束边界 -
    • -
    -
    - 交付物件: - 《范围基准对齐白皮书》 -
    -
    - {/* Phase 2 card */} -
    -
    - 24 - 60h -
    -
    - - 阶段二:主任务核心递进 - -
      -
    • - • - 5个核心子模块并行极速构建 -
    • -
    • - • - 多智能体拓扑动态推演和收敛 -
    • -
    • - • - 自习惯里程碑路径重排与分配 -
    • -
    -
    -
    - 交付物件: - 《容器级物理架构拓扑》 -
    -
    - - {/* Phase 3 card */} -
    -
    - 60 - 72h -
    -
    - - 阶段三:极端风险安全收敛 - -
      -
    • - • - 一键拉闸灾灾备崩溃性沙盒论证 -
    • -
    • - • - 安全审核拦截与物理封签加签 -
    • -
    • - • - 打包大案报告完美交付投产运行 -
    • -
    -
    -
    - 交付物件: - 《投产交付大白皮书》 -
    -
    - -
    -
    - - {/* Item 4 */} -
    -
    - 4 - 资源配置计划 / Budget & Resource Allocation -
    -
    -

    - 主备案并轨总体支出已精精控制在限额内(耗资约 {Math.round(budgetLimit * 0.925 * 10) / 10}万元,含容器、中继微灾备硬件耗销:42%,情报库与政策规则授权:23%,防崩溃校验与极端测试:17%,极客安全风控硬预留:10.5%,剩余资金安全冗余)。 -

    -
    - - 总限额极限: {budgetLimit}万元 - - - 军官与执行队伍编制: 28人(合规限度) - - -
    -
    -
    - - {/* Item 5 */} -
    -
    - 5 - 风险识别与安全应对 / Secondary Risk Management -
    -

    - 通过多智能体委员会滚动博弈对抗和风险拦截验证,提前判定并锁定「需求过载细节波动」、「物理接机到货故障」、「通信源不完整滞缓」等 3 项主要隐忧。全部预案、补偿基金及冗余措施已完成,详见右侧面板。 -

    -
    - - {/* Item 6 */} -
    -
    - 6 - 关键里程碑控制链与下一步工作 / Execution Advice -
    -
    -
    -
    - M1 -
    - 【T+12h 状态核验】 - 拉网对齐所有缺失要素,未达标熔断顺延 + {seat.loading ? ( +
    + + 正在查询 outputs 目录…
    -
    - -
    - M2 -
    - 【T+24h 双活就绪】 - 物理容器拉通,注入最极限崩溃测试演习 + ) : seat.error ? ( +
    + 读取失败:{seat.error}
    -
    - -
    - M3 -
    - 【T+60h 安全审计】 - 突发拔网接管成功,获得整装绿码授权 + ) : seat.files.length === 0 ? ( +
    + 该席位未产生输出文件
    -
    + ) : ( +
      + {seat.files.map((f) => ( +
    • +
      + + + {f.name} + +
      +
      + + {formatBytes(f.sizeBytes)} + + + +
      +
    • + ))} +
    + )}
    -

    - 💡 行动意见: 指挥官裁可此成果后,项目立即固化加锁。每日安排每12小时高维配合对齐站会,强力扣死这 3 颗 M-Class 螺丝钉。 -

    -
    + ))}
    + )} +
    -
    -
    - - {/* Section III: 论证依据支撑库 (Supporting Evidence Pieces) */} -
    -
    - -

    - 二、论证依据要件归档情况 -

    -
    - -

    - 本报告方案具有无可挑剔的底层输入支撑。点击各要件名称,可通过云控制台查看对应沙盒隔离归档详情: -

    - -
    - {[ - { num: "01", name: "用户需求研商访谈纪要.txt", size: "14.2 KB", color: "border-red-100 bg-[var(--brand-200)]/20 text-[var(--brand-900)]" }, - { num: "02", name: "指标现状及运行分析报告.docx", size: "2.4 MB", color: "border-teal-100 bg-teal-50/20 text-teal-750" }, - { num: "03", name: "典型物理拓扑经验 (2份).pdf", size: "4.8 MB", color: "border-purple-100 bg-purple-50/20 text-purple-750" }, - { num: "04", name: "安全极端防范策略库.json", size: "128 KB", color: "border-amber-100 bg-amber-50/20 text-amber-700" }, - { num: "05", name: "全行业合规防灾法规准则.pdf", size: "1.9 MB", color: "border-red-100 bg-red-50/20 text-red-750" } - ].map((doc, index) => ( -
    triggerToast(`正在预览论证原始要件: 《${doc.name}》...`)} - className={`p-3 border rounded-xl flex flex-col justify-between hover:scale-[1.02] active:scale-95 transition cursor-pointer select-none ${doc.color}`} - > -
    - FILE-{doc.num} - - {doc.name} - -
    -
    - 附件档案 - {doc.size} -
    -
    - ))} -
    -
    - - {/* Section IV: 采纳采定理由陈述 (Final Ruling Statement / Stamp Board) */} -
    -
    - -

    - 三、采纳理由与最终裁定判词 -

    -
    -
    -

    - 经由专家委员会团队大模型多视角滚动共计 12 轮 极限对抗研商、冲突演炼得出:在「工期匹配度」(92%)、「预算开支控制度」(90%)、「极端容灾抗体余温」(88%) 与「总价值安全收益率」(91%) 四维雷达对决合围精算测定中,本主案 A 指标总合分最高(90.3分)。相比其他预算爆限、对外部相关配合条件依赖极重的僵硬路线,本规划方案在限时给定的 {budgetLimit}万元 条件内,取得了最高确凿的可执行概率。特此裁决:给予最高优先级采纳,盖印发布! -

    -
    -
    - - {/* Footer Bottom Actions Box */} + {/* Footer */}
    -
    - - - - - 全案终审已经盖印下发。若再次起草新路线会商,可点左侧清归。 -
    - -
    - - -
    + + 如需重新立项,可清空当前研讨,回到任务理解阶段重新起草。 + +
    -
    + {previewing && ( + setPreviewing(null)} + /> + )}
    ); }; diff --git a/frontend-web/src/roundtable-planning/components/ScrollToBottomButton.tsx b/frontend-web/src/roundtable-planning/components/ScrollToBottomButton.tsx new file mode 100644 index 00000000..df8e88fe --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/ScrollToBottomButton.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { ChevronDown } from "lucide-react"; + +interface ScrollToBottomButtonProps { + onClick: () => void; + /** Label shown next to the icon. Defaults to "回到底部". */ + label?: string; + /** Tailwind classes appended to the default positioning. */ + className?: string; +} + +/** + * Pill-shaped floating button shown when the user has scrolled up away from + * the bottom of a streamed conversation. Click → smooth-scroll back to the + * bottom and resume auto-follow. + * + * Positioning is `absolute` — render inside a `relative` wrapper so it + * floats over the scroll container's lower-center without scrolling along + * with content. + */ +export const ScrollToBottomButton: React.FC = ({ + onClick, + label = "回到底部", + className = "", +}) => ( + +); diff --git a/frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts b/frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts new file mode 100644 index 00000000..86c996c9 --- /dev/null +++ b/frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts @@ -0,0 +1,74 @@ +import { useCallback, useEffect, useRef, useState, type DependencyList, type RefObject } from "react"; + +/** + * Auto-scroll a scrollable container while the user is at (or near) the + * bottom; stop following as soon as the user scrolls up, and resume when + * the user clicks the floating "回到底部" button (which calls `scrollToBottom`). + * + * Why a custom hook: the page has two streamed-message lists (Step 1 chat, + * Step 2 roundtable) with identical scroll-follow semantics, and reading + * `scrollHeight` after every state change inside the component would mean + * duplicating the scroll listener / auto-scroll / pause logic per list. + */ +export function useAutoScroll( + ref: RefObject, + deps: DependencyList, + options: { + /** How close to the bottom (px) still counts as "at bottom". */ + threshold?: number; + } = {}, +): { + /** True when the user is at the bottom — drives the button's visibility. */ + atBottom: boolean; + /** Force-scroll to the bottom and resume auto-follow. */ + scrollToBottom: () => void; +} { + const threshold = options.threshold ?? 32; + // Ref mirrors state so the deps-effect can read the latest value without + // re-subscribing when atBottom flips. State is for the button's render. + const atBottomRef = useRef(true); + const [atBottom, setAtBottom] = useState(true); + + // Track scroll position. The handler runs for BOTH user-initiated scrolls + // and programmatic `scrollTo` calls — after a programmatic scroll to the + // bottom, the resulting onScroll naturally leaves atBottom=true, so the + // auto-follow effect can keep firing on subsequent deps changes. + useEffect(() => { + const el = ref.current; + if (!el) return; + const onScroll = () => { + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + const isAtBottom = distanceFromBottom <= threshold; + if (atBottomRef.current !== isAtBottom) { + atBottomRef.current = isAtBottom; + setAtBottom(isAtBottom); + } + }; + el.addEventListener("scroll", onScroll, { passive: true }); + // Initial measurement so we don't start in a stale state if the container + // mounted with content already overflowing. + onScroll(); + return () => el.removeEventListener("scroll", onScroll); + }, [ref, threshold]); + + // Auto-scroll on deps change — but only if the user hasn't scrolled away. + useEffect(() => { + const el = ref.current; + if (!el) return; + if (!atBottomRef.current) return; + el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + const scrollToBottom = useCallback(() => { + const el = ref.current; + if (!el) return; + el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); + // Optimistically mark as at-bottom so the next deps change resumes + // auto-follow without waiting for the smooth-scroll's onScroll to land. + atBottomRef.current = true; + setAtBottom(true); + }, [ref]); + + return { atBottom, scrollToBottom }; +} diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 6dedc67e..6c0ed51e 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -2,6 +2,8 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { HighFidelityReport } from "@/roundtable-planning/components/HighFidelityReport"; +import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton"; +import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll"; import { useTheme } from "@/contexts/ThemeContext"; import { cn } from "@/lib/utils"; import { useModels } from "@/core/models/hooks"; @@ -13,30 +15,43 @@ import { type StreamPhase, type ThreadIdMap, } from "@/roundtable-planning/api/multi-agent"; -import { initIntent, streamIntent, type IntentSummary } from "@/roundtable-planning/api/intent"; -import { - Shield, - MessageSquare, - Clipboard, - Users, - Activity, - CheckCircle, - HelpCircle, - Smile, - Send, - Repeat, - X, - Save, - Clock, - Play, - Pause, +import { + initIntent, + streamIntent, + type IntentStreamPhase, + type IntentSummary, +} from "@/roundtable-planning/api/intent"; +import { + deleteDraft, + deriveDraftName, + listDrafts, + renameDraft, + upsertDraft, + type DraftRecord, +} from "@/roundtable-planning/utils/drafts"; +import { + Shield, + MessageSquare, + Clipboard, + Users, + Activity, + CheckCircle, + HelpCircle, + Smile, + Send, + Repeat, + X, + Save, + Clock, + Play, + Pause, ChevronRight, ChevronDown, Settings, - Download, - Share2, - Check, - Sparkles, + Download, + Share2, + Check, + Sparkles, AlertTriangle, Layers, ArrowRight, @@ -46,6 +61,10 @@ import { Star, ShieldCheck, Loader2, + History, + Trash2, + Pencil, + Upload, } from "lucide-react"; // --- Custom Crisp SVGs to Match High Fidelity Avatars --- @@ -185,6 +204,39 @@ function newCoordinatorName() { return `roundtable-coordinator-${Date.now().toString(36)}`; } +// Matches the `[INTENT_READY]\n```json {...}```` handoff signal the intent +// agent emits when it judges the task fully specified. Same regex shape as +// the backend's parser; mirrored here so the Step 1 chat can render a nice +// card instead of the raw JSON block. Returns the parsed payload + the +// pre/post text so the bubble can show the agent's prose, the structured +// card, and any closing remarks separately. +const INTENT_READY_RE = /\[INTENT_READY\]\s*```json\s*(\{[\s\S]*?\})\s*```/; + +function splitIntentReadyBlock(text: string): { + before: string; + summary: IntentSummary | null; + after: string; +} { + if (!text) return { before: text, summary: null, after: "" }; + const m = INTENT_READY_RE.exec(text); + if (!m) return { before: text, summary: null, after: "" }; + let summary: IntentSummary | null = null; + try { + const parsed = JSON.parse(m[1]); + if (parsed && typeof parsed === "object" && typeof parsed.objective === "string") { + summary = parsed as IntentSummary; + } + } catch { + // Malformed JSON: leave the raw block in `before` so the user still sees it. + return { before: text, summary: null, after: "" }; + } + return { + before: text.slice(0, m.index).trimEnd(), + summary, + after: text.slice(m.index + m[0].length).trimStart(), + }; +} + export default function RoundtablePlanningPage() { const { theme } = useTheme(); const isDark = theme === "dark"; @@ -316,6 +368,14 @@ export default function RoundtablePlanningPage() { const [intentInitError, setIntentInitError] = useState(null); const [isIntentSending, setIsIntentSending] = useState(false); const step1InitRef = useRef(false); + // Monotonic counter incremented every time Step 1 init starts. The in-flight + // async checks this on completion and bails if a newer init has begun (e.g. + // after "重新起草"), avoiding a stale fetch overwriting fresh state. This + // also replaces a closure-based `cancelled` flag that broke under React 18 + // StrictMode's double-invoke (cleanup set cancelled=true; the second mount + // early-returned because the ref was already set; net result: the async + // landed but its setState got skipped, leaving intentThreadId null forever). + const step1InitGenRef = useRef(0); // Multi-agent orchestration state (lives only for one step-2 visit). const [threadIds, setThreadIds] = useState(null); @@ -323,6 +383,31 @@ export default function RoundtablePlanningPage() { const [isInitializing, setIsInitializing] = useState(false); const [initError, setInitError] = useState(null); const [isOrchestrating, setIsOrchestrating] = useState(false); + // Coordinator's final text from the most recent leader cycle — used by + // Step 3 to render the consensus conclusion. Refreshed every leader call so + // it always reflects the latest reasoning, even mid-discussion. + const [lastLeaderContent, setLastLeaderContent] = useState(""); + // True once the leader returned with an empty dispatch list (i.e. consensus + // reached). Drives the "已达成共识" badge in Step 3. + const [hasConsensus, setHasConsensus] = useState(false); + + // ── Draft persistence ───────────────────────────────────────────────── + // Drafts are full session snapshots saved to localStorage. `currentDraftId` + // is non-null when the in-memory session is bound to a stored draft, so + // subsequent saves update the same record instead of forking copies. + const [drafts, setDrafts] = useState(() => listDrafts()); + const [currentDraftId, setCurrentDraftId] = useState(null); + const [isHistoryOpen, setIsHistoryOpen] = useState(false); + const [renamingDraftId, setRenamingDraftId] = useState(null); + const [renameDraftValue, setRenameDraftValue] = useState(""); + // True while the current session was just hydrated from a draft. Both step1 + // and step2 init effects bail out when this is set so their reset/init + // logic doesn't clobber the restored state. + const loadedFromDraftRef = useRef(false); + // Previous step value so an effect can detect "moved forward" transitions + // (1→2, 2→3) and auto-save without manually wiring every nav button. + const prevStepRef = useRef(1); + const historyDropdownRef = useRef(null); // When the coordinator calls ask_clarification, the leader run ends with // this payload populated; the page renders a question card and only // resumes orchestration once the user replies. @@ -350,25 +435,17 @@ export default function RoundtablePlanningPage() { } }, [showToast]); - // Step 1 Scroll to bottom automatically - useEffect(() => { - if (step1ScrollRef.current) { - step1ScrollRef.current.scrollTo({ - top: step1ScrollRef.current.scrollHeight, - behavior: "smooth" - }); - } - }, [interactiveMessages]); - - // Step 2 Scroll to bottom automatically - useEffect(() => { - if (step2ScrollRef.current) { - step2ScrollRef.current.scrollTo({ - top: step2ScrollRef.current.scrollHeight, - behavior: "smooth" - }); - } - }, [step2RoundtableDialogues]); + // Auto-follow the streamed conversations: track scroll position, stop + // following when the user scrolls up, expose a `回到底部` button that + // re-engages auto-follow. Same hook for both steps. + const { atBottom: step1AtBottom, scrollToBottom: scrollStep1ToBottom } = useAutoScroll( + step1ScrollRef, + [interactiveMessages], + ); + const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } = useAutoScroll( + step2ScrollRef, + [step2RoundtableDialogues], + ); // Push one dialogue card; uses Date.now()+rand to avoid React key collisions. const appendDialogue = useCallback((card: { @@ -528,8 +605,11 @@ export default function RoundtablePlanningPage() { // Build the Step 2 leader's initial prompt. Prefer the intent agent's // structured summary when available — it already encodes objective + - // constraints + assumptions. Fall back to "last user message + budget" - // for sessions that bypassed Step 1 or hit an init error. + // constraints + assumptions. Fall back to the user's raw Step 1 turns when + // the user skipped clarification or jumped to Step 2 via the header tab, + // **without** injecting any hard-coded budget/time/headcount: those numbers + // (e.g. "预算 500 万") would otherwise pollute the leader prompt and make + // the discussion drift away from the user's actual task. const buildInitialIntent = useCallback(() => { if (intentReady) { const parts: string[] = [intentReady.objective]; @@ -547,11 +627,19 @@ export default function RoundtablePlanningPage() { parts.push("用户已经过任务理解智能体澄清,以上信息可以直接作为研讨依据。请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨,不要再反问用户。"); return parts.join("\n"); } - const lastUserMessage = [...interactiveMessages].reverse().find(m => m.sender === "user"); - const userIntent = lastUserMessage?.content || "用户尚未给出明确意图"; - const constraints = `预算硬约束:${budgetLimit} 万元`; - return `${userIntent}\n\n约束条件:${constraints}\n\n请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨。`; - }, [interactiveMessages, budgetLimit, intentReady]); + // Fallback: build the prompt from whatever the user actually said in + // Step 1 (in chronological order) so the leader sees the same context + // the human would. No mock numbers. + const userTurns = interactiveMessages + .filter((m) => (m as { sender?: string }).sender === "user") + .map((m) => (m as { content?: string }).content?.trim()) + .filter((s): s is string => !!s); + if (userTurns.length === 0) { + return "用户尚未在第一步明确任务意图。请先调用 ask_clarification,简要询问目标、范围与硬性约束,再进入研讨。"; + } + const conversation = userTurns.map((t, i) => `用户#${i + 1}: ${t}`).join("\n"); + return `${conversation}\n\n注意:用户尚未完成结构化的任务理解流程,以上是原始输入。若信息不足以开展研讨,请先调用 ask_clarification 询问关键约束,再开始派活。`; + }, [interactiveMessages, intentReady]); // Run one full orchestration step: 1) leader call → 2) dispatch specials in // parallel → 3) recurse with a synthesis prompt until leader stops dispatching. @@ -631,9 +719,16 @@ export default function RoundtablePlanningPage() { leaderRes.dispatched.length === 0 ? "全案盖印" : "派活说明", ); + // Capture the latest leader text so Step 3 can show the most recent + // synthesis even if the user navigates over mid-discussion. + if (leaderRes.content) { + setLastLeaderContent(leaderRes.content); + } + if (leaderRes.dispatched.length === 0) { highlightSpeaker(null); setConsensusPercentage(100); + setHasConsensus(true); triggerToast("🎉 圆桌研讨论证会圆满落幕!已取得 100% 专家组共识"); break; } @@ -726,17 +821,16 @@ export default function RoundtablePlanningPage() { // a fresh coordinator (per spec). useEffect(() => { if (currentStep !== 2) { - // tear down: cancel any in-flight orchestration; reset bookkeeping. + // Leaving Step 2: stop in-flight orchestration so backend streams + // don't keep running in the background, but *do not* reset + // step2InitRef. The user's threadIds/dialogues stay in memory so + // returning to Step 2 replays the saved state instead of calling + // /api/multi-agent/init a second time and wiping the discussion. orchestrationCancelRef.current = true; abortAllInFlight(); - step2InitRef.current = false; setIsOrchestrating(false); setPendingClarification(null); setClarificationDraft(""); - // Re-entering step 2 should start with the discussion nominally - // "running" so the suspend button comes back; the prior visit might - // have ended in a paused state. - setIsRoundTableRunning(true); return; } @@ -750,6 +844,8 @@ export default function RoundtablePlanningPage() { setInitError(null); setStep2RoundtableDialogues([]); setConsensusPercentage(0); + setLastLeaderContent(""); + setHasConsensus(false); const coord = newCoordinatorName(); try { const initRes = await initMultiAgent({ @@ -806,6 +902,10 @@ export default function RoundtablePlanningPage() { // Pause/resume button — pausing aborts every in-flight stream so the run // halts immediately rather than waiting for the current LLM call to finish. // Resuming kicks off another leader cycle. + // + // Deps intentionally exclude `currentStep`: we don't want re-entering Step 2 + // (e.g. coming back from Step 3 to inspect the discussion) to auto-fire a + // new orchestration round when isRoundTableRunning hasn't actually toggled. useEffect(() => { if (currentStep !== 2) return; if (!isRoundTableRunning) { @@ -816,7 +916,7 @@ export default function RoundtablePlanningPage() { // resumed and idle: nudge with a continuation prompt runOrchestration("用户已恢复研讨,请继续上一轮的协调工作。", threadIds, coordinatorName); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isRoundTableRunning, currentStep]); + }, [isRoundTableRunning]); // Adjust budget constraint dynamically const handleBudgetChange = (newVal: number) => { @@ -898,6 +998,282 @@ export default function RoundtablePlanningPage() { setShowToast(msg); }; + // Reset Step 1 to its blank slate (welcome message + empty timeline) and + // kick off /api/intent/init to obtain a fresh intent thread. Sets + // step1InitRef so the Step 1 useEffect skips its own init pass after we've + // already done the work here. Wrapped so both the page's initial-mount + // effect and `handleStartNewTask` can call the exact same code path. + const runStep1Init = useCallback(() => { + step1InitRef.current = true; + const myGen = ++step1InitGenRef.current; + setIntentReady(null); + setIntentInitError(null); + setIntentThreadId(null); + setTimelineEntries([]); + setInteractiveMessages([ + { + id: 1, + sender: "agent", + senderName: "任务理解智能体", + content: "你好,我是任务理解智能体。请用 1-2 句话告诉我你这次想做什么,以及有哪些预算/时间/合规上的硬约束?", + time: nowTime(), + }, + ]); + (async () => { + try { + const res = await initIntent(DEFAULT_MODEL); + if (step1InitGenRef.current !== myGen) return; + setIntentThreadId(res.thread_id); + setTimelineEntries([ + { + id: 1, + time: nowTime(), + title: "任务理解智能体已就绪", + desc: `agent_id=${res.agent_id}`, + }, + ]); + } catch (err) { + if (step1InitGenRef.current !== myGen) return; + console.error("init intent failed:", err); + setIntentInitError((err as Error).message); + } + })(); + }, []); + + // Wipe every piece of the current session and re-init Step 1 from scratch. + // Shared by the header "新建任务" button and Step 3's "重新起草新规划" link. + // We call runStep1Init synchronously so the UI clears immediately — relying + // on the Step 1 effect alone fails when the user is already on Step 1 + // (setCurrentStep(1) doesn't re-fire effects with unchanged deps). + const handleStartNewTask = useCallback(() => { + // Stop anything in flight before swapping state out. + orchestrationCancelRef.current = true; + abortAllInFlight(); + setIsOrchestrating(false); + setIsInitializing(false); + setPendingClarification(null); + setClarificationDraft(""); + + // Detach from any loaded draft. `runStep1Init` below sets + // step1InitRef = true after seeding fresh state, so we don't reset it + // here; step2InitRef is reset so the next Step 2 entry re-creates the + // coordinator + thread map from scratch. + loadedFromDraftRef.current = false; + step2InitRef.current = false; + setCurrentDraftId(null); + + // Clear Step 2 state (Step 1 state is reset inside runStep1Init). + setThreadIds(null); + setCoordinatorName(null); + setInitError(null); + setStep2RoundtableDialogues([]); + setLastLeaderContent(""); + setHasConsensus(false); + setConsensusPercentage(0); + + setCurrentStep(1); + // Seed a fresh intent thread + welcome bubble immediately. Calling + // runStep1Init directly (instead of relying on the effect) covers the + // "user clicked 新建任务 while already on Step 1" case where currentStep + // doesn't change and the effect wouldn't re-fire. + runStep1Init(); + triggerToast("✨ 已开始新任务,请在第一步与任务理解智能体澄清需求"); + }, [abortAllInFlight, runStep1Init]); + + // ── Draft helpers ────────────────────────────────────────────────────── + // Build a fresh snapshot of the current in-memory session. Called by every + // save path (manual button, auto step-transition save) so the persisted + // shape is defined in one place. + const buildDraftPayload = useCallback( + ( + furthestStep: 1 | 2 | 3, + idOverride?: string | null, + ): Parameters[0] => { + const resolvedId = idOverride ?? currentDraftId; + return { + ...(resolvedId ? { id: resolvedId } : {}), + name: deriveDraftName({ intentReady, interactiveMessages }), + furthestStep, + step1: { + intentThreadId, + intentReady, + interactiveMessages, + timelineEntries, + }, + step2: { + threadIds, + coordinatorName, + step2RoundtableDialogues, + lastLeaderContent, + hasConsensus, + consensusPercentage, + budgetLimit, + }, + }; + }, + [ + currentDraftId, + intentReady, + interactiveMessages, + timelineEntries, + intentThreadId, + threadIds, + coordinatorName, + step2RoundtableDialogues, + lastLeaderContent, + hasConsensus, + consensusPercentage, + budgetLimit, + ], + ); + + // Persist whatever step the session is currently at. Returns the saved + // record so callers can pick up the freshly-assigned id on first save. + const saveDraft = useCallback( + (furthestStep: 1 | 2 | 3): DraftRecord | null => { + // Skip empty sessions: if the user hasn't said anything to the intent + // agent and hasn't entered Step 2, there's nothing meaningful to keep. + const hasUserMessage = interactiveMessages.some( + (m) => (m as { sender?: string }).sender === "user", + ); + if (!hasUserMessage && step2RoundtableDialogues.length === 0) return null; + + const saved = upsertDraft(buildDraftPayload(furthestStep)); + setCurrentDraftId(saved.id); + setDrafts(listDrafts()); + return saved; + }, + [buildDraftPayload, interactiveMessages, step2RoundtableDialogues.length], + ); + + // Manual-save handler shared by the header button and the Step 1 inline + // button. Toasts so the user gets visible feedback. + const handleManualSave = useCallback(() => { + const furthest = (currentStep as 1 | 2 | 3) ?? 1; + const saved = saveDraft(furthest); + if (saved) { + triggerToast(`💾 已保存为草稿:${saved.name}`); + } else { + triggerToast("当前会话还没有可保存的内容"); + } + }, [saveDraft, currentStep]); + + // Auto-save whenever the user advances to a later step (1→2, 2→3). Skipped + // on backward navigation so re-entering an earlier step doesn't downgrade + // the saved `furthestStep` and lose later progress. + useEffect(() => { + const prev = prevStepRef.current; + prevStepRef.current = currentStep; + if (currentStep > prev) { + saveDraft(currentStep as 1 | 2 | 3); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentStep]); + + // Restore a previously-saved session. Sets the loaded-from-draft latch so + // both step init effects bail out and the role panel uses 在线 statuses. + const handleLoadDraft = useCallback( + (id: string) => { + const draft = drafts.find((d) => d.id === id) ?? null; + if (!draft) { + triggerToast("⚠️ 该草稿已不存在"); + setDrafts(listDrafts()); + return; + } + + // Cancel anything in-flight before swapping state out from under it. + orchestrationCancelRef.current = true; + abortAllInFlight(); + setIsOrchestrating(false); + setIsInitializing(false); + setPendingClarification(null); + setClarificationDraft(""); + + // Latch loaded mode FIRST so the upcoming setCurrentStep doesn't + // trigger the step1/step2 cleanup logic that would reset refs. + loadedFromDraftRef.current = true; + + // Step 1 + setIntentThreadId(draft.step1.intentThreadId); + setIntentReady(draft.step1.intentReady); + setIntentInitError(null); + setInteractiveMessages(draft.step1.interactiveMessages as typeof interactiveMessages); + setTimelineEntries(draft.step1.timelineEntries as typeof timelineEntries); + step1InitRef.current = true; + // Bump the generation so any /api/intent/init still in flight from + // before the load doesn't land late and overwrite the restored + // intentThreadId. + step1InitGenRef.current += 1; + + // Step 2 + setThreadIds(draft.step2.threadIds); + setCoordinatorName(draft.step2.coordinatorName); + setStep2RoundtableDialogues(draft.step2.step2RoundtableDialogues as typeof step2RoundtableDialogues); + setLastLeaderContent(draft.step2.lastLeaderContent); + setHasConsensus(draft.step2.hasConsensus); + setConsensusPercentage(draft.step2.consensusPercentage); + setBudgetLimit(draft.step2.budgetLimit); + // Only mark step2 as "initialized" when the draft was actually past + // Step 1 — otherwise entering step 2 should run init normally and + // create fresh coordinator threads. + step2InitRef.current = draft.furthestStep >= 2; + + setCurrentDraftId(draft.id); + setIsHistoryOpen(false); + prevStepRef.current = draft.furthestStep; + setCurrentStep(draft.furthestStep); + triggerToast(`📂 已加载草稿:${draft.name}`); + }, + [drafts, abortAllInFlight], + ); + + const handleDeleteDraft = useCallback( + (id: string) => { + if (typeof window !== "undefined" && !window.confirm("确定要删除这条历史记录吗?此操作不可恢复。")) { + return; + } + deleteDraft(id); + setDrafts(listDrafts()); + if (currentDraftId === id) setCurrentDraftId(null); + if (renamingDraftId === id) setRenamingDraftId(null); + triggerToast("🗑 已删除历史记录"); + }, + [currentDraftId, renamingDraftId], + ); + + const handleStartRename = useCallback((draft: DraftRecord) => { + setRenamingDraftId(draft.id); + setRenameDraftValue(draft.name); + }, []); + + const handleCommitRename = useCallback( + (id: string) => { + const updated = renameDraft(id, renameDraftValue); + if (updated) { + setDrafts(listDrafts()); + triggerToast("✏️ 已重命名"); + } + setRenamingDraftId(null); + setRenameDraftValue(""); + }, + [renameDraftValue], + ); + + // Click-outside closes the history dropdown so it doesn't shadow other + // header controls. Re-attached only while the panel is open to avoid an + // always-on listener. + useEffect(() => { + if (!isHistoryOpen) return; + const handler = (e: MouseEvent) => { + if (!historyDropdownRef.current) return; + if (e.target instanceof Node && !historyDropdownRef.current.contains(e.target)) { + setIsHistoryOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [isHistoryOpen]); + // Chat sender logic for Step 1 interaction — drives the real intent agent // via /api/intent/stream. The agent streams its reply into a placeholder // bubble; if its final status is "done" we capture the structured summary @@ -938,7 +1314,9 @@ export default function RoundtablePlanningPage() { }, ]); - // Placeholder agent bubble — text deltas accumulate into it. + // Placeholder agent bubble — text deltas accumulate into it. `phase` + // tracks the inferred SSE stage so the "in flight" caption can flip from + // "调度中" → "已连接,等待响应" → "正在思考问题" as the run progresses. const agentMsgId = Date.now() + Math.random() + 0.01; setInteractiveMessages(prev => [ ...prev, @@ -949,9 +1327,29 @@ export default function RoundtablePlanningPage() { content: "", time: nowTime(), streaming: true, + phase: "调度中", } as any, ]); + // Map an inferred phase + (optional) tool name to a human-readable label + // shown next to the spinner. Tool-call phases highlight ask_clarification + // explicitly since that's the only tool the intent agent normally uses. + const intentPhaseLabel = (phase: IntentStreamPhase, toolName?: string): string => { + switch (phase) { + case "connecting": + return "调度中"; + case "connected": + return "已连接,等待响应"; + case "tool_calling": + if (toolName === "ask_clarification") return "正在生成追问"; + return toolName ? `调用工具:${toolName}` : "调用工具"; + case "streaming": + return "正在思考问题"; + default: + return "正在思考"; + } + }; + setIsIntentSending(true); try { const res = await streamIntent({ @@ -963,6 +1361,12 @@ export default function RoundtablePlanningPage() { prev.map(m => (m.id === agentMsgId ? { ...m, content: (m.content ?? "") + delta } : m)), ); }, + onPhaseChange: (phase, toolName) => { + const label = intentPhaseLabel(phase, toolName); + setInteractiveMessages(prev => + prev.map(m => (m.id === agentMsgId ? ({ ...m, phase: label } as any) : m)), + ); + }, }); // Finalize the bubble with the authoritative final text from the // status frame (covers cases where deltas didn't accumulate cleanly). @@ -1035,59 +1439,16 @@ export default function RoundtablePlanningPage() { // the user lands on step 1 (and on every re-entry, since clearing thread // history is desirable here — each new planning session starts clean). // - // Note on React 18 StrictMode: effects run twice in dev. We reset - // `step1InitRef.current` to false in the cleanup so the second mount can - // actually run init — otherwise the ref stays true, the second effect - // returns early, and the first effect's `cancelled` flag prevents its - // result from setting state. Net effect would be a successful API call - // whose result never lands and the Send button stays disabled. + // Note on React 18 StrictMode: the effect runs twice in dev (mount → cleanup + // → mount). Staleness is tracked through `step1InitGenRef` rather than a + // closure flag: the second mount short-circuits on `step1InitRef.current` + // and the original async still lands its setState, so the panel actually + // flips to "在线" instead of being stuck at "正在上线". useEffect(() => { - if (currentStep !== 1) { - step1InitRef.current = false; - return; - } + if (currentStep !== 1) return; if (step1InitRef.current) return; - step1InitRef.current = true; - - let cancelled = false; - (async () => { - setIntentReady(null); - setIntentInitError(null); - setIntentThreadId(null); - setTimelineEntries([]); - setInteractiveMessages([ - { - id: 1, - sender: "agent", - senderName: "任务理解智能体", - content: "你好,我是任务理解智能体。请用 1-2 句话告诉我你这次想做什么,以及有哪些预算/时间/合规上的硬约束?", - time: nowTime(), - }, - ]); - try { - const res = await initIntent(DEFAULT_MODEL); - if (cancelled) return; - setIntentThreadId(res.thread_id); - setTimelineEntries([ - { - id: 1, - time: nowTime(), - title: "任务理解智能体已就绪", - desc: `agent_id=${res.agent_id}`, - }, - ]); - } catch (err) { - if (cancelled) return; - console.error("init intent failed:", err); - setIntentInitError((err as Error).message); - } - })(); - return () => { - cancelled = true; - step1InitRef.current = false; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentStep]); + runStep1Init(); + }, [currentStep, runStep1Init]); // Role panel transitions: // - Step 1 shows ONLY the commander + the task-intent agent (2 entries). @@ -1108,7 +1469,11 @@ export default function RoundtablePlanningPage() { }, ]); } else if (currentStep === 2) { - setRoles(makeRoles("正在上线", { 1: "在线" })); + // Draft-loaded sessions skip the SSE init that would normally flip each + // seat to 在线 — assume all seats are online so the panel doesn't get + // stuck at "正在上线". + const baseStatus = loadedFromDraftRef.current ? "在线" : "正在上线"; + setRoles(makeRoles(baseStatus, { 1: "在线" })); } else { setRoles(makeRoles("已完成")); } @@ -1211,54 +1576,112 @@ export default function RoundtablePlanningPage() { - {/* Step 2 */} - + {/* Step 2 — locked until intent-clarification is done. */} + {(() => { + const step2Locked = !intentReady; + const step2Active = currentStep === 2; + return ( + + ); + })()} - {/* Step 3 */} - + {/* Step 3 — locked until the roundtable reaches consensus. */} + {(() => { + const step3Locked = !hasConsensus; + const step3Active = currentStep === 3; + return ( + + ); + })()}
    {/* Right Actions */}
    - + + - + {/* History dropdown trigger + panel (anchored to this button). */} +
    + + + {isHistoryOpen && ( +
    +
    + 历史草稿 + {drafts.length} 条 +
    + {drafts.length === 0 ? ( +
    + 暂无历史。点击「保存草稿」或走完一步会自动保存到这里。 +
    + ) : ( +
      + {drafts.map((d) => { + const isCurrent = d.id === currentDraftId; + const isRenaming = d.id === renamingDraftId; + return ( +
    • +
      +
      + {isRenaming ? ( + setRenameDraftValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleCommitRename(d.id); + if (e.key === "Escape") { + setRenamingDraftId(null); + setRenameDraftValue(""); + } + }} + onBlur={() => handleCommitRename(d.id)} + className="w-full text-[11.5px] font-semibold text-slate-800 border border-slate-300 rounded px-2 py-1 outline-none focus:border-primary dark:bg-background dark:text-foreground" + /> + ) : ( + + )} +
      +
      + {!isRenaming && ( + <> + + + + + )} +
      +
      +
    • + ); + })} +
    + )} +
    + )} +
    +
    {/* Settings gear */} @@ -1691,34 +2220,128 @@ export default function RoundtablePlanningPage() {
    {/* Content dialogue lines inside interaction */} -
    +
    +
    {interactiveMessages.map((msg) => { const isStreaming = (msg as { streaming?: boolean }).streaming === true; const hasContent = (msg.content ?? "").length > 0; + const isAgent = msg.sender !== "user"; + // Only agent replies carry markdown / [INTENT_READY]; user + // messages stay as plain text so casing/whitespace are + // preserved verbatim. + const parts = isAgent + ? splitIntentReadyBlock(msg.content ?? "") + : { before: msg.content ?? "", summary: null, after: "" }; return (
    -
    +
    {msg.sender === "user" ? ( ) : ( )} -
    +
    {msg.senderName} - {/* Pre-text spinner: shown only when streaming AND no content has arrived. */} + {/* Pre-text spinner: shown only when streaming AND no content has arrived. + Caption reflects the inferred SSE phase so users see what the + backend is doing during the first-token wait. */} {isStreaming && !hasContent ? ( - 正在思考... + {(msg as { phase?: string }).phase ?? "正在连接智能体..."} - ) : ( - - {msg.content} + ) : isAgent ? ( +
    + {parts.before && ( +

    {children}

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

    {children}

    , + h2: ({ children }) =>

    {children}

    , + h3: ({ children }) =>

    {children}

    , + h4: ({ children }) =>

    {children}

    , + strong: ({ children }) => {children}, + em: ({ children }) => {children}, + code: ({ children, className }) => { + const isBlock = /language-/.test(className ?? ""); + if (isBlock) { + return ( +
    +                                                {children}
    +                                              
    + ); + } + return ( + + {children} + + ); + }, + a: ({ children, href }) => ( + + {children} + + ), + }} + > + {parts.before} +
    + )} + {parts.summary && ( +
    +
    + + 任务意图已明确 +
    +
    + 核心目标 +

    {parts.summary.objective}

    +
    + {parts.summary.constraints?.length > 0 && ( +
    + 约束条件 +
      + {parts.summary.constraints.map((c, i) => ( +
    • {c}
    • + ))} +
    +
    + )} + {parts.summary.assumptions && parts.summary.assumptions.length > 0 && ( +
    + 关键假设 +
      + {parts.summary.assumptions.map((a, i) => ( +
    • {a}
    • + ))} +
    +
    + )} +
    + )} + {parts.after && ( +

    {children}

    , + }}> + {parts.after} +
    + )} {isStreaming && ( )} +
    + ) : ( + + {msg.content} )}
    @@ -1727,6 +2350,8 @@ export default function RoundtablePlanningPage() {
    ); })} +
    + {!step1AtBottom && }
    @@ -1794,7 +2419,7 @@ export default function RoundtablePlanningPage() { 继续追问 - + +
    {/* Right side buttons */} -
    @@ -2450,12 +3111,21 @@ export default function RoundtablePlanningPage() { {/* STEP 3 SCREEN VIEW: COMPREHENSIVE OUTPUT DASHBOARD */} {currentStep === 3 && ( d.confidence === "子智能体交付") + .map((d) => ({ + sender: String(d.sender ?? ""), + avatarType: String(d.avatarType ?? "blue"), + content: String(d.content ?? ""), + }))} + seatNameMap={Object.fromEntries( + Object.entries(AGENT_NAME_TO_ROLE).map(([k, v]) => [k, v.displayName]), + )} + onReset={handleStartNewTask} /> )} diff --git a/frontend-web/src/roundtable-planning/utils/drafts.ts b/frontend-web/src/roundtable-planning/utils/drafts.ts new file mode 100644 index 00000000..ce0b97c2 --- /dev/null +++ b/frontend-web/src/roundtable-planning/utils/drafts.ts @@ -0,0 +1,195 @@ +/** + * Local-only draft storage for /page/roundtable/planning. + * + * One draft = one full roundtable session (Step 1 intent + Step 2 multi-agent + * roundtable). Persisted in `localStorage` so refresh / browser-restart keeps + * it; cross-device sync is out of scope (would need a backend). The Step 2 + * back-end LangGraph threads are reused on load, so list/preview/download of + * artifacts in Step 3 continue to work as long as the user hasn't deleted the + * threads server-side. + */ + +import type { IntentSummary } from "@/roundtable-planning/api/intent"; +import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent"; + +const STORAGE_KEY = "roundtable-planning:drafts:v1"; + +/** + * Snapshot of every piece of UI state needed to resume a session. We capture + * shapes loosely (`unknown[]`) for the chat / role lists because their inline + * types in the page component are wide and would force a refactor we don't + * want as part of the persistence feature. + */ +export interface DraftStep1Snapshot { + intentThreadId: string | null; + intentReady: IntentSummary | null; + interactiveMessages: unknown[]; + timelineEntries: unknown[]; +} + +export interface DraftStep2Snapshot { + threadIds: ThreadIdMap | null; + coordinatorName: string | null; + step2RoundtableDialogues: unknown[]; + lastLeaderContent: string; + hasConsensus: boolean; + consensusPercentage: number; + budgetLimit: number; +} + +export interface DraftRecord { + id: string; + /** Auto-derived task summary (objective → first user message → timestamp). */ + name: string; + createdAt: string; + updatedAt: string; + /** Furthest step the user had reached when this snapshot was taken. */ + furthestStep: 1 | 2 | 3; + step1: DraftStep1Snapshot; + step2: DraftStep2Snapshot; +} + +function isBrowser(): boolean { + return typeof window !== "undefined" && typeof window.localStorage !== "undefined"; +} + +function readAll(): DraftRecord[] { + if (!isBrowser()) return []; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed as DraftRecord[]; + } catch (err) { + console.warn("[drafts] failed to parse localStorage payload:", err); + return []; + } +} + +function writeAll(records: DraftRecord[]): void { + if (!isBrowser()) return; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(records)); + } catch (err) { + // Quota errors are the realistic failure mode — surface but don't throw, + // since the user can keep working with their in-memory state. + console.warn("[drafts] failed to write localStorage payload:", err); + } +} + +function newId(): string { + if (isBrowser() && typeof window.crypto !== "undefined" && typeof window.crypto.randomUUID === "function") { + return window.crypto.randomUUID(); + } + return `draft-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** List drafts newest first. */ +export function listDrafts(): DraftRecord[] { + return readAll() + .slice() + .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1)); +} + +export function getDraft(id: string): DraftRecord | null { + return readAll().find((d) => d.id === id) ?? null; +} + +/** + * Create-or-update a draft. If `id` is given and exists, that record is + * overwritten (updatedAt refreshed). Otherwise a new record is inserted. + * Returns the (re-)saved record so callers can read back the new id. + */ +export function upsertDraft( + payload: Omit & { id?: string }, +): DraftRecord { + const records = readAll(); + const now = new Date().toISOString(); + + if (payload.id) { + const idx = records.findIndex((d) => d.id === payload.id); + if (idx >= 0) { + const existing = records[idx]; + const updated: DraftRecord = { + ...existing, + name: payload.name, + furthestStep: payload.furthestStep, + step1: payload.step1, + step2: payload.step2, + updatedAt: now, + }; + records[idx] = updated; + writeAll(records); + return updated; + } + } + + const created: DraftRecord = { + id: payload.id ?? newId(), + name: payload.name, + furthestStep: payload.furthestStep, + step1: payload.step1, + step2: payload.step2, + createdAt: now, + updatedAt: now, + }; + records.push(created); + writeAll(records); + return created; +} + +export function deleteDraft(id: string): void { + const records = readAll(); + const next = records.filter((d) => d.id !== id); + if (next.length !== records.length) writeAll(next); +} + +/** + * Rename a draft. No-op when `id` isn't found or `name` is empty. Returns the + * updated record so callers can refresh local UI state. + */ +export function renameDraft(id: string, name: string): DraftRecord | null { + const trimmed = name.trim(); + if (!trimmed) return null; + const records = readAll(); + const idx = records.findIndex((d) => d.id === id); + if (idx < 0) return null; + const updated: DraftRecord = { + ...records[idx], + name: trimmed, + updatedAt: new Date().toISOString(), + }; + records[idx] = updated; + writeAll(records); + return updated; +} + +/** + * Derive a short task-summary name from whatever the user has supplied so far. + * Preference order: intent agent's structured objective → first user message + * in Step 1 → ISO timestamp fallback. The result is trimmed to ~40 chars so + * the dropdown rows stay scannable. + */ +export function deriveDraftName(args: { + intentReady?: IntentSummary | null; + interactiveMessages?: unknown[]; +}): string { + const MAX = 40; + const truncate = (s: string) => (s.length > MAX ? s.slice(0, MAX) + "…" : s); + + const objective = args.intentReady?.objective?.trim(); + if (objective) return truncate(objective); + + if (Array.isArray(args.interactiveMessages)) { + for (const m of args.interactiveMessages) { + if (!m || typeof m !== "object") continue; + const obj = m as Record; + if (obj.sender === "user" && typeof obj.content === "string" && obj.content.trim()) { + return truncate(obj.content.trim()); + } + } + } + + return `未命名草稿 ${new Date().toLocaleString("zh-CN", { hour12: false })}`; +} diff --git a/offline-backend-20260512/backend/app/gateway/routers/artifacts.py b/offline-backend-20260512/backend/app/gateway/routers/artifacts.py index 4ee0ef86..eabc1e9a 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/artifacts.py +++ b/offline-backend-20260512/backend/app/gateway/routers/artifacts.py @@ -1,6 +1,7 @@ import logging import mimetypes import zipfile +from datetime import datetime, timezone from pathlib import Path from urllib.parse import quote @@ -10,6 +11,8 @@ from pydantic import BaseModel from app.gateway.authz import require_permission from app.gateway.path_utils import resolve_thread_virtual_path +from deerflow.config.paths import get_paths +from deerflow.runtime.user_context import get_effective_user_id logger = logging.getLogger(__name__) @@ -94,6 +97,61 @@ def _extract_file_from_skill_archive(zip_path: Path, internal_path: str) -> byte return None +def collect_outputs_listing(outputs_dir: Path) -> list[dict]: + """Walk an outputs directory and return artifact listing entries. + + Each entry has ``path`` (virtual ``mnt/user-data/outputs/...``), ``name``, + ``size_bytes``, ``mime_type``, and ``modified_at`` (ISO 8601 UTC). Symlinks + that escape the outputs root are dropped to keep the listing scoped to the + thread. The result is sorted by ``modified_at`` descending (newest first). + """ + if not outputs_dir.exists() or not outputs_dir.is_dir(): + return [] + + outputs_root = outputs_dir.resolve() + files: list[dict] = [] + for entry in outputs_root.rglob("*"): + if not entry.is_file(): + continue + try: + resolved = entry.resolve() + resolved.relative_to(outputs_root) + except (OSError, ValueError): + continue + relative = resolved.relative_to(outputs_root).as_posix() + mime_type, _ = mimetypes.guess_type(resolved.name) + stat = resolved.stat() + files.append( + { + "path": f"mnt/user-data/outputs/{relative}", + "name": resolved.name, + "size_bytes": stat.st_size, + "mime_type": mime_type, + "modified_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + } + ) + + files.sort(key=lambda f: f["modified_at"], reverse=True) + return files + + +@router.get( + "/threads/{thread_id}/artifacts", + summary="List Artifact Files", + description="List all files under the thread's outputs directory (/mnt/user-data/outputs/). Returns an empty list if the thread has no outputs.", +) +@require_permission("threads", "read", owner_check=True) +async def list_artifacts(thread_id: str, request: Request) -> dict: + """List artifact files generated by the agent under outputs/. + + Walks ``backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/`` + recursively. Returns an empty list if the directory does not exist (e.g. the + thread has not produced any artifacts yet). + """ + outputs_dir = get_paths().sandbox_outputs_dir(thread_id, user_id=get_effective_user_id()) + return {"thread_id": thread_id, "files": collect_outputs_listing(outputs_dir)} + + @router.get( "/threads/{thread_id}/artifacts/{path:path}", summary="Get Artifact File", diff --git a/offline-backend-20260512/backend/app/gateway/routers/intent.py b/offline-backend-20260512/backend/app/gateway/routers/intent.py index 3c9c4fb3..8001d2e4 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/intent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/intent.py @@ -27,6 +27,8 @@ from fastapi.responses import StreamingResponse from app.gateway.routers.multi_agent import ( _auth_headers, _create_thread, + _find_dispatch_message, + _find_trailing_ai_text, _flatten_content, _loopback_base, _parse_last_messages, @@ -59,7 +61,12 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str, """ base = _loopback_base(request) headers = _auth_headers(request) - async with httpx.AsyncClient(timeout=None) as client: + # trust_env=False: this client only talks to the same-process gateway at + # 127.0.0.1, so honoring HTTP_PROXY / HTTPS_PROXY env vars (set system-wide + # on the dev machine) makes httpx route loopback calls through an upstream + # proxy that can't reach 127.0.0.1, costing 40+ seconds of timeout per + # request before falling back. Symptom in logs: `headers_in=42.160s`. + async with httpx.AsyncClient(timeout=None, trust_env=False) as client: thread_id = await _create_thread(client, base, headers) return { "status": "success", @@ -93,7 +100,11 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin headers = _auth_headers(request) async def stream_generator() -> AsyncIterator[str]: - async with httpx.AsyncClient(timeout=None) as client: + # trust_env=False: same-process loopback to 127.0.0.1 — honoring + # HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines) + # causes httpx to route loopback calls through an unreachable upstream + # proxy and wait ~40 seconds for timeout before falling back. + async with httpx.AsyncClient(timeout=None, trust_env=False) as client: try: body = _run_payload( agent_name=INTENT_AGENT_ID, @@ -119,16 +130,38 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin captured = done messages = _parse_last_messages(captured) - # Find the latest AIMessage's text content — that's what the - # frontend renders and what we scan for the handoff signal. - last_ai: dict[str, Any] = {} - for msg in reversed(messages): - if not isinstance(msg, dict): - continue - if (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"): - last_ai = msg - break - content = _flatten_content(last_ai.get("content", "")) + # Find the latest AIMessage with tool_calls (typically + # ask_clarification) — falls back to the latest plain + # AIMessage when the model produced no tool call. + dispatch_msg = _find_dispatch_message(messages) + content = _flatten_content(dispatch_msg.get("content", "")) + tool_calls = dispatch_msg.get("tool_calls") or [] + + # Clarification branch: the agent ended its run by calling + # ask_clarification. ClarificationMiddleware intercepts with + # Command(goto=END) and stashes the structured question in the + # tool_call args, so the AIMessage `content` is only the + # preamble ("下面我用几个问题确认:") and stops there. We splice + # the question back into the user-facing text so the frontend + # actually shows what the agent wants to ask. + clarification_call = next( + (tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"), + None, + ) + if clarification_call is not None: + cargs = clarification_call.get("args") or {} + question = str(cargs.get("question") or "").strip() + follow_up = _find_trailing_ai_text(messages, exclude_text=content) + # Build a single content string the frontend can render + # as-is: preamble (if any) + the actual question. + parts: list[str] = [] + if content.strip(): + parts.append(content.strip()) + if follow_up and follow_up != question: + parts.append(follow_up) + if question: + parts.append(question) + content = "\n\n".join(parts) if parts else content summary: dict[str, Any] | None = None match = _INTENT_READY_PATTERN.search(content) diff --git a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py index ce0f5ff1..6a49ca43 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py @@ -285,7 +285,11 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" async def stream_generator() -> AsyncIterator[str]: - async with httpx.AsyncClient(timeout=None) as client: + # trust_env=False: same-process loopback to 127.0.0.1 — honoring + # HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines) + # causes httpx to route loopback calls through an unreachable upstream + # proxy and wait ~40 seconds for timeout before falling back. + async with httpx.AsyncClient(timeout=None, trust_env=False) as client: try: # Per-seat prepare task — same body as before, but we no longer # await them with asyncio.gather. Instead, asyncio.as_completed @@ -775,7 +779,11 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) -> headers = _auth_headers(request) async def stream_generator() -> AsyncIterator[str]: - async with httpx.AsyncClient(timeout=None) as client: + # trust_env=False: same-process loopback to 127.0.0.1 — honoring + # HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines) + # causes httpx to route loopback calls through an unreachable upstream + # proxy and wait ~40 seconds for timeout before falling back. + async with httpx.AsyncClient(timeout=None, trust_env=False) as client: try: if agent_type == "leader": gen = _leader_run(