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/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index 63cee2fb..f9581a7a 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -24,6 +24,7 @@ import type { Subtask } from "@/core/tasks"; import { useUpdateSubtask } from "@/core/tasks/context"; import type { AgentThreadState } from "@/core/threads"; import { cn } from "@/lib/utils"; +import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification"; import { ArtifactFileList } from "../artifacts/artifact-file-list"; import { StreamingIndicator } from "../streaming-indicator"; @@ -102,7 +103,9 @@ export function MessageList({ [], ); - const handleSubmitAllClarifications = useCallback(async () => { + const handleSubmitAllClarifications = useCallback(async ( + selectionOverrides?: Map, + ) => { if (pendingClarificationIds.length === 0 || !onClarificationSubmit) { return; } @@ -111,7 +114,7 @@ export function MessageList({ const id = pendingClarificationIds[index]!; const message = messages.find((msg) => msg.id === id); const payload = message ? getClarificationPayload(message) : null; - const selection = clarificationSelections.get(id) ?? { + const selection = selectionOverrides?.get(id) ?? clarificationSelections.get(id) ?? { selectedIds: [], custom: "", }; @@ -221,7 +224,12 @@ export function MessageList({ } submitting={submittingClarification} canSubmit={hasAnyClarificationAnswer} - onSubmitAll={handleSubmitAllClarifications} + onSubmitAll={(override) => { + const overrides = override + ? new Map([[messageId, override]]) + : undefined; + void handleSubmitAllClarifications(overrides); + }} /> void; submitting: boolean; canSubmit: boolean; - onSubmitAll: () => void | Promise; + onSubmitAll: (override?: ClarificationSelection) => void | Promise; }) { const payload = getClarificationPayload(message); @@ -624,11 +633,21 @@ function ClarificationCard({ const disabled = !isPending || submitting; const multiPending = (pendingCount ?? 0) > 1; + const allowMultiple = resolveAllowMultiple( + payload.allow_multiple, + payload.clarification_type, + ); - function toggleOption(optionId: string) { + function handleOptionClick(optionId: string) { if (disabled) { return; } + if (!allowMultiple) { + const next = { selectedIds: [optionId], custom: "" }; + onSelectionChange(next); + void onSubmitAll(next); + return; + } const next = selection.selectedIds.includes(optionId) ? selection.selectedIds.filter((id) => id !== optionId) : [...selection.selectedIds, optionId]; @@ -666,7 +685,7 @@ function ClarificationCard({ {options.length > 0 ? (
- 可多选(再次点击取消选择) + {allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}
{options.map((option) => { const optionId = option.id || option.label; @@ -675,10 +694,10 @@ function ClarificationCard({ @@ -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 new file mode 100644 index 00000000..db73b4ed --- /dev/null +++ b/frontend-web/src/roundtable-planning/api/recommend.ts @@ -0,0 +1,303 @@ +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 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; +} + +/** 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"; +} + +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 + * each helper independently readable. + */ +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; + 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 }; + let rawContent = ""; + let lastRationale = ""; + let lastPickCount = 0; + + 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); + rawContent += delta.text; + const { rationale, picks } = parseRecommendStreamContent(rawContent); + if (rationale !== lastRationale) { + lastRationale = rationale; + req.onRationaleUpdate?.(rationale); + } + if (picks.length > 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 ? ( +
+