多智能问答
This commit is contained in:
1 parent
77db1a6297
commit
05815d9475
11 files changed
+2045
-727
No files matched your search
@@ -47,6 +47,15 @@ export interface IntentSummary {
|
||||
assumptions?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Same phases as streamMultiAgent so the UI can reuse one phase-label mapper.
|
||||
* `connecting` ≈ HTTP sent, no frames yet; `connected` ≈ got `event: metadata`;
|
||||
* `streaming` ≈ first AI text chunk; `tool_calling` ≈ ask_clarification or
|
||||
* similar tool call (which usually means the run is about to interrupt and
|
||||
* the user-visible text won't grow further this turn).
|
||||
*/
|
||||
export type IntentStreamPhase = "connecting" | "connected" | "streaming" | "tool_calling";
|
||||
|
||||
export interface StreamIntentRequest {
|
||||
threadId: string;
|
||||
message: string;
|
||||
@@ -54,6 +63,13 @@ export interface StreamIntentRequest {
|
||||
signal?: AbortSignal;
|
||||
/** Fires for every AI text chunk parsed out of LangGraph messages-tuple frames. */
|
||||
onTextDelta?: (text: string, messageId: string) => void;
|
||||
/**
|
||||
* Fires whenever the inferred SSE phase changes. The UI uses this to flip
|
||||
* the placeholder bubble's caption from "调度中" → "已连接,等待响应" →
|
||||
* "正在输出" so users see what's happening during the inevitable LLM
|
||||
* first-token wait.
|
||||
*/
|
||||
onPhaseChange?: (phase: IntentStreamPhase, toolName?: string) => void;
|
||||
}
|
||||
|
||||
export interface StreamIntentResult {
|
||||
@@ -139,6 +155,17 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
||||
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) {
|
||||
@@ -153,6 +180,12 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
||||
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;
|
||||
@@ -176,6 +209,37 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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 })}`;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -27,6 +27,8 @@ from fastapi.responses import StreamingResponse
|
||||
from app.gateway.routers.multi_agent import (
|
||||
_auth_headers,
|
||||
_create_thread,
|
||||
_find_dispatch_message,
|
||||
_find_trailing_ai_text,
|
||||
_flatten_content,
|
||||
_loopback_base,
|
||||
_parse_last_messages,
|
||||
@@ -59,7 +61,12 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str,
|
||||
"""
|
||||
base = _loopback_base(request)
|
||||
headers = _auth_headers(request)
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
# trust_env=False: this client only talks to the same-process gateway at
|
||||
# 127.0.0.1, so honoring HTTP_PROXY / HTTPS_PROXY env vars (set system-wide
|
||||
# on the dev machine) makes httpx route loopback calls through an upstream
|
||||
# proxy that can't reach 127.0.0.1, costing 40+ seconds of timeout per
|
||||
# request before falling back. Symptom in logs: `headers_in=42.160s`.
|
||||
async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
|
||||
thread_id = await _create_thread(client, base, headers)
|
||||
return {
|
||||
"status": "success",
|
||||
@@ -93,7 +100,11 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
headers = _auth_headers(request)
|
||||
|
||||
async def stream_generator() -> AsyncIterator[str]:
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
# trust_env=False: same-process loopback to 127.0.0.1 — honoring
|
||||
# HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines)
|
||||
# causes httpx to route loopback calls through an unreachable upstream
|
||||
# proxy and wait ~40 seconds for timeout before falling back.
|
||||
async with httpx.AsyncClient(timeout=None, trust_env=False) as client:
|
||||
try:
|
||||
body = _run_payload(
|
||||
agent_name=INTENT_AGENT_ID,
|
||||
@@ -119,16 +130,38 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
|
||||
captured = done
|
||||
|
||||
messages = _parse_last_messages(captured)
|
||||
# Find the latest AIMessage's text content — that's what the
|
||||
# frontend renders and what we scan for the handoff signal.
|
||||
last_ai: dict[str, Any] = {}
|
||||
for msg in reversed(messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"):
|
||||
last_ai = msg
|
||||
break
|
||||
content = _flatten_content(last_ai.get("content", ""))
|
||||
# Find the latest AIMessage with tool_calls (typically
|
||||
# ask_clarification) — falls back to the latest plain
|
||||
# AIMessage when the model produced no tool call.
|
||||
dispatch_msg = _find_dispatch_message(messages)
|
||||
content = _flatten_content(dispatch_msg.get("content", ""))
|
||||
tool_calls = dispatch_msg.get("tool_calls") or []
|
||||
|
||||
# Clarification branch: the agent ended its run by calling
|
||||
# ask_clarification. ClarificationMiddleware intercepts with
|
||||
# Command(goto=END) and stashes the structured question in the
|
||||
# tool_call args, so the AIMessage `content` is only the
|
||||
# preamble ("下面我用几个问题确认:") and stops there. We splice
|
||||
# the question back into the user-facing text so the frontend
|
||||
# actually shows what the agent wants to ask.
|
||||
clarification_call = next(
|
||||
(tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"),
|
||||
None,
|
||||
)
|
||||
if clarification_call is not None:
|
||||
cargs = clarification_call.get("args") or {}
|
||||
question = str(cargs.get("question") or "").strip()
|
||||
follow_up = _find_trailing_ai_text(messages, exclude_text=content)
|
||||
# Build a single content string the frontend can render
|
||||
# as-is: preamble (if any) + the actual question.
|
||||
parts: list[str] = []
|
||||
if content.strip():
|
||||
parts.append(content.strip())
|
||||
if follow_up and follow_up != question:
|
||||
parts.append(follow_up)
|
||||
if question:
|
||||
parts.append(question)
|
||||
content = "\n\n".join(parts) if parts else content
|
||||
|
||||
summary: dict[str, Any] | None = None
|
||||
match = _INTENT_READY_PATTERN.search(content)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user