多智能体协作

This commit is contained in:
董宏凯 committed 2026-05-25 17:48:59 +08:00
1 parent 9a70329db2
commit 525218f9a7
13 files changed
+753 -180

No files matched your search

@@ -24,6 +24,7 @@ import type { Subtask } from "@/core/tasks";
import { useUpdateSubtask } from "@/core/tasks/context"; import { useUpdateSubtask } from "@/core/tasks/context";
import type { AgentThreadState } from "@/core/threads"; import type { AgentThreadState } from "@/core/threads";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
import { ArtifactFileList } from "../artifacts/artifact-file-list"; import { ArtifactFileList } from "../artifacts/artifact-file-list";
import { StreamingIndicator } from "../streaming-indicator"; 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) { if (pendingClarificationIds.length === 0 || !onClarificationSubmit) {
return; return;
} }
@@ -111,7 +114,7 @@ export function MessageList({
const id = pendingClarificationIds[index]!; const id = pendingClarificationIds[index]!;
const message = messages.find((msg) => msg.id === id); const message = messages.find((msg) => msg.id === id);
const payload = message ? getClarificationPayload(message) : null; const payload = message ? getClarificationPayload(message) : null;
const selection = clarificationSelections.get(id) ?? { const selection = selectionOverrides?.get(id) ?? clarificationSelections.get(id) ?? {
selectedIds: [], selectedIds: [],
custom: "", custom: "",
}; };
@@ -221,7 +224,12 @@ export function MessageList({
} }
submitting={submittingClarification} submitting={submittingClarification}
canSubmit={hasAnyClarificationAnswer} canSubmit={hasAnyClarificationAnswer}
onSubmitAll={handleSubmitAllClarifications} onSubmitAll={(override) => {
const overrides = override
? new Map([[messageId, override]])
: undefined;
void handleSubmitAllClarifications(overrides);
}}
/> />
<MessageTokenUsageList <MessageTokenUsageList
enabled={tokenUsageEnabled} enabled={tokenUsageEnabled}
@@ -412,6 +420,7 @@ type ClarificationPayload = {
clarification_type?: string; clarification_type?: string;
options?: ClarificationOption[]; options?: ClarificationOption[];
allow_custom?: boolean; allow_custom?: boolean;
allow_multiple?: boolean;
}; };
type ClarificationSelection = { type ClarificationSelection = {
@@ -600,7 +609,7 @@ function ClarificationCard({
onSelectionChange: (next: ClarificationSelection) => void; onSelectionChange: (next: ClarificationSelection) => void;
submitting: boolean; submitting: boolean;
canSubmit: boolean; canSubmit: boolean;
onSubmitAll: () => void | Promise<void>; onSubmitAll: (override?: ClarificationSelection) => void | Promise<void>;
}) { }) {
const payload = getClarificationPayload(message); const payload = getClarificationPayload(message);
@@ -624,11 +633,21 @@ function ClarificationCard({
const disabled = !isPending || submitting; const disabled = !isPending || submitting;
const multiPending = (pendingCount ?? 0) > 1; const multiPending = (pendingCount ?? 0) > 1;
const allowMultiple = resolveAllowMultiple(
payload.allow_multiple,
payload.clarification_type,
);
function toggleOption(optionId: string) { function handleOptionClick(optionId: string) {
if (disabled) { if (disabled) {
return; return;
} }
if (!allowMultiple) {
const next = { selectedIds: [optionId], custom: "" };
onSelectionChange(next);
void onSubmitAll(next);
return;
}
const next = selection.selectedIds.includes(optionId) const next = selection.selectedIds.includes(optionId)
? selection.selectedIds.filter((id) => id !== optionId) ? selection.selectedIds.filter((id) => id !== optionId)
: [...selection.selectedIds, optionId]; : [...selection.selectedIds, optionId];
@@ -666,7 +685,7 @@ function ClarificationCard({
{options.length > 0 ? ( {options.length > 0 ? (
<div className="mt-4 grid gap-2"> <div className="mt-4 grid gap-2">
<div className="text-muted-foreground text-xs"> <div className="text-muted-foreground text-xs">
可多选(再次点击取消选择) {allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}
</div> </div>
{options.map((option) => { {options.map((option) => {
const optionId = option.id || option.label; const optionId = option.id || option.label;
@@ -675,10 +694,10 @@ function ClarificationCard({
<Button <Button
key={optionId} key={optionId}
type="button" type="button"
variant={isSelected ? "default" : "outline"} variant={isSelected && allowMultiple ? "default" : "outline"}
className="h-auto justify-start whitespace-normal px-3 py-2 text-left" className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
disabled={disabled} disabled={disabled}
onClick={() => toggleOption(optionId)} onClick={() => handleOptionClick(optionId)}
> >
<span className="flex min-w-0 flex-1 flex-col items-start gap-1"> <span className="flex min-w-0 flex-1 flex-col items-start gap-1">
<span>{option.label}</span> <span>{option.label}</span>
@@ -695,7 +714,7 @@ function ClarificationCard({
</span> </span>
) : null} ) : null}
</span> </span>
{isSelected ? ( {isSelected && allowMultiple ? (
<CheckIcon className="ml-2 h-4 w-4 shrink-0" /> <CheckIcon className="ml-2 h-4 w-4 shrink-0" />
) : null} ) : null}
</Button> </Button>
@@ -720,10 +739,10 @@ function ClarificationCard({
</div> </div>
) : null} ) : null}
{isPending ? ( {isPending && (allowMultiple || payload.allow_custom !== false) ? (
isLastPending ? ( isLastPending ? (
<div className="mt-3 flex items-center justify-end gap-2"> <div className="mt-3 flex items-center justify-end gap-2">
{multiPending ? ( {multiPending && allowMultiple ? (
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
将一起提交以上 {pendingCount} 个问题的回答 将一起提交以上 {pendingCount} 个问题的回答
</span> </span>
@@ -735,14 +754,14 @@ function ClarificationCard({
onClick={() => void onSubmitAll()} onClick={() => void onSubmitAll()}
> >
<SendIcon className="h-4 w-4" /> <SendIcon className="h-4 w-4" />
{submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"} {submitting ? "发送中…" : multiPending && allowMultiple ? "全部发送" : "发送回答"}
</Button> </Button>
</div> </div>
) : ( ) : allowMultiple ? (
<div className="text-muted-foreground mt-3 text-right text-xs"> <div className="text-muted-foreground mt-3 text-right text-xs">
选择后等待最后一题统一提交 选择后等待最后一题统一提交
</div> </div>
) ) : null
) : null} ) : null}
</div> </div>
); );
@@ -1,6 +1,11 @@
import { apiFetch } from "@/core/api/fetch-client"; import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config"; 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 * 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 * built-in agent and no per-call coordinator: one /init creates a thread
@@ -73,12 +78,17 @@ export interface StreamIntentRequest {
} }
export interface StreamIntentResult { export interface StreamIntentResult {
/** "asking" → keep the dialogue going; "done" → summary is ready; "error" → backend failure. */ /** "asking" → keep the dialogue going; "clarification" → structured question card; "done" → summary is ready; "error" → backend failure. */
status: "asking" | "done" | "error"; status: "asking" | "clarification" | "done" | "error";
/** Final assistant text for this turn (markdown, may contain the [INTENT_READY] block). */ /** Final assistant text for this turn (markdown, may contain the [INTENT_READY] block). */
content: string; content: string;
/** Populated only when status === "done". */ /** Populated only when status === "done". */
summary: IntentSummary | null; 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. * is optional.
*/ */
function isFinalIntentFrame(value: unknown): value is { function isFinalIntentFrame(value: unknown): value is {
status: "asking" | "done" | "error"; status: "asking" | "clarification" | "done" | "error";
content?: string; content?: string;
summary?: IntentSummary | null; 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 (!value || typeof value !== "object") return false;
if (!("status" in value) || !("content" in value)) return false; if (!("status" in value) || !("content" in value)) return false;
const status = (value as { status: unknown }).status; 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); req.onPhaseChange?.(next, toolName);
}; };
const result: StreamIntentResult = { status: "asking", content: "", summary: null }; const result: StreamIntentResult = {
status: "asking",
content: "",
summary: null,
clarification: null,
};
while (true) { while (true) {
const { done, value } = await reader.read(); 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") { if (parsed.summary && typeof parsed.summary === "object") {
result.summary = parsed.summary as IntentSummary; 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; continue;
} }
@@ -1,6 +1,8 @@
import { apiFetch } from "@/core/api/fetch-client"; import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification";
export type ThreadIdMap = Record<string, string>; export type ThreadIdMap = Record<string, string>;
export interface SeatReadyEvent { export interface SeatReadyEvent {
@@ -222,6 +224,8 @@ export interface ClarificationPayload {
options: unknown[]; options: unknown[];
/** Whether the user is allowed to type a custom answer. */ /** Whether the user is allowed to type a custom answer. */
allowCustom: boolean; allowCustom: boolean;
/** When false, option cards are single-select and send on click. Omitted → inferred from clarificationType. */
allowMultiple?: boolean;
} }
export interface StreamMultiAgentResult { export interface StreamMultiAgentResult {
@@ -257,6 +261,7 @@ function isFinalStatusFrame(value: unknown): value is {
clarification_context?: string; clarification_context?: string;
options?: unknown[]; options?: unknown[];
allow_custom?: boolean; allow_custom?: boolean;
allow_multiple?: boolean;
} { } {
if (!value || typeof value !== "object") return false; if (!value || typeof value !== "object") return false;
if (!("status" in value) || !("content" in value)) 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 question (or chip choices) instead of mistaking the run for
// a consensus. // a consensus.
if (parsed.status === "clarification") { if (parsed.status === "clarification") {
const clarificationType =
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "";
result.clarification = { result.clarification = {
question: typeof parsed.question === "string" ? parsed.question : "", question: typeof parsed.question === "string" ? parsed.question : "",
clarificationType: clarificationType,
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "",
context: context:
typeof parsed.clarification_context === "string" ? parsed.clarification_context : "", typeof parsed.clarification_context === "string" ? parsed.clarification_context : "",
options: Array.isArray(parsed.options) ? parsed.options : [], options: Array.isArray(parsed.options) ? parsed.options : [],
allowCustom: parsed.allow_custom !== false, allowCustom: parsed.allow_custom !== false,
allowMultiple: resolveAllowMultiple(
typeof parsed.allow_multiple === "boolean" ? parsed.allow_multiple : undefined,
clarificationType,
),
}; };
} }
} }
@@ -47,7 +47,11 @@ export interface StreamRecommendRequest {
candidates: RecommendCandidate[]; candidates: RecommendCandidate[];
model?: string; model?: string;
signal?: AbortSignal; signal?: AbortSignal;
/** Fires for every AI text chunk from upstream LangGraph messages-tuple frames. */ /** Fires whenever parsed natural-language rationale changes (excludes [RECOMMEND_READY] + JSON). */
onRationaleUpdate?: (rationale: string) => void;
/** Fires whenever newly complete pick objects are parsed from the streaming JSON block. */
onPicksUpdate?: (picks: RecommendPick[]) => void;
/** Fires for every raw AI text chunk (includes JSON); prefer onRationaleUpdate/onPicksUpdate in UI. */
onTextDelta?: (text: string, messageId: string) => void; onTextDelta?: (text: string, messageId: string) => void;
/** Fires whenever the inferred SSE phase changes. */ /** Fires whenever the inferred SSE phase changes. */
onPhaseChange?: (phase: RecommendStreamPhase) => void; onPhaseChange?: (phase: RecommendStreamPhase) => void;
@@ -103,6 +107,50 @@ function isFinalRecommendFrame(value: unknown): value is {
return status === "done" || status === "asking" || status === "error"; 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 * 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 * shape as the helper in intent.ts; duplicated rather than imported to keep
@@ -110,6 +158,15 @@ function isFinalRecommendFrame(value: unknown): value is {
*/ */
function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null { function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null {
if (!Array.isArray(parsed) || parsed.length < 1) return 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]; const chunk = parsed[0];
if (!chunk || typeof chunk !== "object") return null; if (!chunk || typeof chunk !== "object") return null;
const obj = chunk as Record<string, unknown>; const obj = chunk as Record<string, unknown>;
@@ -171,6 +228,9 @@ export async function streamRecommend(req: StreamRecommendRequest): Promise<Stre
}; };
const result: StreamRecommendResult = { status: "asking", content: "", picks: null }; const result: StreamRecommendResult = { status: "asking", content: "", picks: null };
let rawContent = "";
let lastRationale = "";
let lastPickCount = 0;
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
@@ -214,10 +274,30 @@ export async function streamRecommend(req: StreamRecommendRequest): Promise<Stre
if (delta) { if (delta) {
setPhase("streaming"); setPhase("streaming");
req.onTextDelta?.(delta.text, delta.id); 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; 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>
);
}
@@ -18,6 +18,7 @@ import { cn } from "@/lib/utils";
import type { IntentSummary } from "../api/intent"; import type { IntentSummary } from "../api/intent";
import { import {
filterRecommendCandidates, filterRecommendCandidates,
parseRecommendStreamContent,
streamRecommend, streamRecommend,
type RecommendCandidate, type RecommendCandidate,
type RecommendPick, type RecommendPick,
@@ -200,13 +201,29 @@ export function RecommendAgentsDialog({
candidates, candidates,
model, model,
signal: controller.signal, signal: controller.signal,
// Append every AI text delta to the rationale display. The onRationaleUpdate: (rationale) => {
// recommender's [RECOMMEND_READY] + json block ends up in here too;
// that's fine — it's terse and rendering it shows users the model
// actually emitted the structured handoff.
onTextDelta: (text) => {
if (myGen !== generationRef.current) return; if (myGen !== generationRef.current) return;
setState((prev) => ({ ...prev, rationale: prev.rationale + text })); 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) => { onPhaseChange: (phase) => {
if (myGen !== generationRef.current) return; if (myGen !== generationRef.current) return;
@@ -227,10 +244,11 @@ export function RecommendAgentsDialog({
if (result.status === "done" && recommendedIds.length >= MIN_SELECTED) { if (result.status === "done" && recommendedIds.length >= MIN_SELECTED) {
const seed = intersectInOrder(recommendedIds, candidates); const seed = intersectInOrder(recommendedIds, candidates);
setSelectedIds(new Set(seed)); setSelectedIds(new Set(seed));
const { rationale } = parseRecommendStreamContent(result.content || "");
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
phase: "ready", phase: "ready",
rationale: result.content || prev.rationale, rationale: rationale || prev.rationale,
recommendedIds, recommendedIds,
reasons, reasons,
streamPhase: null, streamPhase: null,
@@ -246,10 +264,11 @@ export function RecommendAgentsDialog({
? fallback ? fallback
: candidates.slice(0, MIN_SELECTED).map((c) => c.agent_id); : candidates.slice(0, MIN_SELECTED).map((c) => c.agent_id);
setSelectedIds(new Set(seed)); setSelectedIds(new Set(seed));
const { rationale } = parseRecommendStreamContent(result.content || "");
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
phase: "fallback", phase: "fallback",
rationale: result.content || prev.rationale, rationale: rationale || prev.rationale,
recommendedIds: seed, recommendedIds: seed,
reasons, reasons,
streamPhase: null, streamPhase: null,
@@ -368,6 +387,29 @@ export function RecommendAgentsDialog({
<div className="text-sm text-destructive p-4 border border-destructive/30 rounded-md bg-destructive/5"> <div className="text-sm text-destructive p-4 border border-destructive/30 rounded-md bg-destructive/5">
{state.errorMessage ?? "未知错误"} {state.errorMessage ?? "未知错误"}
</div> </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.phase === "ready" || state.phase === "fallback") ? (
<> <>
{state.warning && ( {state.warning && (
@@ -464,15 +506,17 @@ function RationaleBlock({ state }: { state: InternalState }) {
? "已连接,等待响应..." ? "已连接,等待响应..."
: "正在生成推荐..."; : "正在生成推荐...";
return ( return (
<div className="space-y-2"> <div className="space-y-1">
<div className="flex items-center gap-2 text-muted-foreground text-xs"> <div className="flex items-center gap-2 text-muted-foreground text-xs">
<Loader2Icon className="size-3 animate-spin" /> <Loader2Icon className="size-3 animate-spin" />
{label} {label}
</div> </div>
{state.rationale && ( {state.rationale ? (
<pre className="whitespace-pre-wrap break-words font-sans text-xs text-foreground"> <p className="whitespace-pre-wrap break-words text-xs text-foreground leading-relaxed">
{state.rationale} {state.rationale}
</pre> </p>
) : (
<p className="text-xs text-muted-foreground">正在分析任务与候选智能体...</p>
)} )}
</div> </div>
); );
@@ -482,9 +526,9 @@ function RationaleBlock({ state }: { state: InternalState }) {
return <div className="text-xs text-muted-foreground">推荐完成。</div>; return <div className="text-xs text-muted-foreground">推荐完成。</div>;
} }
return ( return (
<pre className="whitespace-pre-wrap break-words font-sans text-xs text-foreground max-h-32 overflow-y-auto"> <p className="whitespace-pre-wrap break-words text-xs text-foreground leading-relaxed max-h-32 overflow-y-auto">
{state.rationale} {state.rationale}
</pre> </p>
); );
} }
if (state.phase === "error") { if (state.phase === "error") {
@@ -510,9 +554,18 @@ interface AgentCardProps {
recommended: boolean; recommended: boolean;
checked: boolean; checked: boolean;
onToggle: () => void; onToggle: () => void;
/** Card is still being filled in from the SSE stream. */
streaming?: boolean;
} }
function AgentCard({ candidate, reason, recommended, checked, onToggle }: AgentCardProps) { function AgentCard({
candidate,
reason,
recommended,
checked,
onToggle,
streaming = false,
}: AgentCardProps) {
return ( return (
<button <button
type="button" type="button"
@@ -522,6 +575,7 @@ function AgentCard({ candidate, reason, recommended, checked, onToggle }: AgentC
checked checked
? "border-primary bg-primary/5 hover:bg-primary/10" ? "border-primary bg-primary/5 hover:bg-primary/10"
: "border-border hover:bg-muted/40", : "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 */} {/* Visual check box — simulated, since the project ships no Checkbox primitive */}
@@ -547,11 +601,13 @@ function AgentCard({ candidate, reason, recommended, checked, onToggle }: AgentC
{candidate.description} {candidate.description}
</div> </div>
)} )}
{reason && ( {reason ? (
<div className="text-xs text-primary/80 mt-1 italic"> <div className="text-xs text-primary/80 mt-1 italic">
<span className="font-medium not-italic">推荐理由:</span> {reason} <span className="font-medium not-italic">推荐理由:</span> {reason}
</div> </div>
)} ) : streaming ? (
<div className="text-xs text-muted-foreground mt-1 italic">正在生成推荐理由...</div>
) : null}
</div> </div>
</button> </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;
}
@@ -21,6 +21,11 @@ import {
type IntentStreamPhase, type IntentStreamPhase,
type IntentSummary, type IntentSummary,
} from "@/roundtable-planning/api/intent"; } from "@/roundtable-planning/api/intent";
import {
IntentClarificationCard,
formatIntentClarificationAnswer,
type IntentClarificationSelection,
} from "@/roundtable-planning/components/IntentClarificationCard";
import { RecommendAgentsDialog } from "@/roundtable-planning/components/RecommendAgentsDialog"; import { RecommendAgentsDialog } from "@/roundtable-planning/components/RecommendAgentsDialog";
import { import {
deleteDraft, deleteDraft,
@@ -447,6 +452,10 @@ export default function RoundtablePlanningPage() {
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null); const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
const [intentInitError, setIntentInitError] = useState<string | null>(null); const [intentInitError, setIntentInitError] = useState<string | null>(null);
const [isIntentSending, setIsIntentSending] = useState<boolean>(false); 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); const step1InitRef = useRef<boolean>(false);
// Monotonic counter incremented every time Step 1 init starts. The in-flight // 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. // async checks this on completion and bails if a newer init has begun (e.g.
@@ -522,6 +531,22 @@ export default function RoundtablePlanningPage() {
step1ScrollRef, step1ScrollRef,
[interactiveMessages], [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( const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } = useAutoScroll(
step2ScrollRef, step2ScrollRef,
[step2RoundtableDialogues], [step2RoundtableDialogues],
@@ -1105,6 +1130,8 @@ export default function RoundtablePlanningPage() {
setIntentReady(null); setIntentReady(null);
setIntentInitError(null); setIntentInitError(null);
setIntentThreadId(null); setIntentThreadId(null);
setPendingIntentClarificationMsgId(null);
setIntentClarificationSelections(new Map());
setTimelineEntries([]); setTimelineEntries([]);
setInteractiveMessages([ setInteractiveMessages([
{ {
@@ -1373,10 +1400,11 @@ export default function RoundtablePlanningPage() {
// Chat sender logic for Step 1 interaction — drives the real intent agent // Chat sender logic for Step 1 interaction — drives the real intent agent
// via /api/intent/stream. The agent streams its reply into a placeholder // via /api/intent/stream. The agent streams its reply into a placeholder
// bubble; if its final status is "done" we capture the structured summary // bubble; if its final status is "done" we capture the structured summary
// for the Step 2 handoff. // for the Step 2 handoff. When status is "clarification", render option
const handleSendMessage = async () => { // cards like the main chat ClarificationCard.
const userText = inputText.trim(); const sendIntentMessage = async (userText: string) => {
if (!userText) return; const trimmed = userText.trim();
if (!trimmed) return;
if (!intentThreadId) { if (!intentThreadId) {
triggerToast("⚠️ 任务理解智能体尚未就绪,请稍候"); triggerToast("⚠️ 任务理解智能体尚未就绪,请稍候");
return; return;
@@ -1386,33 +1414,31 @@ export default function RoundtablePlanningPage() {
return; return;
} }
setPendingIntentClarificationMsgId(null);
const userMsgTime = nowTime(); const userMsgTime = nowTime();
const userMsgId = Date.now() + Math.random(); const userMsgId = Date.now() + Math.random();
const userMsg = { const userMsg = {
id: userMsgId, id: userMsgId,
sender: "user", sender: "user",
senderName: "用户", senderName: "用户",
content: userText, content: trimmed,
time: userMsgTime, time: userMsgTime,
}; };
setInteractiveMessages(prev => [...prev, userMsg]); setInteractiveMessages(prev => [...prev, userMsg]);
setInputText(""); setInputText("");
setCharactersLeft(0); setCharactersLeft(0);
// Right-side timeline gets a short summary too.
setTimelineEntries(prev => [ setTimelineEntries(prev => [
...prev, ...prev,
{ {
id: prev.length + 1, id: prev.length + 1,
time: userMsgTime, time: userMsgTime,
title: "用户实时反馈", 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; const agentMsgId = Date.now() + Math.random() + 0.01;
setInteractiveMessages(prev => [ setInteractiveMessages(prev => [
...prev, ...prev,
@@ -1427,9 +1453,6 @@ export default function RoundtablePlanningPage() {
} as any, } 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 => { const intentPhaseLabel = (phase: IntentStreamPhase, toolName?: string): string => {
switch (phase) { switch (phase) {
case "connecting": case "connecting":
@@ -1450,7 +1473,7 @@ export default function RoundtablePlanningPage() {
try { try {
const res = await streamIntent({ const res = await streamIntent({
threadId: intentThreadId, threadId: intentThreadId,
message: userText, message: trimmed,
model: DEFAULT_MODEL, model: DEFAULT_MODEL,
onTextDelta: (delta) => { onTextDelta: (delta) => {
setInteractiveMessages(prev => setInteractiveMessages(prev =>
@@ -1464,29 +1487,44 @@ export default function RoundtablePlanningPage() {
); );
}, },
}); });
// Finalize the bubble with the authoritative final text from the const parsedFromContent = splitIntentReadyBlock(res.content || "").summary;
// status frame (covers cases where deltas didn't accumulate cleanly). const resolvedSummary = res.summary ?? parsedFromContent;
setInteractiveMessages(prev => setInteractiveMessages(prev =>
prev.map(m => prev.map(m =>
m.id === agentMsgId 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, : m,
), ),
); );
if (res.status === "done" && res.summary) {
setIntentReady(res.summary); if (resolvedSummary) {
setIntentReady(resolvedSummary);
setPendingIntentClarificationMsgId(null);
setTimelineEntries(prev => [ setTimelineEntries(prev => [
...prev, ...prev,
{ {
id: prev.length + 1, id: prev.length + 1,
time: nowTime(), time: nowTime(),
title: "任务意图已明确", title: "任务意图已明确",
desc: res.summary!.objective.length > 25 desc: resolvedSummary.objective.length > 25
? res.summary!.objective.substring(0, 25) + "..." ? resolvedSummary.objective.substring(0, 25) + "..."
: res.summary!.objective, : resolvedSummary.objective,
}, },
]); ]);
triggerToast("✅ 任务理解完成,可进入多智能体圆桌研讨"); 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") { } else if (res.status === "error") {
triggerToast(`❌ 智能体调用失败: ${res.content}`); triggerToast(`❌ 智能体调用失败: ${res.content}`);
} }
@@ -1505,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 // Skip step logic
const handleSkipQuestion = () => { const handleSkipQuestion = () => {
const systemSkipMsg = { const systemSkipMsg = {
@@ -2324,12 +2385,26 @@ export default function RoundtablePlanningPage() {
const isStreaming = (msg as { streaming?: boolean }).streaming === true; const isStreaming = (msg as { streaming?: boolean }).streaming === true;
const hasContent = (msg.content ?? "").length > 0; const hasContent = (msg.content ?? "").length > 0;
const isAgent = msg.sender !== "user"; const isAgent = msg.sender !== "user";
// Only agent replies carry markdown / [INTENT_READY]; user const clarification = (msg as { clarification?: ClarificationPayload }).clarification;
// messages stay as plain text so casing/whitespace are
// preserved verbatim.
const parts = isAgent const parts = isAgent
? splitIntentReadyBlock(msg.content ?? "") ? splitIntentReadyBlock(msg.content ?? "")
: { before: msg.content ?? "", summary: null, after: "" }; : { 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 ( 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 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"> <div className="flex items-start gap-2.5 min-w-0">
@@ -2352,7 +2427,7 @@ export default function RoundtablePlanningPage() {
</span> </span>
) : isAgent ? ( ) : isAgent ? (
<div className="text-xs text-slate-700 leading-normal font-normal break-words space-y-2"> <div className="text-xs text-slate-700 leading-normal font-normal break-words space-y-2">
{parts.before && ( {introText && (
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={{ components={{
@@ -2388,7 +2463,7 @@ export default function RoundtablePlanningPage() {
), ),
}} }}
> >
{parts.before} {introText}
</ReactMarkdown> </ReactMarkdown>
)} )}
{parts.summary && ( {parts.summary && (
@@ -2433,6 +2508,25 @@ export default function RoundtablePlanningPage() {
{isStreaming && ( {isStreaming && (
<span className="inline-block w-1 h-3 ml-0.5 bg-primary align-middle animate-pulse" /> <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> </div>
) : ( ) : (
<span className="text-xs text-slate-700 leading-normal font-normal whitespace-pre-wrap break-words"> <span className="text-xs text-slate-700 leading-normal font-normal whitespace-pre-wrap break-words">
@@ -2468,7 +2562,11 @@ export default function RoundtablePlanningPage() {
handleSendMessage(); 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" 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} maxLength={500}
/> />
@@ -35,6 +35,7 @@ from app.gateway.routers.multi_agent import (
_run_payload, _run_payload,
_stream_upstream, _stream_upstream,
) )
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/intent", tags=["intent"]) 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: through transparently, then appends one status frame:
{ {
"status": "asking" | "done" | "error", "status": "asking" | "clarification" | "done" | "error",
"content": "<final AI text>", "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() thread_id = str(payload.get("thread_id") or "").strip()
@@ -143,44 +147,10 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
captured = done captured = done
messages = _parse_last_messages(captured) messages = _parse_last_messages(captured)
# Find the latest AIMessage with tool_calls (typically
# ask_clarification) — falls back to the latest plain
# AIMessage when the model produced no tool call.
dispatch_msg = _find_dispatch_message(messages)
content = _flatten_content(dispatch_msg.get("content", ""))
tool_calls = dispatch_msg.get("tool_calls") or []
# Clarification branch: the agent ended its run by calling # [INTENT_READY] wins over ask_clarification: the model may emit
# ask_clarification. ClarificationMiddleware intercepts with # the handoff marker in an earlier AI message while the latest
# Command(goto=END) and stashes the structured question in the # dispatch still carries a stale clarification tool call.
# tool_call args, so the AIMessage `content` is only the
# preamble ("下面我用几个问题确认:") and stops there. We splice
# the question back into the user-facing text so the frontend
# actually shows what the agent wants to ask.
clarification_call = next(
(tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"),
None,
)
if clarification_call is not None:
cargs = clarification_call.get("args") or {}
question = str(cargs.get("question") or "").strip()
follow_up = _find_trailing_ai_text(messages, exclude_text=content)
# Build a single content string the frontend can render
# as-is: preamble (if any) + the actual question.
parts: list[str] = []
if content.strip():
parts.append(content.strip())
if follow_up and follow_up != question:
parts.append(follow_up)
if question:
parts.append(question)
content = "\n\n".join(parts) if parts else content
# Scan ALL AI messages for the [INTENT_READY] marker, not just
# dispatch_msg.content. The model often emits the summary in an
# earlier AI message and then keeps yapping (or doing extra
# tool calls) — the marker is the source of truth, wherever it
# appears in the run.
summary: dict[str, Any] | None = None summary: dict[str, Any] | None = None
ready_text: str = "" ready_text: str = ""
for msg in reversed(messages): for msg in reversed(messages):
@@ -198,17 +168,49 @@ async def stream_intent(request: Request, payload: dict = Body(...)) -> Streamin
summary = None summary = None
ready_text = msg_text ready_text = msg_text
break break
# When the marker was found in an earlier message, surface that
# message's text (which contains the user-facing 📋 汇总 + JSON)
# instead of the final filler AI text like "好的,收到".
if summary and ready_text: if summary and ready_text:
content = 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.
dispatch_msg = _find_dispatch_message(messages)
content = _flatten_content(dispatch_msg.get("content", ""))
tool_calls = dispatch_msg.get("tool_calls") or []
clarification_call = next(
(tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"),
None,
)
if clarification_call is not None:
cargs = clarification_call.get("args") or {}
question = str(cargs.get("question") or "").strip()
follow_up = _find_trailing_ai_text(messages, exclude_text=content)
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
status_value = "done" if summary else "asking"
final_frame: dict[str, Any] = { final_frame: dict[str, Any] = {
"status": status_value, "status": "asking",
"content": content, "content": content,
"summary": summary, "summary": None,
} }
yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n" yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n"
@@ -25,6 +25,7 @@ import time
from typing import Any, AsyncIterator from typing import Any, AsyncIterator
import httpx import httpx
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
from fastapi import APIRouter, Body, HTTPException, Request from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@@ -616,6 +617,7 @@ async def _leader_run(
"clarification_context": str(cargs.get("context") or ""), "clarification_context": str(cargs.get("context") or ""),
"options": cargs.get("options") or [], "options": cargs.get("options") or [],
"allow_custom": bool(cargs.get("allow_custom", True)), "allow_custom": bool(cargs.get("allow_custom", True)),
"allow_multiple": resolve_allow_multiple(cargs),
"agent_name": agent_name, "agent_name": agent_name,
} }
return return
@@ -6,6 +6,8 @@ from collections.abc import Callable
from hashlib import sha256 from hashlib import sha256
from typing import override from typing import override
from deerflow.tools.builtins.clarification_utils import resolve_allow_multiple
from langchain.agents import AgentState from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware from langchain.agents.middleware import AgentMiddleware
from langchain_core.messages import ToolMessage from langchain_core.messages import ToolMessage
@@ -67,7 +69,7 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
question = args.get("question", "") question = args.get("question", "")
clarification_type = args.get("clarification_type", "missing_info") clarification_type = args.get("clarification_type", "missing_info")
context = args.get("context") context = args.get("context")
options = self._normalize_options(args.get("options", [])) options = self._normalize_options(args.get("options", []))
# Type-specific icons # Type-specific icons
type_icons = { type_icons = {
@@ -95,62 +97,63 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
# Add options in a cleaner format # Add options in a cleaner format
if options and len(options) > 0: if options and len(options) > 0:
message_parts.append("") # blank line for spacing message_parts.append("") # blank line for spacing
for i, option in enumerate(options, 1): for i, option in enumerate(options, 1):
label = option.get("label") or option.get("id") or "" label = option.get("label") or option.get("id") or ""
message_parts.append(f" {i}. {label}") message_parts.append(f" {i}. {label}")
return "\n".join(message_parts) return "\n".join(message_parts)
def _normalize_options(self, raw_options: object) -> list[dict[str, str]]: def _normalize_options(self, raw_options: object) -> list[dict[str, str]]:
"""Normalize model-produced options into a stable UI-friendly shape.""" """Normalize model-produced options into a stable UI-friendly shape."""
options = raw_options options = raw_options
# Some models serialize array parameters as JSON strings instead of native arrays. # Some models serialize array parameters as JSON strings instead of native arrays.
if isinstance(options, str): if isinstance(options, str):
try: try:
options = json.loads(options) options = json.loads(options)
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
options = [options] options = [options]
if options is None: if options is None:
values: list[object] = [] values: list[object] = []
elif isinstance(options, list): elif isinstance(options, list):
values = options values = options
else: else:
values = [options] values = [options]
normalized: list[dict[str, str]] = [] normalized: list[dict[str, str]] = []
for index, value in enumerate(values, 1): for index, value in enumerate(values, 1):
if isinstance(value, dict): 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") 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 "" label = str(raw_label).strip() if raw_label is not None else ""
if not label: if not label:
continue continue
raw_id = value.get("id") or value.get("value") or f"option-{index}" raw_id = value.get("id") or value.get("value") or f"option-{index}"
option = { option = {
"id": str(raw_id).strip() or f"option-{index}", "id": str(raw_id).strip() or f"option-{index}",
"label": label, "label": label,
} }
description = value.get("description") description = value.get("description")
if description is not None and str(description).strip(): if description is not None and str(description).strip():
option["description"] = str(description).strip() option["description"] = str(description).strip()
normalized.append(option) normalized.append(option)
else: else:
label = str(value).strip() label = str(value).strip()
if label: if label:
normalized.append({"id": f"option-{index}", "label": label}) normalized.append({"id": f"option-{index}", "label": label})
return normalized return normalized
def _build_payload(self, args: dict) -> dict: def _build_payload(self, args: dict) -> dict:
"""Build structured clarification data for rich clients.""" """Build structured clarification data for rich clients."""
return { return {
"question": str(args.get("question", "")).strip(), "question": str(args.get("question", "")).strip(),
"clarification_type": args.get("clarification_type", "missing_info"), "clarification_type": args.get("clarification_type", "missing_info"),
"context": args.get("context"), "context": args.get("context"),
"options": self._normalize_options(args.get("options", [])), "options": self._normalize_options(args.get("options", [])),
"allow_custom": True, "allow_custom": True,
} "allow_multiple": resolve_allow_multiple(args),
}
def _handle_clarification(self, request: ToolCallRequest) -> Command: def _handle_clarification(self, request: ToolCallRequest) -> Command:
"""Handle clarification request and return command to interrupt execution. """Handle clarification request and return command to interrupt execution.
@@ -176,13 +179,13 @@ class ClarificationMiddleware(AgentMiddleware[ClarificationMiddlewareState]):
# Create a ToolMessage with the formatted question # Create a ToolMessage with the formatted question
# This will be added to the message history # This will be added to the message history
tool_message = ToolMessage( tool_message = ToolMessage(
id=self._stable_message_id(tool_call_id, formatted_message), id=self._stable_message_id(tool_call_id, formatted_message),
content=formatted_message, content=formatted_message,
tool_call_id=tool_call_id, tool_call_id=tool_call_id,
name="ask_clarification", name="ask_clarification",
additional_kwargs={"clarification": self._build_payload(args)}, additional_kwargs={"clarification": self._build_payload(args)},
) )
# Return a Command that: # Return a Command that:
# 1. Adds the formatted tool message # 1. Adds the formatted tool message
@@ -15,6 +15,7 @@ def ask_clarification_tool(
], ],
context: str | None = None, context: str | None = None,
options: list[str] | None = None, options: list[str] | None = None,
allow_multiple: bool | None = None,
) -> str: ) -> str:
"""Ask the user for clarification when you need more information to proceed. """Ask the user for clarification when you need more information to proceed.
@@ -50,8 +51,9 @@ def ask_clarification_tool(
Best practices: Best practices:
- One question per ask_clarification call — keep each `question` focused on a single decision. - One question per ask_clarification call — keep each `question` focused on a single decision.
- Be specific and clear in your question. - Be specific and clear in your question.
- Provide `options` (with concise labels) whenever there is a discrete set of reasonable choices — - 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. - 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. - Don't make assumptions when clarification is needed.
- For risky operations, ALWAYS ask for confirmation. - For risky operations, ALWAYS ask for confirmation.
- After calling this tool (or several in one turn), execution will be interrupted automatically. - 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. 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). 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. 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 # This is a placeholder implementation
# The actual logic is handled by ClarificationMiddleware which intercepts this tool call # 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