Merge branch 'dhk-1' into 'master'

接口联调

See merge request knowledge-base-bj/deerflow-server!51
This commit is contained in:
董宏凯 committed 2026-05-25 09:50:01 +00:00
commit 86e3b4de06
16 files changed
+2220 -209

No files matched your search

@@ -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<StreamRecommendResult>
```
**排除规则(`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<string, { avatarType: string; displayName: string }>
const FALLBACK_AVATAR_TYPES = ["blue","purple","emerald","amber","pink"]
function getAvatarTypeFor(agentId, index): string
function getDisplayNameFor(agent: SelectedAgent): string
function buildRoleTemplates(agents): ReadonlyArray<Omit<RoleEntry,"status">>
function makeRolesFromTemplates(templates, defaultStatus, overrides?): RoleEntry[]
function buildAgentNameToRole(agents): Record<string, {speakerId,avatarType,displayName}>
const DEFAULT_SELECTED_AGENTS: SelectedAgent[] // 原 6 个内置席位
```
**新增 state + useMemo:**
```typescript
const [selectedAgents, setSelectedAgents] = useState<SelectedAgent[]>(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 ? <RobotAvatar ...>` 链 | `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<boolean>(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
<RecommendAgentsDialog
open={isRecommendDialogOpen}
intent={intentReady}
model={DEFAULT_MODEL}
onCancel={() => 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` 字段按新选角色动态渲染
@@ -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<string, ClarificationSelection>,
) => {
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);
}}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
@@ -412,6 +420,7 @@ type ClarificationPayload = {
clarification_type?: string;
options?: ClarificationOption[];
allow_custom?: boolean;
allow_multiple?: boolean;
};
type ClarificationSelection = {
@@ -600,7 +609,7 @@ function ClarificationCard({
onSelectionChange: (next: ClarificationSelection) => void;
submitting: boolean;
canSubmit: boolean;
onSubmitAll: () => void | Promise<void>;
onSubmitAll: (override?: ClarificationSelection) => void | Promise<void>;
}) {
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 ? (
<div className="mt-4 grid gap-2">
<div className="text-muted-foreground text-xs">
可多选(再次点击取消选择)
{allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}
</div>
{options.map((option) => {
const optionId = option.id || option.label;
@@ -675,10 +694,10 @@ function ClarificationCard({
<Button
key={optionId}
type="button"
variant={isSelected ? "default" : "outline"}
variant={isSelected && allowMultiple ? "default" : "outline"}
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
disabled={disabled}
onClick={() => toggleOption(optionId)}
onClick={() => handleOptionClick(optionId)}
>
<span className="flex min-w-0 flex-1 flex-col items-start gap-1">
<span>{option.label}</span>
@@ -695,7 +714,7 @@ function ClarificationCard({
</span>
) : null}
</span>
{isSelected ? (
{isSelected && allowMultiple ? (
<CheckIcon className="ml-2 h-4 w-4 shrink-0" />
) : null}
</Button>
@@ -720,10 +739,10 @@ function ClarificationCard({
</div>
) : null}
{isPending ? (
{isPending && (allowMultiple || payload.allow_custom !== false) ? (
isLastPending ? (
<div className="mt-3 flex items-center justify-end gap-2">
{multiPending ? (
{multiPending && allowMultiple ? (
<span className="text-muted-foreground text-xs">
将一起提交以上 {pendingCount} 个问题的回答
</span>
@@ -735,14 +754,14 @@ function ClarificationCard({
onClick={() => void onSubmitAll()}
>
<SendIcon className="h-4 w-4" />
{submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"}
{submitting ? "发送中…" : multiPending && allowMultiple ? "全部发送" : "发送回答"}
</Button>
</div>
) : (
) : allowMultiple ? (
<div className="text-muted-foreground mt-3 text-right text-xs">
选择后等待最后一题统一提交
</div>
)
) : null
) : null}
</div>
);
@@ -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<StreamInte
req.onPhaseChange?.(next, toolName);
};
const result: StreamIntentResult = { status: "asking", content: "", summary: null };
const result: StreamIntentResult = {
status: "asking",
content: "",
summary: null,
clarification: null,
};
while (true) {
const { done, value } = await reader.read();
@@ -205,6 +231,22 @@ export async function streamIntent(req: StreamIntentRequest): Promise<StreamInte
if (parsed.summary && typeof parsed.summary === "object") {
result.summary = parsed.summary as IntentSummary;
}
if (parsed.status === "clarification") {
const clarificationType =
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "";
result.clarification = {
question: typeof parsed.question === "string" ? parsed.question : "",
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,
),
};
}
continue;
}
@@ -1,6 +1,8 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
export type ThreadIdMap = Record<string, string>;
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,
),
};
}
}
@@ -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<string>(["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<string>();
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<string, unknown>;
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<string, unknown>;
const type = String(obj.type ?? "").toLowerCase();
if (type !== "ai" && type !== "aimessagechunk" && type !== "aimessage") return null;
const content = obj.content;
let text = "";
if (typeof content === "string") {
text = content;
} else if (Array.isArray(content)) {
for (const item of content) {
if (typeof item === "string") text += item;
else if (item && typeof item === "object") {
const block = item as Record<string, unknown>;
const blockText = block.text;
if (typeof blockText === "string") text += blockText;
}
}
}
if (!text) return null;
const id = typeof obj.id === "string" ? obj.id : "";
return { text, id };
}
/**
* Open an SSE connection to `/api/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<StreamRecommendResult> {
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;
}
@@ -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 (
<div className="mt-2 rounded-xl border border-red-100 bg-white p-3 shadow-sm">
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold uppercase tracking-wide text-primary">
需要你的确认
</span>
{payload.clarificationType ? (
<span className="rounded-full bg-[var(--brand-200)]/60 px-2 py-0.5 text-[9px] font-mono font-bold text-[var(--brand-950)]">
{payload.clarificationType}
</span>
) : null}
</div>
{payload.context ? (
<p className="text-[11px] leading-relaxed text-slate-500 italic">{payload.context}</p>
) : null}
<div className="text-xs font-medium leading-relaxed text-slate-800">
{payload.question || fallbackContent}
</div>
</div>
{options.length > 0 ? (
<div className="mt-3 grid gap-2">
<div className="text-[10px] text-slate-500">
{allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}
</div>
{options.map((option) => {
const isSelected = selection.selectedIds.includes(option.id);
return (
<button
key={option.id}
type="button"
disabled={disabled || submitting}
onClick={() => handleOptionClick(option.id)}
className={cn(
"flex w-full items-start justify-between gap-2 rounded-xl border px-3 py-2 text-left transition",
isSelected && allowMultiple
? "border-primary bg-primary text-white shadow-sm"
: "border-slate-200 bg-slate-50/80 text-slate-800 hover:border-primary/40 hover:bg-white",
!allowMultiple && !disabled && !submitting && "active:scale-[0.99]",
(disabled || submitting) && "cursor-not-allowed opacity-60",
)}
>
<span className="flex min-w-0 flex-1 flex-col items-start gap-1">
<span className="text-[11px] font-bold">{option.label}</span>
{option.description ? (
<span
className={cn(
"text-[10px] leading-snug",
isSelected && allowMultiple ? "text-white/80" : "text-slate-500",
)}
>
{option.description}
</span>
) : null}
</span>
{isSelected && allowMultiple ? (
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0" />
) : null}
</button>
);
})}
</div>
) : null}
{payload.allowCustom !== false ? (
<div className="mt-3 flex flex-col gap-2">
<Textarea
value={selection.custom}
onChange={(event) => updateCustom(event.currentTarget.value)}
placeholder={
options.length > 0
? allowMultiple
? "可选:补充说明(与所选项一起发送),或在此填写自定义回答"
: "如果以上选项都不合适,可以在这里输入自定义回答"
: "如果以上选项都不合适,可以在这里输入你的回答"
}
disabled={disabled || submitting}
className="min-h-16 resize-none border-slate-200 text-xs"
/>
</div>
) : null}
{showSubmitButton ? (
<div className="mt-3 flex justify-end">
<Button
type="button"
size="sm"
disabled={disabled || submitting || !canSubmit}
onClick={() => onSubmit()}
className="h-8 rounded-xl px-4 text-xs font-bold"
>
{submitting ? "发送中…" : "发送回答"}
</Button>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,614 @@
"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,
parseRecommendStreamContent,
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<string, string>;
/** 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<string>();
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<InternalState>(INITIAL_STATE);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// Latest stream abort controller, so closing or re-running cancels the
// in-flight request instead of leaking it.
const abortRef = useRef<AbortController | null>(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,
onRationaleUpdate: (rationale) => {
if (myGen !== generationRef.current) return;
setState((prev) => ({ ...prev, rationale }));
},
onPicksUpdate: (picks) => {
if (myGen !== generationRef.current) return;
const reasons: Record<string, string> = {};
const recommendedIds: string[] = [];
for (const p of picks) {
reasons[p.agent_id] = p.reason;
recommendedIds.push(p.agent_id);
}
const seed = intersectInOrder(recommendedIds, candidates);
setSelectedIds((prev) => {
const next = new Set(prev);
for (const id of seed) next.add(id);
return next;
});
setState((prev) => ({
...prev,
recommendedIds,
reasons,
}));
},
onPhaseChange: (phase) => {
if (myGen !== generationRef.current) return;
setState((prev) => ({ ...prev, streamPhase: phase }));
},
});
if (myGen !== generationRef.current) return;
const reasons: Record<string, string> = {};
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));
const { rationale } = parseRecommendStreamContent(result.content || "");
setState((prev) => ({
...prev,
phase: "ready",
rationale: rationale || 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));
const { rationale } = parseRecommendStreamContent(result.content || "");
setState((prev) => ({
...prev,
phase: "fallback",
rationale: rationale || 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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<SparklesIcon className="size-5 text-purple-500" />
选择参会智能体
</DialogTitle>
<DialogDescription>
根据你的任务目标,系统为你推荐了最合适的智能体。你可以取消勾选,也可以从下方添加其他智能体。
</DialogDescription>
</DialogHeader>
{/* Rationale + status block */}
<div className="border rounded-md bg-muted/30 p-3 text-sm">
<RationaleBlock state={state} />
</div>
{/* Selection area: scrollable, takes remaining height */}
<div className="flex-1 min-h-0 overflow-y-auto pr-1 -mr-1 space-y-4">
{state.phase === "error" ? (
<div className="text-sm text-destructive p-4 border border-destructive/30 rounded-md bg-destructive/5">
{state.errorMessage ?? "未知错误"}
</div>
) : state.phase === "recommending" ? (
<>
{recommendedCards.length > 0 ? (
<Section title={`推荐参会 (${recommendedCards.length})`}>
{recommendedCards.map((c) => (
<AgentCard
key={c.agent_id}
candidate={c}
reason={state.reasons[c.agent_id]}
recommended
checked={selectedIds.has(c.agent_id)}
onToggle={() => handleToggle(c.agent_id)}
streaming
/>
))}
</Section>
) : (
<div className="flex items-center gap-2 text-xs text-muted-foreground px-1 py-2">
<Loader2Icon className="size-3 animate-spin" />
等待推荐结果...
</div>
)}
</>
) : (state.phase === "ready" || state.phase === "fallback") ? (
<>
{state.warning && (
<div className="text-xs text-amber-700 dark:text-amber-300 p-2 border border-amber-300/50 dark:border-amber-700/50 rounded-md bg-amber-50 dark:bg-amber-950/30">
{state.warning}
</div>
)}
{recommendedCards.length > 0 && (
<Section title={`推荐参会 (${recommendedCards.length})`}>
{recommendedCards.map((c) => (
<AgentCard
key={c.agent_id}
candidate={c}
reason={state.reasons[c.agent_id]}
recommended
checked={selectedIds.has(c.agent_id)}
onToggle={() => handleToggle(c.agent_id)}
/>
))}
</Section>
)}
{otherCards.length > 0 && (
<Section title={`其他候选 (${otherCards.length})`}>
{otherCards.map((c) => (
<AgentCard
key={c.agent_id}
candidate={c}
reason={undefined}
recommended={false}
checked={selectedIds.has(c.agent_id)}
onToggle={() => handleToggle(c.agent_id)}
/>
))}
</Section>
)}
</>
) : null}
</div>
<DialogFooter className="flex-row sm:justify-between items-center gap-2 flex-wrap">
<div className="flex items-center gap-3 text-xs">
<span
className={cn(
"tabular-nums",
selectedCount < MIN_SELECTED || selectedCount > MAX_SELECTED
? "text-destructive"
: "text-muted-foreground",
)}
>
{selectionHint}
</span>
{(state.phase === "ready" || state.phase === "fallback" || state.phase === "error") && (
<Button
variant="ghost"
size="sm"
onClick={() => void runRecommendation()}
className="h-7 px-2 text-xs"
>
<RefreshCwIcon className="size-3 mr-1" />
重新推荐
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={onCancel}>
取消
</Button>
<Button onClick={handleConfirm} disabled={!canConfirm}>
进入研讨
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function RationaleBlock({ state }: { state: InternalState }) {
if (state.phase === "loading_agents") {
return (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2Icon className="size-4 animate-spin" />
正在加载候选智能体...
</div>
);
}
if (state.phase === "recommending") {
const label =
state.streamPhase === "connecting"
? "调度推荐智能体..."
: state.streamPhase === "connected"
? "已连接,等待响应..."
: "正在生成推荐...";
return (
<div className="space-y-1">
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<Loader2Icon className="size-3 animate-spin" />
{label}
</div>
{state.rationale ? (
<p className="whitespace-pre-wrap break-words text-xs text-foreground leading-relaxed">
{state.rationale}
</p>
) : (
<p className="text-xs text-muted-foreground">正在分析任务与候选智能体...</p>
)}
</div>
);
}
if (state.phase === "ready" || state.phase === "fallback") {
if (!state.rationale) {
return <div className="text-xs text-muted-foreground">推荐完成。</div>;
}
return (
<p className="whitespace-pre-wrap break-words text-xs text-foreground leading-relaxed max-h-32 overflow-y-auto">
{state.rationale}
</p>
);
}
if (state.phase === "error") {
return <div className="text-xs text-destructive">推荐失败,请使用下方的"重新推荐"重试,或取消后再次进入。</div>;
}
return null;
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{title}
</div>
<div className="grid gap-2">{children}</div>
</div>
);
}
interface AgentCardProps {
candidate: RecommendCandidate;
reason: string | undefined;
recommended: boolean;
checked: boolean;
onToggle: () => void;
/** Card is still being filled in from the SSE stream. */
streaming?: boolean;
}
function AgentCard({
candidate,
reason,
recommended,
checked,
onToggle,
streaming = false,
}: AgentCardProps) {
return (
<button
type="button"
onClick={onToggle}
className={cn(
"text-left rounded-md border p-3 transition-colors flex gap-3 items-start",
checked
? "border-primary bg-primary/5 hover:bg-primary/10"
: "border-border hover:bg-muted/40",
streaming && "animate-in fade-in slide-in-from-bottom-1 duration-300",
)}
>
{/* Visual check box — simulated, since the project ships no Checkbox primitive */}
<span
className={cn(
"mt-0.5 size-4 rounded border flex items-center justify-center shrink-0",
checked ? "border-primary bg-primary text-primary-foreground" : "border-input bg-background",
)}
>
{checked && <CheckIcon className="size-3" />}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<div className="font-medium text-sm truncate">{candidate.name || candidate.agent_id}</div>
{recommended && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/10 text-purple-700 dark:text-purple-300 font-medium">
推荐
</span>
)}
</div>
{candidate.description && (
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{candidate.description}
</div>
)}
{reason ? (
<div className="text-xs text-primary/80 mt-1 italic">
<span className="font-medium not-italic">推荐理由:</span> {reason}
</div>
) : streaming ? (
<div className="text-xs text-muted-foreground mt-1 italic">正在生成推荐理由...</div>
) : null}
</div>
</button>
);
}
@@ -0,0 +1,13 @@
/**
* Resolve whether option cards should allow multi-select.
* Default is single-select (click to send); only multi when explicitly requested.
*/
export function resolveAllowMultiple(
allowMultiple: boolean | undefined,
_clarificationType?: string | undefined,
): boolean {
if (typeof allowMultiple === "boolean") {
return allowMultiple;
}
return false;
}
@@ -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,12 @@ import {
type IntentStreamPhase,
type IntentSummary,
} from "@/roundtable-planning/api/intent";
import {
IntentClarificationCard,
formatIntentClarificationAnswer,
type IntentClarificationSelection,
} from "@/roundtable-planning/components/IntentClarificationCard";
import { RecommendAgentsDialog } from "@/roundtable-planning/components/RecommendAgentsDialog";
import {
deleteDraft,
deriveDraftName,
@@ -159,42 +165,111 @@ type RoleEntry = {
status: string;
};
const ROUNDTABLE_ROLE_TEMPLATES: ReadonlyArray<Omit<RoleEntry, "status">> = [
{ 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<number, string> = {}): 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<string, { avatarType: string; displayName: string }> = {
"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<Omit<RoleEntry, "status">> {
const out: Omit<RoleEntry, "status">[] = [
{ 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<Omit<RoleEntry, "status">>,
defaultStatus: string,
overrides: Record<number, string> = {},
): 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<string, { speakerId: number; avatarType: string; displayName: string }> {
const out: Record<string, { speakerId: number; avatarType: string; displayName: string }> = {};
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<string, { speakerId: number; avatarType: string; displayName: string }> = {
"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 +409,27 @@ export default function RoundtablePlanningPage() {
}
]);
// Roles in roundtable state (updates as we switch steps!)
const [roles, setRoles] = useState<RoleEntry[]>([
{ 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<SelectedAgent[]>(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<RoleEntry[]>(() =>
makeRolesFromTemplates(buildRoleTemplates(DEFAULT_SELECTED_AGENTS), "等待激活"),
);
// Dialog: recommend agents before entering Step 2.
const [isRecommendDialogOpen, setIsRecommendDialogOpen] = useState<boolean>(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.
@@ -367,6 +452,10 @@ export default function RoundtablePlanningPage() {
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
const [intentInitError, setIntentInitError] = useState<string | null>(null);
const [isIntentSending, setIsIntentSending] = useState<boolean>(false);
const [pendingIntentClarificationMsgId, setPendingIntentClarificationMsgId] = useState<number | null>(null);
const [intentClarificationSelections, setIntentClarificationSelections] = useState<
Map<number, IntentClarificationSelection>
>(new Map());
const step1InitRef = useRef<boolean>(false);
// Monotonic counter incremented every time Step 1 init starts. The in-flight
// async checks this on completion and bails if a newer init has begun (e.g.
@@ -442,6 +531,22 @@ export default function RoundtablePlanningPage() {
step1ScrollRef,
[interactiveMessages],
);
// Sync intentReady when [INTENT_READY] appears in chat but the stream frame
// missed it (e.g. clarification returned before summary was parsed).
useEffect(() => {
if (intentReady) return;
for (let i = interactiveMessages.length - 1; i >= 0; i--) {
const msg = interactiveMessages[i];
if (msg.sender === "user") continue;
const { summary } = splitIntentReadyBlock(msg.content ?? "");
if (summary) {
setIntentReady(summary);
setPendingIntentClarificationMsgId(null);
break;
}
}
}, [interactiveMessages, intentReady]);
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } = useAutoScroll(
step2ScrollRef,
[step2RoundtableDialogues],
@@ -739,7 +844,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 +911,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 +970,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
@@ -1009,6 +1130,8 @@ export default function RoundtablePlanningPage() {
setIntentReady(null);
setIntentInitError(null);
setIntentThreadId(null);
setPendingIntentClarificationMsgId(null);
setIntentClarificationSelections(new Map());
setTimelineEntries([]);
setInteractiveMessages([
{
@@ -1277,10 +1400,11 @@ export default function RoundtablePlanningPage() {
// Chat sender logic for Step 1 interaction — drives the real intent agent
// via /api/intent/stream. The agent streams its reply into a placeholder
// bubble; if its final status is "done" we capture the structured summary
// for the Step 2 handoff.
const handleSendMessage = async () => {
const userText = inputText.trim();
if (!userText) return;
// for the Step 2 handoff. When status is "clarification", render option
// cards like the main chat ClarificationCard.
const sendIntentMessage = async (userText: string) => {
const trimmed = userText.trim();
if (!trimmed) return;
if (!intentThreadId) {
triggerToast("⚠️ 任务理解智能体尚未就绪,请稍候");
return;
@@ -1290,33 +1414,31 @@ export default function RoundtablePlanningPage() {
return;
}
setPendingIntentClarificationMsgId(null);
const userMsgTime = nowTime();
const userMsgId = Date.now() + Math.random();
const userMsg = {
id: userMsgId,
sender: "user",
senderName: "用户",
content: userText,
content: trimmed,
time: userMsgTime,
};
setInteractiveMessages(prev => [...prev, userMsg]);
setInputText("");
setCharactersLeft(0);
// Right-side timeline gets a short summary too.
setTimelineEntries(prev => [
...prev,
{
id: prev.length + 1,
time: userMsgTime,
title: "用户实时反馈",
desc: userText.length > 25 ? userText.substring(0, 25) + "..." : userText,
desc: trimmed.length > 25 ? trimmed.substring(0, 25) + "..." : trimmed,
},
]);
// Placeholder agent bubble — text deltas accumulate into it. `phase`
// tracks the inferred SSE stage so the "in flight" caption can flip from
// "调度中" → "已连接,等待响应" → "正在思考问题" as the run progresses.
const agentMsgId = Date.now() + Math.random() + 0.01;
setInteractiveMessages(prev => [
...prev,
@@ -1331,9 +1453,6 @@ export default function RoundtablePlanningPage() {
} as any,
]);
// Map an inferred phase + (optional) tool name to a human-readable label
// shown next to the spinner. Tool-call phases highlight ask_clarification
// explicitly since that's the only tool the intent agent normally uses.
const intentPhaseLabel = (phase: IntentStreamPhase, toolName?: string): string => {
switch (phase) {
case "connecting":
@@ -1354,7 +1473,7 @@ export default function RoundtablePlanningPage() {
try {
const res = await streamIntent({
threadId: intentThreadId,
message: userText,
message: trimmed,
model: DEFAULT_MODEL,
onTextDelta: (delta) => {
setInteractiveMessages(prev =>
@@ -1368,29 +1487,44 @@ export default function RoundtablePlanningPage() {
);
},
});
// Finalize the bubble with the authoritative final text from the
// status frame (covers cases where deltas didn't accumulate cleanly).
const parsedFromContent = splitIntentReadyBlock(res.content || "").summary;
const resolvedSummary = res.summary ?? parsedFromContent;
setInteractiveMessages(prev =>
prev.map(m =>
m.id === agentMsgId
? { ...m, content: res.content || m.content || "(无文本输出)", streaming: false } as any
? {
...m,
content: res.content || m.content || "(无文本输出)",
streaming: false,
clarification: resolvedSummary ? undefined : res.clarification ?? undefined,
} as any
: m,
),
);
if (res.status === "done" && res.summary) {
setIntentReady(res.summary);
if (resolvedSummary) {
setIntentReady(resolvedSummary);
setPendingIntentClarificationMsgId(null);
setTimelineEntries(prev => [
...prev,
{
id: prev.length + 1,
time: nowTime(),
title: "任务意图已明确",
desc: res.summary!.objective.length > 25
? res.summary!.objective.substring(0, 25) + "..."
: res.summary!.objective,
desc: resolvedSummary.objective.length > 25
? resolvedSummary.objective.substring(0, 25) + "..."
: resolvedSummary.objective,
},
]);
triggerToast("✅ 任务理解完成,可进入多智能体圆桌研讨");
} else if (res.status === "clarification" && res.clarification) {
setPendingIntentClarificationMsgId(agentMsgId);
setIntentClarificationSelections(prev => {
const next = new Map(prev);
next.set(agentMsgId, { selectedIds: [], custom: "" });
return next;
});
} else if (res.status === "error") {
triggerToast(`❌ 智能体调用失败: ${res.content}`);
}
@@ -1409,6 +1543,29 @@ export default function RoundtablePlanningPage() {
}
};
const handleSendMessage = () => {
void sendIntentMessage(inputText);
};
const submitIntentClarification = (
msgId: number,
payload: ClarificationPayload,
overrideSelection?: IntentClarificationSelection,
) => {
const selection =
overrideSelection ??
intentClarificationSelections.get(msgId) ?? {
selectedIds: [],
custom: "",
};
const answer = formatIntentClarificationAnswer(payload, selection);
if (!answer) {
triggerToast("请先选择选项或输入回答");
return;
}
void sendIntentMessage(answer);
};
// Skip step logic
const handleSkipQuestion = () => {
const systemSkipMsg = {
@@ -1473,11 +1630,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 +1701,14 @@ export default function RoundtablePlanningPage() {
>
{/* Toast Notice Banner */}
<RecommendAgentsDialog
open={isRecommendDialogOpen}
intent={intentReady}
model={DEFAULT_MODEL}
onCancel={() => setIsRecommendDialogOpen(false)}
onConfirm={handleConfirmAgents}
/>
{showToast && (
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 bg-primary text-white text-xs px-4 py-2.5 rounded-xl shadow-lg border border-primary/40 font-medium flex items-center gap-2 animate-bounce">
<Sparkles className="h-4 w-4 shrink-0" />
@@ -2009,16 +2174,10 @@ export default function RoundtablePlanningPage() {
}`}
>
<div className="flex items-center gap-2.5">
{/* Avatar chooser based on role.id */}
{role.type === "human" ? (
<Man1Avatar size="h-7 w-7" />
) : (
role.id === 2 ? <RobotAvatar headColor="#f5222d" size="h-7 w-7" /> :
role.id === 3 ? <PurpleRobotAvatar size="h-7 w-7" /> :
role.id === 4 ? <EmeraldRobotAvatar size="h-7 w-7" /> :
role.id === 6 ? <AmberRobotAvatar size="h-7 w-7" /> :
role.id === 7 ? <PinkRobotAvatar size="h-7 w-7" /> :
<BlueRobotAvatar size="h-7 w-7" />
getAvatar(role.agentId ? (agentNameToRole[role.agentId]?.avatarType ?? "blue") : "blue", "h-7 w-7")
)}
<div className="flex flex-col">
@@ -2146,10 +2305,7 @@ export default function RoundtablePlanningPage() {
</div>
<button
id="confirm-roundtable-btn"
onClick={() => {
setCurrentStep(2);
triggerToast("成功确认!已切换至第二阶段圆桌研讨系统");
}}
onClick={() => setIsRecommendDialogOpen(true)}
className="bg-primary hover:bg-[var(--brand-900)] text-white font-bold text-xs px-4 py-2 rounded-xl transition shadow-[0_2px_8px_rgba(245,34,45,0.2)] active:scale-95 shrink-0"
>
用户确认,进入多智能体研讨
@@ -2229,12 +2385,26 @@ export default function RoundtablePlanningPage() {
const isStreaming = (msg as { streaming?: boolean }).streaming === true;
const hasContent = (msg.content ?? "").length > 0;
const isAgent = msg.sender !== "user";
// Only agent replies carry markdown / [INTENT_READY]; user
// messages stay as plain text so casing/whitespace are
// preserved verbatim.
const clarification = (msg as { clarification?: ClarificationPayload }).clarification;
const parts = isAgent
? splitIntentReadyBlock(msg.content ?? "")
: { before: msg.content ?? "", summary: null, after: "" };
const isPendingClarification =
isAgent &&
clarification &&
!intentReady &&
!parts.summary &&
pendingIntentClarificationMsgId === msg.id;
const clarificationSelection =
intentClarificationSelections.get(msg.id) ?? {
selectedIds: [],
custom: "",
};
const introText =
clarification &&
parts.before.trim() === (clarification.question || "").trim()
? ""
: parts.before;
return (
<div key={msg.id} className="flex items-start justify-between gap-3 border-b border-slate-100/50 pb-2.5 last:border-0 last:pb-0">
<div className="flex items-start gap-2.5 min-w-0">
@@ -2257,7 +2427,7 @@ export default function RoundtablePlanningPage() {
</span>
) : isAgent ? (
<div className="text-xs text-slate-700 leading-normal font-normal break-words space-y-2">
{parts.before && (
{introText && (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
@@ -2293,7 +2463,7 @@ export default function RoundtablePlanningPage() {
),
}}
>
{parts.before}
{introText}
</ReactMarkdown>
)}
{parts.summary && (
@@ -2338,6 +2508,25 @@ export default function RoundtablePlanningPage() {
{isStreaming && (
<span className="inline-block w-1 h-3 ml-0.5 bg-primary align-middle animate-pulse" />
)}
{isPendingClarification && clarification && (
<IntentClarificationCard
payload={clarification}
fallbackContent={parts.before || undefined}
selection={clarificationSelection}
onSelectionChange={(next) => {
setIntentClarificationSelections(prev => {
const map = new Map(prev);
map.set(msg.id, next);
return map;
});
}}
disabled={!isPendingClarification}
submitting={isIntentSending && isPendingClarification}
onSubmit={(override) =>
submitIntentClarification(msg.id, clarification, override)
}
/>
)}
</div>
) : (
<span className="text-xs text-slate-700 leading-normal font-normal whitespace-pre-wrap break-words">
@@ -2373,7 +2562,11 @@ export default function RoundtablePlanningPage() {
handleSendMessage();
}
}}
placeholder="请输入补充信息 / 回答问题 / 继续澄清 (Enter 快速发送)"
placeholder={
pendingIntentClarificationMsgId
? "也可在此输入自定义回答 (Enter 快速发送)"
: "请输入补充信息 / 回答问题 / 继续澄清 (Enter 快速发送)"
}
className="w-full text-xs text-slate-700 placeholder:text-slate-400 resize-none h-[48px] focus:outline-none leading-relaxed font-normal"
maxLength={500}
/>
@@ -2435,7 +2628,7 @@ export default function RoundtablePlanningPage() {
triggerToast("⚠️ 请先与任务理解智能体完成澄清,生成任务摘要后再进入");
return;
}
setCurrentStep(2);
setIsRecommendDialogOpen(true);
}}
disabled={!intentReady}
title={intentReady ? undefined : "请先完成任务意图澄清"}
@@ -3123,7 +3316,7 @@ export default function RoundtablePlanningPage() {
content: String(d.content ?? ""),
}))}
seatNameMap={Object.fromEntries(
Object.entries(AGENT_NAME_TO_ROLE).map(([k, v]) => [k, v.displayName]),
Object.entries(agentNameToRole).map(([k, v]) => [k, v.displayName]),
)}
onReset={handleStartNewTask}
/>
@@ -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.
@@ -35,6 +35,7 @@ from app.gateway.routers.multi_agent import (
_run_payload,
_stream_upstream,
)
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/intent", tags=["intent"])
@@ -83,9 +84,12 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
through transparently, then appends one status frame:
{
"status": "asking" | "done" | "error",
"status": "asking" | "clarification" | "done" | "error",
"content": "<final AI text>",
"summary": {...} | null // only present when status == "done"
"summary": {...} | null, // only when status == "done"
// when status == "clarification":
"question", "clarification_type", "clarification_context",
"options", "allow_custom", "allow_multiple"
}
"""
thread_id = str(payload.get("thread_id") or "").strip()
@@ -111,13 +115,26 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
model_name=model_name,
thread_id=thread_id,
new_message=message,
# Intent agent shouldn't dispatch, search, write files, or
# view images — its only tool of note is ask_clarification.
# Intent agent only ever needs ask_clarification. Block
# everything else so the model can't drift into solutioning
# (e.g. saving a design doc with write_file) — the model
# tends to ignore SOUL prose, hard exclusion is reliable.
excluded_tools=[
"web_search",
"present_files",
"view_image",
"agent_orchestration",
"bash",
"ls",
"read_file",
"write_file",
"str_replace",
"memory",
"hindsight_recall",
"hindsight_reflect",
"hindsight_retain",
"task",
"write_todos",
],
skill_stop_names=[],
)
@@ -130,6 +147,36 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
captured = done
messages = _parse_last_messages(captured)
# [INTENT_READY] wins over ask_clarification: the model may emit
# the handoff marker in an earlier AI message while the latest
# dispatch still carries a stale clarification tool call.
summary: dict[str, Any] | None = None
ready_text: str = ""
for msg in reversed(messages):
if not isinstance(msg, dict):
continue
if (msg.get("type") or "").lower() not in ("ai", "aimessage", "aimessagechunk"):
continue
msg_text = _flatten_content(msg.get("content", ""))
m = _INTENT_READY_PATTERN.search(msg_text)
if m:
try:
summary = json.loads(m.group(1))
except json.JSONDecodeError as exc:
logger.warning("intent summary JSON parse failed: %s", exc)
summary = None
ready_text = msg_text
break
if summary and ready_text:
final_frame = {
"status": "done",
"content": ready_text,
"summary": summary,
}
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
return
# Find the latest AIMessage with tool_calls (typically
# ask_clarification) — falls back to the latest plain
# AIMessage when the model produced no tool call.
@@ -137,13 +184,6 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
content = _flatten_content(dispatch_msg.get("content", ""))
tool_calls = dispatch_msg.get("tool_calls") or []
# Clarification branch: the agent ended its run by calling
# ask_clarification. ClarificationMiddleware intercepts with
# Command(goto=END) and stashes the structured question in the
# tool_call args, so the AIMessage `content` is only the
# preamble ("下面我用几个问题确认:") and stops there. We splice
# the question back into the user-facing text so the frontend
# actually shows what the agent wants to ask.
clarification_call = next(
(tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"),
None,
@@ -152,31 +192,25 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
cargs = clarification_call.get("args") or {}
question = str(cargs.get("question") or "").strip()
follow_up = _find_trailing_ai_text(messages, exclude_text=content)
# Build a single content string the frontend can render
# as-is: preamble (if any) + the actual question.
parts: list[str] = []
if content.strip():
parts.append(content.strip())
if follow_up and follow_up != question:
parts.append(follow_up)
if question:
parts.append(question)
content = "\n\n".join(parts) if parts else content
display_content = follow_up or content or question
final_frame: dict[str, Any] = {
"status": "clarification",
"content": display_content,
"question": question,
"clarification_type": str(cargs.get("clarification_type") or ""),
"clarification_context": str(cargs.get("context") or ""),
"options": cargs.get("options") or [],
"allow_custom": bool(cargs.get("allow_custom", True)),
"allow_multiple": resolve_allow_multiple(cargs),
"summary": None,
}
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
return
summary: dict[str, Any] | None = None
match = _INTENT_READY_PATTERN.search(content)
if match:
try:
summary = json.loads(match.group(1))
except json.JSONDecodeError as exc:
logger.warning("intent summary JSON parse failed: %s", exc)
summary = None
status_value = "done" if summary else "asking"
final_frame: dict[str, Any] = {
"status": status_value,
"status": "asking",
"content": content,
"summary": summary,
"summary": None,
}
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
@@ -25,6 +25,7 @@ import time
from typing import Any, AsyncIterator
import httpx
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse
@@ -616,6 +617,7 @@ async def _leader_run(
"clarification_context": str(cargs.get("context") or ""),
"options": cargs.get("options") or [],
"allow_custom": bool(cargs.get("allow_custom", True)),
"allow_multiple": resolve_allow_multiple(cargs),
"agent_name": agent_name,
}
return
@@ -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: "- <id> (<name>): <description>" — 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": "<final AI text>",
"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",
},
)
@@ -6,6 +6,8 @@ from collections.abc import Callable
from hashlib import sha256
from typing import override
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain_core.messages import ToolMessage
@@ -67,7 +69,7 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
question = args.get("question", "")
clarification_type = args.get("clarification_type", "missing_info")
context = args.get("context")
options = self._normalize_options(args.get("options", []))
options = self._normalize_options(args.get("options", []))
# Type-specific icons
type_icons = {
@@ -95,62 +97,63 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
# Add options in a cleaner format
if options and len(options) > 0:
message_parts.append("") # blank line for spacing
for i, option in enumerate(options, 1):
label = option.get("label") or option.get("id") or ""
message_parts.append(f" {i}. {label}")
return "\n".join(message_parts)
def _normalize_options(self, raw_options: object) -> list[dict[str, str]]:
"""Normalize model-produced options into a stable UI-friendly shape."""
options = raw_options
# Some models serialize array parameters as JSON strings instead of native arrays.
if isinstance(options, str):
try:
options = json.loads(options)
except (json.JSONDecodeError, TypeError):
options = [options]
if options is None:
values: list[object] = []
elif isinstance(options, list):
values = options
else:
values = [options]
normalized: list[dict[str, str]] = []
for index, value in enumerate(values, 1):
if isinstance(value, dict):
raw_label = value.get("label") or value.get("title") or value.get("name") or value.get("value") or value.get("id")
label = str(raw_label).strip() if raw_label is not None else ""
if not label:
continue
raw_id = value.get("id") or value.get("value") or f"option-{index}"
option = {
"id": str(raw_id).strip() or f"option-{index}",
"label": label,
}
description = value.get("description")
if description is not None and str(description).strip():
option["description"] = str(description).strip()
normalized.append(option)
else:
label = str(value).strip()
if label:
normalized.append({"id": f"option-{index}", "label": label})
return normalized
def _build_payload(self, args: dict) -> dict:
"""Build structured clarification data for rich clients."""
return {
"question": str(args.get("question", "")).strip(),
"clarification_type": args.get("clarification_type", "missing_info"),
"context": args.get("context"),
"options": self._normalize_options(args.get("options", [])),
"allow_custom": True,
}
for i, option in enumerate(options, 1):
label = option.get("label") or option.get("id") or ""
message_parts.append(f" {i}. {label}")
return "\n".join(message_parts)
def _normalize_options(self, raw_options: object) -> list[dict[str, str]]:
"""Normalize model-produced options into a stable UI-friendly shape."""
options = raw_options
# Some models serialize array parameters as JSON strings instead of native arrays.
if isinstance(options, str):
try:
options = json.loads(options)
except (json.JSONDecodeError, TypeError):
options = [options]
if options is None:
values: list[object] = []
elif isinstance(options, list):
values = options
else:
values = [options]
normalized: list[dict[str, str]] = []
for index, value in enumerate(values, 1):
if isinstance(value, dict):
raw_label = value.get("label") or value.get("title") or value.get("name") or value.get("value") or value.get("id")
label = str(raw_label).strip() if raw_label is not None else ""
if not label:
continue
raw_id = value.get("id") or value.get("value") or f"option-{index}"
option = {
"id": str(raw_id).strip() or f"option-{index}",
"label": label,
}
description = value.get("description")
if description is not None and str(description).strip():
option["description"] = str(description).strip()
normalized.append(option)
else:
label = str(value).strip()
if label:
normalized.append({"id": f"option-{index}", "label": label})
return normalized
def _build_payload(self, args: dict) -> dict:
"""Build structured clarification data for rich clients."""
return {
"question": str(args.get("question", "")).strip(),
"clarification_type": args.get("clarification_type", "missing_info"),
"context": args.get("context"),
"options": self._normalize_options(args.get("options", [])),
"allow_custom": True,
"allow_multiple": resolve_allow_multiple(args),
}
def _handle_clarification(self, request: ToolCallRequest) -> Command:
"""Handle clarification request and return command to interrupt execution.
@@ -176,13 +179,13 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
# Create a ToolMessage with the formatted question
# This will be added to the message history
tool_message = ToolMessage(
id=self._stable_message_id(tool_call_id, formatted_message),
content=formatted_message,
tool_call_id=tool_call_id,
name="ask_clarification",
additional_kwargs={"clarification": self._build_payload(args)},
)
tool_message = ToolMessage(
id=self._stable_message_id(tool_call_id, formatted_message),
content=formatted_message,
tool_call_id=tool_call_id,
name="ask_clarification",
additional_kwargs={"clarification": self._build_payload(args)},
)
# Return a Command that:
# 1. Adds the formatted tool message
@@ -15,6 +15,7 @@ def ask_clarification_tool(
],
context: str | None = None,
options: list[str] | None = None,
allow_multiple: bool | None = None,
) -> str:
"""Ask the user for clarification when you need more information to proceed.
@@ -50,8 +51,9 @@ def ask_clarification_tool(
Best practices:
- One question per ask_clarification call — keep each `question` focused on a single decision.
- Be specific and clear in your question.
- Provide `options` (with concise labels) whenever there is a discrete set of reasonable choices —
this lets the user multi-select in the UI instead of typing free text.
- Provide `options` (with concise labels) whenever there is a discrete set of reasonable choices.
- By default the UI is **single-select** (click an option to send). Pass `allow_multiple=True`
only when the user may legitimately pick several options together (e.g. multiple constraints).
- Don't make assumptions when clarification is needed.
- For risky operations, ALWAYS ask for confirmation.
- After calling this tool (or several in one turn), execution will be interrupted automatically.
@@ -60,7 +62,9 @@ def ask_clarification_tool(
question: The clarification question to ask the user. Focused on a single decision; be specific and clear.
clarification_type: The type of clarification needed (missing_info, ambiguous_requirement, approach_choice, risk_confirmation, suggestion).
context: Optional context explaining why clarification is needed. Helps the user understand the situation.
options: Optional list of choices (for approach_choice or suggestion types). Present clear options for the user to choose from; the UI supports multi-select.
options: Optional list of choices. Present clear options for the user to choose from.
allow_multiple: When True, the UI allows multi-select plus a submit button. When False or
omitted, the UI is single-select and sends immediately on click (default).
"""
# This is a placeholder implementation
# The actual logic is handled by ClarificationMiddleware which intercepts this tool call
@@ -0,0 +1,27 @@
"""Shared helpers for ask_clarification option selection mode."""
from __future__ import annotations
def coerce_bool(value: object | None, *, default: bool) -> bool:
"""Parse model/tool args that may arrive as bool, number, or string."""
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in ("true", "1", "yes", "y", "on"):
return True
if lowered in ("false", "0", "no", "n", "off"):
return False
return bool(value)
def resolve_allow_multiple(args: dict) -> bool:
"""Return True only when the model explicitly requests multi-select."""
if "allow_multiple" in args and args["allow_multiple"] is not None:
return coerce_bool(args["allow_multiple"], default=False)
return False