import { apiFetch } from "@/core/api/fetch-client"; import { getBackendBaseURL } from "@/core/config"; import type { ClarificationPayload } from "./multi-agent"; import { reportRoundtableError } from "./diagnostics-report"; import { resolveAllowMultiple } from "@/roundtable-planning/lib/clarification"; import { normalizeIntentReadySummary } from "@/roundtable-planning/lib/roundtable-constants"; export type { ClarificationPayload }; /** Stable built-in agent used by the multi-agent roundtable Step 1 flow. */ export const INTENT_AGENT_ID = "roundtable-intent"; /** Dedicated Step 1 agent for the position-roundtable workspace. */ export const POSITION_INTENT_AGENT_ID = "position-roundtable-intent"; /** Dedicated Step 1 agent for the 8BF roundtable workspace (same prompt family * as `roundtable-intent`, independently tunable via the agent config UI). */ export const EIGHTBF_INTENT_AGENT_ID = "eightbf-roundtable-intent"; /** * 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 * tied to the built-in `roundtable-intent` agent, and each /stream POST is * one user-turn of the clarification dialogue. The terminal status frame * carries either `asking` (more turns needed) or `done` (structured summary * available — hand off to Step 2). */ export interface IntentInitResponse { status: "success"; agent_id: string; thread_id: string; } /** * Structured diagnostic payload surfaced by the backend whenever an intent * route raises (init) or yields a final `status: "error"` frame (stream). * Same shape across `init_intent` / `stream_intent`; renderer keys off `code` * for quick visual triage, but also shows `hint` + `trace_tail` as a panel * so deployment-time issues (config.yaml unreachable LLM, missing builtin * agent dirs, loopback going through proxy) are debuggable from the UI alone * without tail-ing backend logs. */ export interface IntentErrorDetail { /** Stable short category — see backend `_diagnose_exception`. */ code: string; /** One-line human-readable description (already includes exception type). */ message: string; exception_type: string; exception_repr: string; /** Root of the `__cause__` chain when distinct from the outer exception. */ root_type?: string | null; root_repr?: string | null; /** Actionable hint in Chinese, keyed by `code`. May be null for unrecognized errors. */ hint?: string | null; /** Last ~12 lines of the traceback for in-UI inspection. */ trace_tail?: string[]; /** Caller-supplied label (e.g. "intent_init"). */ context?: string | null; } /** * Thrown by `initIntent` when the HTTP call fails. Carries the parsed * `IntentErrorDetail` (if the backend emitted one) so the page can render * a rich diagnostic panel instead of a one-line message. */ export class IntentInitError extends Error { /** HTTP status (typically 500 for diagnosed failures, 401/403 for auth). */ readonly status: number; /** Structured detail when the backend emitted `_diagnose_exception` output. */ readonly detail: IntentErrorDetail | null; /** Raw body text when JSON parse failed (kept for completeness). */ readonly rawBody: string; constructor(status: number, detail: IntentErrorDetail | null, rawBody: string) { super(detail?.message || `init intent failed (${status}): ${rawBody}`); this.name = "IntentInitError"; this.status = status; this.detail = detail; this.rawBody = rawBody; } } function parseIntentErrorDetail(value: unknown): IntentErrorDetail | null { if (!value || typeof value !== "object") return null; const obj = value as Record; if (typeof obj.code !== "string" || typeof obj.message !== "string") return null; return { code: obj.code, message: obj.message, exception_type: typeof obj.exception_type === "string" ? obj.exception_type : "", exception_repr: typeof obj.exception_repr === "string" ? obj.exception_repr : "", root_type: typeof obj.root_type === "string" ? obj.root_type : null, root_repr: typeof obj.root_repr === "string" ? obj.root_repr : null, hint: typeof obj.hint === "string" ? obj.hint : null, trace_tail: Array.isArray(obj.trace_tail) ? obj.trace_tail.filter((l): l is string => typeof l === "string") : [], context: typeof obj.context === "string" ? obj.context : null, }; } /** * Create a fresh thread for an intent-clarification session. * The backend returns the built-in agent id so the frontend doesn't hardcode it. * * On failure throws `IntentInitError` whose `.detail` carries the backend's * `_diagnose_exception` payload — the page should surface `detail.hint` / * `detail.trace_tail` to help diagnose intranet deployment issues without * shell access to the server. */ export async function initIntent( model?: string, taskContext?: Record | null, intentAgentId?: string, ): Promise { const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, { method: "POST", headers: { "Content-Type": "application/json" }, // 岗位会商在创建意图线程时就写入父任务标识。这样即使用户尚未确认 // 业务链,第一阶段的对话也已经可以按 taskId 恢复。 body: JSON.stringify({ model, task_context: taskContext ?? undefined, intent_agent_id: intentAgentId || undefined, }), }); if (!res.ok) { const raw = await res.text().catch(() => ""); let detail: IntentErrorDetail | null = null; try { const parsed = JSON.parse(raw); // FastAPI HTTPException(detail=dict) → { "detail": {...} } if (parsed && typeof parsed === "object" && parsed !== null) { detail = parseIntentErrorDetail((parsed as { detail?: unknown }).detail) || parseIntentErrorDetail(parsed); } } catch { // body wasn't JSON — fall through with raw text only. } throw new IntentInitError(res.status, detail, raw); } return (await res.json()) as IntentInitResponse; } /** * Structured task summary the intent agent emits when it judges the user's * task is fully specified. The frontend feeds this into Step 2's initial * leader prompt. */ export interface IntentSummary { /** One-line statement of what the user wants accomplished. */ objective: string; /** Hard constraints (budget / time / compliance / scope). At least 1 entry. */ constraints: string[]; /** Optional assumptions the agent is making on the user's behalf. */ assumptions?: string[]; /** Present only for the dedicated position-roundtable intent agent. */ coreGoals?: string[]; riskWarnings?: string[]; keyPoints?: string[]; strategicSignificance?: string[]; } /** Final hand-off shape emitted only by the position-roundtable intent agent. */ export interface PositionIntentSummary { coreGoals: string[]; riskWarnings: string[]; keyPoints: string[]; strategicSignificance: string[]; } /** * Same phases as streamMultiAgent so the UI can reuse one phase-label mapper. * `connecting` ≈ HTTP sent, no frames yet; `connected` ≈ got `event: metadata`; * `streaming` ≈ first AI text chunk; `tool_calling` ≈ ask_clarification or * similar tool call (which usually means the run is about to interrupt and * the user-visible text won't grow further this turn). */ export type IntentStreamPhase = "connecting" | "connected" | "streaming" | "tool_calling"; /** * 用户上传文件的引用(已经过 `/api/threads/{thread_id}/uploads`)。透传给后端 * `additional_kwargs.files`,由 UploadsMiddleware 注入文件清单并解锁 read_file 等工具。 * 字段与普通聊天的 `FileInMessage` 对齐:`path` 用上传返回的 `virtual_path`。 */ export interface IntentFileRef { filename: string; size: number; path: string; status?: "uploading" | "uploaded"; } export interface StreamIntentRequest { threadId: string; message: string; model?: string; /** Explicit built-in Step-1 agent. Omit to retain the multi-agent default. */ intentAgentId?: string; /** * 本回合幂等键(Phase 4 单飞):同一 (threadId, clientTurnId) 在后端至多一个 run。 * 缺省由调用方(useStep1Intent)每条消息生成;同一回合的重试 / 双击复用它,后端据此 * 去重(在跑→丢弃重复请求 / 已完成→幂等回放),杜绝并发下双发意图 / 重复澄清卡。 */ clientTurnId?: string; /** Optional hidden business/task context injected into the model run. */ taskContext?: Record | null; /** Step 1 用户上传的参考文件(可选)。 */ files?: IntentFileRef[]; signal?: AbortSignal; /** Fires for every AI text chunk parsed out of LangGraph messages-tuple frames. */ onTextDelta?: (text: string, messageId: string) => void; /** * Fires whenever the inferred SSE phase changes. The UI uses this to flip * the placeholder bubble's caption from "调度中" → "已连接,等待响应" → * "正在输出" so users see what's happening during the inevitable LLM * first-token wait. */ onPhaseChange?: ( phase: IntentStreamPhase, toolName?: string, args?: Record, ) => void; /** * Fires for every `ask_clarification` tool message parsed out of the live * stream (the intent agent may emit several in a single turn). Lets the UI * surface each assistance question in real-time instead of waiting for the * terminal frame. `index` is the 0-based arrival order within this turn. */ onClarification?: (clarification: ClarificationPayload, index: number) => void; } export interface StreamIntentResult { /** "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. * This is the *first* clarification (kept for backward compat); prefer * `clarifications` which carries the full set. */ clarification: ClarificationPayload | null; /** * All `ask_clarification` questions the agent asked this turn, in arrival * order. Collected live and reconciled against the terminal frame so a * reconnect cannot lose cards. Empty unless status === "clarification". */ clarifications: ClarificationPayload[]; /** * Populated only when status === "error" and the backend emitted a * structured diagnostic (new in `_diagnose_exception`). UI uses this to show * the same rich panel as init failures. */ errorDetail: IntentErrorDetail | null; } /** * Recognise the terminal status frame the backend appends after the upstream * LangGraph stream finishes. Must carry `status` and `content`; `summary` * is optional. */ function isFinalIntentFrame(value: unknown): value is { 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; clarifications?: unknown[]; error_detail?: unknown; } { 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 === "clarification" || status === "done" || status === "error" ); } /** * Reuse the same delta-extraction shape as multi-agent.ts so AI text streams * smoothly into the frontend bubble. Returns null for tool / value / tool * result frames. */ function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null { if (!Array.isArray(parsed) || parsed.length < 1) return null; const chunk = parsed[0]; if (!chunk || typeof chunk !== "object") return null; const obj = chunk as Record; const type = String(obj.type ?? "").toLowerCase(); if (type !== "ai" && type !== "aimessagechunk" && type !== "aimessage") return null; const content = obj.content; let text = ""; if (typeof content === "string") { text = content; } else if (Array.isArray(content)) { for (const item of content) { if (typeof item === "string") text += item; else if (item && typeof item === "object") { const block = item as Record; const blockText = block.text; if (typeof blockText === "string") text += blockText; } } } if (!text) return null; const id = typeof obj.id === "string" ? obj.id : ""; return { text, id }; } /** * Parse a live `type:"tool"` `ask_clarification` frame into a ClarificationPayload. * * The intent agent emits one such frame per question (ClarificationMiddleware * formats them), each carrying a fully-formed clarification in * `additional_kwargs.clarification` — options already normalized to * `{id, label}` objects, snake_case keys. Returns null for any other frame * (AI text chunks, tool_call_chunks, value snapshots). */ function extractClarificationToolMessage( parsed: unknown, ): { payload: ClarificationPayload; toolCallId: string } | null { if (!Array.isArray(parsed) || parsed.length < 1) return null; const msg = parsed[0]; if (!msg || typeof msg !== "object") return null; const obj = msg as Record; if (String(obj.type ?? "").toLowerCase() !== "tool") return null; if (obj.name !== "ask_clarification") return null; const kwargs = obj.additional_kwargs; if (!kwargs || typeof kwargs !== "object") return null; const clar = (kwargs as Record).clarification; if (!clar || typeof clar !== "object") return null; const c = clar as Record; const toolCallId = typeof obj.tool_call_id === "string" && obj.tool_call_id ? obj.tool_call_id : typeof obj.id === "string" ? obj.id : ""; const clarificationType = typeof c.clarification_type === "string" ? c.clarification_type : ""; return { toolCallId, payload: { id: toolCallId || undefined, question: typeof c.question === "string" ? c.question : "", clarificationType, context: typeof c.context === "string" ? c.context : "", options: Array.isArray(c.options) ? c.options : [], allowCustom: c.allow_custom !== false, allowMultiple: resolveAllowMultiple( typeof c.allow_multiple === "boolean" ? c.allow_multiple : undefined, clarificationType, ), }, }; } /** * Normalize one clarification included in the terminal status frame. This is * the reconnect / proxy fallback for cases where one or more live `tool` * frames were not delivered to the browser. */ function parseTerminalClarification( value: unknown, ): { payload: ClarificationPayload; toolCallId: string } | null { if (!value || typeof value !== "object") return null; const raw = value as Record; const clarificationType = typeof raw.clarification_type === "string" ? raw.clarification_type : ""; const toolCallId = typeof raw.id === "string" ? raw.id : ""; return { toolCallId, payload: { id: toolCallId || undefined, question: typeof raw.question === "string" ? raw.question : "", clarificationType, context: typeof raw.clarification_context === "string" ? raw.clarification_context : typeof raw.context === "string" ? raw.context : "", options: Array.isArray(raw.options) ? raw.options : [], allowCustom: raw.allow_custom !== false, allowMultiple: resolveAllowMultiple( typeof raw.allow_multiple === "boolean" ? raw.allow_multiple : undefined, clarificationType, ), }, }; } /** * Open an SSE connection to /api/intent/stream and resolve when the upstream * stream completes. Each call is one user turn; reuse the same threadId * across calls to keep the dialogue history. */ export async function streamIntent(req: StreamIntentRequest): Promise { const res = await apiFetch(`${getBackendBaseURL()}/api/intent/stream`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ thread_id: req.threadId, message: req.message, model: req.model, intent_agent_id: req.intentAgentId || undefined, task_context: req.taskContext ?? undefined, // Phase 4 单飞幂等键:缺省由后端兜底生成,但调用方应始终传(useStep1Intent 每条消息生成)。 client_turn_id: req.clientTurnId || undefined, // 仅在有文件时带上,空数组后端也安全忽略。 files: req.files && req.files.length > 0 ? req.files : undefined, }), signal: req.signal, }); if (!res.ok || !res.body) { const text = await res.text().catch(() => ""); reportRoundtableError({ stage: "step1_intent", event: "intent_stream_http_error", message: `第一步意图生成失败 (HTTP ${res.status}): ${text.slice(0, 500)}`, httpStatus: res.status, }); throw new Error(`intent/stream failed (${res.status}): ${text}`); } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let lastEvent = ""; // Single source of truth for the inferred SSE phase. The helper only fires // the callback when the phase actually changes (or when a tool name is // provided, since the same `tool_calling` phase may surface a new tool). let currentPhase: IntentStreamPhase = "connecting"; req.onPhaseChange?.("connecting"); const setPhase = ( next: IntentStreamPhase, toolName?: string, args?: Record, ) => { if (next === currentPhase && !toolName) return; currentPhase = next; req.onPhaseChange?.(next, toolName, args); }; const result: StreamIntentResult = { status: "asking", content: "", summary: null, clarification: null, clarifications: [], errorDetail: null, }; // Clarifications collected live from `type:"tool"` frames, deduped by // tool_call_id, then reconciled against the complete terminal list. const seenClarificationIds = new Set(); // A clarification tool call is a hard pause in the Step 1 conversation. // Some model providers keep emitting text (and can even produce an // INTENT_READY terminal frame) after the tool result. That text belongs to // a continuation the user has not approved yet, so never let it replace the // assistance card in the current turn. let awaitingUserClarification = false; const collectClarification = (payload: ClarificationPayload, toolCallId: string) => { awaitingUserClarification = true; const dedupeKey = toolCallId || `idx-${result.clarifications.length}`; if (seenClarificationIds.has(dedupeKey)) return; seenClarificationIds.add(dedupeKey); const index = result.clarifications.length; result.clarifications.push(payload); if (!result.clarification) result.clarification = payload; req.onClarification?.(payload, index); }; 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(); // First `event: metadata` = the upstream LangGraph run was accepted. // Flip out of "connecting" so the bubble label changes from // "调度中" → "已连接,等待响应". 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 (isFinalIntentFrame(parsed)) { // Reconcile any terminal clarification cards, but preserve the first // tool-call pause as the authoritative state for this turn. In // particular, do not accept a later `done` frame / summary before the // user has answered the displayed questions. if (awaitingUserClarification) { if (parsed.status === "clarification") { const terminalCards = Array.isArray(parsed.clarifications) ? parsed.clarifications .map(parseTerminalClarification) .filter((item): item is { payload: ClarificationPayload; toolCallId: string } => item !== null) : []; if (terminalCards.length > 0) { terminalCards.forEach((card, index) => { collectClarification(card.payload, card.toolCallId || `terminal-${index}`); }); } } continue; } result.status = parsed.status; if (typeof parsed.content === "string") result.content = parsed.content; if (parsed.summary && typeof parsed.summary === "object") { result.summary = normalizeIntentReadySummary(parsed.summary); } if (parsed.status === "error") { result.errorDetail = parseIntentErrorDetail(parsed.error_detail); reportRoundtableError({ stage: "step1_intent", event: "intent_error_frame", message: `第一步意图生成报错:${(result.content || "").slice(0, 800)}`, detail: result.errorDetail, }); } if (parsed.status === "clarification") { // Newer backend frames contain every card. Merge them even when a // subset already arrived live: an SSE reconnect can otherwise leave // the user with only the first card in a multi-question assistance // round. Older backends still fall back to the legacy top-level card. const terminalCards = Array.isArray(parsed.clarifications) ? parsed.clarifications .map(parseTerminalClarification) .filter((item): item is { payload: ClarificationPayload; toolCallId: string } => item !== null) : []; if (terminalCards.length > 0) { terminalCards.forEach((card, index) => { collectClarification(card.payload, card.toolCallId || `terminal-${index}`); }); } else if (result.clarifications.length === 0) { const fallback = parseTerminalClarification(parsed); if (fallback) { collectClarification(fallback.payload, fallback.toolCallId || "terminal-0"); } } } continue; } if (lastEvent === "messages") { // `ask_clarification` tool result frame → collect + surface in real-time. // These carry the fully-formed clarification (one per question); the // agent may emit several in a single turn. const toolClar = extractClarificationToolMessage(parsed); if (toolClar) { collectClarification(toolClar.payload, toolClar.toolCallId); continue; } // Do not show follow-up thinking / generated summary text while the // user-assistance card is waiting for an answer. This also prevents a // later AI delta from hiding the card through an [INTENT_READY] block. if (awaitingUserClarification) continue; // Phase inference from LangGraph messages-tuple frames. Tool calls // (typically ask_clarification) flip phase to tool_calling; AI text // chunks flip to streaming. Everything else leaves phase alone. if (Array.isArray(parsed) && parsed.length > 0) { const chunk = parsed[0]; if (chunk && typeof chunk === "object") { const chunkObj = chunk as Record; const chunkType = String(chunkObj.type ?? "").toLowerCase(); const toolCalls = chunkObj.tool_calls; const toolCallChunks = chunkObj.tool_call_chunks; const content = chunkObj.content; const hasToolCall = (Array.isArray(toolCalls) && toolCalls.length > 0) || (Array.isArray(toolCallChunks) && toolCallChunks.length > 0); if (hasToolCall) { const firstCall = Array.isArray(toolCalls) && toolCalls.length > 0 ? (toolCalls[0] as Record) : Array.isArray(toolCallChunks) && toolCallChunks.length > 0 ? (toolCallChunks[0] as Record) : null; const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : ""; // 把 tool args 一并透传给 UI;onPhaseChange 在 useStep1Intent 里 // 会把 args 累积进 message.steps,让 MessageStepsCard 渲染路径 chip / // 命令代码块等子节点(与主聊天 MessageGroup 视觉一致)。 const toolArgs = firstCall && typeof firstCall.args === "object" && firstCall.args !== null ? (firstCall.args as Record) : undefined; setPhase("tool_calling", toolName, toolArgs); } else if ( (chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") && ((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0)) ) { setPhase("streaming"); } } } const delta = extractAiTextDelta(parsed); if (delta) req.onTextDelta?.(delta.text, delta.id); } } } if (awaitingUserClarification && result.clarifications.length > 0) { result.status = "clarification"; result.summary = null; result.clarification = result.clarifications[0] ?? null; } return result; }