Merge branch 'dhk-1' into 'master'

Dhk 1

See merge request knowledge-base-bj/deerflow-server!49
This commit is contained in:
董宏凯 committed 2026-05-24 13:13:48 +00:00
commit 61358b6233
12 files changed
+2668 -871

No files matched your search

@@ -0,0 +1,250 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
/**
* Task-intent agent (Step 1) API. Mirror of multi-agent.ts but with a single
* built-in agent and no per-call coordinator: one /init creates a thread
* tied to the built-in `roundtable-intent` agent, and each /stream POST is
* one user-turn of the clarification dialogue. The terminal status frame
* carries either `asking` (more turns needed) or `done` (structured summary
* available — hand off to Step 2).
*/
export interface IntentInitResponse {
status: "success";
agent_id: string;
thread_id: string;
}
/**
* Create a fresh thread for an intent-clarification session.
* The backend returns the built-in agent id so the frontend doesn't hardcode it.
*/
export async function initIntent(model?: string): Promise<IntentInitResponse> {
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model }),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`init intent failed (${res.status}): ${text}`);
}
return (await res.json()) as IntentInitResponse;
}
/**
* Structured task summary the intent agent emits when it judges the user's
* task is fully specified. The frontend feeds this into Step 2's initial
* leader prompt.
*/
export interface IntentSummary {
/** One-line statement of what the user wants accomplished. */
objective: string;
/** Hard constraints (budget / time / compliance / scope). At least 1 entry. */
constraints: string[];
/** Optional assumptions the agent is making on the user's behalf. */
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;
model?: string;
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 {
/** "asking" → keep the dialogue going; "done" → summary is ready; "error" → backend failure. */
status: "asking" | "done" | "error";
/** Final assistant text for this turn (markdown, may contain the [INTENT_READY] block). */
content: string;
/** Populated only when status === "done". */
summary: IntentSummary | null;
}
/**
* Recognise the terminal status frame the backend appends after the upstream
* LangGraph stream finishes. Must carry `status` and `content`; `summary`
* is optional.
*/
function isFinalIntentFrame(value: unknown): value is {
status: "asking" | "done" | "error";
content?: string;
summary?: IntentSummary | null;
} {
if (!value || typeof value !== "object") return false;
if (!("status" in value) || !("content" in value)) return false;
const status = (value as { status: unknown }).status;
return status === "asking" || status === "done" || status === "error";
}
/**
* Reuse the same delta-extraction shape as multi-agent.ts so AI text streams
* smoothly into the frontend bubble. Returns null for tool / value / tool
* result frames.
*/
function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null {
if (!Array.isArray(parsed) || parsed.length < 1) return null;
const chunk = parsed[0];
if (!chunk || typeof chunk !== "object") return null;
const obj = chunk as Record<string, unknown>;
const type = String(obj.type ?? "").toLowerCase();
if (type !== "ai" && type !== "aimessagechunk" && type !== "aimessage") return null;
const content = obj.content;
let text = "";
if (typeof content === "string") {
text = content;
} else if (Array.isArray(content)) {
for (const item of content) {
if (typeof item === "string") text += item;
else if (item && typeof item === "object") {
const block = item as Record<string, unknown>;
const blockText = block.text;
if (typeof blockText === "string") text += blockText;
}
}
}
if (!text) return null;
const id = typeof obj.id === "string" ? obj.id : "";
return { text, id };
}
/**
* Open an SSE connection to /api/intent/stream and resolve when the upstream
* stream completes. Each call is one user turn; reuse the same threadId
* across calls to keep the dialogue history.
*/
export async function streamIntent(req: StreamIntentRequest): Promise<StreamIntentResult> {
const res = await apiFetch(`${getBackendBaseURL()}/api/intent/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
thread_id: req.threadId,
message: req.message,
model: req.model,
}),
signal: req.signal,
});
if (!res.ok || !res.body) {
const text = await res.text().catch(() => "");
throw new Error(`intent/stream failed (${res.status}): ${text}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let lastEvent = "";
// Single source of truth for the inferred SSE phase. The helper only fires
// the callback when the phase actually changes (or when a tool name is
// provided, since the same `tool_calling` phase may surface a new tool).
let currentPhase: IntentStreamPhase = "connecting";
req.onPhaseChange?.("connecting");
const setPhase = (next: IntentStreamPhase, toolName?: string) => {
if (next === currentPhase && !toolName) return;
currentPhase = next;
req.onPhaseChange?.(next, toolName);
};
const result: StreamIntentResult = { status: "asking", content: "", summary: null };
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const rawLine of lines) {
const line = rawLine.trimEnd();
if (!line) continue;
if (line.startsWith("event:")) {
lastEvent = line.slice(6).trim();
// First `event: metadata` = the upstream LangGraph run was accepted.
// Flip out of "connecting" so the bubble label changes from
// "调度中" → "已连接,等待响应".
if (lastEvent === "metadata" && currentPhase === "connecting") {
setPhase("connected");
}
continue;
}
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (!payload) continue;
let parsed: unknown;
try {
parsed = JSON.parse(payload);
} catch {
continue;
}
if (isFinalIntentFrame(parsed)) {
result.status = parsed.status;
if (typeof parsed.content === "string") result.content = parsed.content;
if (parsed.summary && typeof parsed.summary === "object") {
result.summary = parsed.summary as IntentSummary;
}
continue;
}
if (lastEvent === "messages") {
// Phase inference from LangGraph messages-tuple frames. Tool calls
// (typically ask_clarification) flip phase to tool_calling; AI text
// chunks flip to streaming. Everything else leaves phase alone.
if (Array.isArray(parsed) && parsed.length > 0) {
const chunk = parsed[0];
if (chunk && typeof chunk === "object") {
const chunkObj = chunk as Record<string, unknown>;
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<string, unknown>)
: Array.isArray(toolCallChunks) && toolCallChunks.length > 0
? (toolCallChunks[0] as Record<string, unknown>)
: 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);
}
}
}
return result;
}
@@ -41,6 +41,24 @@ export interface InitMultiAgentResponse {
export async function initMultiAgent( export async function initMultiAgent(
payload: InitMultiAgentRequest, payload: InitMultiAgentRequest,
): Promise<InitMultiAgentResponse> { ): Promise<InitMultiAgentResponse> {
// 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`, { const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/init`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -51,6 +69,7 @@ export async function initMultiAgent(
}), }),
signal: payload.signal, signal: payload.signal,
}); });
tlog(`fetch.headers status=${res.status}`);
if (!res.ok || !res.body) { if (!res.ok || !res.body) {
const text = await res.text().catch(() => ""); const text = await res.text().catch(() => "");
throw new Error(`init multi-agent failed (${res.status}): ${text}`); throw new Error(`init multi-agent failed (${res.status}): ${text}`);
@@ -68,6 +87,10 @@ export async function initMultiAgent(
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; 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 }); buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n"); const lines = buffer.split("\n");
buffer = lines.pop() ?? ""; buffer = lines.pop() ?? "";
@@ -88,6 +111,8 @@ export async function initMultiAgent(
const event = typeof obj.event === "string" ? obj.event : ""; const event = typeof obj.event === "string" ? obj.event : "";
switch (event) { switch (event) {
case "seat_ready": { case "seat_ready": {
seatCount += 1;
tlog(`seat_ready #${seatCount} ${String(obj.agent_name ?? "?")}`);
payload.onSeatReady?.({ payload.onSeatReady?.({
agentName: String(obj.agent_name ?? ""), agentName: String(obj.agent_name ?? ""),
threadId: String(obj.thread_id ?? ""), threadId: String(obj.thread_id ?? ""),
@@ -97,6 +122,8 @@ export async function initMultiAgent(
break; break;
} }
case "coordinator_ready": { case "coordinator_ready": {
tCoord = performance.now();
tlog(`coordinator_ready (since-first-seat=${(tCoord - tFirstByte).toFixed(0)}ms)`);
payload.onCoordinatorReady?.({ payload.onCoordinatorReady?.({
mainAgentName: String(obj.main_agent_name ?? ""), mainAgentName: String(obj.main_agent_name ?? ""),
threadId: String(obj.thread_id ?? ""), threadId: String(obj.thread_id ?? ""),
@@ -104,6 +131,7 @@ export async function initMultiAgent(
break; break;
} }
case "init_done": { case "init_done": {
tlog(`init_done seats=${seatCount}`);
mainAgentName = String(obj.main_agent_name ?? ""); mainAgentName = String(obj.main_agent_name ?? "");
threadIds = (obj.thread_ids ?? {}) as ThreadIdMap; threadIds = (obj.thread_ids ?? {}) as ThreadIdMap;
break; break;
@@ -123,6 +151,8 @@ export async function initMultiAgent(
if (!aborted) throw err; if (!aborted) throw err;
} }
tlog(`total ${(performance.now() - t0).toFixed(0)}ms (seats=${seatCount})`);
if (errorDetail) { if (errorDetail) {
throw new Error(`init multi-agent failed: ${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 }; 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 * Open an SSE connection to /api/multi-agent/run/stream and resolve once the
* stream completes. The final frame (`{status: ...}`) is interpreted; every * stream completes. The final frame (`{status: ...}`) is interpreted; every
@@ -357,24 +349,6 @@ export async function streamMultiAgent(
typeof window !== "undefined" && typeof window !== "undefined" &&
(window as unknown as { __MULTI_AGENT_DEBUG__?: boolean }).__MULTI_AGENT_DEBUG__ !== false; (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 = { const result: StreamMultiAgentResult = {
dispatched: [], dispatched: [],
message: null, message: null,
@@ -393,38 +367,16 @@ export async function streamMultiAgent(
req.onPhaseChange?.(next, name); req.onPhaseChange?.(next, name);
}; };
// Wrap the read loop so we always flush the transcript to disk, even on while (true) {
// abort / network failure. Otherwise an interrupted run would lose the very const { done, value } = await reader.read();
// evidence we want to inspect. if (done) break;
const finishDump = (reason: string) => { const chunk = decoder.decode(value, { stream: true });
if (!dumpEnabled) return; buffer += chunk;
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"));
};
try { const lines = buffer.split("\n");
while (true) { buffer = lines.pop() ?? "";
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);
for (const rawLine of lines) {
const line = rawLine.trimEnd(); const line = rawLine.trimEnd();
if (!line) continue; if (!line) continue;
if (line.startsWith("event:")) { if (line.startsWith("event:")) {
@@ -525,16 +477,82 @@ export async function streamMultiAgent(
} }
} }
if (debugEnabled) { if (debugEnabled) {
console.debug(debugLabel, `stream ended after ${frameSeq} frames; final content length=${result.content.length}`); 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;
} }
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<SessionArtifactsResponse> {
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;
} }
@@ -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) => <p className="my-1.5 whitespace-pre-wrap leading-relaxed">{children}</p>,
ul: ({ children }: any) => <ul className="list-disc pl-5 my-1.5 space-y-1">{children}</ul>,
ol: ({ children }: any) => <ol className="list-decimal pl-5 my-1.5 space-y-1">{children}</ol>,
li: ({ children }: any) => <li className="leading-snug">{children}</li>,
h1: ({ children }: any) => <h1 className="text-base font-bold mt-3 mb-2">{children}</h1>,
h2: ({ children }: any) => <h2 className="text-sm font-bold mt-3 mb-1.5">{children}</h2>,
h3: ({ children }: any) => <h3 className="text-xs font-bold mt-2 mb-1">{children}</h3>,
h4: ({ children }: any) => <h4 className="text-xs font-bold mt-1.5 mb-1">{children}</h4>,
strong: ({ children }: any) => <strong className="font-bold text-slate-900">{children}</strong>,
em: ({ children }: any) => <em className="italic">{children}</em>,
code: ({ children, className }: any) => {
const isBlock = /language-/.test(className ?? "");
if (isBlock) {
return (
<pre className="bg-slate-100 text-slate-800 rounded-md p-3 my-2 overflow-x-auto text-[12px] font-mono">
<code>{children}</code>
</pre>
);
}
return <code className="bg-slate-100 text-slate-800 rounded px-1 py-0.5 text-[12px] font-mono">{children}</code>;
},
blockquote: ({ children }: any) => (
<blockquote className="border-l-2 border-slate-300 pl-3 my-2 text-slate-600 italic">{children}</blockquote>
),
a: ({ children, href }: any) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline hover:opacity-80">
{children}
</a>
),
table: ({ children }: any) => <table className="my-2 border-collapse text-[12px] w-full">{children}</table>,
th: ({ children }: any) => (
<th className="border border-slate-300 px-2 py-1 bg-slate-50 font-bold text-left">{children}</th>
),
td: ({ children }: any) => <td className="border border-slate-200 px-2 py-1">{children}</td>,
hr: () => <hr className="my-3 border-slate-200" />,
};
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<ArtifactPreviewModalProps> = ({
threadId,
path,
name,
mimeType,
onClose,
}) => {
const [content, setContent] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(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 (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-6"
onMouseDown={(e) => {
// 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();
}}
>
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[80vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="shrink-0 px-5 py-3.5 border-b border-slate-150 flex items-center justify-between gap-3 bg-gradient-to-r from-slate-50 to-white">
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-4 h-4 text-primary shrink-0" />
<span className="text-sm font-bold text-slate-800 truncate" title={name}>{name}</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
type="button"
onClick={handleDownload}
className="bg-white border border-slate-200 text-slate-600 hover:bg-slate-50 text-[11px] font-bold px-2.5 py-1.5 rounded-lg flex items-center gap-1 transition"
title="下载原文件"
>
<Download className="w-3 h-3" />
下载
</button>
<button
type="button"
onClick={onClose}
className="p-1.5 text-slate-500 hover:text-slate-800 hover:bg-slate-100 rounded-lg transition"
aria-label="关闭"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Body */}
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-4 text-sm text-slate-700">
{loading ? (
<div className="flex items-center justify-center gap-2 py-12 text-slate-400">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
<span className="text-xs">正在加载文件内容…</span>
</div>
) : error ? (
<div className="text-rose-600 text-xs py-6 text-center">
加载失败:{error}
</div>
) : content === null ? null : renderAsMarkdown ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
{content}
</ReactMarkdown>
) : (
<pre className="whitespace-pre-wrap break-words text-xs text-slate-700 font-mono">{content}</pre>
)}
</div>
</div>
</div>
);
};
@@ -1,498 +1,477 @@
import React from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { import ReactMarkdown from "react-markdown";
ShieldCheck, import remarkGfm from "remark-gfm";
Layers, import {
Clipboard, CheckCircle,
Star, ChevronDown,
CheckCircle, ChevronRight,
Download, Download,
Share2, Eye,
Sparkles FileText,
Loader2,
Printer,
RefreshCw,
} from "lucide-react"; } from "lucide-react";
interface HighFidelityReportProps { import {
budgetLimit: number; buildArtifactDownloadUrl,
consensusPercentage: number; listSessionArtifacts,
setCurrentStep: (step: number) => void; type SessionArtifactFile,
setStep1MessageHistory: (history: any) => void; type ThreadIdMap,
setStep2RoundtableDialogues: (dialogues: any) => void; } from "@/roundtable-planning/api/multi-agent";
triggerToast: (msg: string) => void; 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<string, string>;
/** 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) => <p className="my-1.5 whitespace-pre-wrap leading-relaxed">{children}</p>,
ul: ({ children }: any) => <ul className="list-disc pl-5 my-1.5 space-y-1">{children}</ul>,
ol: ({ children }: any) => <ol className="list-decimal pl-5 my-1.5 space-y-1">{children}</ol>,
li: ({ children }: any) => <li className="leading-snug">{children}</li>,
h1: ({ children }: any) => <h1 className="text-sm font-bold mt-3 mb-1.5">{children}</h1>,
h2: ({ children }: any) => <h2 className="text-sm font-bold mt-3 mb-1.5">{children}</h2>,
h3: ({ children }: any) => <h3 className="text-xs font-bold mt-2 mb-1">{children}</h3>,
h4: ({ children }: any) => <h4 className="text-xs font-bold mt-1.5 mb-1">{children}</h4>,
strong: ({ children }: any) => <strong className="font-bold text-slate-900">{children}</strong>,
em: ({ children }: any) => <em className="italic">{children}</em>,
code: ({ children, className }: any) => {
const isBlock = /language-/.test(className ?? "");
if (isBlock) {
return (
<pre className="bg-slate-100 text-slate-800 rounded-md p-2 my-1 overflow-x-auto text-[11px] font-mono">
<code>{children}</code>
</pre>
);
}
return (
<code className="bg-slate-100 text-slate-800 rounded px-1 py-0.5 text-[11px] font-mono">{children}</code>
);
},
blockquote: ({ children }: any) => (
<blockquote className="border-l-2 border-slate-300 pl-2 my-1 text-slate-600 italic">{children}</blockquote>
),
a: ({ children, href }: any) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline hover:opacity-80">
{children}
</a>
),
table: ({ children }: any) => <table className="my-2 border-collapse text-[11px]">{children}</table>,
th: ({ children }: any) => (
<th className="border border-slate-300 px-1.5 py-0.5 bg-slate-50 font-bold text-left">{children}</th>
),
td: ({ children }: any) => <td className="border border-slate-200 px-1.5 py-0.5">{children}</td>,
hr: () => <hr className="my-2 border-slate-200" />,
};
export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
budgetLimit, threadIds,
consensusPercentage, coordinatorName,
setCurrentStep, consensusText,
setStep1MessageHistory, hasConsensus,
setStep2RoundtableDialogues, seatDeliveries,
triggerToast 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<string, string>();
for (const d of seatDeliveries) m.set(d.sender, d.content);
return m;
}, [seatDeliveries]);
const [artifactsBySeat, setArtifactsBySeat] = useState<SeatArtifacts[]>([]);
const [expandedSeats, setExpandedSeats] = useState<Set<string>>(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 ( return (
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto text-slate-800"> <div className="flex-1 flex flex-col min-h-0 overflow-y-auto text-slate-800">
{/* Header */}
{/* Output title block */}
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-gradient-to-r from-slate-50 to-white flex-wrap gap-3"> <div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-gradient-to-r from-slate-50 to-white flex-wrap gap-3">
<div className="flex flex-wrap items-center gap-2.5"> <div className="flex flex-wrap items-center gap-2.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-emerald-500" /> <CheckCircle className="w-5 h-5 text-emerald-500" />
<h3 className="text-sm font-black text-slate-900 tracking-tight"> <h3 className="text-sm font-black text-slate-900 tracking-tight">最终方案展示</h3>
最终架构规划方案 (成果展示大屏)
</h3>
</div> </div>
<div className="flex items-center gap-1.5 shrink-0"> <div className="flex items-center gap-1.5 shrink-0">
<span className="text-[10px] font-black text-emerald-700 bg-emerald-50 border border-emerald-200 px-2 py-0.5 rounded-full shadow-sm"> {hasConsensus && (
● 已达成共识 <span className="text-[10px] font-black text-emerald-700 bg-emerald-50 border border-emerald-200 px-2 py-0.5 rounded-full shadow-sm">
</span> ● 已达成共识
</span>
)}
<span className="text-[10px] font-mono text-slate-400 font-semibold bg-slate-100/80 px-2 py-0.5 rounded-lg"> <span className="text-[10px] font-mono text-slate-400 font-semibold bg-slate-100/80 px-2 py-0.5 rounded-lg">
生成时间:2026-05-21 11:01:12 生成时间:{generatedAt}
</span> </span>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
id="report-download-pdf-btn" type="button"
onClick={() => triggerToast("📥 架构方案白皮书 PDF 已开始准备传输...")} onClick={() => {
if (typeof window !== "undefined") window.print();
}}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer" className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer"
> >
<Download className="w-3.5 h-3.5 text-slate-500" /> <Printer className="w-3.5 h-3.5 text-slate-500" />
导出方案 PDF 打印 / 另存 PDF
</button> </button>
<button
<button type="button"
id="report-share-btn" onClick={() => setReloadKey((k) => k + 1)}
onClick={() => triggerToast("🔗 方案安全访问及沙盒校验链接已复制到剪贴板!")}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer" className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition cursor-pointer"
> >
<Share2 className="w-3.5 h-3.5 text-slate-500" /> <RefreshCw className="w-3.5 h-3.5 text-slate-500" />
安全分享 刷新文件清单
</button> </button>
</div> </div>
</div> </div>
{/* Comprehensive high fidelity report details */}
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
{/* Section 1: Coordinator consensus conclusion */}
{/* Dashboard Metric Overview Rows */} <section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
{/* Metric 1 */} <CheckCircle className="w-4 h-4 text-emerald-500" />
<div className="bg-gradient-to-br from-white to-slate-50/20 border border-slate-150 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.015)] text-xs text-slate-500 font-medium hover:scale-[1.01] transition-all"> 总控最终结论
<span className="text-[10px] font-extrabold uppercase tracking-wider text-slate-400 block mb-1">设计全要素通过率</span> </h4>
<div className="flex items-baseline gap-1 mt-1"> {consensusText ? (
<span className="text-2xl font-black text-slate-900 font-mono"> <div className="text-xs text-slate-700 leading-relaxed">
{budgetLimit >= 500 ? "99.4%" : budgetLimit > 400 ? "98.2%" : "96.5%"} <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
</span> {consensusText}
<span className="text-[9px] font-black text-emerald-600 bg-emerald-50 px-1.5 py-0.2 rounded-full">合规通过</span> </ReactMarkdown>
</div> </div>
<p className="text-[9.5px] text-slate-400 mt-2 leading-tight">安全拦截红线保障 0 出圈、0 超限</p> ) : (
</div> <p className="text-[11px] text-slate-400 italic">
暂无总控结论。返回 Step 2 完成至少一轮研讨后再查看此页。
{/* Metric 2 */}
<div className="bg-gradient-to-br from-white to-slate-50/20 border border-slate-150 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.015)] text-xs text-slate-500 font-medium hover:scale-[1.01] transition-all">
<span className="text-[10px] font-extrabold uppercase tracking-wider text-primary block mb-1">安全冗余评估能级</span>
<div className="flex items-baseline gap-1 mt-1">
<span className="text-2xl font-black text-primary font-mono">
{budgetLimit >= 540 ? "Tier III+" : budgetLimit > 410 ? "Tier II" : "Tier I"}
</span>
<span className="text-[9px] font-black text-[var(--brand-900)] bg-[var(--brand-200)] px-1.5 py-0.2 rounded-full">异地容灾</span>
</div>
<p className="text-[9.5px] text-slate-400 mt-2 leading-tight">多路容错算力热备与热切换防线</p>
</div>
{/* Metric 3 */}
<div className="bg-gradient-to-br from-white to-slate-50/20 border border-slate-150 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.015)] text-xs text-slate-500 font-medium hover:scale-[1.01] transition-all">
<span className="text-[10px] font-extrabold uppercase tracking-wider text-slate-400 block mb-1">研讨总核销资金预算</span>
<div className="flex items-baseline gap-1 mt-1">
<span className="text-2xl font-black text-slate-800 font-mono">
{Math.round(budgetLimit * 0.925 * 10) / 10}万
</span>
<span className="text-[9px] font-black text-emerald-600 bg-emerald-50 px-1.5 py-0.2 rounded-full font-sans">
省 {Math.round(budgetLimit * 0.075 * 10) / 10}w
</span>
</div>
<p className="text-[9.5px] text-slate-400 mt-2 leading-tight">包含逻辑容器、极端微物理灾灾备购置</p>
</div>
{/* Metric 4 */}
<div className="bg-gradient-to-br from-white to-slate-50/20 border border-slate-150 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.015)] text-xs text-slate-500 font-medium hover:scale-[1.01] transition-all">
<span className="text-[10px] font-extrabold uppercase tracking-wider text-slate-400 block mb-1">终审共识度评分</span>
<div className="flex items-baseline gap-1 mt-1">
<span className="text-2xl font-black text-slate-900 font-mono">42 轮研商</span>
<span className="text-[9px] font-black text-amber-600 bg-amber-50 px-1.5 py-0.2 rounded-full">
共识率 {consensusPercentage}%
</span>
</div>
<p className="text-[9.5px] text-slate-400 mt-2 leading-tight">多智能体委员会滚动合议完美收敛</p>
</div>
</div>
{/* Section I: 执行摘要 (Executive Summary) */}
<div className="bg-gradient-to-br from-[var(--brand-200)]/30 to-[var(--brand-200)]/10 border border-red-100 rounded-2xl p-5 hover:shadow-md transition">
<div className="flex items-center gap-2 mb-3">
<ShieldCheck className="w-4.5 h-4.5 text-[var(--brand-900)]" />
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest">
执行摘要 / Executive Summary
</h4>
</div>
<div className="text-xs text-slate-755 leading-relaxed space-y-2 font-medium">
<p className="flex items-start gap-1.5">
<span className="text-primary text-md leading-none">•</span>
<span>本路线规划方案在总核销支出预算限制 ≤ <b className="text-slate-900 font-bold">{budgetLimit}万(主备案并轨共耗约 {Math.round(budgetLimit * 0.925 * 10) / 10}万元)</b>、核心编制队伍限制 ≤ <b className="text-slate-900 font-bold">30人</b>、绝对周转上线规定 <b className="text-slate-900 font-bold">72小时</b> 内交付的目标约束下制定。最终裁决收敛并采纳包含【信息补全】、【主干推进】与【风险收敛】三阶段非对称推进的稳健路径。</span>
</p> </p>
<p className="flex items-start gap-1.5"> )}
<span className="text-primary text-md leading-none">•</span> </section>
<span>经多角色 AI 官群及人类指挥官深度澄清和在沙盒拓扑中演算测试,采用本规划方案,在安全拦截(合规绿码)、运行负载及高灾备级余度三大维度上均符合最苛刻的可落地标准。</span>
{/* Section 2: Per-seat deliveries (collapsible) */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<FileText className="w-4 h-4 text-primary" />
各席位最终交付
<span className="text-[10px] font-mono font-semibold text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
{seatDeliveries.length}
</span>
</h4>
{seatDeliveries.length === 0 ? (
<p className="text-[11px] text-slate-400 italic">
本次研讨尚未产生子智能体交付。Leader 派活并收到子智能体回复后,该区块会自动填充。
</p> </p>
<p className="flex items-start gap-1.5"> ) : (
<span className="text-primary text-md leading-none">•</span> <div className="space-y-2">
<span>项目具有明确的关键里程碑控制链、主备熔断备份计划,可执行确凿性最优,推荐自批复时点起立即启动第一阶段工作。</span> {seatDeliveries.map((seat, idx) => {
</p> const key = `${seat.sender}-${idx}`;
</div> const expanded = expandedSeats.has(key);
</div> return (
<div key={key} className="border border-slate-150 rounded-xl overflow-hidden">
{/* Section II: 方案详细大纲 (Detailed Core Outline) */} <button
<div className="bg-white border border-slate-150 rounded-2xl p-5 hover:shadow-md transition space-y-6"> type="button"
<div className="flex items-center gap-2 border-b border-slate-100 pb-3 mb-1"> onClick={() => toggleSeat(key)}
<Layers className="w-4.5 h-4.5 text-primary" strokeWidth={2.5} /> className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-slate-50 hover:bg-slate-100 transition text-left"
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest"> >
一、最终规划方案内容大纲 <div className="flex items-center gap-2 min-w-0">
</h4> {expanded ? (
</div> <ChevronDown className="w-3.5 h-3.5 text-slate-500 shrink-0" />
) : (
<div className="space-y-6 text-xs text-slate-750 font-medium leading-relaxed"> <ChevronRight className="w-3.5 h-3.5 text-slate-500 shrink-0" />
)}
{/* Item 1 */} <span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.sender}</span>
<div className="space-y-1.5"> </div>
<h5 className="font-extrabold text-slate-900 flex items-center gap-1.5 text-[12px]"> <span className="text-[10px] text-slate-400 font-mono shrink-0">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-[var(--brand-200)] text-primary font-bold font-mono text-[10.5px]">1</span> {seat.content.length} 字
任务背景与目标 / Target Scope </span>
</h5> </button>
<p className="pl-6.5 text-slate-500 leading-normal"> {expanded && (
围绕给定预算上限(限额:{budgetLimit}万)与资源约束(人数:≤30人,时限:72小时),建立一套主备、高容错、防崩溃的多模态适应性部署方案。通过多智能体委员会多轮博弈对抗演炼,彻底击碎不确定性风险,平滑过渡并投产运行。 <div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white">
</p> <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{seat.content || "(空)"}
</ReactMarkdown>
</div>
)}
</div>
);
})}
</div> </div>
)}
</section>
{/* Item 2 */} {/* Section 3: Generated files per seat */}
<div className="space-y-1.5"> <section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h5 className="font-extrabold text-slate-900 flex items-center gap-1.5 text-[12px]"> <h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-[var(--brand-200)] text-[var(--brand-900)] font-bold font-mono text-[10.5px]">2</span> <Download className="w-4 h-4 text-primary" />
约束条件与关键假设 / Ground Hypotheses 生成的报告文件
</h5> <span className="text-[10px] font-mono font-semibold text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
<div className="pl-6.5 grid grid-cols-1 md:grid-cols-2 gap-3 mt-1.5 text-slate-500"> {totalFileCount}
<div className="p-3 bg-slate-50 border border-slate-150 rounded-xl space-y-1"> </span>
<span className="font-black text-slate-800 text-[10.5px] block">📌 指向性硬约束:</span> </h4>
<ul className="list-disc pl-4 space-y-1 text-[10px]">
<li>预算开销指示极限: <b className="text-slate-850">≤ {budgetLimit}万</b></li>
<li>人力池最大可用总额: <b className="text-slate-850">≤ 30人</b></li>
<li>全投产周转交付极限: <b className="text-slate-850">≤ 72小时</b></li>
</ul>
</div>
<div className="p-3 bg-slate-50 border border-slate-150 rounded-xl space-y-1">
<span className="font-black text-slate-800 text-[10.5px] block">🔮 关键假设依据:</span>
<ul className="list-disc pl-4 space-y-1 text-[10px]">
<li>阶段一能够在24h内快捷、高配合度、无阻碍补全关键参数</li>
<li>灾灾备系统对逻辑双微高可用(云原生 K3s)具有高度适配性</li>
<li>极端环境历史数据具有一定参照性,各微服务配合维持稳定</li>
</ul>
</div>
</div>
</div>
{/* Item 3 (Interactive roadmap timeline pipeline mockup) */} {artifactsBySeat.length === 0 ? (
<p className="text-[11px] text-slate-400 italic">尚未初始化席位线程,无法列出文件。</p>
) : (
<div className="space-y-3"> <div className="space-y-3">
<h5 className="font-extrabold text-slate-900 flex items-center gap-1.5 text-[12px]"> {artifactsBySeat.map((seat) => (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-purple-50 text-purple-600 font-bold font-mono text-[10.5px]">3</span> <div key={seat.agentName} className="border border-slate-150 rounded-xl">
三阶段推进方案路线图 / Three-Phase Integration Roadmap <div className="flex items-center justify-between gap-2 px-3 py-2 bg-slate-50 border-b border-slate-150 rounded-t-xl">
</h5> <span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.displayName}</span>
<p className="pl-6.5 text-slate-500 pb-1"> <span className="text-[10px] text-slate-400 font-mono shrink-0">
项目设计采用分段非对称递进开发,首尾相扣,利用熔断和回滚阀门最大化减少交付风险: {seat.loading ? "加载中…" : `${seat.files.length} 个文件`}
</p>
<div className="pl-6.5 grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Phase 1 card */}
<div className="bg-slate-50 border border-slate-150 rounded-2xl p-4 flex flex-col justify-between hover:border-red-200 transition relative">
<div className="absolute top-2.5 right-3 px-2 py-0.5 rounded-full bg-[var(--brand-200)] text-primary border border-red-100 font-mono font-bold text-[9px]">
0 - 24h
</div>
<div>
<span className="font-black text-[11.5px] tracking-tight block mb-2 text-primary">
阶段一:系统信息补全
</span> </span>
<ul className="text-[10px] text-slate-500 space-y-1.5 leading-snug">
<li className="flex items-start gap-1">
<span className="text-primary">•</span>
<span>补缺所有缺失现状对齐指标</span>
</li>
<li className="flex items-start gap-1">
<span className="text-primary">•</span>
<span>拉网厘清核心需求物理缺失</span>
</li>
<li className="flex items-start gap-1">
<span className="text-primary">•</span>
<span>与一线用户对齐约束边界</span>
</li>
</ul>
</div> </div>
<div className="border-t border-slate-150 pt-3 mt-4 flex items-center justify-between text-[9px]">
<span className="text-slate-400 font-bold">交付物件:</span>
<span className="text-slate-700 bg-slate-200/50 px-1.5 py-0.5 rounded font-bold font-sans">《范围基准对齐白皮书》</span>
</div>
</div>
{/* Phase 2 card */} {seat.loading ? (
<div className="bg-slate-50 border border-slate-150 rounded-2xl p-4 flex flex-col justify-between hover:border-purple-200 transition relative"> <div className="flex items-center gap-2 px-3 py-3 text-[11px] text-slate-500">
<div className="absolute top-2.5 right-3 px-2 py-0.5 rounded-full bg-purple-50 text-purple-600 border border-purple-100 font-mono font-bold text-[9px]"> <Loader2 className="w-3 h-3 animate-spin text-primary" />
24 - 60h <span>正在查询 outputs 目录…</span>
</div>
<div>
<span className="font-black text-[11.5px] tracking-tight block mb-2 text-purple-650">
阶段二:主任务核心递进
</span>
<ul className="text-[10px] text-slate-500 space-y-1.5 leading-snug">
<li className="flex items-start gap-1">
<span className="text-purple-500">•</span>
<span>5个核心子模块并行极速构建</span>
</li>
<li className="flex items-start gap-1">
<span className="text-purple-500">•</span>
<span>多智能体拓扑动态推演和收敛</span>
</li>
<li className="flex items-start gap-1">
<span className="text-purple-500">•</span>
<span>自习惯里程碑路径重排与分配</span>
</li>
</ul>
</div>
<div className="border-t border-slate-150 pt-3 mt-4 flex items-center justify-between text-[9px]">
<span className="text-slate-400 font-bold">交付物件:</span>
<span className="text-slate-700 bg-slate-200/50 px-1.5 py-0.5 rounded font-bold font-sans">《容器级物理架构拓扑》</span>
</div>
</div>
{/* Phase 3 card */}
<div className="bg-slate-50 border border-slate-150 rounded-2xl p-4 flex flex-col justify-between hover:border-emerald-200 transition relative">
<div className="absolute top-2.5 right-3 px-2 py-0.5 rounded-full bg-emerald-50 text-emerald-600 border border-emerald-100 font-mono font-bold text-[9px]">
60 - 72h
</div>
<div>
<span className="font-black text-[11.5px] tracking-tight block mb-2 text-emerald-650">
阶段三:极端风险安全收敛
</span>
<ul className="text-[10px] text-slate-500 space-y-1.5 leading-snug">
<li className="flex items-start gap-1">
<span className="text-emerald-500">•</span>
<span>一键拉闸灾灾备崩溃性沙盒论证</span>
</li>
<li className="flex items-start gap-1">
<span className="text-emerald-500">•</span>
<span>安全审核拦截与物理封签加签</span>
</li>
<li className="flex items-start gap-1">
<span className="text-emerald-500">•</span>
<span>打包大案报告完美交付投产运行</span>
</li>
</ul>
</div>
<div className="border-t border-slate-150 pt-3 mt-4 flex items-center justify-between text-[9px]">
<span className="text-slate-400 font-bold">交付物件:</span>
<span className="text-slate-700 bg-slate-200/50 px-1.5 py-0.5 rounded font-bold font-sans">《投产交付大白皮书》</span>
</div>
</div>
</div>
</div>
{/* Item 4 */}
<div className="space-y-1.5">
<h5 className="font-extrabold text-slate-900 flex items-center gap-1.5 text-[12px]">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-amber-50 text-amber-600 font-bold font-mono text-[10.5px]">4</span>
资源配置计划 / Budget & Resource Allocation
</h5>
<div className="pl-6.5 text-slate-500 space-y-2 leading-relaxed">
<p>
主备案并轨总体支出已精精控制在限额内(耗资约 <b className="text-slate-900 font-black">{Math.round(budgetLimit * 0.925 * 10) / 10}万元</b>,含容器、中继微灾备硬件耗销:42%,情报库与政策规则授权:23%,防崩溃校验与极端测试:17%,极客安全风控硬预留:10.5%,剩余资金安全冗余)。
</p>
<div className="flex flex-wrap gap-2 pt-1 font-bold text-[10px]">
<span className="bg-slate-100 px-2.5 py-1 rounded-lg border border-slate-150 text-slate-700">
总限额极限: <b>{budgetLimit}万元</b>
</span>
<span className="bg-[var(--brand-200)] text-[var(--brand-900)] px-2.5 py-1 rounded-lg border border-red-200">
军官与执行队伍编制: <b>28人(合规限度)</b>
</span>
<button
onClick={() => triggerToast("📅 正在基于实时72小时主过程进行甘特图渲染...")}
className="text-primary hover:underline flex items-center gap-1 cursor-pointer font-black"
>
[在线查看项目开发实施甘特图 timeline]
</button>
</div>
</div>
</div>
{/* Item 5 */}
<div className="space-y-1.5">
<h5 className="font-extrabold text-slate-900 flex items-center gap-1.5 text-[12px]">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-red-50 text-red-650 font-bold font-mono text-[10.5px]">5</span>
风险识别与安全应对 / Secondary Risk Management
</h5>
<p className="pl-6.5 text-slate-500 leading-normal">
通过多智能体委员会滚动博弈对抗和风险拦截验证,提前判定并锁定「需求过载细节波动」、「物理接机到货故障」、「通信源不完整滞缓」等 3 项主要隐忧。全部预案、补偿基金及冗余措施已完成,详见右侧面板。
</p>
</div>
{/* Item 6 */}
<div className="space-y-1.5">
<h5 className="font-extrabold text-slate-900 flex items-center gap-1.5 text-[12px]">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-emerald-50 text-emerald-650 font-bold font-mono text-[10.5px]">6</span>
关键里程碑控制链与下一步工作 / Execution Advice
</h5>
<div className="pl-6.5 text-slate-500 space-y-2 mt-2 leading-relaxed">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 text-[9.5px] font-medium">
<div className="p-2 bg-slate-50 border border-slate-150 rounded-xl flex items-start gap-2">
<span className="h-4.5 w-4.5 rounded bg-[var(--brand-200)]0 text-white flex items-center justify-center shrink-0 font-bold font-mono text-[9px]">M1</span>
<div>
<b className="text-slate-800 block text-[10.5px]">【T+12h 状态核验】</b>
<span>拉网对齐所有缺失要素,未达标熔断顺延</span>
</div> </div>
</div> ) : seat.error ? (
<div className="px-3 py-3 text-[11px] text-rose-600">
<div className="p-2 bg-slate-50 border border-slate-150 rounded-xl flex items-start gap-2"> 读取失败:{seat.error}
<span className="h-4.5 w-4.5 rounded bg-purple-500 text-white flex items-center justify-center shrink-0 font-bold font-mono text-[9px]">M2</span>
<div>
<b className="text-slate-800 block text-[10.5px]">【T+24h 双活就绪】</b>
<span>物理容器拉通,注入最极限崩溃测试演习</span>
</div> </div>
</div> ) : seat.files.length === 0 ? (
<div className="px-3 py-3 text-[11px] text-slate-400 italic">
<div className="p-2 bg-slate-50 border border-slate-150 rounded-xl flex items-start gap-2"> 该席位未产生输出文件
<span className="h-4.5 w-4.5 rounded bg-emerald-500 text-white flex items-center justify-center shrink-0 font-bold font-mono text-[9px]">M3</span>
<div>
<b className="text-slate-800 block text-[10.5px]">【T+60h 安全审计】</b>
<span>突发拔网接管成功,获得整装绿码授权</span>
</div> </div>
</div> ) : (
<ul className="divide-y divide-slate-100">
{seat.files.map((f) => (
<li
key={f.path}
className="px-3 py-2 flex items-center justify-between gap-2 hover:bg-slate-50/60"
>
<div className="flex items-center gap-2 min-w-0">
<FileText className="w-3.5 h-3.5 text-slate-400 shrink-0" />
<span className="text-[11.5px] font-medium text-slate-800 truncate" title={f.name}>
{f.name}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] font-mono text-slate-400">
{formatBytes(f.sizeBytes)}
</span>
<button
type="button"
onClick={() => handlePreview(seat.threadId, f)}
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
title="预览"
>
<Eye className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleDownload(seat.threadId, f)}
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
title="下载"
>
<Download className="w-3.5 h-3.5" />
</button>
</div>
</li>
))}
</ul>
)}
</div> </div>
<p className="text-[10.5px] text-slate-700 bg-amber-50/60 p-2.5 rounded-lg border border-amber-100 leading-normal font-medium mt-1.5"> ))}
💡 <b>行动意见:</b> 指挥官裁可此成果后,项目立即固化加锁。每日安排每12小时高维配合对齐站会,强力扣死这 3 颗 M-Class 螺丝钉。
</p>
</div>
</div> </div>
)}
</section>
</div> {/* Footer */}
</div>
{/* Section III: 论证依据支撑库 (Supporting Evidence Pieces) */}
<div className="bg-white border border-slate-150 rounded-2xl p-5 hover:shadow-md transition space-y-4">
<div className="flex items-center gap-2 border-b border-slate-100 pb-3 mb-1">
<Clipboard className="w-4.5 h-4.5 text-primary" strokeWidth={2.5} />
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest">
二、论证依据要件归档情况
</h4>
</div>
<p className="text-xs text-slate-400 font-medium">
本报告方案具有无可挑剔的底层输入支撑。点击各要件名称,可通过云控制台查看对应沙盒隔离归档详情:
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3 pt-1 text-xs">
{[
{ 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) => (
<div
key={index}
onClick={() => 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}`}
>
<div>
<b className="font-mono text-[9px] block mb-1 opacity-70">FILE-{doc.num}</b>
<span className="font-bold tracking-tight text-[11px] leading-snug block line-clamp-2 min-h-[32px]">
{doc.name}
</span>
</div>
<div className="flex items-center justify-between text-[9px] opacity-75 mt-3 border-t border-slate-200/50 pt-1.5 font-mono">
<span>附件档案</span>
<span>{doc.size}</span>
</div>
</div>
))}
</div>
</div>
{/* Section IV: 采纳采定理由陈述 (Final Ruling Statement / Stamp Board) */}
<div className="bg-gradient-to-r from-amber-50/40 via-yellow-50/30 to-amber-50/20 border border-amber-200/70 rounded-2xl p-5 hover:shadow-md transition">
<div className="flex items-center gap-2 mb-2.5">
<Star className="w-4.5 h-4.5 text-amber-500 fill-amber-400" />
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest">
三、采纳理由与最终裁定判词
</h4>
</div>
<div className="text-xs text-slate-800 leading-relaxed font-semibold">
<p>
经由专家委员会团队大模型多视角滚动共计 <b className="text-slate-900 font-extrabold">12 轮</b> 极限对抗研商、冲突演炼得出:在「工期匹配度」(92%)、「预算开支控制度」(90%)、「极端容灾抗体余温」(88%) 与「总价值安全收益率」(91%) 四维雷达对决合围精算测定中,本主案 A 指标总合分最高(<b>90.3分</b>)。相比其他预算爆限、对外部相关配合条件依赖极重的僵硬路线,本规划方案在限时给定的 {budgetLimit}万元 条件内,取得了最高确凿的可执行概率。特此裁决:<b>给予最高优先级采纳,盖印发布!</b>
</p>
</div>
</div>
{/* Footer Bottom Actions Box */}
<div className="pt-2 border-t border-slate-100 flex items-center justify-between flex-wrap gap-4"> <div className="pt-2 border-t border-slate-100 flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-1 text-slate-400 text-xs font-medium"> <span className="text-xs text-slate-400 font-medium">
<svg className="w-4 h-4 text-slate-400 animate-pulse shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5"> 如需重新立项,可清空当前研讨,回到任务理解阶段重新起草。
<circle cx="12" cy="12" r="10" /> </span>
<path d="M12 6v6l4 2" /> <button
</svg> type="button"
<span className="line-clamp-1">全案终审已经盖印下发。若再次起草新路线会商,可点左侧清归。</span> onClick={onReset}
</div> className="border border-slate-700 text-slate-700 hover:text-slate-900 hover:bg-slate-50 text-[10.5px] font-bold px-4 py-3 rounded-2xl transition cursor-pointer select-none"
>
<div className="flex items-center gap-2 w-full md:w-auto"> 重新起草新规划
<button </button>
id="step3-reset-btn"
onClick={() => {
setCurrentStep(1);
setStep1MessageHistory([
{
id: 1,
sender: "理解专家 (Gemini)",
avatarType: "blue",
content: "您好!我是多智能体理解与转化专家。请问我们本次需要进行什么业务路线图的顶层研商?(您可以选择下方预设模板一键录入任务环境)",
time: "10:00:00"
}
]);
setStep2RoundtableDialogues([
{
id: 1,
sender: "总结陈述智能体 (Gemini 2.5 Pro)",
avatarType: "blue",
content: "各位专家好,本论证轮主要针对“任务目标72小时极速打通投产”及“总预算在500万内开销”做可行度分析与冗余设计研商。请各抒己见。",
time: "10:01:05",
confidence: "主席初始化"
}
]);
triggerToast("✨ 指导系统成功完成归档,重新开始新一轮规划会商");
}}
className="flex-1 md:flex-none border border-slate-700 text-slate-700 hover:text-slate-900 hover:bg-slate-50 text-[10.5px] font-bold px-4 py-3 rounded-2xl transition cursor-pointer select-none font-sans"
>
重新起草新规划
</button>
<button
id="step3-sign-btn"
onClick={() => {
triggerToast("🚀 [最高指令授权签批]:协同全案已加签封存下发!核心路由组和灾灾备机制已就绪保障投产!");
}}
className="flex-1 md:flex-none bg-primary hover:bg-[var(--brand-900)] text-white text-[10.5px] font-black px-5 py-3 rounded-2xl shadow-lg shadow-primary/20 active:scale-95 transition cursor-pointer select-none flex items-center gap-1.5 justify-center"
>
<Sparkles className="w-3.5 h-3.5 text-red-200" />
签发起草成果并投产运行
</button>
</div>
</div> </div>
</div> </div>
{previewing && (
<ArtifactPreviewModal
threadId={previewing.threadId}
path={previewing.file.path}
name={previewing.file.name}
mimeType={previewing.file.mimeType}
onClose={() => setPreviewing(null)}
/>
)}
</div> </div>
); );
}; };
@@ -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<ScrollToBottomButtonProps> = ({
onClick,
label = "回到底部",
className = "",
}) => (
<button
type="button"
onClick={onClick}
className={`absolute left-1/2 -translate-x-1/2 bottom-3 z-10 bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-[11px] font-bold px-3 py-1.5 rounded-full shadow-lg shadow-slate-900/10 flex items-center gap-1 transition active:scale-95 ${className}`}
aria-label={label}
>
<ChevronDown className="w-3.5 h-3.5" />
{label}
</button>
);
@@ -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<T extends HTMLElement>(
ref: RefObject<T | null>,
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 };
}
File diff suppressed because it is too large. Load diff
@@ -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<DraftRecord, "createdAt" | "updatedAt" | "id"> & { 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<string, unknown>;
if (obj.sender === "user" && typeof obj.content === "string" && obj.content.trim()) {
return truncate(obj.content.trim());
}
}
}
return `未命名草稿 ${new Date().toLocaleString("zh-CN", { hour12: false })}`;
}
@@ -32,6 +32,7 @@ from app.gateway.routers import (
auth, auth,
channels, channels,
feedback, feedback,
intent,
llm_metrics, llm_metrics,
mcp, mcp,
memory, memory,
@@ -864,6 +865,12 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Multi-agent orchestration (coordinator + sub-agent broadcast/dispatch) # Multi-agent orchestration (coordinator + sub-agent broadcast/dispatch)
app.include_router(multi_agent.router) app.include_router(multi_agent.router)
# Task-intent agent (Step 1 of the roundtable flow): single built-in
# agent runs a 2-5 turn clarification dialogue, then hands a structured
# {objective, constraints, assumptions} summary off to the multi-agent
# roundtable as its initial intent.
app.include_router(intent.router)
@app.get("/health", tags=["health"]) @app.get("/health", tags=["health"])
async def health_check() -> dict: async def health_check() -> dict:
"""Health check endpoint. """Health check endpoint.
@@ -1,6 +1,7 @@
import logging import logging
import mimetypes import mimetypes
import zipfile import zipfile
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
@@ -10,6 +11,8 @@ from pydantic import BaseModel
from app.gateway.authz import require_permission from app.gateway.authz import require_permission
from app.gateway.path_utils import resolve_thread_virtual_path 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__) logger = logging.getLogger(__name__)
@@ -94,6 +97,61 @@ def _extract_file_from_skill_archive(zip_path: Path, internal_path: str) -> byte
return None 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( @router.get(
"/threads/{thread_id}/artifacts/{path:path}", "/threads/{thread_id}/artifacts/{path:path}",
summary="Get Artifact File", summary="Get Artifact File",
@@ -0,0 +1,200 @@
"""Task-intent agent endpoints (Step 1 of the roundtable flow).
Lightweight wrapper around the lead-agent runtime that runs a single built-in
agent (``roundtable-intent``) on its own thread. The agent's job is to do 2-5
clarifying turns with the user and then emit a structured
``{objective, constraints, assumptions}`` summary the frontend hands off to
the multi-agent roundtable as its initial intent.
Same SSE shape as ``multi_agent.py`` (transparent passthrough + appended
status frame), so the frontend can reuse the same reader pattern.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from typing import Any, AsyncIterator
import httpx
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse
# Reuse the building blocks already in multi_agent.py — keeps both routers
# identical in connection handling, payload shape, and error mapping.
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,
_run_payload,
_stream_upstream,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/intent", tags=["intent"])
INTENT_AGENT_ID = "roundtable-intent"
# Matches the SOUL-mandated handoff signal: a [INTENT_READY] line followed by
# a ```json ... ``` block. The model is told to emit this only when it judges
# the user's task is fully specified.
_INTENT_READY_PATTERN = re.compile(
r"\[INTENT_READY\]\s*```json\s*(\{.*?\})\s*```",
re.DOTALL,
)
@router.post("/init")
async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str, Any]:
"""Create a fresh thread for an intent-clarification session.
Returns the stable built-in ``agent_id`` so the frontend doesn't need to
know it. Unlike multi-agent ``/init``, no coordinator is built — the
intent agent is long-lived and already registered via the legacy
agent-sync path on disk.
"""
base = _loopback_base(request)
headers = _auth_headers(request)
# 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",
"agent_id": INTENT_AGENT_ID,
"thread_id": thread_id,
}
@router.post("/stream")
async def stream_intent(request: Request, payload: dict = Body(...)) -> StreamingResponse:
"""Drive one turn of the intent dialogue via SSE.
Body: ``{thread_id, message, model?}``. Streams upstream LangGraph frames
through transparently, then appends one status frame:
{
"status": "asking" | "done" | "error",
"content": "<final AI text>",
"summary": {...} | null // only present when status == "done"
}
"""
thread_id = str(payload.get("thread_id") or "").strip()
message = str(payload.get("message") or "").strip()
model_name = str(payload.get("model") or "deepseek-chat")
if not thread_id:
raise HTTPException(status_code=400, detail="thread_id is required")
if not message:
raise HTTPException(status_code=400, detail="message is required")
base = _loopback_base(request)
headers = _auth_headers(request)
async def stream_generator() -> AsyncIterator[str]:
# 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,
model_name=model_name,
thread_id=thread_id,
new_message=message,
# Intent agent shouldn't dispatch, search, write files, or
# view images — its only tool of note is ask_clarification.
excluded_tools=[
"web_search",
"present_files",
"view_image",
"agent_orchestration",
],
skill_stop_names=[],
)
captured: list[str] = []
async for line, done in _stream_upstream(client, base, headers, thread_id, body):
if line is not None:
yield line if line.endswith("\n") else line + "\n"
elif done is not None:
captured = done
messages = _parse_last_messages(captured)
# 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)
if match:
try:
summary = json.loads(match.group(1))
except json.JSONDecodeError as exc:
logger.warning("intent summary JSON parse failed: %s", exc)
summary = None
status_value = "done" if summary else "asking"
final_frame: dict[str, Any] = {
"status": status_value,
"content": content,
"summary": summary,
}
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
except HTTPException as exc:
logger.warning("intent stream HTTPException: %s %s", exc.status_code, exc.detail)
err = {"status": "error", "content": f"[{exc.status_code}] {exc.detail}", "summary": None}
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
except Exception as exc:
logger.exception("intent stream unexpected failure")
err = {"status": "error", "content": f"unexpected: {exc!r}", "summary": None}
yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n"
return StreamingResponse(
stream_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
@@ -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" return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
async def stream_generator() -> AsyncIterator[str]: 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: try:
# Per-seat prepare task — same body as before, but we no longer # Per-seat prepare task — same body as before, but we no longer
# await them with asyncio.gather. Instead, asyncio.as_completed # 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) headers = _auth_headers(request)
async def stream_generator() -> AsyncIterator[str]: 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: try:
if agent_type == "leader": if agent_type == "leader":
gen = _leader_run( gen = _leader_run(