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 1/5] =?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 2/5] =?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 ? ( +
+