From 21254a060a156ff18f520729a04d9e46701ce9a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sat, 23 May 2026 23:36:41 +0800 Subject: [PATCH 01/30] =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E8=81=94=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/roundtable-planning/api/intent.ts | 186 ++++++ .../pages/RoundtablePlanningPage.tsx | 557 +++++++++++------- .../backend/app/gateway/app.py | 7 + .../backend/app/gateway/routers/intent.py | 167 ++++++ 4 files changed, 698 insertions(+), 219 deletions(-) create mode 100644 frontend-web/src/roundtable-planning/api/intent.ts create mode 100644 offline-backend-20260512/backend/app/gateway/routers/intent.py diff --git a/frontend-web/src/roundtable-planning/api/intent.ts b/frontend-web/src/roundtable-planning/api/intent.ts new file mode 100644 index 00000000..8bb76c18 --- /dev/null +++ b/frontend-web/src/roundtable-planning/api/intent.ts @@ -0,0 +1,186 @@ +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 { + 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[]; +} + +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; +} + +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; + 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; + 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 { + 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 = ""; + + 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(); + 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") { + const delta = extractAiTextDelta(parsed); + if (delta) req.onTextDelta?.(delta.text, delta.id); + } + } + } + + return result; +} diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 7ef56684..6dedc67e 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -13,6 +13,7 @@ import { type StreamPhase, type ThreadIdMap, } from "@/roundtable-planning/api/multi-agent"; +import { initIntent, streamIntent, type IntentSummary } from "@/roundtable-planning/api/intent"; import { Shield, MessageSquare, @@ -305,6 +306,17 @@ export default function RoundtablePlanningPage() { // Collapsing gives the conversation log more vertical space. const [isUpperPanelCollapsed, setIsUpperPanelCollapsed] = useState(false); + // Step 1 intent-clarification state (lives only for one step-1 visit). + // The thread is created lazily on first entry; intentReady is the + // structured summary returned by the intent agent when it judges the + // task is fully specified — Step 2's initial leader prompt is built + // from this. + const [intentThreadId, setIntentThreadId] = useState(null); + const [intentReady, setIntentReady] = useState(null); + const [intentInitError, setIntentInitError] = useState(null); + const [isIntentSending, setIsIntentSending] = useState(false); + const step1InitRef = useRef(false); + // Multi-agent orchestration state (lives only for one step-2 visit). const [threadIds, setThreadIds] = useState(null); const [coordinatorName, setCoordinatorName] = useState(null); @@ -514,14 +526,32 @@ export default function RoundtablePlanningPage() { ); }, []); - // Pull the consolidated user intent out of step-1 history. Falls back to a - // generic prompt if the user never spoke. + // Build the Step 2 leader's initial prompt. Prefer the intent agent's + // structured summary when available — it already encodes objective + + // constraints + assumptions. Fall back to "last user message + budget" + // for sessions that bypassed Step 1 or hit an init error. const buildInitialIntent = useCallback(() => { + if (intentReady) { + const parts: string[] = [intentReady.objective]; + if (intentReady.constraints?.length) { + parts.push(""); + parts.push("约束条件:"); + for (const c of intentReady.constraints) parts.push(`- ${c}`); + } + if (intentReady.assumptions?.length) { + parts.push(""); + parts.push("关键假设:"); + for (const a of intentReady.assumptions) parts.push(`- ${a}`); + } + parts.push(""); + parts.push("用户已经过任务理解智能体澄清,以上信息可以直接作为研讨依据。请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨,不要再反问用户。"); + return parts.join("\n"); + } const lastUserMessage = [...interactiveMessages].reverse().find(m => m.sender === "user"); const userIntent = lastUserMessage?.content || "用户尚未给出明确意图"; const constraints = `预算硬约束:${budgetLimit} 万元`; return `${userIntent}\n\n约束条件:${constraints}\n\n请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨。`; - }, [interactiveMessages, budgetLimit]); + }, [interactiveMessages, budgetLimit, intentReady]); // Run one full orchestration step: 1) leader call → 2) dispatch specials in // parallel → 3) recurse with a synthesis prompt until leader stops dispatching. @@ -868,49 +898,111 @@ export default function RoundtablePlanningPage() { setShowToast(msg); }; - // Chat sender logic for Step 1 interaction - const handleSendMessage = () => { - if (!inputText.trim()) return; + // Chat sender logic for Step 1 interaction — drives the real intent agent + // via /api/intent/stream. The agent streams its reply into a placeholder + // bubble; if its final status is "done" we capture the structured summary + // for the Step 2 handoff. + const handleSendMessage = async () => { + const userText = inputText.trim(); + if (!userText) return; + if (!intentThreadId) { + triggerToast("⚠️ 任务理解智能体尚未就绪,请稍候"); + return; + } + if (isIntentSending) { + triggerToast("⏳ 智能体正在回复,请稍候"); + return; + } - const userMsgTime = new Date().toLocaleTimeString("zh-CN", { hour12: false }); + const userMsgTime = nowTime(); + const userMsgId = Date.now() + Math.random(); const userMsg = { - id: interactiveMessages.length + 1, + id: userMsgId, sender: "user", senderName: "用户", - content: inputText, + content: userText, time: userMsgTime, }; - - // Append user message immediately - const updatedMessages = [...interactiveMessages, userMsg]; - setInteractiveMessages(updatedMessages); + setInteractiveMessages(prev => [...prev, userMsg]); setInputText(""); setCharactersLeft(0); - // Update Clarification timeline list on the right beautifully! - const newTimelineEntry = { - id: timelineEntries.length + 1, - time: userMsgTime, - title: "用户实时反馈", - desc: userMsg.content.length > 25 ? userMsg.content.substring(0, 25) + "..." : userMsg.content - }; - setTimelineEntries(prev => [...prev, newTimelineEntry]); + // Right-side timeline gets a short summary too. + setTimelineEntries(prev => [ + ...prev, + { + id: prev.length + 1, + time: userMsgTime, + title: "用户实时反馈", + desc: userText.length > 25 ? userText.substring(0, 25) + "..." : userText, + }, + ]); - triggerToast("消息发送成功!任务意图分析中..."); - - // Simulated responsive intelligence robot typing back - setTimeout(() => { - const responseTime = new Date().toLocaleTimeString("zh-CN", { hour12: false }); - const automatedReplyLine = { - id: updatedMessages.length + 2, + // Placeholder agent bubble — text deltas accumulate into it. + const agentMsgId = Date.now() + Math.random() + 0.01; + setInteractiveMessages(prev => [ + ...prev, + { + id: agentMsgId, sender: "agent", senderName: "任务理解智能体", - content: `已收到关于“${userMsg.content}”的更新!我已自动校对预算配比与合规规范。当前规划框架可信度提升 4.2%。请您继续审视下方待确认事项,或随时确认以进入多智能体圆桌研讨。`, - time: responseTime, - }; - setInteractiveMessages(prev => [...prev, automatedReplyLine]); - triggerToast("任务理解智能体已回复!"); - }, 1500); + content: "", + time: nowTime(), + streaming: true, + } as any, + ]); + + setIsIntentSending(true); + try { + const res = await streamIntent({ + threadId: intentThreadId, + message: userText, + model: DEFAULT_MODEL, + onTextDelta: (delta) => { + setInteractiveMessages(prev => + prev.map(m => (m.id === agentMsgId ? { ...m, content: (m.content ?? "") + delta } : m)), + ); + }, + }); + // Finalize the bubble with the authoritative final text from the + // status frame (covers cases where deltas didn't accumulate cleanly). + setInteractiveMessages(prev => + prev.map(m => + m.id === agentMsgId + ? { ...m, content: res.content || m.content || "(无文本输出)", streaming: false } as any + : m, + ), + ); + if (res.status === "done" && res.summary) { + setIntentReady(res.summary); + setTimelineEntries(prev => [ + ...prev, + { + id: prev.length + 1, + time: nowTime(), + title: "任务意图已明确", + desc: res.summary!.objective.length > 25 + ? res.summary!.objective.substring(0, 25) + "..." + : res.summary!.objective, + }, + ]); + triggerToast("✅ 任务理解完成,可进入多智能体圆桌研讨"); + } else if (res.status === "error") { + triggerToast(`❌ 智能体调用失败: ${res.content}`); + } + } catch (err) { + console.error("intent stream failed:", err); + setInteractiveMessages(prev => + prev.map(m => + m.id === agentMsgId + ? { ...m, content: `(错误) ${(err as Error).message}`, streaming: false } as any + : m, + ), + ); + triggerToast(`❌ 发送失败: ${(err as Error).message}`); + } finally { + setIsIntentSending(false); + } }; // Skip step logic @@ -939,20 +1031,88 @@ export default function RoundtablePlanningPage() { triggerToast("追问生成就绪!"); }; - // Step 2 role statuses transition when entering roundtable! - // On Step 2 entry we set every agent seat to "正在上线" (spinner state); - // the /init SSE stream then flips each one to "在线" as its thread/agent - // actually finishes on the backend. Seat 1 (human commander) stays online - // throughout — it's not part of the bring-up. + // Step 1 init effect: create a fresh intent-agent thread the first time + // the user lands on step 1 (and on every re-entry, since clearing thread + // history is desirable here — each new planning session starts clean). + // + // Note on React 18 StrictMode: effects run twice in dev. We reset + // `step1InitRef.current` to false in the cleanup so the second mount can + // actually run init — otherwise the ref stays true, the second effect + // returns early, and the first effect's `cancelled` flag prevents its + // result from setting state. Net effect would be a successful API call + // whose result never lands and the Send button stays disabled. + useEffect(() => { + if (currentStep !== 1) { + step1InitRef.current = false; + return; + } + if (step1InitRef.current) return; + step1InitRef.current = true; + + let cancelled = false; + (async () => { + setIntentReady(null); + setIntentInitError(null); + setIntentThreadId(null); + setTimelineEntries([]); + setInteractiveMessages([ + { + id: 1, + sender: "agent", + senderName: "任务理解智能体", + content: "你好,我是任务理解智能体。请用 1-2 句话告诉我你这次想做什么,以及有哪些预算/时间/合规上的硬约束?", + time: nowTime(), + }, + ]); + try { + const res = await initIntent(DEFAULT_MODEL); + if (cancelled) return; + setIntentThreadId(res.thread_id); + setTimelineEntries([ + { + id: 1, + time: nowTime(), + title: "任务理解智能体已就绪", + desc: `agent_id=${res.agent_id}`, + }, + ]); + } catch (err) { + if (cancelled) return; + console.error("init intent failed:", err); + setIntentInitError((err as Error).message); + } + })(); + return () => { + cancelled = true; + step1InitRef.current = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentStep]); + + // Role panel transitions: + // - Step 1 shows ONLY the commander + the task-intent agent (2 entries). + // The intent agent flips to 在线 once /api/intent/init returns; if it + // errors we mark 异常 so the user knows why send is disabled. + // - Step 2 lights up all 7 seats (each flipped 在线 as init SSE reports). + // - Step 3 marks the whole roster as 已完成. useEffect(() => { if (currentStep === 1) { - setRoles(makeRoles("等待激活", { 2: "在线" })); + setRoles([ + { id: 1, name: "指挥员 / 主持人", type: "human", status: "在线" }, + { + id: 100, + name: "任务理解", + agentId: "roundtable-intent", + type: "agent", + status: intentInitError ? "异常" : intentThreadId ? "在线" : "正在上线", + }, + ]); } else if (currentStep === 2) { setRoles(makeRoles("正在上线", { 1: "在线" })); } else { setRoles(makeRoles("已完成")); } - }, [currentStep]); + }, [currentStep, intentThreadId, intentInitError]); // Inject user input to the roundtable room — sends it as a new leader call. const handleInjectToRoundtable = () => { @@ -1426,157 +1586,95 @@ export default function RoundtablePlanningPage() { 任务意图理解 (人机问答) + {/* Real turn counter — counts user messages in the live + interactiveMessages list. "X 轮" instead of "X/3" because + we don't bound the number of clarification turns. */} - 问答轮次:3/3 + 问答轮次:{interactiveMessages.filter(m => m.sender === "user").length} 轮 {/* Chat list viewport */}
- {/* Message 1 (User / 用户) */} -
-
- -
-
- 用户 + {/* Step 1 dialogue is rendered by `interactiveMessages.map()` + inside the 问答交互区 sub-block below. The static demo + bubbles that previously lived here have been removed. */} + + {/* --- INTENT SUMMARY BOX --- emerald (ready) / slate (waiting) --- + Rendered from `intentReady` when the agent has emitted a + structured summary; otherwise shows a placeholder with + a disabled "进入下一步" button so the user knows what they + need to wait for. */} + {intentReady ? ( +
+
+
+ + + 已形成明确任务意图 +
-

- 请帮我围绕某复杂任务制定详细规划方案。 -

+
-
- 10:32:15 -
- {/* Message 2 (Agent) */} -
-
- -
-
- 任务理解智能体 +
+
+ 任务目标 +

{intentReady.objective}

-

- 请明确目标、时间要求与资源限制。 -

-
-
- 10:32:22 -
- - {/* Message 3 (User / 用户) */} -
-
- -
-
- 用户 +
+ 硬约束 +
    + {intentReady.constraints.map((c, i) => ( +
  • {c}
  • + ))} +
-

- 72小时内完成,可用预算500万,人员30人以内。 -

+ {intentReady.assumptions && intentReady.assumptions.length > 0 && ( +
+ 关键假设 +
    + {intentReady.assumptions.map((a, i) => ( +
  • {a}
  • + ))} +
+
+ )}
- 10:33:02 -
- - {/* Message 4 (Agent) */} -
-
- -
-
- 任务理解智能体 + ) : ( +
+
+
+ + + 等待任务理解智能体澄清完成... +
-

- 是否优先保证主方案可执行性,并同时给出保守备选方案? + +

+ {intentInitError && ( +

+ 智能体初始化失败: {intentInitError}

-
+ )}
- 10:33:15 -
- - {/* Message 5 (User / 用户) */} -
-
- -
-
- 用户 -
-

- 是,请保留主方案并给出备选方案。 -

-
-
- 10:33:36 -
- - {/* Message 6 (Agent) */} -
-
- -
-
- 任务理解智能体 -
-

- 已基本形成任务意图,请确认。 -

-
-
- 10:33:52 -
- - {/* --- GREEN INTENT BOX (1-to-1) --- */} -
- {/* Top row */} -
-
- - - 已形成明确任务意图 - -
- - -
- - {/* Structured properties in columns */} -
-
- 目标 -

制定可执行的
详细规划方案

-
-
- 时间边界 -

72小时内完成

-
-
- 资源约束 -

预算 ≤ 500万,
人员 ≤ 30人

-
-
- 优先级 -

主方案可执行性优先,
兼顾备选方案

-
-
- 待确认事项 -

备选方案范围、
预算触发条件

-
-
-
+ )} {/* --- Q&A INTERACTION AREA (问答交互区) --- */}
@@ -1594,26 +1692,41 @@ export default function RoundtablePlanningPage() { {/* Content dialogue lines inside interaction */}
- {interactiveMessages.map((msg) => ( -
-
- {msg.sender === "user" ? ( - - ) : ( - - )} -
- - {msg.senderName} - - - {msg.content} - + {interactiveMessages.map((msg) => { + const isStreaming = (msg as { streaming?: boolean }).streaming === true; + const hasContent = (msg.content ?? "").length > 0; + return ( +
+
+ {msg.sender === "user" ? ( + + ) : ( + + )} +
+ + {msg.senderName} + + {/* Pre-text spinner: shown only when streaming AND no content has arrived. */} + {isStreaming && !hasContent ? ( + + + 正在思考... + + ) : ( + + {msg.content} + {isStreaming && ( + + )} + + )} +
+ {msg.time}
- {msg.time} -
- ))} + ); + })}
@@ -1662,13 +1775,14 @@ export default function RoundtablePlanningPage() { {/* Left row buttons */}
-
diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index e1e10155..9678a42d 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -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. diff --git a/offline-backend-20260512/backend/app/gateway/routers/intent.py b/offline-backend-20260512/backend/app/gateway/routers/intent.py new file mode 100644 index 00000000..3c9c4fb3 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/intent.py @@ -0,0 +1,167 @@ +"""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, + _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) + async with httpx.AsyncClient(timeout=None) 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": "", + "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]: + async with httpx.AsyncClient(timeout=None) 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'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", "")) + + 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", + }, + ) From 05815d94757938851a8154814c417eeb47a5cf75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sun, 24 May 2026 21:13:19 +0800 Subject: [PATCH 02/30] =?UTF-8?q?=E5=A4=9A=E6=99=BA=E8=83=BD=E9=97=AE?= =?UTF-8?q?=E7=AD=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/roundtable-planning/api/intent.ts | 64 ++ .../roundtable-planning/api/multi-agent.ts | 212 ++-- .../components/ArtifactPreviewModal.tsx | 184 +++ .../components/HighFidelityReport.tsx | 869 +++++++------- .../components/ScrollToBottomButton.tsx | 35 + .../hooks/useAutoScroll.ts | 74 ++ .../pages/RoundtablePlanningPage.tsx | 1012 ++++++++++++++--- .../src/roundtable-planning/utils/drafts.ts | 195 ++++ .../backend/app/gateway/routers/artifacts.py | 58 + .../backend/app/gateway/routers/intent.py | 57 +- .../app/gateway/routers/multi_agent.py | 12 +- 11 files changed, 2045 insertions(+), 727 deletions(-) create mode 100644 frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx create mode 100644 frontend-web/src/roundtable-planning/components/ScrollToBottomButton.tsx create mode 100644 frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts create mode 100644 frontend-web/src/roundtable-planning/utils/drafts.ts diff --git a/frontend-web/src/roundtable-planning/api/intent.ts b/frontend-web/src/roundtable-planning/api/intent.ts index 8bb76c18..481b9d00 100644 --- a/frontend-web/src/roundtable-planning/api/intent.ts +++ b/frontend-web/src/roundtable-planning/api/intent.ts @@ -47,6 +47,15 @@ export interface IntentSummary { assumptions?: string[]; } +/** + * Same phases as streamMultiAgent so the UI can reuse one phase-label mapper. + * `connecting` ≈ HTTP sent, no frames yet; `connected` ≈ got `event: metadata`; + * `streaming` ≈ first AI text chunk; `tool_calling` ≈ ask_clarification or + * similar tool call (which usually means the run is about to interrupt and + * the user-visible text won't grow further this turn). + */ +export type IntentStreamPhase = "connecting" | "connected" | "streaming" | "tool_calling"; + export interface StreamIntentRequest { threadId: string; message: string; @@ -54,6 +63,13 @@ export interface StreamIntentRequest { signal?: AbortSignal; /** Fires for every AI text chunk parsed out of LangGraph messages-tuple frames. */ onTextDelta?: (text: string, messageId: string) => void; + /** + * Fires whenever the inferred SSE phase changes. The UI uses this to flip + * the placeholder bubble's caption from "调度中" → "已连接,等待响应" → + * "正在输出" so users see what's happening during the inevitable LLM + * first-token wait. + */ + onPhaseChange?: (phase: IntentStreamPhase, toolName?: string) => void; } export interface StreamIntentResult { @@ -139,6 +155,17 @@ export async function streamIntent(req: StreamIntentRequest): Promise { + if (next === currentPhase && !toolName) return; + currentPhase = next; + req.onPhaseChange?.(next, toolName); + }; + const result: StreamIntentResult = { status: "asking", content: "", summary: null }; while (true) { @@ -153,6 +180,12 @@ export async function streamIntent(req: StreamIntentRequest): Promise 0) { + const chunk = parsed[0]; + if (chunk && typeof chunk === "object") { + const chunkObj = chunk as Record; + const chunkType = String(chunkObj.type ?? "").toLowerCase(); + const toolCalls = chunkObj.tool_calls; + const toolCallChunks = chunkObj.tool_call_chunks; + const content = chunkObj.content; + const hasToolCall = + (Array.isArray(toolCalls) && toolCalls.length > 0) || + (Array.isArray(toolCallChunks) && toolCallChunks.length > 0); + if (hasToolCall) { + const firstCall = Array.isArray(toolCalls) && toolCalls.length > 0 + ? (toolCalls[0] as Record) + : Array.isArray(toolCallChunks) && toolCallChunks.length > 0 + ? (toolCallChunks[0] as Record) + : null; + const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : ""; + setPhase("tool_calling", toolName); + } else if ( + (chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") && + ((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0)) + ) { + setPhase("streaming"); + } + } + } + const delta = extractAiTextDelta(parsed); if (delta) req.onTextDelta?.(delta.text, delta.id); } diff --git a/frontend-web/src/roundtable-planning/api/multi-agent.ts b/frontend-web/src/roundtable-planning/api/multi-agent.ts index ebe98422..29f28ba1 100644 --- a/frontend-web/src/roundtable-planning/api/multi-agent.ts +++ b/frontend-web/src/roundtable-planning/api/multi-agent.ts @@ -41,6 +41,24 @@ export interface InitMultiAgentResponse { export async function initMultiAgent( payload: InitMultiAgentRequest, ): Promise { + // Timing telemetry for /api/multi-agent/init — enabled by default so we can + // see where time goes when users report "Step 2 init feels slow". Disable + // with `window.__MULTI_AGENT_INIT_TIMING__ = false` in devtools. + const timingEnabled = + typeof window === "undefined" + ? false + : (window as unknown as { __MULTI_AGENT_INIT_TIMING__?: boolean }).__MULTI_AGENT_INIT_TIMING__ !== false; + const t0 = performance.now(); + let tFirstByte = 0; + let tCoord = 0; + let seatCount = 0; + const tlog = (label: string) => { + if (!timingEnabled) return; + const elapsed = (performance.now() - t0).toFixed(0); + console.debug(`[init-timing] +${elapsed}ms ${label}`); + }; + tlog("fetch.start"); + const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/init`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -51,6 +69,7 @@ export async function initMultiAgent( }), signal: payload.signal, }); + tlog(`fetch.headers status=${res.status}`); if (!res.ok || !res.body) { const text = await res.text().catch(() => ""); throw new Error(`init multi-agent failed (${res.status}): ${text}`); @@ -68,6 +87,10 @@ export async function initMultiAgent( while (true) { const { done, value } = await reader.read(); if (done) break; + if (tFirstByte === 0) { + tFirstByte = performance.now(); + tlog(`first-byte (TTFB-after-headers=${(tFirstByte - t0).toFixed(0)}ms)`); + } buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; @@ -88,6 +111,8 @@ export async function initMultiAgent( const event = typeof obj.event === "string" ? obj.event : ""; switch (event) { case "seat_ready": { + seatCount += 1; + tlog(`seat_ready #${seatCount} ${String(obj.agent_name ?? "?")}`); payload.onSeatReady?.({ agentName: String(obj.agent_name ?? ""), threadId: String(obj.thread_id ?? ""), @@ -97,6 +122,8 @@ export async function initMultiAgent( break; } case "coordinator_ready": { + tCoord = performance.now(); + tlog(`coordinator_ready (since-first-seat=${(tCoord - tFirstByte).toFixed(0)}ms)`); payload.onCoordinatorReady?.({ mainAgentName: String(obj.main_agent_name ?? ""), threadId: String(obj.thread_id ?? ""), @@ -104,6 +131,7 @@ export async function initMultiAgent( break; } case "init_done": { + tlog(`init_done seats=${seatCount}`); mainAgentName = String(obj.main_agent_name ?? ""); threadIds = (obj.thread_ids ?? {}) as ThreadIdMap; break; @@ -123,6 +151,8 @@ export async function initMultiAgent( if (!aborted) throw err; } + tlog(`total ${(performance.now() - t0).toFixed(0)}ms (seats=${seatCount})`); + if (errorDetail) { throw new Error(`init multi-agent failed: ${errorDetail}`); } @@ -276,44 +306,6 @@ function extractAiTextDelta(parsed: unknown): { text: string; id: string } | nul return { text, id }; } -/** - * Trigger a browser download for a text blob. No-op outside the browser. - * Used by streamMultiAgent's dump-to-file debug mode. - */ -function downloadTextBlob(filename: string, text: string): void { - if (typeof window === "undefined" || typeof document === "undefined") return; - try { - const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.style.display = "none"; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - // Defer revoke so the browser has a chance to start the download. - setTimeout(() => URL.revokeObjectURL(url), 60_000); - } catch (err) { - // Downloads must never break the actual stream — just log and continue. - console.warn("[stream] downloadTextBlob failed:", err); - } -} - -/** - * Whether streamMultiAgent should auto-download the raw SSE transcript to a - * .txt when each call ends. Default ON for now because we're debugging the - * "sub-agent stream looks empty" issue and the browser console truncates - * large message logs. Toggle from devtools: - * window.__MULTI_AGENT_DUMP__ = false - */ -function isDumpEnabled(): boolean { - if (typeof window === "undefined") return false; - const flag = (window as unknown as { __MULTI_AGENT_DUMP__?: boolean }).__MULTI_AGENT_DUMP__; - // Default: enabled. Only disabled if explicitly set to false. - return flag !== false; -} - /** * Open an SSE connection to /api/multi-agent/run/stream and resolve once the * stream completes. The final frame (`{status: ...}`) is interpreted; every @@ -357,24 +349,6 @@ export async function streamMultiAgent( typeof window !== "undefined" && (window as unknown as { __MULTI_AGENT_DEBUG__?: boolean }).__MULTI_AGENT_DEBUG__ !== false; - // Raw SSE transcript buffer. Every raw line read from the stream (event:, - // data:, blank framing lines, anything) is appended verbatim so the dumped - // .txt is byte-for-byte what the frontend received off the socket. This is - // independent of parsing: even malformed/non-JSON frames end up in the file. - const dumpEnabled = isDumpEnabled(); - const transcript: string[] = dumpEnabled - ? [ - `# multi-agent stream transcript`, - `# agent_type: ${req.agentType}`, - `# agent_name: ${req.agentName}`, - `# started_at: ${new Date().toISOString()}`, - `# new_message: ${req.newMessage.replace(/\n/g, " ⏎ ")}`, - `# model: ${req.model ?? "(default)"}`, - `# ---- raw SSE follows ----`, - "", - ] - : []; - const result: StreamMultiAgentResult = { dispatched: [], message: null, @@ -393,38 +367,16 @@ export async function streamMultiAgent( req.onPhaseChange?.(next, name); }; - // Wrap the read loop so we always flush the transcript to disk, even on - // abort / network failure. Otherwise an interrupted run would lose the very - // evidence we want to inspect. - const finishDump = (reason: string) => { - if (!dumpEnabled) return; - transcript.push(""); - transcript.push(`# ---- stream ended (${reason}) ----`); - transcript.push(`# ended_at: ${new Date().toISOString()}`); - transcript.push(`# frames_parsed: ${frameSeq}`); - transcript.push(`# final_content_length: ${result.content.length}`); - transcript.push(`# dispatched_count: ${result.dispatched.length}`); - const tsSafe = new Date().toISOString().replace(/[:.]/g, "-"); - const nameSafe = req.agentName.replace(/[^a-zA-Z0-9_-]/g, "_"); - const filename = `multi-agent_${req.agentType}_${nameSafe}_${tsSafe}.txt`; - downloadTextBlob(filename, transcript.join("\n")); - }; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = decoder.decode(value, { stream: true }); + buffer += chunk; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - const chunk = decoder.decode(value, { stream: true }); - buffer += chunk; - - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - - for (const rawLine of lines) { - // Record EVERY raw line — including blank framing lines and event: - // headers — so the dumped transcript matches what the socket sent. - if (dumpEnabled) transcript.push(rawLine); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const rawLine of lines) { const line = rawLine.trimEnd(); if (!line) continue; if (line.startsWith("event:")) { @@ -525,16 +477,82 @@ export async function streamMultiAgent( } } - if (debugEnabled) { - console.debug(debugLabel, `stream ended after ${frameSeq} frames; final content length=${result.content.length}`); - } - finishDump("normal"); - return result; - } catch (err) { - // Flush whatever we got so the user can still inspect the transcript on - // abort / network drop. Then rethrow so callers' own handling runs. - const aborted = (err as Error)?.name === "AbortError" || req.signal?.aborted === true; - finishDump(aborted ? "aborted" : `error: ${(err as Error)?.message ?? String(err)}`); - throw err; + if (debugEnabled) { + console.debug(debugLabel, `stream ended after ${frameSeq} frames; final content length=${result.content.length}`); } + return result; +} + +export interface SessionArtifactFile { + /** Virtual path under the sandbox, e.g. `mnt/user-data/outputs/foo.md`. */ + path: string; + /** Bare filename (basename of `path`). */ + name: string; + sizeBytes: number; + /** MIME type guessed from the extension, or null when unknown. */ + mimeType: string | null; + /** ISO 8601 UTC timestamp of last modification. */ + modifiedAt: string; +} + +export interface SessionArtifactsResponse { + threadId: string; + files: SessionArtifactFile[]; +} + +/** + * List every file written to `/mnt/user-data/outputs/` for a thread. Used by + * the Step 3 final-report view to surface a thread's produced artifacts so the + * user can preview/download them via {@link buildArtifactDownloadUrl}. + * + * Returns an empty `files` array when the thread produced no artifacts (the + * backend treats a missing outputs directory as empty, not 404). + */ +export async function listSessionArtifacts( + threadId: string, + options: { signal?: AbortSignal } = {}, +): Promise { + const res = await apiFetch( + `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/artifacts`, + { method: "GET", signal: options.signal }, + ); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`list artifacts failed (${res.status}): ${text}`); + } + const json = (await res.json()) as { + thread_id: string; + files: Array<{ + path: string; + name: string; + size_bytes: number; + mime_type: string | null; + modified_at: string; + }>; + }; + return { + threadId: json.thread_id, + files: (json.files ?? []).map((f) => ({ + path: f.path, + name: f.name, + sizeBytes: f.size_bytes, + mimeType: f.mime_type, + modifiedAt: f.modified_at, + })), + }; +} + +/** + * Build a URL that previews (inline) or downloads (attachment) an artifact + * file. The `path` must be a virtual path returned by + * {@link listSessionArtifacts} (e.g. `mnt/user-data/outputs/foo.md`). + */ +export function buildArtifactDownloadUrl( + threadId: string, + virtualPath: string, + options: { download?: boolean } = {}, +): string { + const segments = virtualPath.split("/").map((s) => encodeURIComponent(s)).join("/"); + const base = `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/artifacts/${segments}`; + return options.download ? `${base}?download=true` : base; } diff --git a/frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx b/frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx new file mode 100644 index 00000000..2b5cdc95 --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/ArtifactPreviewModal.tsx @@ -0,0 +1,184 @@ +import React, { useCallback, useEffect, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { Download, FileText, Loader2, X } from "lucide-react"; + +import { apiFetch } from "@/core/api/fetch-client"; +import { buildArtifactDownloadUrl } from "@/roundtable-planning/api/multi-agent"; + +interface ArtifactPreviewModalProps { + /** The thread that owns the artifact. Both fetch and download URLs use it. */ + threadId: string; + /** Virtual path under the sandbox (e.g. `mnt/user-data/outputs/foo.md`). */ + path: string; + /** Bare filename — shown in the modal header. */ + name: string; + /** Mime type from the listing; drives the renderer choice. */ + mimeType: string | null; + onClose: () => void; +} + +const MARKDOWN_COMPONENTS = { + p: ({ children }: any) =>

{children}

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

    {children}

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

    {children}

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

    {children}

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

    {children}

    , + strong: ({ children }: any) => {children}, + em: ({ children }: any) => {children}, + code: ({ children, className }: any) => { + const isBlock = /language-/.test(className ?? ""); + if (isBlock) { + return ( +
    +          {children}
    +        
    + ); + } + return {children}; + }, + blockquote: ({ children }: any) => ( +
    {children}
    + ), + a: ({ children, href }: any) => ( + + {children} + + ), + table: ({ children }: any) => {children}
    , + th: ({ children }: any) => ( + {children} + ), + td: ({ children }: any) => {children}, + hr: () =>
    , +}; + +function isMarkdown(mimeType: string | null, name: string): boolean { + if (mimeType === "text/markdown") return true; + return /\.(md|markdown)$/i.test(name); +} + +export const ArtifactPreviewModal: React.FC = ({ + threadId, + path, + name, + mimeType, + onClose, +}) => { + const [content, setContent] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Esc closes the modal. Attached once per mount. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [onClose]); + + // Fetch the artifact body via the same URL the original download link used. + // The GET endpoint already returns text/* as decoded UTF-8, so res.text() + // gives us a renderable string directly. + useEffect(() => { + let cancelled = false; + const ctrl = new AbortController(); + setLoading(true); + setError(null); + setContent(null); + + (async () => { + try { + const url = buildArtifactDownloadUrl(threadId, path); + const res = await apiFetch(url, { method: "GET", signal: ctrl.signal }); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const text = await res.text(); + if (!cancelled) setContent(text); + } catch (err) { + if (cancelled || (err as Error).name === "AbortError") return; + setError((err as Error).message); + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { + cancelled = true; + ctrl.abort(); + }; + }, [threadId, path]); + + const handleDownload = useCallback(() => { + if (typeof window === "undefined") return; + const url = buildArtifactDownloadUrl(threadId, path, { download: true }); + window.open(url, "_blank", "noopener,noreferrer"); + }, [threadId, path]); + + const renderAsMarkdown = isMarkdown(mimeType, name); + + return ( +
    { + // Only close on outer-overlay click — clicks inside the modal panel + // bubble up here too, so check the target. + if (e.target === e.currentTarget) onClose(); + }} + > +
    + {/* Header */} +
    +
    + + {name} +
    +
    + + +
    +
    + + {/* Body */} +
    + {loading ? ( +
    + + 正在加载文件内容… +
    + ) : error ? ( +
    + 加载失败:{error} +
    + ) : content === null ? null : renderAsMarkdown ? ( + + {content} + + ) : ( +
    {content}
    + )} +
    +
    +
    + ); +}; diff --git a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx index 12ac31e3..6c658f21 100644 --- a/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx +++ b/frontend-web/src/roundtable-planning/components/HighFidelityReport.tsx @@ -1,498 +1,477 @@ -import React from "react"; -import { - ShieldCheck, - Layers, - Clipboard, - Star, - CheckCircle, - Download, - Share2, - Sparkles +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { + CheckCircle, + ChevronDown, + ChevronRight, + Download, + Eye, + FileText, + Loader2, + Printer, + RefreshCw, } from "lucide-react"; -interface HighFidelityReportProps { - budgetLimit: number; - consensusPercentage: number; - setCurrentStep: (step: number) => void; - setStep1MessageHistory: (history: any) => void; - setStep2RoundtableDialogues: (dialogues: any) => void; - triggerToast: (msg: string) => void; +import { + buildArtifactDownloadUrl, + listSessionArtifacts, + type SessionArtifactFile, + type ThreadIdMap, +} from "@/roundtable-planning/api/multi-agent"; +import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal"; + +/** + * Text-ish files we render in an in-app preview modal. Other types (pdf, + * images, html) keep the original window.open behaviour so the browser's + * native viewer handles them. + */ +function shouldPreviewInModal(file: { name: string; mimeType: string | null }): boolean { + const mime = file.mimeType ?? ""; + if (mime === "text/markdown" || mime === "text/plain" || mime === "text/csv" || mime === "application/json") { + return true; + } + if (mime.startsWith("text/")) return true; + return /\.(md|markdown|txt|json|csv|log|yml|yaml|xml)$/i.test(file.name); } +interface SeatDelivery { + /** Display name of the seat (e.g. "情报收集"). */ + sender: string; + /** Avatar color hint inherited from the dialogue card. Unused for layout right now. */ + avatarType: string; + /** The seat's final markdown delivery from Step 2. */ + content: string; +} + +interface HighFidelityReportProps { + threadIds: ThreadIdMap | null; + coordinatorName: string | null; + /** The most recent leader-agent text — rendered as the final consensus. */ + consensusText: string; + /** True once leader.dispatched === [] was observed (natural convergence). */ + hasConsensus: boolean; + /** Each sub-agent's final delivery, filtered out of step2 dialogues. */ + seatDeliveries: SeatDelivery[]; + /** lowercase agent name → display name (e.g. "roundtable-intelligence" → "情报收集"). */ + seatNameMap: Record; + /** Resets state and navigates back to Step 1. */ + onReset: () => void; +} + +interface SeatArtifacts { + agentName: string; + displayName: string; + threadId: string; + files: SessionArtifactFile[]; + loading: boolean; + error: string | null; +} + +function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`; + return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + +function formatGeneratedAt(d: Date): string { + const pad = (n: number) => n.toString().padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +const markdownComponents = { + p: ({ children }: any) =>

    {children}

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

    {children}

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

    {children}

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

    {children}

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

    {children}

    , + strong: ({ children }: any) => {children}, + em: ({ children }: any) => {children}, + code: ({ children, className }: any) => { + const isBlock = /language-/.test(className ?? ""); + if (isBlock) { + return ( +
    +          {children}
    +        
    + ); + } + return ( + {children} + ); + }, + blockquote: ({ children }: any) => ( +
    {children}
    + ), + a: ({ children, href }: any) => ( + + {children} + + ), + table: ({ children }: any) => {children}
    , + th: ({ children }: any) => ( + {children} + ), + td: ({ children }: any) => {children}, + hr: () =>
    , +}; + export const HighFidelityReport: React.FC = ({ - budgetLimit, - consensusPercentage, - setCurrentStep, - setStep1MessageHistory, - setStep2RoundtableDialogues, - triggerToast + threadIds, + coordinatorName, + consensusText, + hasConsensus, + seatDeliveries, + seatNameMap, + onReset, }) => { + // Build the per-seat list (excludes the coordinator). useMemo so the file + // fetch effect doesn't re-fire on every parent render. + const seatThreadEntries = useMemo(() => { + if (!threadIds) return [] as Array<{ agentName: string; threadId: string; displayName: string }>; + return Object.entries(threadIds) + .filter(([name]) => !coordinatorName || name !== coordinatorName) + .map(([agentName, threadId]) => ({ + agentName, + threadId, + displayName: seatNameMap[agentName.toLowerCase()] ?? agentName, + })); + }, [threadIds, coordinatorName, seatNameMap]); + + // Map sender display name → its delivery content for the accordion rows. + // Built once per seatDeliveries change; later entries with the same sender + // win so the *latest* delivery wins. + const deliveryByDisplay = useMemo(() => { + const m = new Map(); + for (const d of seatDeliveries) m.set(d.sender, d.content); + return m; + }, [seatDeliveries]); + + const [artifactsBySeat, setArtifactsBySeat] = useState([]); + const [expandedSeats, setExpandedSeats] = useState>(new Set()); + const [reloadKey, setReloadKey] = useState(0); + // Active preview target — non-null means the modal is open. We carry the + // owning threadId so the modal can fetch + build download URLs without + // needing a second prop drill. + const [previewing, setPreviewing] = useState<{ threadId: string; file: SessionArtifactFile } | null>(null); + const generatedAt = useMemo(() => formatGeneratedAt(new Date()), []); + + // Fetch artifact listings for every seat thread in parallel. Re-runs when + // the user clicks "刷新文件清单" (reloadKey ticks) or when the thread set + // changes (rare — only on a true re-init). + useEffect(() => { + if (seatThreadEntries.length === 0) { + setArtifactsBySeat([]); + return; + } + + const initial: SeatArtifacts[] = seatThreadEntries.map((s) => ({ + ...s, + files: [], + loading: true, + error: null, + })); + setArtifactsBySeat(initial); + + const abort = new AbortController(); + let cancelled = false; + + (async () => { + const results = await Promise.all( + seatThreadEntries.map(async (seat) => { + try { + const res = await listSessionArtifacts(seat.threadId, { signal: abort.signal }); + return { seat, files: res.files, error: null as string | null }; + } catch (err) { + if ((err as Error).name === "AbortError") { + return { seat, files: [] as SessionArtifactFile[], error: null }; + } + return { seat, files: [] as SessionArtifactFile[], error: (err as Error).message }; + } + }), + ); + if (cancelled) return; + setArtifactsBySeat( + results.map(({ seat, files, error }) => ({ + ...seat, + files, + loading: false, + error, + })), + ); + })(); + + return () => { + cancelled = true; + abort.abort(); + }; + }, [seatThreadEntries, reloadKey]); + + const totalFileCount = useMemo( + () => artifactsBySeat.reduce((sum, s) => sum + s.files.length, 0), + [artifactsBySeat], + ); + + const toggleSeat = useCallback((key: string) => { + setExpandedSeats((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); + + const handlePreview = useCallback( + (threadId: string, file: SessionArtifactFile) => { + // Text-ish artifacts (markdown / txt / json / csv / yaml / ...) open + // in our in-app modal so the user can read the content without leaving + // the planning page. Binary or active content (pdf / png / html) still + // hands off to the browser's native viewer via the original tab. + if (shouldPreviewInModal({ name: file.name, mimeType: file.mimeType })) { + setPreviewing({ threadId, file }); + return; + } + if (typeof window !== "undefined") { + const url = buildArtifactDownloadUrl(threadId, file.path); + window.open(url, "_blank", "noopener,noreferrer"); + } + }, + [], + ); + + const handleDownload = useCallback( + (threadId: string, file: SessionArtifactFile) => { + const url = buildArtifactDownloadUrl(threadId, file.path, { download: true }); + if (typeof window !== "undefined") { + window.open(url, "_blank", "noopener,noreferrer"); + } + }, + [], + ); + return (
    - - {/* Output title block */} + {/* Header */}
    -

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

    +

    最终方案展示

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

    + + 总控最终结论 +

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

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

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

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

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

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

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

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

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

    - 执行摘要 / Executive Summary -

    -
    -
    -

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

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

    -

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

    +

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

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

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

    -

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    -
    - -

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

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

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

    -
    -
    -

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

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

    {children}

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

    {children}

    , + h2: ({ children }) =>

    {children}

    , + h3: ({ children }) =>

    {children}

    , + h4: ({ children }) =>

    {children}

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

    {parts.summary.objective}

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

    {children}

    , + }}> + {parts.after} +
    + )} {isStreaming && ( )} +
    + ) : ( + + {msg.content} )}
    @@ -1727,6 +2350,8 @@ export default function RoundtablePlanningPage() {
    ); })} +
    + {!step1AtBottom && }
    @@ -1794,7 +2419,7 @@ export default function RoundtablePlanningPage() { 继续追问 - + +
    {/* Right side buttons */} -
    @@ -2450,12 +3111,21 @@ export default function RoundtablePlanningPage() { {/* STEP 3 SCREEN VIEW: COMPREHENSIVE OUTPUT DASHBOARD */} {currentStep === 3 && ( d.confidence === "子智能体交付") + .map((d) => ({ + sender: String(d.sender ?? ""), + avatarType: String(d.avatarType ?? "blue"), + content: String(d.content ?? ""), + }))} + seatNameMap={Object.fromEntries( + Object.entries(AGENT_NAME_TO_ROLE).map(([k, v]) => [k, v.displayName]), + )} + onReset={handleStartNewTask} /> )} diff --git a/frontend-web/src/roundtable-planning/utils/drafts.ts b/frontend-web/src/roundtable-planning/utils/drafts.ts new file mode 100644 index 00000000..ce0b97c2 --- /dev/null +++ b/frontend-web/src/roundtable-planning/utils/drafts.ts @@ -0,0 +1,195 @@ +/** + * Local-only draft storage for /page/roundtable/planning. + * + * One draft = one full roundtable session (Step 1 intent + Step 2 multi-agent + * roundtable). Persisted in `localStorage` so refresh / browser-restart keeps + * it; cross-device sync is out of scope (would need a backend). The Step 2 + * back-end LangGraph threads are reused on load, so list/preview/download of + * artifacts in Step 3 continue to work as long as the user hasn't deleted the + * threads server-side. + */ + +import type { IntentSummary } from "@/roundtable-planning/api/intent"; +import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent"; + +const STORAGE_KEY = "roundtable-planning:drafts:v1"; + +/** + * Snapshot of every piece of UI state needed to resume a session. We capture + * shapes loosely (`unknown[]`) for the chat / role lists because their inline + * types in the page component are wide and would force a refactor we don't + * want as part of the persistence feature. + */ +export interface DraftStep1Snapshot { + intentThreadId: string | null; + intentReady: IntentSummary | null; + interactiveMessages: unknown[]; + timelineEntries: unknown[]; +} + +export interface DraftStep2Snapshot { + threadIds: ThreadIdMap | null; + coordinatorName: string | null; + step2RoundtableDialogues: unknown[]; + lastLeaderContent: string; + hasConsensus: boolean; + consensusPercentage: number; + budgetLimit: number; +} + +export interface DraftRecord { + id: string; + /** Auto-derived task summary (objective → first user message → timestamp). */ + name: string; + createdAt: string; + updatedAt: string; + /** Furthest step the user had reached when this snapshot was taken. */ + furthestStep: 1 | 2 | 3; + step1: DraftStep1Snapshot; + step2: DraftStep2Snapshot; +} + +function isBrowser(): boolean { + return typeof window !== "undefined" && typeof window.localStorage !== "undefined"; +} + +function readAll(): DraftRecord[] { + if (!isBrowser()) return []; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed as DraftRecord[]; + } catch (err) { + console.warn("[drafts] failed to parse localStorage payload:", err); + return []; + } +} + +function writeAll(records: DraftRecord[]): void { + if (!isBrowser()) return; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(records)); + } catch (err) { + // Quota errors are the realistic failure mode — surface but don't throw, + // since the user can keep working with their in-memory state. + console.warn("[drafts] failed to write localStorage payload:", err); + } +} + +function newId(): string { + if (isBrowser() && typeof window.crypto !== "undefined" && typeof window.crypto.randomUUID === "function") { + return window.crypto.randomUUID(); + } + return `draft-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** List drafts newest first. */ +export function listDrafts(): DraftRecord[] { + return readAll() + .slice() + .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1)); +} + +export function getDraft(id: string): DraftRecord | null { + return readAll().find((d) => d.id === id) ?? null; +} + +/** + * Create-or-update a draft. If `id` is given and exists, that record is + * overwritten (updatedAt refreshed). Otherwise a new record is inserted. + * Returns the (re-)saved record so callers can read back the new id. + */ +export function upsertDraft( + payload: Omit & { id?: string }, +): DraftRecord { + const records = readAll(); + const now = new Date().toISOString(); + + if (payload.id) { + const idx = records.findIndex((d) => d.id === payload.id); + if (idx >= 0) { + const existing = records[idx]; + const updated: DraftRecord = { + ...existing, + name: payload.name, + furthestStep: payload.furthestStep, + step1: payload.step1, + step2: payload.step2, + updatedAt: now, + }; + records[idx] = updated; + writeAll(records); + return updated; + } + } + + const created: DraftRecord = { + id: payload.id ?? newId(), + name: payload.name, + furthestStep: payload.furthestStep, + step1: payload.step1, + step2: payload.step2, + createdAt: now, + updatedAt: now, + }; + records.push(created); + writeAll(records); + return created; +} + +export function deleteDraft(id: string): void { + const records = readAll(); + const next = records.filter((d) => d.id !== id); + if (next.length !== records.length) writeAll(next); +} + +/** + * Rename a draft. No-op when `id` isn't found or `name` is empty. Returns the + * updated record so callers can refresh local UI state. + */ +export function renameDraft(id: string, name: string): DraftRecord | null { + const trimmed = name.trim(); + if (!trimmed) return null; + const records = readAll(); + const idx = records.findIndex((d) => d.id === id); + if (idx < 0) return null; + const updated: DraftRecord = { + ...records[idx], + name: trimmed, + updatedAt: new Date().toISOString(), + }; + records[idx] = updated; + writeAll(records); + return updated; +} + +/** + * Derive a short task-summary name from whatever the user has supplied so far. + * Preference order: intent agent's structured objective → first user message + * in Step 1 → ISO timestamp fallback. The result is trimmed to ~40 chars so + * the dropdown rows stay scannable. + */ +export function deriveDraftName(args: { + intentReady?: IntentSummary | null; + interactiveMessages?: unknown[]; +}): string { + const MAX = 40; + const truncate = (s: string) => (s.length > MAX ? s.slice(0, MAX) + "…" : s); + + const objective = args.intentReady?.objective?.trim(); + if (objective) return truncate(objective); + + if (Array.isArray(args.interactiveMessages)) { + for (const m of args.interactiveMessages) { + if (!m || typeof m !== "object") continue; + const obj = m as Record; + if (obj.sender === "user" && typeof obj.content === "string" && obj.content.trim()) { + return truncate(obj.content.trim()); + } + } + } + + return `未命名草稿 ${new Date().toLocaleString("zh-CN", { hour12: false })}`; +} diff --git a/offline-backend-20260512/backend/app/gateway/routers/artifacts.py b/offline-backend-20260512/backend/app/gateway/routers/artifacts.py index 4ee0ef86..eabc1e9a 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/artifacts.py +++ b/offline-backend-20260512/backend/app/gateway/routers/artifacts.py @@ -1,6 +1,7 @@ import logging import mimetypes import zipfile +from datetime import datetime, timezone from pathlib import Path from urllib.parse import quote @@ -10,6 +11,8 @@ from pydantic import BaseModel from app.gateway.authz import require_permission from app.gateway.path_utils import resolve_thread_virtual_path +from deerflow.config.paths import get_paths +from deerflow.runtime.user_context import get_effective_user_id logger = logging.getLogger(__name__) @@ -94,6 +97,61 @@ def _extract_file_from_skill_archive(zip_path: Path, internal_path: str) -> byte return None +def collect_outputs_listing(outputs_dir: Path) -> list[dict]: + """Walk an outputs directory and return artifact listing entries. + + Each entry has ``path`` (virtual ``mnt/user-data/outputs/...``), ``name``, + ``size_bytes``, ``mime_type``, and ``modified_at`` (ISO 8601 UTC). Symlinks + that escape the outputs root are dropped to keep the listing scoped to the + thread. The result is sorted by ``modified_at`` descending (newest first). + """ + if not outputs_dir.exists() or not outputs_dir.is_dir(): + return [] + + outputs_root = outputs_dir.resolve() + files: list[dict] = [] + for entry in outputs_root.rglob("*"): + if not entry.is_file(): + continue + try: + resolved = entry.resolve() + resolved.relative_to(outputs_root) + except (OSError, ValueError): + continue + relative = resolved.relative_to(outputs_root).as_posix() + mime_type, _ = mimetypes.guess_type(resolved.name) + stat = resolved.stat() + files.append( + { + "path": f"mnt/user-data/outputs/{relative}", + "name": resolved.name, + "size_bytes": stat.st_size, + "mime_type": mime_type, + "modified_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + } + ) + + files.sort(key=lambda f: f["modified_at"], reverse=True) + return files + + +@router.get( + "/threads/{thread_id}/artifacts", + summary="List Artifact Files", + description="List all files under the thread's outputs directory (/mnt/user-data/outputs/). Returns an empty list if the thread has no outputs.", +) +@require_permission("threads", "read", owner_check=True) +async def list_artifacts(thread_id: str, request: Request) -> dict: + """List artifact files generated by the agent under outputs/. + + Walks ``backend/.deer-flow/users/{user_id}/threads/{thread_id}/user-data/outputs/`` + recursively. Returns an empty list if the directory does not exist (e.g. the + thread has not produced any artifacts yet). + """ + outputs_dir = get_paths().sandbox_outputs_dir(thread_id, user_id=get_effective_user_id()) + return {"thread_id": thread_id, "files": collect_outputs_listing(outputs_dir)} + + @router.get( "/threads/{thread_id}/artifacts/{path:path}", summary="Get Artifact File", diff --git a/offline-backend-20260512/backend/app/gateway/routers/intent.py b/offline-backend-20260512/backend/app/gateway/routers/intent.py index 3c9c4fb3..8001d2e4 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/intent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/intent.py @@ -27,6 +27,8 @@ from fastapi.responses import StreamingResponse from app.gateway.routers.multi_agent import ( _auth_headers, _create_thread, + _find_dispatch_message, + _find_trailing_ai_text, _flatten_content, _loopback_base, _parse_last_messages, @@ -59,7 +61,12 @@ async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str, """ base = _loopback_base(request) headers = _auth_headers(request) - async with httpx.AsyncClient(timeout=None) as client: + # trust_env=False: this client only talks to the same-process gateway at + # 127.0.0.1, so honoring HTTP_PROXY / HTTPS_PROXY env vars (set system-wide + # on the dev machine) makes httpx route loopback calls through an upstream + # proxy that can't reach 127.0.0.1, costing 40+ seconds of timeout per + # request before falling back. Symptom in logs: `headers_in=42.160s`. + async with httpx.AsyncClient(timeout=None, trust_env=False) as client: thread_id = await _create_thread(client, base, headers) return { "status": "success", @@ -93,7 +100,11 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin headers = _auth_headers(request) async def stream_generator() -> AsyncIterator[str]: - async with httpx.AsyncClient(timeout=None) as client: + # trust_env=False: same-process loopback to 127.0.0.1 — honoring + # HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines) + # causes httpx to route loopback calls through an unreachable upstream + # proxy and wait ~40 seconds for timeout before falling back. + async with httpx.AsyncClient(timeout=None, trust_env=False) as client: try: body = _run_payload( agent_name=INTENT_AGENT_ID, @@ -119,16 +130,38 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin captured = done messages = _parse_last_messages(captured) - # Find the latest AIMessage's text content — that's what the - # frontend renders and what we scan for the handoff signal. - last_ai: dict[str, Any] = {} - for msg in reversed(messages): - if not isinstance(msg, dict): - continue - if (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"): - last_ai = msg - break - content = _flatten_content(last_ai.get("content", "")) + # Find the latest AIMessage with tool_calls (typically + # ask_clarification) — falls back to the latest plain + # AIMessage when the model produced no tool call. + dispatch_msg = _find_dispatch_message(messages) + content = _flatten_content(dispatch_msg.get("content", "")) + tool_calls = dispatch_msg.get("tool_calls") or [] + + # Clarification branch: the agent ended its run by calling + # ask_clarification. ClarificationMiddleware intercepts with + # Command(goto=END) and stashes the structured question in the + # tool_call args, so the AIMessage `content` is only the + # preamble ("下面我用几个问题确认:") and stops there. We splice + # the question back into the user-facing text so the frontend + # actually shows what the agent wants to ask. + clarification_call = next( + (tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"), + None, + ) + if clarification_call is not None: + cargs = clarification_call.get("args") or {} + question = str(cargs.get("question") or "").strip() + follow_up = _find_trailing_ai_text(messages, exclude_text=content) + # Build a single content string the frontend can render + # as-is: preamble (if any) + the actual question. + parts: list[str] = [] + if content.strip(): + parts.append(content.strip()) + if follow_up and follow_up != question: + parts.append(follow_up) + if question: + parts.append(question) + content = "\n\n".join(parts) if parts else content summary: dict[str, Any] | None = None match = _INTENT_READY_PATTERN.search(content) diff --git a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py index ce0f5ff1..6a49ca43 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py @@ -285,7 +285,11 @@ async def init_multi_agent(request: Request, payload: dict = Body(...)) -> Strea return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" async def stream_generator() -> AsyncIterator[str]: - async with httpx.AsyncClient(timeout=None) as client: + # trust_env=False: same-process loopback to 127.0.0.1 — honoring + # HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines) + # causes httpx to route loopback calls through an unreachable upstream + # proxy and wait ~40 seconds for timeout before falling back. + async with httpx.AsyncClient(timeout=None, trust_env=False) as client: try: # Per-seat prepare task — same body as before, but we no longer # await them with asyncio.gather. Instead, asyncio.as_completed @@ -775,7 +779,11 @@ async def run_multi_agent_stream(request: Request, payload: dict = Body(...)) -> headers = _auth_headers(request) async def stream_generator() -> AsyncIterator[str]: - async with httpx.AsyncClient(timeout=None) as client: + # trust_env=False: same-process loopback to 127.0.0.1 — honoring + # HTTP_PROXY / HTTPS_PROXY env vars (commonly set on dev machines) + # causes httpx to route loopback calls through an unreachable upstream + # proxy and wait ~40 seconds for timeout before falling back. + async with httpx.AsyncClient(timeout=None, trust_env=False) as client: try: if agent_type == "leader": gen = _leader_run( From 0b5cc2d639b865231126fa64227d53e8c3161643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sun, 24 May 2026 22:16:25 +0800 Subject: [PATCH 03/30] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspace/workspace-nav-chat-list.tsx | 14 +++++++++++++- frontend-web/src/pages/WorkspaceRoutes.tsx | 14 ++++++++++++++ frontend-web/vite.config.ts | 3 ++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index ff7ac125..99f69bf8 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -13,7 +13,7 @@ import { } from "@/components/ui/sidebar"; import { useI18n } from "@/core/i18n/hooks"; // import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react"; -import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon } from "lucide-react"; +import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, SparklesIcon, Users } from "lucide-react"; import { SidebarCollapseButton } from "./workspace-header"; import { cn } from "@/lib/utils"; @@ -119,6 +119,18 @@ export function WorkspaceNavChatList({ + + + + + 多智能体规划 + + + } /> + } + /> + + + + } + /> } diff --git a/frontend-web/vite.config.ts b/frontend-web/vite.config.ts index 8d6e4335..d49aad30 100644 --- a/frontend-web/vite.config.ts +++ b/frontend-web/vite.config.ts @@ -3,11 +3,12 @@ import { fileURLToPath } from "node:url"; import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; +import sourceIdentifierPlugin from "vite-plugin-source-identifier"; const rootDir = fileURLToPath(new URL(".", import.meta.url)); export default defineConfig({ - plugins: [react()], + plugins: [sourceIdentifierPlugin(), react()], resolve: { alias: [ { From 6396ccc701799eab733400af89ccb91765a32d27 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Mon, 25 May 2026 14:14:01 +0800 Subject: [PATCH 04/30] =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/app/gateway/routers/intent.py | 50 +++++++++++++++---- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/offline-backend-20260512/backend/app/gateway/routers/intent.py b/offline-backend-20260512/backend/app/gateway/routers/intent.py index 8001d2e4..0835bb73 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/intent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/intent.py @@ -111,13 +111,26 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin 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. + # Intent agent only ever needs ask_clarification. Block + # everything else so the model can't drift into solutioning + # (e.g. saving a design doc with write_file) — the model + # tends to ignore SOUL prose, hard exclusion is reliable. excluded_tools=[ "web_search", "present_files", "view_image", "agent_orchestration", + "bash", + "ls", + "read_file", + "write_file", + "str_replace", + "memory", + "hindsight_recall", + "hindsight_reflect", + "hindsight_retain", + "task", + "write_todos", ], skill_stop_names=[], ) @@ -163,14 +176,33 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin parts.append(question) content = "\n\n".join(parts) if parts else content + # Scan ALL AI messages for the [INTENT_READY] marker, not just + # dispatch_msg.content. The model often emits the summary in an + # earlier AI message and then keeps yapping (or doing extra + # tool calls) — the marker is the source of truth, wherever it + # appears in the run. 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 + ready_text: str = "" + for msg in reversed(messages): + if not isinstance(msg, dict): + continue + if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"): + continue + msg_text = _flatten_content(msg.get("content", "")) + m = _INTENT_READY_PATTERN.search(msg_text) + if m: + try: + summary = json.loads(m.group(1)) + except json.JSONDecodeError as exc: + logger.warning("intent summary JSON parse failed: %s", exc) + summary = None + ready_text = msg_text + break + # When the marker was found in an earlier message, surface that + # message's text (which contains the user-facing 📋 汇总 + JSON) + # instead of the final filler AI text like "好的,收到". + if summary and ready_text: + content = ready_text status_value = "done" if summary else "asking" final_frame: dict[str, Any] = { From 8f9451a24bcfab389bc7386f9e9a0d7cd36bfd92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Mon, 25 May 2026 14:14:31 +0800 Subject: [PATCH 05/30] =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/roundtable-planning/api/recommend.ts | 223 +++++++ .../components/RecommendAgentsDialog.tsx | 558 ++++++++++++++++++ .../backend/app/gateway/app.py | 6 + .../backend/app/gateway/routers/recommend.py | 256 ++++++++ 4 files changed, 1043 insertions(+) create mode 100644 frontend-web/src/roundtable-planning/api/recommend.ts create mode 100644 frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx create mode 100644 offline-backend-20260512/backend/app/gateway/routers/recommend.py diff --git a/frontend-web/src/roundtable-planning/api/recommend.ts b/frontend-web/src/roundtable-planning/api/recommend.ts new file mode 100644 index 00000000..c8adca74 --- /dev/null +++ b/frontend-web/src/roundtable-planning/api/recommend.ts @@ -0,0 +1,223 @@ +import { apiFetch } from "@/core/api/fetch-client"; +import { getBackendBaseURL } from "@/core/config"; +import type { Agent } from "@/core/agents"; + +import type { IntentSummary } from "./intent"; + +/** + * Agent-recommendation API. Sits between Step 1 (intent clarification) and + * Step 2 (multi-agent roundtable): given the IntentSummary and a candidate + * agent pool, the built-in `roundtable-recommender` picks 2-6 agents most + * relevant to the task and explains why. + * + * One-shot, stateless: no /init, no thread_id, each call spins up a fresh + * backend-managed thread. Use {@link filterRecommendCandidates} to clean the + * `/api/agents` list before passing it as `candidates`. + */ + +/** Single agent pick returned by the recommender. */ +export interface RecommendPick { + agent_id: string; + reason: string; +} + +/** Final status frame the backend appends after the upstream stream finishes. */ +export interface StreamRecommendResult { + /** + * `done` → picks parsed and validated, ready to populate the dialog as + * default-checked selections. + * `asking` → recommender failed to emit a usable [RECOMMEND_READY] block, + * caller should apply its fallback list (typically the 6 roundtable-* seats). + * `error` → upstream / network failure. + */ + status: "done" | "asking" | "error"; + /** Full final AI text (includes the natural-language rationale). */ + content: string; + /** Populated only when status === "done"; null otherwise. */ + picks: RecommendPick[] | null; +} + +/** Phase mirror of streamIntent so the UI can reuse one loading-label mapper. */ +export type RecommendStreamPhase = "connecting" | "connected" | "streaming"; + +export interface StreamRecommendRequest { + /** Step 1 output. `objective` is required by the backend. */ + intent: IntentSummary; + /** Pre-filtered candidate pool (use {@link filterRecommendCandidates}). */ + candidates: RecommendCandidate[]; + model?: string; + signal?: AbortSignal; + /** Fires for every AI text chunk from upstream LangGraph messages-tuple frames. */ + onTextDelta?: (text: string, messageId: string) => void; + /** Fires whenever the inferred SSE phase changes. */ + onPhaseChange?: (phase: RecommendStreamPhase) => void; +} + +/** Shape the backend expects in `candidates[]`. */ +export interface RecommendCandidate { + agent_id: string; + name: string; + description: string; +} + +/** + * Agent ids that must never enter the candidate pool. Centralized here so + * every caller (recommend dialog, future tools) applies the same rule. + * + * - `roundtable-intent` — Step 1 clarification specialist, no role in research. + * - `roundtable-recommender`— the recommender itself, can't recommend itself. + * - `roundtable-coordinator-*` — disposable Step 2 coordinator shells (DB + * accumulates one per session); meaningless as roundtable participants. + */ +const EXCLUDED_AGENT_ID_PREFIX = "roundtable-coordinator-"; +const EXCLUDED_AGENT_IDS = new Set(["roundtable-intent", "roundtable-recommender"]); + +/** + * Drop excluded ids from a raw `GET /api/agents` list and project each entry + * to the slim shape the backend expects. Returns a fresh array; original + * objects are not mutated. + */ +export function filterRecommendCandidates(agents: Agent[]): RecommendCandidate[] { + const out: RecommendCandidate[] = []; + for (const a of agents) { + if (!a || !a.id) continue; + if (EXCLUDED_AGENT_IDS.has(a.id)) continue; + if (a.id.startsWith(EXCLUDED_AGENT_ID_PREFIX)) continue; + out.push({ + agent_id: a.id, + name: a.name ?? a.id, + description: a.description ?? "", + }); + } + return out; +} + +function isFinalRecommendFrame(value: unknown): value is { + status: "done" | "asking" | "error"; + content?: string; + picks?: RecommendPick[] | 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 === "done" || status === "asking" || status === "error"; +} + +/** + * Extract an AI text delta from one LangGraph `messages-tuple` frame. Same + * shape as the helper in intent.ts; duplicated rather than imported to keep + * each helper independently readable. + */ +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; + 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; + 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/recommend/stream` and resolve once the + * upstream stream finishes. Each call is one recommendation round — the + * recommender is stateless, so calling twice in a row produces two + * independent runs. + */ +export async function streamRecommend(req: StreamRecommendRequest): Promise { + const res = await apiFetch(`${getBackendBaseURL()}/api/recommend/stream`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + intent: req.intent, + candidates: req.candidates, + model: req.model, + }), + signal: req.signal, + }); + + if (!res.ok || !res.body) { + const text = await res.text().catch(() => ""); + throw new Error(`recommend/stream failed (${res.status}): ${text}`); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let lastEvent = ""; + + let currentPhase: RecommendStreamPhase = "connecting"; + req.onPhaseChange?.("connecting"); + const setPhase = (next: RecommendStreamPhase) => { + if (next === currentPhase) return; + currentPhase = next; + req.onPhaseChange?.(next); + }; + + const result: StreamRecommendResult = { status: "asking", content: "", picks: 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(); + 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 (isFinalRecommendFrame(parsed)) { + result.status = parsed.status; + if (typeof parsed.content === "string") result.content = parsed.content; + if (Array.isArray(parsed.picks)) { + result.picks = parsed.picks as RecommendPick[]; + } + continue; + } + + if (lastEvent === "messages") { + const delta = extractAiTextDelta(parsed); + if (delta) { + setPhase("streaming"); + req.onTextDelta?.(delta.text, delta.id); + } + } + } + } + + return result; +} diff --git a/frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx b/frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx new file mode 100644 index 00000000..c2b77ed7 --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/RecommendAgentsDialog.tsx @@ -0,0 +1,558 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { CheckIcon, Loader2Icon, RefreshCwIcon, SparklesIcon } from "lucide-react"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { listAgents, type Agent } from "@/core/agents"; +import { cn } from "@/lib/utils"; + +import type { IntentSummary } from "../api/intent"; +import { + filterRecommendCandidates, + streamRecommend, + type RecommendCandidate, + type RecommendPick, + type RecommendStreamPhase, +} from "../api/recommend"; + +/** + * Picks-selection dialog that sits between Step 1 (intent) and Step 2 + * (roundtable). Opens with a streaming recommendation from + * `roundtable-recommender`, defaults its picks to checked, and lets the user + * trim or extend the selection from the rest of the agent pool before + * confirming. + * + * Selection bounds (2 ≤ N ≤ 8) are enforced on the confirm button — the + * backend also clamps but UI feedback is faster here. + */ + +export interface RecommendAgentsDialogProps { + open: boolean; + intent: IntentSummary | null; + /** Falls back to the recommender's own default if omitted. */ + model?: string; + onCancel: () => void; + /** Called with the user's final selection of agent ids (in display order). */ + onConfirm: (agentIds: string[]) => void; +} + +const MIN_SELECTED = 2; +const MAX_SELECTED = 8; + +/** + * Default selection when the recommender fails or returns < 2 valid picks. + * Intersected with the live candidate pool at runtime so missing built-ins + * don't break the dialog. + */ +const FALLBACK_AGENT_IDS = [ + "roundtable-intelligence", + "roundtable-environment", + "roundtable-solution-design", + "roundtable-risk-review", + "roundtable-execution-plan", + "roundtable-summary", +]; + +type DialogPhase = + | "idle" + | "loading_agents" + | "recommending" + | "ready" + | "fallback" + | "error"; + +interface InternalState { + phase: DialogPhase; + /** Streaming + final rationale text from the recommender. */ + rationale: string; + /** Streaming SSE phase from the recommender helper. */ + streamPhase: RecommendStreamPhase | null; + /** Pre-filtered candidate pool fetched from `/api/agents`. */ + candidates: RecommendCandidate[]; + /** Reason-by-agent_id, only for picks returned by the recommender. */ + reasons: Record; + /** Agent ids the recommender returned (in order). */ + recommendedIds: string[]; + /** User-visible warning ("推荐失败,已使用默认席位") for the fallback case. */ + warning: string | null; + /** Latest error message; only shown when `phase === "error"`. */ + errorMessage: string | null; +} + +const INITIAL_STATE: InternalState = { + phase: "idle", + rationale: "", + streamPhase: null, + candidates: [], + reasons: {}, + recommendedIds: [], + warning: null, + errorMessage: null, +}; + +/** + * Resolve which candidate ids should be checked by default given a set of + * recommended ids. The result preserves recommendation order and silently + * drops ids that aren't in the candidate pool. + */ +function intersectInOrder(preferred: string[], pool: RecommendCandidate[]): string[] { + const poolIds = new Set(pool.map((c) => c.agent_id)); + const seen = new Set(); + const out: string[] = []; + for (const id of preferred) { + if (!poolIds.has(id) || seen.has(id)) continue; + out.push(id); + seen.add(id); + } + return out; +} + +export function RecommendAgentsDialog({ + open, + intent, + model, + onCancel, + onConfirm, +}: RecommendAgentsDialogProps) { + const [state, setState] = useState(INITIAL_STATE); + const [selectedIds, setSelectedIds] = useState>(new Set()); + + // Latest stream abort controller, so closing or re-running cancels the + // in-flight request instead of leaking it. + const abortRef = useRef(null); + // Generation token guards against stale completion handlers writing into + // state after a re-run (e.g. user clicks "重新推荐" mid-stream). + const generationRef = useRef(0); + + const cancelInflight = useCallback(() => { + if (abortRef.current) { + abortRef.current.abort(); + abortRef.current = null; + } + }, []); + + /** + * The full pipeline: fetch agents → run recommendation → seed selection. + * Wrapped so the user can re-trigger it via the "重新推荐" button. + */ + const runRecommendation = useCallback(async () => { + if (!intent || !intent.objective) { + setState({ + ...INITIAL_STATE, + phase: "error", + errorMessage: "缺少任务目标,无法启动推荐", + }); + return; + } + + cancelInflight(); + const myGen = ++generationRef.current; + const controller = new AbortController(); + abortRef.current = controller; + + setState({ ...INITIAL_STATE, phase: "loading_agents" }); + setSelectedIds(new Set()); + + let agents: Agent[]; + try { + agents = await listAgents(); + } catch (err) { + if (myGen !== generationRef.current) return; + setState({ + ...INITIAL_STATE, + phase: "error", + errorMessage: `加载智能体列表失败:${err instanceof Error ? err.message : String(err)}`, + }); + return; + } + if (myGen !== generationRef.current) return; + + const candidates = filterRecommendCandidates(agents); + if (candidates.length === 0) { + setState({ + ...INITIAL_STATE, + phase: "error", + errorMessage: "候选智能体池为空,请先在系统里创建至少 2 个智能体", + }); + return; + } + + setState((prev) => ({ + ...prev, + phase: "recommending", + candidates, + rationale: "", + streamPhase: "connecting", + })); + + try { + const result = await streamRecommend({ + intent, + candidates, + model, + signal: controller.signal, + // Append every AI text delta to the rationale display. The + // recommender's [RECOMMEND_READY] + json block ends up in here too; + // that's fine — it's terse and rendering it shows users the model + // actually emitted the structured handoff. + onTextDelta: (text) => { + if (myGen !== generationRef.current) return; + setState((prev) => ({ ...prev, rationale: prev.rationale + text })); + }, + onPhaseChange: (phase) => { + if (myGen !== generationRef.current) return; + setState((prev) => ({ ...prev, streamPhase: phase })); + }, + }); + if (myGen !== generationRef.current) return; + + const reasons: Record = {}; + const recommendedIds: string[] = []; + if (result.picks) { + for (const p of result.picks) { + reasons[p.agent_id] = p.reason; + recommendedIds.push(p.agent_id); + } + } + + if (result.status === "done" && recommendedIds.length >= MIN_SELECTED) { + const seed = intersectInOrder(recommendedIds, candidates); + setSelectedIds(new Set(seed)); + setState((prev) => ({ + ...prev, + phase: "ready", + rationale: result.content || prev.rationale, + recommendedIds, + reasons, + streamPhase: null, + warning: null, + })); + return; + } + + // Fallback: status === "asking" or picks too few. Use the 6 built-in + // roundtable seats, intersected against the live pool. + const fallback = intersectInOrder(FALLBACK_AGENT_IDS, candidates); + const seed = fallback.length >= MIN_SELECTED + ? fallback + : candidates.slice(0, MIN_SELECTED).map((c) => c.agent_id); + setSelectedIds(new Set(seed)); + setState((prev) => ({ + ...prev, + phase: "fallback", + rationale: result.content || prev.rationale, + recommendedIds: seed, + reasons, + streamPhase: null, + warning: "智能推荐未生成有效结果,已为你勾选默认的圆桌席位,可手动调整。", + })); + } catch (err) { + if (myGen !== generationRef.current) return; + // AbortError fires when the user closes the dialog mid-stream — no + // state update needed because the dialog is going away. + if (err instanceof DOMException && err.name === "AbortError") return; + setState((prev) => ({ + ...prev, + phase: "error", + streamPhase: null, + errorMessage: `推荐过程出错:${err instanceof Error ? err.message : String(err)}`, + })); + } + }, [intent, model, cancelInflight]); + + // Trigger the pipeline whenever the dialog opens. Closing it aborts any + // in-flight stream so the next open starts clean. + useEffect(() => { + if (!open) { + cancelInflight(); + return; + } + void runRecommendation(); + return () => { + cancelInflight(); + }; + }, [open, runRecommendation, cancelInflight]); + + const handleToggle = useCallback((agentId: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(agentId)) next.delete(agentId); + else next.add(agentId); + return next; + }); + }, []); + + // Split candidates into "recommended" and "other" — order within each + // group matches the recommender's pick order / the API's list order so the + // dialog is deterministic between renders. + const { recommendedCards, otherCards } = useMemo(() => { + const recSet = new Set(state.recommendedIds); + const byId = new Map(state.candidates.map((c) => [c.agent_id, c])); + const recommended: RecommendCandidate[] = []; + for (const id of state.recommendedIds) { + const c = byId.get(id); + if (c) recommended.push(c); + } + const others = state.candidates.filter((c) => !recSet.has(c.agent_id)); + return { recommendedCards: recommended, otherCards: others }; + }, [state.candidates, state.recommendedIds]); + + const selectedCount = selectedIds.size; + const canConfirm = + (state.phase === "ready" || state.phase === "fallback") && + selectedCount >= MIN_SELECTED && + selectedCount <= MAX_SELECTED; + + const selectionHint = (() => { + if (selectedCount < MIN_SELECTED) { + return `至少再选 ${MIN_SELECTED - selectedCount} 个`; + } + if (selectedCount > MAX_SELECTED) { + return `已超出上限 ${selectedCount - MAX_SELECTED} 个`; + } + return `已选 ${selectedCount} / ${MAX_SELECTED}`; + })(); + + const handleConfirm = useCallback(() => { + if (!canConfirm) return; + // Order: recommended-and-selected first (in recommender order), then + // user-added picks (in pool order). Keeps the leader's "可调度智能体" + // list visually stable. + const ordered: string[] = []; + for (const c of recommendedCards) { + if (selectedIds.has(c.agent_id)) ordered.push(c.agent_id); + } + for (const c of otherCards) { + if (selectedIds.has(c.agent_id)) ordered.push(c.agent_id); + } + onConfirm(ordered); + }, [canConfirm, recommendedCards, otherCards, selectedIds, onConfirm]); + + const handleOpenChange = useCallback( + (next: boolean) => { + if (!next) onCancel(); + }, + [onCancel], + ); + + return ( + + + + + + 选择参会智能体 + + + 根据你的任务目标,系统为你推荐了最合适的智能体。你可以取消勾选,也可以从下方添加其他智能体。 + + + + {/* Rationale + status block */} +
    + +
    + + {/* Selection area: scrollable, takes remaining height */} +
    + {state.phase === "error" ? ( +
    + {state.errorMessage ?? "未知错误"} +
    + ) : (state.phase === "ready" || state.phase === "fallback") ? ( + <> + {state.warning && ( +
    + {state.warning} +
    + )} + + {recommendedCards.length > 0 && ( +
    + {recommendedCards.map((c) => ( + handleToggle(c.agent_id)} + /> + ))} +
    + )} + + {otherCards.length > 0 && ( +
    + {otherCards.map((c) => ( + handleToggle(c.agent_id)} + /> + ))} +
    + )} + + ) : null} +
    + + +
    + MAX_SELECTED + ? "text-destructive" + : "text-muted-foreground", + )} + > + {selectionHint} + + {(state.phase === "ready" || state.phase === "fallback" || state.phase === "error") && ( + + )} +
    +
    + + +
    +
    +
    +
    + ); +} + +function RationaleBlock({ state }: { state: InternalState }) { + if (state.phase === "loading_agents") { + return ( +
    + + 正在加载候选智能体... +
    + ); + } + if (state.phase === "recommending") { + const label = + state.streamPhase === "connecting" + ? "调度推荐智能体..." + : state.streamPhase === "connected" + ? "已连接,等待响应..." + : "正在生成推荐..."; + return ( +
    +
    + + {label} +
    + {state.rationale && ( +
    +            {state.rationale}
    +          
    + )} +
    + ); + } + if (state.phase === "ready" || state.phase === "fallback") { + if (!state.rationale) { + return
    推荐完成。
    ; + } + return ( +
    +        {state.rationale}
    +      
    + ); + } + if (state.phase === "error") { + return
    推荐失败,请使用下方的"重新推荐"重试,或取消后再次进入。
    ; + } + return null; +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
    +
    + {title} +
    +
    {children}
    +
    + ); +} + +interface AgentCardProps { + candidate: RecommendCandidate; + reason: string | undefined; + recommended: boolean; + checked: boolean; + onToggle: () => void; +} + +function AgentCard({ candidate, reason, recommended, checked, onToggle }: AgentCardProps) { + return ( + + ); +} diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index 9678a42d..72987927 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -40,6 +40,7 @@ from app.gateway.routers import ( multi_agent, notifications, public_scheduled_tasks, + recommend, recommended_questions, runs, scheduled_tasks, @@ -871,6 +872,11 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an # roundtable as its initial intent. app.include_router(intent.router) + # Agent recommendation (between Step 1 and Step 2): given the intent + # summary + a frontend-filtered candidate pool, the built-in + # roundtable-recommender picks 2-6 agents most relevant to the task. + app.include_router(recommend.router) + @app.get("/health", tags=["health"]) async def health_check() -> dict: """Health check endpoint. diff --git a/offline-backend-20260512/backend/app/gateway/routers/recommend.py b/offline-backend-20260512/backend/app/gateway/routers/recommend.py new file mode 100644 index 00000000..83048db5 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/recommend.py @@ -0,0 +1,256 @@ +"""Agent recommendation endpoint (between Step 1 and Step 2 of the roundtable flow). + +Given the Step-1 intent summary (``objective`` + ``constraints`` + ``assumptions``) +and a frontend-filtered candidate agent pool, drives the built-in +``roundtable-recommender`` agent to pick 2-6 agents most relevant to the task +and explain why. + +Single one-shot endpoint — no ``/init`` is exposed because the recommendation +is stateless: the agent never needs the prior turn's history. We create a +fresh thread internally per request and let the LangGraph runtime clean it up +in the background. The frontend handles filtering ``roundtable-intent`` / +``roundtable-recommender`` / ``roundtable-coordinator-*`` out of the candidate +pool before calling here. + +SSE shape is the same family as ``intent.py`` / ``multi_agent.py``: +transparent passthrough of the upstream LangGraph stream, then a single +status frame appended at the end so the frontend can finalize. +""" + +from __future__ import annotations + +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 + +from app.gateway.routers.multi_agent import ( + _auth_headers, + _create_thread, + _flatten_content, + _loopback_base, + _parse_last_messages, + _run_payload, + _stream_upstream, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/recommend", tags=["recommend"]) + +RECOMMENDER_AGENT_ID = "roundtable-recommender" + +# Matches the SOUL-mandated handoff signal: a [RECOMMEND_READY] line followed +# by a ```json ... ``` block carrying {"picks": [{agent_id, reason}, ...]}. +_RECOMMEND_READY_PATTERN = re.compile( + r"\[RECOMMEND_READY\]\s*```json\s*(\{.*?\})\s*```", + re.DOTALL, +) + +# Mirrors the SOUL constraint so a misbehaving model can't push >6 picks to +# the frontend or fall below the "this isn't a roundtable" floor. +_MIN_PICKS = 2 +_MAX_PICKS = 6 + + +def _build_prompt(intent: dict[str, Any], candidates: list[dict[str, Any]]) -> str: + """Render the intent + candidate pool into the single user message the + recommender agent reads. Keep the format close to what the SOUL example + shows so the model has a clean pattern to mirror.""" + objective = str(intent.get("objective") or "").strip() or "(未提供)" + constraints = intent.get("constraints") or [] + assumptions = intent.get("assumptions") or [] + + constraint_lines = "; ".join(str(c).strip() for c in constraints if str(c).strip()) or "(无)" + assumption_lines = "; ".join(str(a).strip() for a in assumptions if str(a).strip()) or "(无)" + + candidate_lines: list[str] = [] + for c in candidates: + cid = str(c.get("agent_id") or c.get("id") or "").strip() + cname = str(c.get("name") or "").strip() + cdesc = str(c.get("description") or "").strip() + if not cid: + continue + # Format: "- (): " — matches the SOUL example. + suffix = f" ({cname})" if cname else "" + desc_part = f": {cdesc}" if cdesc else "" + candidate_lines.append(f"- {cid}{suffix}{desc_part}") + + candidate_block = "\n".join(candidate_lines) if candidate_lines else "(候选池为空)" + + return ( + f"任务目标:{objective}\n" + f"约束:{constraint_lines}\n" + f"关键假设:{assumption_lines}\n\n" + f"候选智能体池:\n{candidate_block}\n\n" + f"请按 SOUL 中规定的格式输出推荐结果(自然语言思路 + [RECOMMEND_READY] + json 块)。" + ) + + +def _validate_picks( + raw_picks: Any, + candidate_ids: set[str], +) -> list[dict[str, str]]: + """Filter the model's picks against the candidate pool and clamp to 2-6. + + Drops picks whose ``agent_id`` isn't in the candidate set (guards against + model hallucination), de-duplicates, and trims to ``_MAX_PICKS`` if the + model over-picked. Returns ``[]`` if validation leaves us with fewer than + ``_MIN_PICKS`` valid picks — the caller treats that as "fall back to + defaults". + """ + if not isinstance(raw_picks, list): + return [] + + picked: list[dict[str, str]] = [] + seen: set[str] = set() + for item in raw_picks: + if not isinstance(item, dict): + continue + aid = str(item.get("agent_id") or "").strip() + reason = str(item.get("reason") or "").strip() + if not aid or aid not in candidate_ids or aid in seen: + continue + picked.append({"agent_id": aid, "reason": reason}) + seen.add(aid) + if len(picked) >= _MAX_PICKS: + break + + if len(picked) < _MIN_PICKS: + return [] + return picked + + +@router.post("/stream") +async def stream_recommend(request: Request, payload: dict = Body(...)) -> StreamingResponse: + """Drive one recommendation turn via SSE. + + Body:: + + { + "intent": {"objective": "...", "constraints": [...], "assumptions": [...]}, + "candidates": [{"agent_id": "...", "name": "...", "description": "..."}, ...], + "model": "deepseek-chat" # optional + } + + Stream: transparent upstream LangGraph frames, followed by one status + frame:: + + { + "status": "done" | "asking" | "error", + "content": "", + "picks": [{"agent_id": "...", "reason": "..."}] | null + } + + ``done`` means picks were parsed and validated; ``asking`` means the + marker was missing or validation left fewer than 2 valid picks (caller + should apply its fallback list); ``error`` is upstream failure. + """ + intent = payload.get("intent") or {} + candidates = payload.get("candidates") or [] + model_name = str(payload.get("model") or "deepseek-chat") + + if not isinstance(intent, dict) or not str(intent.get("objective") or "").strip(): + raise HTTPException(status_code=400, detail="intent.objective is required") + if not isinstance(candidates, list) or not candidates: + raise HTTPException(status_code=400, detail="candidates must be a non-empty array") + + candidate_ids = { + str(c.get("agent_id") or c.get("id") or "").strip() + for c in candidates + if isinstance(c, dict) + } + candidate_ids.discard("") + if not candidate_ids: + raise HTTPException(status_code=400, detail="candidates must contain at least one valid agent_id") + + prompt = _build_prompt(intent, candidates) + 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: + # One-shot recommendation: spin up a fresh thread per request. + # The recommender never needs to see prior turns. + thread_id = await _create_thread(client, base, headers) + + body = _run_payload( + agent_name=RECOMMENDER_AGENT_ID, + model_name=model_name, + thread_id=thread_id, + new_message=prompt, + # Recommender is pure judgment: no tools, no dispatching. + excluded_tools=[ + "web_search", + "present_files", + "view_image", + "agent_orchestration", + "ask_clarification", + ], + 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) + last_ai_content = "" + for msg in reversed(messages): + if not isinstance(msg, dict): + continue + if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"): + continue + text = _flatten_content(msg.get("content", "")).strip() + if text: + last_ai_content = text + break + + picks: list[dict[str, str]] = [] + match = _RECOMMEND_READY_PATTERN.search(last_ai_content) + if match: + try: + parsed = json.loads(match.group(1)) + except json.JSONDecodeError as exc: + logger.warning("recommend picks JSON parse failed: %s", exc) + parsed = {} + if isinstance(parsed, dict): + picks = _validate_picks(parsed.get("picks"), candidate_ids) + + status_value = "done" if picks else "asking" + final_frame: dict[str, Any] = { + "status": status_value, + "content": last_ai_content, + "picks": picks if picks else None, + } + yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n" + + except HTTPException as exc: + logger.warning("recommend stream HTTPException: %s %s", exc.status_code, exc.detail) + err = {"status": "error", "content": f"[{exc.status_code}] {exc.detail}", "picks": None} + yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n" + except Exception as exc: + logger.exception("recommend stream unexpected failure") + err = {"status": "error", "content": f"unexpected: {exc!r}", "picks": 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", + }, + ) From 9a70329db27d53aaa2b5544d3f02cab5c807c3cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Mon, 25 May 2026 16:08:18 +0800 Subject: [PATCH 06/30] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/recommend-agents-progress.md | 268 ++++++++++++++++++ .../pages/RoundtablePlanningPage.tsx | 215 ++++++++++---- 2 files changed, 423 insertions(+), 60 deletions(-) create mode 100644 frontend-web/docs/recommend-agents-progress.md diff --git a/frontend-web/docs/recommend-agents-progress.md b/frontend-web/docs/recommend-agents-progress.md new file mode 100644 index 00000000..f892f033 --- /dev/null +++ b/frontend-web/docs/recommend-agents-progress.md @@ -0,0 +1,268 @@ +# 智能体推荐弹窗功能 — 开发进度 + +> 分支: `dhk-1` +> 最后更新: 2026-05-25 +> 状态: **全部完成 ✅** + +--- + +## 功能概述 + +在圆桌规划流程的 Step 1(任务意图理解)和 Step 2(多智能体圆桌研讨)之间,插入一个**推荐弹窗**: + +1. 用户完成任务澄清后,点击"进入多智能体研讨"→ 弹出推荐弹窗 +2. 弹窗拉取 `GET /api/agents` 候选池,调用 `roundtable-recommender` 智能体流式推荐 2-6 个最合适的智能体 +3. 推荐结果默认勾选,用户可取消勾选 / 从候选池增选(总选 2-8 个) +4. 确认后 Step 2 使用用户最终选定的智能体列表,不再硬编码 6 个圆桌席位 + +--- + +## 已完成的所有步骤 + +### Step 1 — 后端:新建 `roundtable-recommender` 智能体 ✅ + +**文件:** +- `offline-backend-20260512/backend/.deer-flow/agents/roundtable-recommender/config.yaml` +- `offline-backend-20260512/backend/.deer-flow/agents/roundtable-recommender/SOUL.md` + +**要点:** +- `id: roundtable-recommender`,`name: 智能体推荐` +- SOUL.md 规定输出协议:自然语言推理 + `[RECOMMEND_READY]` 标记 + ` ```json {"picks": [...]} ``` ` 块 +- 每个 pick: `{"agent_id": "...", "reason": "..."}`,agent_id 必须来自候选池 +- 约束:2 ≤ picks ≤ 6,禁止使用任何工具 + +--- + +### Step 2 — 后端:新建 `/api/recommend/stream` 接口 ✅ + +**文件:** +- `offline-backend-20260512/backend/app/gateway/routers/recommend.py`(新建) +- `offline-backend-20260512/backend/app/gateway/app.py`(注册路由) + +**接口规格:** +``` +POST /api/recommend/stream +Content-Type: application/json + +Body: +{ + "intent": { "objective": "...", "constraints": [...], "assumptions": [...] }, + "candidates": [{ "agent_id": "...", "name": "...", "description": "..." }, ...], + "model": "deepseek-chat" // 可选 +} + +SSE 流:透传上游 LangGraph frames + 最终状态帧 +最终帧: { "status": "done"|"asking"|"error", "content": "...", "picks": [...] | null } +``` + +**关键实现:** +- 无 `/init`,每次请求内部创建新 thread(stateless) +- `_validate_picks()`:对比候选池 id、去重、夹断到 2-6,< 2 返回 `[]` +- `_build_prompt()`:将 intent + 候选池渲染为单条 user message +- `_RECOMMEND_READY_PATTERN`:正则提取 `[RECOMMEND_READY]` 后的 JSON 块 +- `httpx.AsyncClient(trust_env=False)`:防止 loopback 被代理拦截 +- 复用 `multi_agent.py` 的 `_auth_headers`, `_create_thread`, `_stream_upstream` 等 + +**app.py 注册:** +```python +from app.gateway.routers import recommend +app.include_router(recommend.router) +``` + +--- + +### Step 3 — 前端:`recommend.ts` API 客户端 ✅ + +**文件:** `src/roundtable-planning/api/recommend.ts` + +**导出:** +```typescript +// 类型 +export interface RecommendPick { agent_id: string; reason: string } +export interface StreamRecommendResult { status: "done"|"asking"|"error"; content: string; picks: RecommendPick[]|null } +export type RecommendStreamPhase = "connecting"|"connected"|"streaming" +export interface RecommendCandidate { agent_id: string; name: string; description: string } + +// 候选池过滤(排除规则集中管理) +export function filterRecommendCandidates(agents: Agent[]): RecommendCandidate[] + +// 流式推荐 +export async function streamRecommend(req: StreamRecommendRequest): Promise +``` + +**排除规则(`filterRecommendCandidates`):** +- `roundtable-intent` — Step 1 澄清专员 +- `roundtable-recommender` — 推荐者自身 +- 前缀 `roundtable-coordinator-` — 每 session 生成的一次性协调器 + +--- + +### Step 4 — 前端:`RecommendAgentsDialog.tsx` 弹窗组件 ✅ + +**文件:** `src/roundtable-planning/components/RecommendAgentsDialog.tsx` + +**Props:** +```typescript +export interface RecommendAgentsDialogProps { + open: boolean; + intent: IntentSummary | null; + model?: string; + onCancel: () => void; + onConfirm: (agentIds: string[]) => void; +} +``` + +**内部阶段机器:** +``` +"idle" → "loading_agents" → "recommending" → "ready" + ↘ "fallback" (推荐失败,兜底) + ↘ "error" +``` + +**UI 分区:** +- **推荐参会**(默认全选):推荐智能体卡片,带 reason 说明 +- **其他候选**(默认未选):可增选,补足 8 个上限 + +**选择约束:** min 2,max 8;确认按钮在范围外 disabled + +**安全机制:** +- `generationRef` 计数器防止 stale async 污染状态 +- `AbortController` 取消在途推荐请求 +- `intersectInOrder()`:前端二次验证推荐 id 在候选池中存在 +- 兜底列表 `FALLBACK_AGENT_IDS`:6 个内置 roundtable-* 智能体 + +**确认顺序:** 推荐且已选 → 用户增选(按交互顺序) + +--- + +### Step 5 — 前端:`RoundtablePlanningPage.tsx` 动态 Agent 角色 ✅ + +**删除:** `ROUNDTABLE_ROLE_TEMPLATES`, `makeRoles`, `ROUNDTABLE_AGENT_IDS`, `AGENT_NAME_TO_ROLE`(全部硬编码常量) + +**新增模块级:** +```typescript +const COMMANDER_SEAT_ID = 1 +type SelectedAgent = { agent_id: string; name: string } +const BUILTIN_AGENT_META: Record +const FALLBACK_AVATAR_TYPES = ["blue","purple","emerald","amber","pink"] +function getAvatarTypeFor(agentId, index): string +function getDisplayNameFor(agent: SelectedAgent): string +function buildRoleTemplates(agents): ReadonlyArray> +function makeRolesFromTemplates(templates, defaultStatus, overrides?): RoleEntry[] +function buildAgentNameToRole(agents): Record +const DEFAULT_SELECTED_AGENTS: SelectedAgent[] // 原 6 个内置席位 +``` + +**新增 state + useMemo:** +```typescript +const [selectedAgents, setSelectedAgents] = useState(DEFAULT_SELECTED_AGENTS); +const roleTemplates = useMemo(() => buildRoleTemplates(selectedAgents), [selectedAgents]); +const agentNameToRole = useMemo(() => buildAgentNameToRole(selectedAgents), [selectedAgents]); +const roundtableAgentIds = useMemo(() => selectedAgents.map(a => a.agent_id), [selectedAgents]); +``` + +**替换调用点:** +| 替换前 | 替换后 | +|---|---| +| `AGENT_NAME_TO_ROLE[name]` | `agentNameToRole[name]` | +| `ROUNDTABLE_AGENT_IDS` | `roundtableAgentIds` | +| `makeRoles(status, ...)` | `makeRolesFromTemplates(roleTemplates, status, ...)` | +| 角色面板 `role.id === 2 ? ` 链 | `getAvatar(agentNameToRole[role.agentId]?.avatarType ?? "blue", "h-7 w-7")` | +| `HighFidelityReport seatNameMap` 用 `AGENT_NAME_TO_ROLE` | 用 `agentNameToRole` | +| useEffect 依赖 | 加入 `roleTemplates` | +| dispatch useCallback 依赖 | 加入 `agentNameToRole` | + +--- + +### Step 6 — 前端:弹窗接入页面 ✅ + +**新增 import:** +```typescript +import { RecommendAgentsDialog } from "@/roundtable-planning/components/RecommendAgentsDialog"; +``` + +**新增 state:** +```typescript +const [isRecommendDialogOpen, setIsRecommendDialogOpen] = useState(false); +``` + +**新增 callback `handleConfirmAgents`:** +```typescript +const handleConfirmAgents = useCallback((agentIds: string[]) => { + setSelectedAgents( + agentIds.map(id => ({ + agent_id: id, + name: BUILTIN_AGENT_META[id]?.displayName ?? id, + })), + ); + step2InitRef.current = false; // 重置 Step 2,让新 coordinator 按新名单初始化 + setIsRecommendDialogOpen(false); + setCurrentStep(2); + setShowToast("成功确认!已切换至第二阶段圆桌研讨系统"); +}, []); +``` + +**两个"进入多智能体研讨"按钮改为:** +```typescript +onClick={() => setIsRecommendDialogOpen(true)} +``` + +**JSX 渲染(在 showToast 之前):** +```tsx + setIsRecommendDialogOpen(false)} + onConfirm={handleConfirmAgents} +/> +``` + +--- + +## 文件清单(本次改动) + +### 后端(新建) +| 文件 | 说明 | +|---|---| +| `.deer-flow/agents/roundtable-recommender/config.yaml` | 推荐智能体元数据 | +| `.deer-flow/agents/roundtable-recommender/SOUL.md` | 推荐智能体系统提示 | +| `app/gateway/routers/recommend.py` | `/api/recommend/stream` 接口 | + +### 后端(修改) +| 文件 | 说明 | +|---|---| +| `app/gateway/app.py` | 注册 `recommend.router` | + +### 前端(新建) +| 文件 | 说明 | +|---|---| +| `src/roundtable-planning/api/recommend.ts` | 前端 SSE 客户端 | +| `src/roundtable-planning/components/RecommendAgentsDialog.tsx` | 推荐弹窗组件 | + +### 前端(修改) +| 文件 | 说明 | +|---|---| +| `src/roundtable-planning/pages/RoundtablePlanningPage.tsx` | 动态 Agent 角色 + 弹窗接入 | + +--- + +## 下次开始前检查项 + +1. **后端是否已重启** — `recommend.router` 注册需重启 FastAPI(`make dev` 或 `./scripts/restart-all.sh`) +2. **`roundtable-recommender` 智能体已注册** — 访问 `GET /api/agents` 确认列表里有 `roundtable-recommender` +3. **typecheck 干净** — `pnpm typecheck 2>&1 | grep roundtable-planning` 无输出 +4. **弹窗流程端到端测试**: + - Step 1 完成意图澄清 → `intentReady` 不为 null + - 点击"进入多智能体研讨"→ 弹窗出现,开始加载智能体列表 + - 弹窗展示推荐结果(流式文字 + 卡片列表) + - 调整勾选 → 确认 → Step 2 用所选智能体初始化 + +--- + +## 潜在后续工作(未列入本次范围) + +- 弹窗内显示每个候选智能体的完整 description(目前从 `/api/agents` 取,已有字段) +- 推荐失败时的 Toast 提示(目前静默兜底) +- 推荐理由(reason)在 Step 2 角色面板中展示 +- `HighFidelityReport` 的 `avatarType` 字段按新选角色动态渲染 diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 6c0ed51e..c60071b4 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useCallback } from "react"; +import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { HighFidelityReport } from "@/roundtable-planning/components/HighFidelityReport"; @@ -21,6 +21,7 @@ import { type IntentStreamPhase, type IntentSummary, } from "@/roundtable-planning/api/intent"; +import { RecommendAgentsDialog } from "@/roundtable-planning/components/RecommendAgentsDialog"; import { deleteDraft, deriveDraftName, @@ -159,42 +160,111 @@ type RoleEntry = { status: string; }; -const ROUNDTABLE_ROLE_TEMPLATES: ReadonlyArray> = [ - { id: 1, name: "指挥员 / 主持人", type: "human" }, - { id: 2, name: "总结陈述", agentId: "roundtable-summary", type: "agent" }, - { id: 3, name: "情报收集", agentId: "roundtable-intelligence", type: "agent" }, - { id: 4, name: "环境评估", agentId: "roundtable-environment", type: "agent" }, - { id: 5, name: "方案设计", agentId: "roundtable-solution-design", type: "agent" }, - { id: 6, name: "风险审查", agentId: "roundtable-risk-review", type: "agent" }, - { id: 7, name: "执行规划", agentId: "roundtable-execution-plan", type: "agent" }, - { id: 8, name: "总结陈述", agentId: "roundtable-summary", type: "agent" }, -]; +/** Stable seat id reserved for the human commander row. Agent seats start at 2. */ +const COMMANDER_SEAT_ID = 1; -function makeRoles(defaultStatus: string, overrides: Record = {}): RoleEntry[] { - return ROUNDTABLE_ROLE_TEMPLATES.map((t) => ({ +/** One entry per agent the user picked for this round of Step 2. */ +type SelectedAgent = { agent_id: string; name: string }; + +/** + * Display metadata for the 6 built-in roundtable seats — kept so the + * familiar emoji/color/display name survives when the user keeps the + * defaults. Custom agents fall through to {@link FALLBACK_AVATAR_TYPES}. + */ +const BUILTIN_AGENT_META: Record = { + "roundtable-intelligence": { avatarType: "purple", displayName: "情报收集" }, + "roundtable-environment": { avatarType: "emerald", displayName: "环境评估" }, + "roundtable-solution-design": { avatarType: "human", displayName: "方案设计" }, + "roundtable-risk-review": { avatarType: "amber", displayName: "风险审查" }, + "roundtable-execution-plan": { avatarType: "pink", displayName: "执行规划" }, + "roundtable-summary": { avatarType: "blue", displayName: "总结陈述" }, +}; + +/** + * Visual rotation for non-built-in agents. Each entry must be a key + * recognized by {@link getAvatar} — anything else falls back to the plain + * `RobotAvatar`. + */ +const FALLBACK_AVATAR_TYPES = ["blue", "purple", "emerald", "amber", "pink"] as const; + +function getAvatarTypeFor(agentId: string, index: number): string { + return ( + BUILTIN_AGENT_META[agentId]?.avatarType ?? + FALLBACK_AVATAR_TYPES[index % FALLBACK_AVATAR_TYPES.length] + ); +} + +function getDisplayNameFor(agent: SelectedAgent): string { + return BUILTIN_AGENT_META[agent.agent_id]?.displayName ?? agent.name; +} + +/** + * Build the seat-template list shown in the right-rail role panel. + * The commander always sits at id=1; agents take id=2..N+1, in the order + * the user picked them so the panel mirrors the recommend dialog. + */ +function buildRoleTemplates( + agents: SelectedAgent[], +): ReadonlyArray> { + const out: Omit[] = [ + { id: COMMANDER_SEAT_ID, name: "指挥员 / 主持人", type: "human" }, + ]; + agents.forEach((a, i) => { + out.push({ + id: i + 2, + name: getDisplayNameFor(a), + agentId: a.agent_id, + type: "agent", + }); + }); + return out; +} + +function makeRolesFromTemplates( + templates: ReadonlyArray>, + defaultStatus: string, + overrides: Record = {}, +): RoleEntry[] { + return templates.map((t) => ({ ...t, status: overrides[t.id] ?? defaultStatus, })); } -// Subset of role templates that map to real backend agents (excludes the -// human commander seat) — used as the `agent_names` payload on init. -const ROUNDTABLE_AGENT_IDS = Array.from( - new Set( - ROUNDTABLE_ROLE_TEMPLATES.filter((r) => r.agentId).map((r) => r.agentId as string), - ), -); +/** + * Map `agent_name` (lowercase) → bubble meta. Used while streaming output so + * the right speaker bubble lights up and the dialogue card picks the right + * avatar. Speaker ids stay in sync with {@link buildRoleTemplates} (agents + * are id 2..N+1, indexed by selection order). + */ +function buildAgentNameToRole( + agents: SelectedAgent[], +): Record { + const out: Record = {}; + agents.forEach((a, i) => { + out[a.agent_id] = { + speakerId: i + 2, + avatarType: getAvatarTypeFor(a.agent_id, i), + displayName: getDisplayNameFor(a), + }; + }); + return out; +} -// agent_name (lowercase) → bubble meta. Used when streaming output, so we can -// light up the right speaker bubble and pick an avatar for the dialogue card. -const AGENT_NAME_TO_ROLE: Record = { - "roundtable-summary": { speakerId: 2, avatarType: "blue", displayName: "总结陈述" }, - "roundtable-intelligence": { speakerId: 3, avatarType: "purple", displayName: "情报收集" }, - "roundtable-environment": { speakerId: 4, avatarType: "emerald", displayName: "环境评估" }, - "roundtable-solution-design": { speakerId: 5, avatarType: "human", displayName: "方案设计" }, - "roundtable-risk-review": { speakerId: 6, avatarType: "amber", displayName: "风险审查" }, - "roundtable-execution-plan": { speakerId: 7, avatarType: "pink", displayName: "执行规划" }, -}; +/** + * Default Step-2 roster when the user hasn't picked anything yet (e.g. the + * recommend dialog was skipped or hasn't run). Matches the original + * hand-coded 6 roundtable seats so the page renders identically until the + * recommend flow overrides it. + */ +const DEFAULT_SELECTED_AGENTS: SelectedAgent[] = [ + { agent_id: "roundtable-summary", name: "总结陈述" }, + { agent_id: "roundtable-intelligence", name: "情报收集" }, + { agent_id: "roundtable-environment", name: "环境评估" }, + { agent_id: "roundtable-solution-design", name: "方案设计" }, + { agent_id: "roundtable-risk-review", name: "风险审查" }, + { agent_id: "roundtable-execution-plan", name: "执行规划" }, +]; function nowTime() { return new Date().toLocaleTimeString("zh-CN", { hour12: false }); @@ -334,17 +404,27 @@ export default function RoundtablePlanningPage() { } ]); - // Roles in roundtable state (updates as we switch steps!) - const [roles, setRoles] = useState([ - { id: 1, name: "指挥员 / 主持人", type: "human", status: "等待激活" }, - { id: 2, name: "总结陈述", agentId: "roundtable-summary", type: "agent", status: "等待激活" }, - { id: 3, name: "情报收集", agentId: "roundtable-intelligence", type: "agent", status: "等待激活" }, - { id: 4, name: "环境评估", agentId: "roundtable-environment", type: "agent", status: "等待激活" }, - { id: 5, name: "方案设计", agentId: "roundtable-solution-design", type: "agent", status: "等待激活" }, - { id: 6, name: "风险审查", agentId: "roundtable-risk-review", type: "agent", status: "等待激活" }, - { id: 7, name: "执行规划", agentId: "roundtable-execution-plan", type: "agent", status: "等待激活" }, - { id: 8, name: "总结陈述", agentId: "roundtable-summary", type: "agent", status: "等待陈述" }, - ]); + // The user's chosen Step-2 participants. Defaults to the 6 built-in + // roundtable seats so behavior is unchanged before the recommend dialog + // overrides it. Step 6 will replace this via the dialog's onConfirm. + const [selectedAgents, setSelectedAgents] = useState(DEFAULT_SELECTED_AGENTS); + + // Derived: template list for the seat panel + dispatch-time name lookup + + // the `agent_names` payload for /api/multi-agent/init. All three regenerate + // together whenever the selection changes, so seat ids stay in sync. + const roleTemplates = useMemo(() => buildRoleTemplates(selectedAgents), [selectedAgents]); + const agentNameToRole = useMemo(() => buildAgentNameToRole(selectedAgents), [selectedAgents]); + const roundtableAgentIds = useMemo(() => selectedAgents.map((a) => a.agent_id), [selectedAgents]); + + // Roles in roundtable state (updates as we switch steps!). Seeded from + // `roleTemplates`'s initial value (selectedAgents starts at the default + // roster), so the panel renders identically to the previous hand-coded list. + const [roles, setRoles] = useState(() => + makeRolesFromTemplates(buildRoleTemplates(DEFAULT_SELECTED_AGENTS), "等待激活"), + ); + + // Dialog: recommend agents before entering Step 2. + const [isRecommendDialogOpen, setIsRecommendDialogOpen] = useState(false); // Step 2 dialogue list — populated by the real orchestration loop once the // user enters the roundtable. Starts empty so we never flash mock content. @@ -739,7 +819,7 @@ export default function RoundtablePlanningPage() { // when the leader 派活给多个席位。改为顺序后,同一时刻只有一个 SSE 在跑。 for (const [subName, task] of leaderRes.dispatched) { if (orchestrationCancelRef.current) break; - const meta = AGENT_NAME_TO_ROLE[subName.toLowerCase()]; + const meta = agentNameToRole[subName.toLowerCase()]; if (meta) markSpeakerStreaming(meta.speakerId); const perSeatModel = meta ? agentConfigs[meta.speakerId]?.model : undefined; @@ -806,7 +886,23 @@ export default function RoundtablePlanningPage() { clearAllStreamingSpeakers(); setIsOrchestrating(false); } - }, [appendStreamingDialogue, appendToDialogue, finalizeDialogue, highlightSpeaker, markSpeakerStreaming, markSpeakerIdle, clearAllStreamingSpeakers, isOrchestrating, agentConfigs]); + }, [appendStreamingDialogue, appendToDialogue, finalizeDialogue, highlightSpeaker, markSpeakerStreaming, markSpeakerIdle, clearAllStreamingSpeakers, isOrchestrating, agentConfigs, agentNameToRole]); + + // Called by RecommendAgentsDialog onConfirm: update the participant roster, + // reset step-2 init so the new coordinator is created for the new roster, + // then navigate to Step 2. + const handleConfirmAgents = useCallback((agentIds: string[]) => { + setSelectedAgents( + agentIds.map((id) => ({ + agent_id: id, + name: BUILTIN_AGENT_META[id]?.displayName ?? id, + })), + ); + step2InitRef.current = false; + setIsRecommendDialogOpen(false); + setCurrentStep(2); + setShowToast("成功确认!已切换至第二阶段圆桌研讨系统"); + }, []); // Public stop handler used by the pause/stop button and the step-2 teardown. // Sets the cancel flag and aborts every in-flight stream so the loop can @@ -849,7 +945,7 @@ export default function RoundtablePlanningPage() { const coord = newCoordinatorName(); try { const initRes = await initMultiAgent({ - agentNames: ROUNDTABLE_AGENT_IDS, + agentNames: roundtableAgentIds, mainAgentName: coord, model: DEFAULT_MODEL, // As each sub-agent's thread comes online, flip every role that @@ -1473,11 +1569,11 @@ export default function RoundtablePlanningPage() { // seat to 在线 — assume all seats are online so the panel doesn't get // stuck at "正在上线". const baseStatus = loadedFromDraftRef.current ? "在线" : "正在上线"; - setRoles(makeRoles(baseStatus, { 1: "在线" })); + setRoles(makeRolesFromTemplates(roleTemplates, baseStatus, { 1: "在线" })); } else { - setRoles(makeRoles("已完成")); + setRoles(makeRolesFromTemplates(roleTemplates, "已完成")); } - }, [currentStep, intentThreadId, intentInitError]); + }, [currentStep, intentThreadId, intentInitError, roleTemplates]); // Inject user input to the roundtable room — sends it as a new leader call. const handleInjectToRoundtable = () => { @@ -1544,6 +1640,14 @@ export default function RoundtablePlanningPage() { > {/* Toast Notice Banner */} + setIsRecommendDialogOpen(false)} + onConfirm={handleConfirmAgents} + /> + {showToast && (
    @@ -2009,16 +2113,10 @@ export default function RoundtablePlanningPage() { }`} >
    - {/* Avatar chooser based on role.id */} {role.type === "human" ? ( ) : ( - role.id === 2 ? : - role.id === 3 ? : - role.id === 4 ? : - role.id === 6 ? : - role.id === 7 ? : - + getAvatar(role.agentId ? (agentNameToRole[role.agentId]?.avatarType ?? "blue") : "blue", "h-7 w-7") )}
    @@ -2146,10 +2244,7 @@ export default function RoundtablePlanningPage() {
    @@ -720,10 +739,10 @@ function ClarificationCard({
    ) : null} - {isPending ? ( + {isPending && (allowMultiple || payload.allow_custom !== false) ? ( isLastPending ? (
    - {multiPending ? ( + {multiPending && allowMultiple ? ( 将一起提交以上 {pendingCount} 个问题的回答 @@ -735,14 +754,14 @@ function ClarificationCard({ onClick={() => void onSubmitAll()} > - {submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"} + {submitting ? "发送中…" : multiPending && allowMultiple ? "全部发送" : "发送回答"}
    - ) : ( + ) : allowMultiple ? (
    选择后等待最后一题统一提交
    - ) + ) : null ) : null}
    ); diff --git a/frontend-web/src/roundtable-planning/api/intent.ts b/frontend-web/src/roundtable-planning/api/intent.ts index 481b9d00..dabcdb3e 100644 --- a/frontend-web/src/roundtable-planning/api/intent.ts +++ b/frontend-web/src/roundtable-planning/api/intent.ts @@ -1,6 +1,11 @@ import { apiFetch } from "@/core/api/fetch-client"; import { getBackendBaseURL } from "@/core/config"; +import type { ClarificationPayload } from "./multi-agent"; +import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification"; + +export type { ClarificationPayload }; + /** * 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 @@ -73,12 +78,17 @@ export interface StreamIntentRequest { } export interface StreamIntentResult { - /** "asking" → keep the dialogue going; "done" → summary is ready; "error" → backend failure. */ - status: "asking" | "done" | "error"; + /** "asking" → keep the dialogue going; "clarification" → structured question card; "done" → summary is ready; "error" → backend failure. */ + status: "asking" | "clarification" | "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; + /** + * Populated when the intent agent called ask_clarification — render option + * cards + custom input instead of treating the turn as plain text only. + */ + clarification: ClarificationPayload | null; } /** @@ -87,14 +97,25 @@ export interface StreamIntentResult { * is optional. */ function isFinalIntentFrame(value: unknown): value is { - status: "asking" | "done" | "error"; + status: "asking" | "clarification" | "done" | "error"; content?: string; summary?: IntentSummary | null; + question?: string; + clarification_type?: string; + clarification_context?: string; + options?: unknown[]; + allow_custom?: boolean; + allow_multiple?: boolean; } { 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"; + return ( + status === "asking" || + status === "clarification" || + status === "done" || + status === "error" + ); } /** @@ -166,7 +187,12 @@ export async function streamIntent(req: StreamIntentRequest): Promise; export interface SeatReadyEvent { @@ -222,6 +224,8 @@ export interface ClarificationPayload { options: unknown[]; /** Whether the user is allowed to type a custom answer. */ allowCustom: boolean; + /** When false, option cards are single-select and send on click. Omitted → inferred from clarificationType. */ + allowMultiple?: boolean; } export interface StreamMultiAgentResult { @@ -257,6 +261,7 @@ function isFinalStatusFrame(value: unknown): value is { clarification_context?: string; options?: unknown[]; allow_custom?: boolean; + allow_multiple?: boolean; } { if (!value || typeof value !== "object") return false; if (!("status" in value) || !("content" in value)) return false; @@ -447,14 +452,19 @@ export async function streamMultiAgent( // a question (or chip choices) instead of mistaking the run for // a consensus. if (parsed.status === "clarification") { + const clarificationType = + typeof parsed.clarification_type === "string" ? parsed.clarification_type : ""; result.clarification = { question: typeof parsed.question === "string" ? parsed.question : "", - clarificationType: - typeof parsed.clarification_type === "string" ? parsed.clarification_type : "", + clarificationType, context: typeof parsed.clarification_context === "string" ? parsed.clarification_context : "", options: Array.isArray(parsed.options) ? parsed.options : [], allowCustom: parsed.allow_custom !== false, + allowMultiple: resolveAllowMultiple( + typeof parsed.allow_multiple === "boolean" ? parsed.allow_multiple : undefined, + clarificationType, + ), }; } } diff --git a/frontend-web/src/roundtable-planning/api/recommend.ts b/frontend-web/src/roundtable-planning/api/recommend.ts index c8adca74..db73b4ed 100644 --- a/frontend-web/src/roundtable-planning/api/recommend.ts +++ b/frontend-web/src/roundtable-planning/api/recommend.ts @@ -47,7 +47,11 @@ export interface StreamRecommendRequest { candidates: RecommendCandidate[]; model?: string; signal?: AbortSignal; - /** Fires for every AI text chunk from upstream LangGraph messages-tuple frames. */ + /** Fires whenever parsed natural-language rationale changes (excludes [RECOMMEND_READY] + JSON). */ + onRationaleUpdate?: (rationale: string) => void; + /** Fires whenever newly complete pick objects are parsed from the streaming JSON block. */ + onPicksUpdate?: (picks: RecommendPick[]) => void; + /** Fires for every raw AI text chunk (includes JSON); prefer onRationaleUpdate/onPicksUpdate in UI. */ onTextDelta?: (text: string, messageId: string) => void; /** Fires whenever the inferred SSE phase changes. */ onPhaseChange?: (phase: RecommendStreamPhase) => void; @@ -103,6 +107,50 @@ function isFinalRecommendFrame(value: unknown): value is { return status === "done" || status === "asking" || status === "error"; } +const RECOMMEND_READY_MARKER = "[RECOMMEND_READY]"; + +/** Match one complete pick object inside a partially streamed JSON block. */ +const PICK_OBJECT_PATTERN = + /\{\s*"agent_id"\s*:\s*"((?:\\.|[^"\\])*)"\s*,\s*"reason"\s*:\s*"((?:\\.|[^"\\])*)"\s*\}/g; + +function unescapeJsonString(raw: string): string { + try { + return JSON.parse(`"${raw}"`) as string; + } catch { + return raw.replace(/\\"/g, '"').replace(/\\\\/g, "\\"); + } +} + +/** + * Split accumulated recommender output into visible rationale text and any + * pick objects that have fully arrived in the JSON section. + */ +export function parseRecommendStreamContent(raw: string): { + rationale: string; + picks: RecommendPick[]; +} { + const markerIdx = raw.indexOf(RECOMMEND_READY_MARKER); + let rationale = markerIdx >= 0 ? raw.slice(0, markerIdx) : raw; + rationale = rationale.replace(/\[(?:RECOMMEND(?:_(?:READY)?)?)?\]?$/i, "").trimEnd(); + + const jsonSection = + markerIdx >= 0 ? raw.slice(markerIdx + RECOMMEND_READY_MARKER.length) : ""; + + const picks: RecommendPick[] = []; + const seen = new Set(); + PICK_OBJECT_PATTERN.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = PICK_OBJECT_PATTERN.exec(jsonSection)) !== null) { + const agent_id = unescapeJsonString(match[1]).trim(); + const reason = unescapeJsonString(match[2]).trim(); + if (!agent_id || seen.has(agent_id)) continue; + seen.add(agent_id); + picks.push({ agent_id, reason }); + } + + return { rationale, picks }; +} + /** * Extract an AI text delta from one LangGraph `messages-tuple` frame. Same * shape as the helper in intent.ts; duplicated rather than imported to keep @@ -110,6 +158,15 @@ function isFinalRecommendFrame(value: unknown): value is { */ function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null { if (!Array.isArray(parsed) || parsed.length < 1) return null; + + if (parsed.length >= 2 && parsed[1] && typeof parsed[1] === "object") { + const meta = parsed[1] as Record; + const node = String(meta.langgraph_node ?? ""); + if (node.includes("TitleMiddleware")) return null; + const tags = meta.tags; + if (Array.isArray(tags) && tags.includes("middleware:title")) return null; + } + const chunk = parsed[0]; if (!chunk || typeof chunk !== "object") return null; const obj = chunk as Record; @@ -171,6 +228,9 @@ export async function streamRecommend(req: StreamRecommendRequest): Promise lastPickCount) { + lastPickCount = picks.length; + req.onPicksUpdate?.(picks); + } } } } } + if (result.content) { + const { rationale, picks } = parseRecommendStreamContent(result.content); + if (rationale !== lastRationale) { + req.onRationaleUpdate?.(rationale); + } + if (picks.length > lastPickCount) { + req.onPicksUpdate?.(picks); + } + } + return result; } diff --git a/frontend-web/src/roundtable-planning/components/IntentClarificationCard.tsx b/frontend-web/src/roundtable-planning/components/IntentClarificationCard.tsx new file mode 100644 index 00000000..d30fbc5d --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/IntentClarificationCard.tsx @@ -0,0 +1,217 @@ +import { Check } from "lucide-react"; +import { useMemo } from "react"; + +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent"; +import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification"; + +export type IntentClarificationSelection = { + selectedIds: string[]; + custom: string; +}; + +type ClarificationOption = { + id: string; + label: string; + description?: string; +}; + +function normalizeOptions(raw: unknown[]): ClarificationOption[] { + return raw + .map((opt, index) => { + if (typeof opt === "string") { + const label = opt.trim(); + return label ? { id: `option-${index}`, label } : null; + } + if (opt && typeof opt === "object" && "label" in opt) { + const obj = opt as { id?: unknown; label?: unknown; description?: unknown }; + const label = String(obj.label ?? "").trim(); + if (!label) return null; + const id = String(obj.id ?? label).trim() || `option-${index}`; + const description = + typeof obj.description === "string" && obj.description.trim() + ? obj.description.trim() + : undefined; + return { id, label, description }; + } + return null; + }) + .filter((opt): opt is ClarificationOption => opt !== null); +} + +export function formatIntentClarificationAnswer( + payload: ClarificationPayload, + selection: IntentClarificationSelection, +): string | null { + const options = normalizeOptions(Array.isArray(payload.options) ? payload.options : []); + const selectedLabels = selection.selectedIds + .map((sid) => options.find((option) => option.id === sid)?.label) + .filter((label): label is string => Boolean(label && label.trim())); + const trimmedCustom = selection.custom.trim(); + + if (selectedLabels.length > 0 && trimmedCustom) { + return `我选择:${selectedLabels.join("、")};补充:${trimmedCustom}`; + } + if (selectedLabels.length > 0) { + return `我选择:${selectedLabels.join("、")}`; + } + if (trimmedCustom) { + return trimmedCustom; + } + return null; +} + +interface IntentClarificationCardProps { + payload: ClarificationPayload; + fallbackContent?: string; + selection: IntentClarificationSelection; + onSelectionChange: (next: IntentClarificationSelection) => void; + disabled?: boolean; + submitting?: boolean; + /** Multi-select: submit current selection. Single-select: also used for custom text. */ + onSubmit: (overrideSelection?: IntentClarificationSelection) => void; +} + +export function IntentClarificationCard({ + payload, + fallbackContent, + selection, + onSelectionChange, + disabled = false, + submitting = false, + onSubmit, +}: IntentClarificationCardProps) { + const options = useMemo( + () => normalizeOptions(Array.isArray(payload.options) ? payload.options : []), + [payload.options], + ); + + const allowMultiple = resolveAllowMultiple( + payload.allowMultiple, + payload.clarificationType, + ); + + const canSubmit = useMemo(() => { + return selection.selectedIds.length > 0 || selection.custom.trim().length > 0; + }, [selection.custom, selection.selectedIds]); + + const showSubmitButton = allowMultiple || payload.allowCustom !== false; + + function handleOptionClick(optionId: string) { + if (disabled || submitting) return; + if (!allowMultiple) { + onSubmit({ selectedIds: [optionId], custom: "" }); + return; + } + const next = selection.selectedIds.includes(optionId) + ? selection.selectedIds.filter((id) => id !== optionId) + : [...selection.selectedIds, optionId]; + onSelectionChange({ ...selection, selectedIds: next }); + } + + function updateCustom(value: string) { + if (disabled || submitting) return; + onSelectionChange({ ...selection, custom: value }); + } + + return ( +
    +
    +
    + + 需要你的确认 + + {payload.clarificationType ? ( + + {payload.clarificationType} + + ) : null} +
    + {payload.context ? ( +

    {payload.context}

    + ) : null} +
    + {payload.question || fallbackContent} +
    +
    + + {options.length > 0 ? ( +
    +
    + {allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"} +
    + {options.map((option) => { + const isSelected = selection.selectedIds.includes(option.id); + return ( + + ); + })} +
    + ) : null} + + {payload.allowCustom !== false ? ( +
    +