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(
payload: InitMultiAgentRequest,
): 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`, {
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<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 {
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<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> = ({
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<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 (
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto text-slate-800">
{/* Output title block */}
{/* Header */}
<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 items-center gap-2">
<CheckCircle className="w-5 h-5 text-emerald-500" />
<h3 className="text-sm font-black text-slate-900 tracking-tight">
最终架构规划方案 (成果展示大屏)
</h3>
<h3 className="text-sm font-black text-slate-900 tracking-tight">最终方案展示</h3>
</div>
<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">
● 已达成共识
</span>
{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 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>
</div>
</div>
<div className="flex items-center gap-2">
<button
id="report-download-pdf-btn"
onClick={() => triggerToast("📥 架构方案白皮书 PDF 已开始准备传输...")}
<button
type="button"
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"
>
<Download className="w-3.5 h-3.5 text-slate-500" />
导出方案 PDF
<Printer className="w-3.5 h-3.5 text-slate-500" />
打印 / 另存 PDF
</button>
<button
id="report-share-btn"
onClick={() => triggerToast("🔗 方案安全访问及沙盒校验链接已复制到剪贴板!")}
<button
type="button"
onClick={() => setReloadKey((k) => k + 1)}
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>
</div>
</div>
{/* Comprehensive high fidelity report details */}
<div className="p-6 space-y-6">
{/* Dashboard Metric Overview Rows */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{/* Metric 1 */}
<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">
{budgetLimit >= 500 ? "99.4%" : budgetLimit > 400 ? "98.2%" : "96.5%"}
</span>
<span className="text-[9px] font-black text-emerald-600 bg-emerald-50 px-1.5 py-0.2 rounded-full">合规通过</span>
{/* Section 1: Coordinator consensus conclusion */}
<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">
<CheckCircle className="w-4 h-4 text-emerald-500" />
总控最终结论
</h4>
{consensusText ? (
<div className="text-xs text-slate-700 leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{consensusText}
</ReactMarkdown>
</div>
<p className="text-[9.5px] text-slate-400 mt-2 leading-tight">安全拦截红线保障 0 出圈、0 超限</p>
</div>
{/* 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 className="text-[11px] text-slate-400 italic">
暂无总控结论。返回 Step 2 完成至少一轮研讨后再查看此页。
</p>
<p className="flex items-start gap-1.5">
<span className="text-primary text-md leading-none">•</span>
<span>经多角色 AI 官群及人类指挥官深度澄清和在沙盒拓扑中演算测试,采用本规划方案,在安全拦截(合规绿码)、运行负载及高灾备级余度三大维度上均符合最苛刻的可落地标准。</span>
)}
</section>
{/* 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 className="flex items-start gap-1.5">
<span className="text-primary text-md leading-none">•</span>
<span>项目具有明确的关键里程碑控制链、主备熔断备份计划,可执行确凿性最优,推荐自批复时点起立即启动第一阶段工作。</span>
</p>
</div>
</div>
{/* Section II: 方案详细大纲 (Detailed Core Outline) */}
<div className="bg-white border border-slate-150 rounded-2xl p-5 hover:shadow-md transition space-y-6">
<div className="flex items-center gap-2 border-b border-slate-100 pb-3 mb-1">
<Layers 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>
<div className="space-y-6 text-xs text-slate-750 font-medium leading-relaxed">
{/* Item 1 */}
<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-[var(--brand-200)] text-primary font-bold font-mono text-[10.5px]">1</span>
任务背景与目标 / Target Scope
</h5>
<p className="pl-6.5 text-slate-500 leading-normal">
围绕给定预算上限(限额:{budgetLimit}万)与资源约束(人数:≤30人,时限:72小时),建立一套主备、高容错、防崩溃的多模态适应性部署方案。通过多智能体委员会多轮博弈对抗演炼,彻底击碎不确定性风险,平滑过渡并投产运行。
</p>
) : (
<div className="space-y-2">
{seatDeliveries.map((seat, idx) => {
const key = `${seat.sender}-${idx}`;
const expanded = expandedSeats.has(key);
return (
<div key={key} className="border border-slate-150 rounded-xl overflow-hidden">
<button
type="button"
onClick={() => toggleSeat(key)}
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-slate-50 hover:bg-slate-100 transition text-left"
>
<div className="flex items-center gap-2 min-w-0">
{expanded ? (
<ChevronDown className="w-3.5 h-3.5 text-slate-500 shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-slate-500 shrink-0" />
)}
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.sender}</span>
</div>
<span className="text-[10px] text-slate-400 font-mono shrink-0">
{seat.content.length} 字
</span>
</button>
{expanded && (
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{seat.content || "(空)"}
</ReactMarkdown>
</div>
)}
</div>
);
})}
</div>
)}
</section>
{/* Item 2 */}
<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-[var(--brand-200)] text-[var(--brand-900)] font-bold font-mono text-[10.5px]">2</span>
约束条件与关键假设 / Ground Hypotheses
</h5>
<div className="pl-6.5 grid grid-cols-1 md:grid-cols-2 gap-3 mt-1.5 text-slate-500">
<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>预算开销指示极限: <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>
{/* Section 3: Generated files per seat */}
<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">
<Download 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">
{totalFileCount}
</span>
</h4>
{/* Item 3 (Interactive roadmap timeline pipeline mockup) */}
{artifactsBySeat.length === 0 ? (
<p className="text-[11px] text-slate-400 italic">尚未初始化席位线程,无法列出文件。</p>
) : (
<div className="space-y-3">
<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-purple-50 text-purple-600 font-bold font-mono text-[10.5px]">3</span>
三阶段推进方案路线图 / Three-Phase Integration Roadmap
</h5>
<p className="pl-6.5 text-slate-500 pb-1">
项目设计采用分段非对称递进开发,首尾相扣,利用熔断和回滚阀门最大化减少交付风险:
</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">
阶段一:系统信息补全
{artifactsBySeat.map((seat) => (
<div key={seat.agentName} className="border border-slate-150 rounded-xl">
<div className="flex items-center justify-between gap-2 px-3 py-2 bg-slate-50 border-b border-slate-150 rounded-t-xl">
<span className="text-[11.5px] font-bold text-slate-800 truncate">{seat.displayName}</span>
<span className="text-[10px] text-slate-400 font-mono shrink-0">
{seat.loading ? "加载中…" : `${seat.files.length} 个文件`}
</span>
<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 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 */}
<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="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]">
24 - 60h
</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>
{seat.loading ? (
<div className="flex items-center gap-2 px-3 py-3 text-[11px] text-slate-500">
<Loader2 className="w-3 h-3 animate-spin text-primary" />
<span>正在查询 outputs 目录…</span>
</div>
</div>
<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-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>
) : seat.error ? (
<div className="px-3 py-3 text-[11px] text-rose-600">
读取失败:{seat.error}
</div>
</div>
<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>
) : seat.files.length === 0 ? (
<div className="px-3 py-3 text-[11px] text-slate-400 italic">
该席位未产生输出文件
</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>
<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>
)}
</section>
</div>
</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 */}
{/* Footer */}
<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">
<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" />
<path d="M12 6v6l4 2" />
</svg>
<span className="line-clamp-1">全案终审已经盖印下发。若再次起草新路线会商,可点左侧清归。</span>
</div>
<div className="flex items-center gap-2 w-full md:w-auto">
<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>
<span className="text-xs text-slate-400 font-medium">
如需重新立项,可清空当前研讨,回到任务理解阶段重新起草。
</span>
<button
type="button"
onClick={onReset}
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"
>
重新起草新规划
</button>
</div>
</div>
{previewing && (
<ArtifactPreviewModal
threadId={previewing.threadId}
path={previewing.file.path}
name={previewing.file.name}
mimeType={previewing.file.mimeType}
onClose={() => setPreviewing(null)}
/>
)}
</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,
channels,
feedback,
intent,
llm_metrics,
mcp,
memory,
@@ -864,6 +865,12 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Multi-agent orchestration (coordinator + sub-agent broadcast/dispatch)
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"])
async def health_check() -> dict:
"""Health check endpoint.
@@ -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",
@@ -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"
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(