diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index 63cee2fb..f9581a7a 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -24,6 +24,7 @@ import type { Subtask } from "@/core/tasks"; import { useUpdateSubtask } from "@/core/tasks/context"; import type { AgentThreadState } from "@/core/threads"; import { cn } from "@/lib/utils"; +import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification"; import { ArtifactFileList } from "../artifacts/artifact-file-list"; import { StreamingIndicator } from "../streaming-indicator"; @@ -102,7 +103,9 @@ export function MessageList({ [], ); - const handleSubmitAllClarifications = useCallback(async () => { + const handleSubmitAllClarifications = useCallback(async ( + selectionOverrides?: Map, + ) => { if (pendingClarificationIds.length === 0 || !onClarificationSubmit) { return; } @@ -111,7 +114,7 @@ export function MessageList({ const id = pendingClarificationIds[index]!; const message = messages.find((msg) => msg.id === id); const payload = message ? getClarificationPayload(message) : null; - const selection = clarificationSelections.get(id) ?? { + const selection = selectionOverrides?.get(id) ?? clarificationSelections.get(id) ?? { selectedIds: [], custom: "", }; @@ -221,7 +224,12 @@ export function MessageList({ } submitting={submittingClarification} canSubmit={hasAnyClarificationAnswer} - onSubmitAll={handleSubmitAllClarifications} + onSubmitAll={(override) => { + const overrides = override + ? new Map([[messageId, override]]) + : undefined; + void handleSubmitAllClarifications(overrides); + }} /> void; submitting: boolean; canSubmit: boolean; - onSubmitAll: () => void | Promise; + onSubmitAll: (override?: ClarificationSelection) => void | Promise; }) { const payload = getClarificationPayload(message); @@ -624,11 +633,21 @@ function ClarificationCard({ const disabled = !isPending || submitting; const multiPending = (pendingCount ?? 0) > 1; + const allowMultiple = resolveAllowMultiple( + payload.allow_multiple, + payload.clarification_type, + ); - function toggleOption(optionId: string) { + function handleOptionClick(optionId: string) { if (disabled) { return; } + if (!allowMultiple) { + const next = { selectedIds: [optionId], custom: "" }; + onSelectionChange(next); + void onSubmitAll(next); + return; + } const next = selection.selectedIds.includes(optionId) ? selection.selectedIds.filter((id) => id !== optionId) : [...selection.selectedIds, optionId]; @@ -666,7 +685,7 @@ function ClarificationCard({ {options.length > 0 ? (
- 可多选(再次点击取消选择) + {allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}
{options.map((option) => { const optionId = option.id || option.label; @@ -675,10 +694,10 @@ function ClarificationCard({ @@ -720,10 +739,10 @@ function ClarificationCard({
) : null} - {isPending ? ( + {isPending && (allowMultiple || payload.allow_custom !== false) ? ( isLastPending ? (
- {multiPending ? ( + {multiPending && allowMultiple ? ( 将一起提交以上 {pendingCount} 个问题的回答 @@ -735,14 +754,14 @@ function ClarificationCard({ onClick={() => void onSubmitAll()} > - {submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"} + {submitting ? "发送中…" : multiPending && allowMultiple ? "全部发送" : "发送回答"}
- ) : ( + ) : allowMultiple ? (
选择后等待最后一题统一提交
- ) + ) : null ) : null} ); diff --git a/frontend-web/src/roundtable-planning/api/intent.ts b/frontend-web/src/roundtable-planning/api/intent.ts index 481b9d00..dabcdb3e 100644 --- a/frontend-web/src/roundtable-planning/api/intent.ts +++ b/frontend-web/src/roundtable-planning/api/intent.ts @@ -1,6 +1,11 @@ import { apiFetch } from "@/core/api/fetch-client"; import { getBackendBaseURL } from "@/core/config"; +import type { ClarificationPayload } from "./multi-agent"; +import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification"; + +export type { ClarificationPayload }; + /** * Task-intent agent (Step 1) API. Mirror of multi-agent.ts but with a single * built-in agent and no per-call coordinator: one /init creates a thread @@ -73,12 +78,17 @@ export interface StreamIntentRequest { } export interface StreamIntentResult { - /** "asking" → keep the dialogue going; "done" → summary is ready; "error" → backend failure. */ - status: "asking" | "done" | "error"; + /** "asking" → keep the dialogue going; "clarification" → structured question card; "done" → summary is ready; "error" → backend failure. */ + status: "asking" | "clarification" | "done" | "error"; /** Final assistant text for this turn (markdown, may contain the [INTENT_READY] block). */ content: string; /** Populated only when status === "done". */ summary: IntentSummary | null; + /** + * Populated when the intent agent called ask_clarification — render option + * cards + custom input instead of treating the turn as plain text only. + */ + clarification: ClarificationPayload | null; } /** @@ -87,14 +97,25 @@ export interface StreamIntentResult { * is optional. */ function isFinalIntentFrame(value: unknown): value is { - status: "asking" | "done" | "error"; + status: "asking" | "clarification" | "done" | "error"; content?: string; summary?: IntentSummary | null; + question?: string; + clarification_type?: string; + clarification_context?: string; + options?: unknown[]; + allow_custom?: boolean; + allow_multiple?: boolean; } { if (!value || typeof value !== "object") return false; if (!("status" in value) || !("content" in value)) return false; const status = (value as { status: unknown }).status; - return status === "asking" || status === "done" || status === "error"; + return ( + status === "asking" || + status === "clarification" || + status === "done" || + status === "error" + ); } /** @@ -166,7 +187,12 @@ export async function streamIntent(req: StreamIntentRequest): Promise; export interface SeatReadyEvent { @@ -222,6 +224,8 @@ export interface ClarificationPayload { options: unknown[]; /** Whether the user is allowed to type a custom answer. */ allowCustom: boolean; + /** When false, option cards are single-select and send on click. Omitted → inferred from clarificationType. */ + allowMultiple?: boolean; } export interface StreamMultiAgentResult { @@ -257,6 +261,7 @@ function isFinalStatusFrame(value: unknown): value is { clarification_context?: string; options?: unknown[]; allow_custom?: boolean; + allow_multiple?: boolean; } { if (!value || typeof value !== "object") return false; if (!("status" in value) || !("content" in value)) return false; @@ -447,14 +452,19 @@ export async function streamMultiAgent( // a question (or chip choices) instead of mistaking the run for // a consensus. if (parsed.status === "clarification") { + const clarificationType = + typeof parsed.clarification_type === "string" ? parsed.clarification_type : ""; result.clarification = { question: typeof parsed.question === "string" ? parsed.question : "", - clarificationType: - typeof parsed.clarification_type === "string" ? parsed.clarification_type : "", + clarificationType, context: typeof parsed.clarification_context === "string" ? parsed.clarification_context : "", options: Array.isArray(parsed.options) ? parsed.options : [], allowCustom: parsed.allow_custom !== false, + allowMultiple: resolveAllowMultiple( + typeof parsed.allow_multiple === "boolean" ? parsed.allow_multiple : undefined, + clarificationType, + ), }; } } diff --git a/frontend-web/src/roundtable-planning/api/recommend.ts b/frontend-web/src/roundtable-planning/api/recommend.ts index c8adca74..db73b4ed 100644 --- a/frontend-web/src/roundtable-planning/api/recommend.ts +++ b/frontend-web/src/roundtable-planning/api/recommend.ts @@ -47,7 +47,11 @@ export interface StreamRecommendRequest { candidates: RecommendCandidate[]; model?: string; signal?: AbortSignal; - /** Fires for every AI text chunk from upstream LangGraph messages-tuple frames. */ + /** Fires whenever parsed natural-language rationale changes (excludes [RECOMMEND_READY] + JSON). */ + onRationaleUpdate?: (rationale: string) => void; + /** Fires whenever newly complete pick objects are parsed from the streaming JSON block. */ + onPicksUpdate?: (picks: RecommendPick[]) => void; + /** Fires for every raw AI text chunk (includes JSON); prefer onRationaleUpdate/onPicksUpdate in UI. */ onTextDelta?: (text: string, messageId: string) => void; /** Fires whenever the inferred SSE phase changes. */ onPhaseChange?: (phase: RecommendStreamPhase) => void; @@ -103,6 +107,50 @@ function isFinalRecommendFrame(value: unknown): value is { return status === "done" || status === "asking" || status === "error"; } +const RECOMMEND_READY_MARKER = "[RECOMMEND_READY]"; + +/** Match one complete pick object inside a partially streamed JSON block. */ +const PICK_OBJECT_PATTERN = + /\{\s*"agent_id"\s*:\s*"((?:\\.|[^"\\])*)"\s*,\s*"reason"\s*:\s*"((?:\\.|[^"\\])*)"\s*\}/g; + +function unescapeJsonString(raw: string): string { + try { + return JSON.parse(`"${raw}"`) as string; + } catch { + return raw.replace(/\\"/g, '"').replace(/\\\\/g, "\\"); + } +} + +/** + * Split accumulated recommender output into visible rationale text and any + * pick objects that have fully arrived in the JSON section. + */ +export function parseRecommendStreamContent(raw: string): { + rationale: string; + picks: RecommendPick[]; +} { + const markerIdx = raw.indexOf(RECOMMEND_READY_MARKER); + let rationale = markerIdx >= 0 ? raw.slice(0, markerIdx) : raw; + rationale = rationale.replace(/\[(?:RECOMMEND(?:_(?:READY)?)?)?\]?$/i, "").trimEnd(); + + const jsonSection = + markerIdx >= 0 ? raw.slice(markerIdx + RECOMMEND_READY_MARKER.length) : ""; + + const picks: RecommendPick[] = []; + const seen = new Set(); + PICK_OBJECT_PATTERN.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = PICK_OBJECT_PATTERN.exec(jsonSection)) !== null) { + const agent_id = unescapeJsonString(match[1]).trim(); + const reason = unescapeJsonString(match[2]).trim(); + if (!agent_id || seen.has(agent_id)) continue; + seen.add(agent_id); + picks.push({ agent_id, reason }); + } + + return { rationale, picks }; +} + /** * Extract an AI text delta from one LangGraph `messages-tuple` frame. Same * shape as the helper in intent.ts; duplicated rather than imported to keep @@ -110,6 +158,15 @@ function isFinalRecommendFrame(value: unknown): value is { */ function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null { if (!Array.isArray(parsed) || parsed.length < 1) return null; + + if (parsed.length >= 2 && parsed[1] && typeof parsed[1] === "object") { + const meta = parsed[1] as Record; + const node = String(meta.langgraph_node ?? ""); + if (node.includes("TitleMiddleware")) return null; + const tags = meta.tags; + if (Array.isArray(tags) && tags.includes("middleware:title")) return null; + } + const chunk = parsed[0]; if (!chunk || typeof chunk !== "object") return null; const obj = chunk as Record; @@ -171,6 +228,9 @@ export async function streamRecommend(req: StreamRecommendRequest): Promise lastPickCount) { + lastPickCount = picks.length; + req.onPicksUpdate?.(picks); + } } } } } + if (result.content) { + const { rationale, picks } = parseRecommendStreamContent(result.content); + if (rationale !== lastRationale) { + req.onRationaleUpdate?.(rationale); + } + if (picks.length > lastPickCount) { + req.onPicksUpdate?.(picks); + } + } + return result; } diff --git a/frontend-web/src/roundtable-planning/components/IntentClarificationCard.tsx b/frontend-web/src/roundtable-planning/components/IntentClarificationCard.tsx new file mode 100644 index 00000000..d30fbc5d --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/IntentClarificationCard.tsx @@ -0,0 +1,217 @@ +import { Check } from "lucide-react"; +import { useMemo } from "react"; + +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent"; +import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification"; + +export type IntentClarificationSelection = { + selectedIds: string[]; + custom: string; +}; + +type ClarificationOption = { + id: string; + label: string; + description?: string; +}; + +function normalizeOptions(raw: unknown[]): ClarificationOption[] { + return raw + .map((opt, index) => { + if (typeof opt === "string") { + const label = opt.trim(); + return label ? { id: `option-${index}`, label } : null; + } + if (opt && typeof opt === "object" && "label" in opt) { + const obj = opt as { id?: unknown; label?: unknown; description?: unknown }; + const label = String(obj.label ?? "").trim(); + if (!label) return null; + const id = String(obj.id ?? label).trim() || `option-${index}`; + const description = + typeof obj.description === "string" && obj.description.trim() + ? obj.description.trim() + : undefined; + return { id, label, description }; + } + return null; + }) + .filter((opt): opt is ClarificationOption => opt !== null); +} + +export function formatIntentClarificationAnswer( + payload: ClarificationPayload, + selection: IntentClarificationSelection, +): string | null { + const options = normalizeOptions(Array.isArray(payload.options) ? payload.options : []); + const selectedLabels = selection.selectedIds + .map((sid) => options.find((option) => option.id === sid)?.label) + .filter((label): label is string => Boolean(label && label.trim())); + const trimmedCustom = selection.custom.trim(); + + if (selectedLabels.length > 0 && trimmedCustom) { + return `我选择:${selectedLabels.join("、")};补充:${trimmedCustom}`; + } + if (selectedLabels.length > 0) { + return `我选择:${selectedLabels.join("、")}`; + } + if (trimmedCustom) { + return trimmedCustom; + } + return null; +} + +interface IntentClarificationCardProps { + payload: ClarificationPayload; + fallbackContent?: string; + selection: IntentClarificationSelection; + onSelectionChange: (next: IntentClarificationSelection) => void; + disabled?: boolean; + submitting?: boolean; + /** Multi-select: submit current selection. Single-select: also used for custom text. */ + onSubmit: (overrideSelection?: IntentClarificationSelection) => void; +} + +export function IntentClarificationCard({ + payload, + fallbackContent, + selection, + onSelectionChange, + disabled = false, + submitting = false, + onSubmit, +}: IntentClarificationCardProps) { + const options = useMemo( + () => normalizeOptions(Array.isArray(payload.options) ? payload.options : []), + [payload.options], + ); + + const allowMultiple = resolveAllowMultiple( + payload.allowMultiple, + payload.clarificationType, + ); + + const canSubmit = useMemo(() => { + return selection.selectedIds.length > 0 || selection.custom.trim().length > 0; + }, [selection.custom, selection.selectedIds]); + + const showSubmitButton = allowMultiple || payload.allowCustom !== false; + + function handleOptionClick(optionId: string) { + if (disabled || submitting) return; + if (!allowMultiple) { + onSubmit({ selectedIds: [optionId], custom: "" }); + return; + } + const next = selection.selectedIds.includes(optionId) + ? selection.selectedIds.filter((id) => id !== optionId) + : [...selection.selectedIds, optionId]; + onSelectionChange({ ...selection, selectedIds: next }); + } + + function updateCustom(value: string) { + if (disabled || submitting) return; + onSelectionChange({ ...selection, custom: value }); + } + + return ( +
+
+
+ + 需要你的确认 + + {payload.clarificationType ? ( + + {payload.clarificationType} + + ) : null} +
+ {payload.context ? ( +

{payload.context}

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