diff --git a/frontend-web/src/roundtable-planning/api/intent.ts b/frontend-web/src/roundtable-planning/api/intent.ts new file mode 100644 index 00000000..8bb76c18 --- /dev/null +++ b/frontend-web/src/roundtable-planning/api/intent.ts @@ -0,0 +1,186 @@ +import { apiFetch } from "@/core/api/fetch-client"; +import { getBackendBaseURL } from "@/core/config"; + +/** + * 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; +} + +/** + * Create a fresh thread for an intent-clarification session. + * The backend returns the built-in agent id so the frontend doesn't hardcode it. + */ +export async function initIntent(model?: string): Promise { + const res = await apiFetch(`${getBackendBaseURL()}/api/intent/init`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model }), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`init intent failed (${res.status}): ${text}`); + } + 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[]; +} + +export interface StreamIntentRequest { + threadId: string; + message: string; + model?: string; + signal?: AbortSignal; + /** Fires for every AI text chunk parsed out of LangGraph messages-tuple frames. */ + onTextDelta?: (text: string, messageId: string) => void; +} + +export interface StreamIntentResult { + /** "asking" → keep the dialogue going; "done" → summary is ready; "error" → backend failure. */ + status: "asking" | "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; +} + +/** + * 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" | "done" | "error"; + content?: string; + summary?: IntentSummary | null; +} { + if (!value || typeof value !== "object") return false; + if (!("status" in value) || !("content" in value)) return false; + const status = (value as { status: unknown }).status; + return status === "asking" || 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 }; +} + +/** + * 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, + }), + signal: req.signal, + }); + + if (!res.ok || !res.body) { + const text = await res.text().catch(() => ""); + throw new Error(`intent/stream failed (${res.status}): ${text}`); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let lastEvent = ""; + + const result: StreamIntentResult = { status: "asking", content: "", summary: null }; + + 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(); + 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)) { + result.status = parsed.status; + if (typeof parsed.content === "string") result.content = parsed.content; + if (parsed.summary && typeof parsed.summary === "object") { + result.summary = parsed.summary as IntentSummary; + } + continue; + } + + if (lastEvent === "messages") { + const delta = extractAiTextDelta(parsed); + if (delta) req.onTextDelta?.(delta.text, delta.id); + } + } + } + + return result; +} diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 7ef56684..6dedc67e 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -13,6 +13,7 @@ import { type StreamPhase, type ThreadIdMap, } from "@/roundtable-planning/api/multi-agent"; +import { initIntent, streamIntent, type IntentSummary } from "@/roundtable-planning/api/intent"; import { Shield, MessageSquare, @@ -305,6 +306,17 @@ export default function RoundtablePlanningPage() { // Collapsing gives the conversation log more vertical space. const [isUpperPanelCollapsed, setIsUpperPanelCollapsed] = useState(false); + // Step 1 intent-clarification state (lives only for one step-1 visit). + // The thread is created lazily on first entry; intentReady is the + // structured summary returned by the intent agent when it judges the + // task is fully specified — Step 2's initial leader prompt is built + // from this. + const [intentThreadId, setIntentThreadId] = useState(null); + const [intentReady, setIntentReady] = useState(null); + const [intentInitError, setIntentInitError] = useState(null); + const [isIntentSending, setIsIntentSending] = useState(false); + const step1InitRef = useRef(false); + // Multi-agent orchestration state (lives only for one step-2 visit). const [threadIds, setThreadIds] = useState(null); const [coordinatorName, setCoordinatorName] = useState(null); @@ -514,14 +526,32 @@ export default function RoundtablePlanningPage() { ); }, []); - // Pull the consolidated user intent out of step-1 history. Falls back to a - // generic prompt if the user never spoke. + // Build the Step 2 leader's initial prompt. Prefer the intent agent's + // structured summary when available — it already encodes objective + + // constraints + assumptions. Fall back to "last user message + budget" + // for sessions that bypassed Step 1 or hit an init error. const buildInitialIntent = useCallback(() => { + if (intentReady) { + const parts: string[] = [intentReady.objective]; + if (intentReady.constraints?.length) { + parts.push(""); + parts.push("约束条件:"); + for (const c of intentReady.constraints) parts.push(`- ${c}`); + } + if (intentReady.assumptions?.length) { + parts.push(""); + parts.push("关键假设:"); + for (const a of intentReady.assumptions) parts.push(`- ${a}`); + } + parts.push(""); + parts.push("用户已经过任务理解智能体澄清,以上信息可以直接作为研讨依据。请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨,不要再反问用户。"); + return parts.join("\n"); + } const lastUserMessage = [...interactiveMessages].reverse().find(m => m.sender === "user"); const userIntent = lastUserMessage?.content || "用户尚未给出明确意图"; const constraints = `预算硬约束:${budgetLimit} 万元`; return `${userIntent}\n\n约束条件:${constraints}\n\n请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨。`; - }, [interactiveMessages, budgetLimit]); + }, [interactiveMessages, budgetLimit, intentReady]); // Run one full orchestration step: 1) leader call → 2) dispatch specials in // parallel → 3) recurse with a synthesis prompt until leader stops dispatching. @@ -868,49 +898,111 @@ export default function RoundtablePlanningPage() { setShowToast(msg); }; - // Chat sender logic for Step 1 interaction - const handleSendMessage = () => { - if (!inputText.trim()) return; + // Chat sender logic for Step 1 interaction — drives the real intent agent + // via /api/intent/stream. The agent streams its reply into a placeholder + // bubble; if its final status is "done" we capture the structured summary + // for the Step 2 handoff. + const handleSendMessage = async () => { + const userText = inputText.trim(); + if (!userText) return; + if (!intentThreadId) { + triggerToast("⚠️ 任务理解智能体尚未就绪,请稍候"); + return; + } + if (isIntentSending) { + triggerToast("⏳ 智能体正在回复,请稍候"); + return; + } - const userMsgTime = new Date().toLocaleTimeString("zh-CN", { hour12: false }); + const userMsgTime = nowTime(); + const userMsgId = Date.now() + Math.random(); const userMsg = { - id: interactiveMessages.length + 1, + id: userMsgId, sender: "user", senderName: "用户", - content: inputText, + content: userText, time: userMsgTime, }; - - // Append user message immediately - const updatedMessages = [...interactiveMessages, userMsg]; - setInteractiveMessages(updatedMessages); + setInteractiveMessages(prev => [...prev, userMsg]); setInputText(""); setCharactersLeft(0); - // Update Clarification timeline list on the right beautifully! - const newTimelineEntry = { - id: timelineEntries.length + 1, - time: userMsgTime, - title: "用户实时反馈", - desc: userMsg.content.length > 25 ? userMsg.content.substring(0, 25) + "..." : userMsg.content - }; - setTimelineEntries(prev => [...prev, newTimelineEntry]); + // Right-side timeline gets a short summary too. + setTimelineEntries(prev => [ + ...prev, + { + id: prev.length + 1, + time: userMsgTime, + title: "用户实时反馈", + desc: userText.length > 25 ? userText.substring(0, 25) + "..." : userText, + }, + ]); - triggerToast("消息发送成功!任务意图分析中..."); - - // Simulated responsive intelligence robot typing back - setTimeout(() => { - const responseTime = new Date().toLocaleTimeString("zh-CN", { hour12: false }); - const automatedReplyLine = { - id: updatedMessages.length + 2, + // Placeholder agent bubble — text deltas accumulate into it. + const agentMsgId = Date.now() + Math.random() + 0.01; + setInteractiveMessages(prev => [ + ...prev, + { + id: agentMsgId, sender: "agent", senderName: "任务理解智能体", - content: `已收到关于“${userMsg.content}”的更新!我已自动校对预算配比与合规规范。当前规划框架可信度提升 4.2%。请您继续审视下方待确认事项,或随时确认以进入多智能体圆桌研讨。`, - time: responseTime, - }; - setInteractiveMessages(prev => [...prev, automatedReplyLine]); - triggerToast("任务理解智能体已回复!"); - }, 1500); + content: "", + time: nowTime(), + streaming: true, + } as any, + ]); + + setIsIntentSending(true); + try { + const res = await streamIntent({ + threadId: intentThreadId, + message: userText, + model: DEFAULT_MODEL, + onTextDelta: (delta) => { + setInteractiveMessages(prev => + prev.map(m => (m.id === agentMsgId ? { ...m, content: (m.content ?? "") + delta } : m)), + ); + }, + }); + // Finalize the bubble with the authoritative final text from the + // status frame (covers cases where deltas didn't accumulate cleanly). + setInteractiveMessages(prev => + prev.map(m => + m.id === agentMsgId + ? { ...m, content: res.content || m.content || "(无文本输出)", streaming: false } as any + : m, + ), + ); + if (res.status === "done" && res.summary) { + setIntentReady(res.summary); + setTimelineEntries(prev => [ + ...prev, + { + id: prev.length + 1, + time: nowTime(), + title: "任务意图已明确", + desc: res.summary!.objective.length > 25 + ? res.summary!.objective.substring(0, 25) + "..." + : res.summary!.objective, + }, + ]); + triggerToast("✅ 任务理解完成,可进入多智能体圆桌研讨"); + } else if (res.status === "error") { + triggerToast(`❌ 智能体调用失败: ${res.content}`); + } + } catch (err) { + console.error("intent stream failed:", err); + setInteractiveMessages(prev => + prev.map(m => + m.id === agentMsgId + ? { ...m, content: `(错误) ${(err as Error).message}`, streaming: false } as any + : m, + ), + ); + triggerToast(`❌ 发送失败: ${(err as Error).message}`); + } finally { + setIsIntentSending(false); + } }; // Skip step logic @@ -939,20 +1031,88 @@ export default function RoundtablePlanningPage() { triggerToast("追问生成就绪!"); }; - // Step 2 role statuses transition when entering roundtable! - // On Step 2 entry we set every agent seat to "正在上线" (spinner state); - // the /init SSE stream then flips each one to "在线" as its thread/agent - // actually finishes on the backend. Seat 1 (human commander) stays online - // throughout — it's not part of the bring-up. + // Step 1 init effect: create a fresh intent-agent thread the first time + // the user lands on step 1 (and on every re-entry, since clearing thread + // history is desirable here — each new planning session starts clean). + // + // Note on React 18 StrictMode: effects run twice in dev. We reset + // `step1InitRef.current` to false in the cleanup so the second mount can + // actually run init — otherwise the ref stays true, the second effect + // returns early, and the first effect's `cancelled` flag prevents its + // result from setting state. Net effect would be a successful API call + // whose result never lands and the Send button stays disabled. + useEffect(() => { + if (currentStep !== 1) { + step1InitRef.current = false; + return; + } + if (step1InitRef.current) return; + step1InitRef.current = true; + + let cancelled = false; + (async () => { + setIntentReady(null); + setIntentInitError(null); + setIntentThreadId(null); + setTimelineEntries([]); + setInteractiveMessages([ + { + id: 1, + sender: "agent", + senderName: "任务理解智能体", + content: "你好,我是任务理解智能体。请用 1-2 句话告诉我你这次想做什么,以及有哪些预算/时间/合规上的硬约束?", + time: nowTime(), + }, + ]); + try { + const res = await initIntent(DEFAULT_MODEL); + if (cancelled) return; + setIntentThreadId(res.thread_id); + setTimelineEntries([ + { + id: 1, + time: nowTime(), + title: "任务理解智能体已就绪", + desc: `agent_id=${res.agent_id}`, + }, + ]); + } catch (err) { + if (cancelled) return; + console.error("init intent failed:", err); + setIntentInitError((err as Error).message); + } + })(); + return () => { + cancelled = true; + step1InitRef.current = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentStep]); + + // Role panel transitions: + // - Step 1 shows ONLY the commander + the task-intent agent (2 entries). + // The intent agent flips to 在线 once /api/intent/init returns; if it + // errors we mark 异常 so the user knows why send is disabled. + // - Step 2 lights up all 7 seats (each flipped 在线 as init SSE reports). + // - Step 3 marks the whole roster as 已完成. useEffect(() => { if (currentStep === 1) { - setRoles(makeRoles("等待激活", { 2: "在线" })); + setRoles([ + { id: 1, name: "指挥员 / 主持人", type: "human", status: "在线" }, + { + id: 100, + name: "任务理解", + agentId: "roundtable-intent", + type: "agent", + status: intentInitError ? "异常" : intentThreadId ? "在线" : "正在上线", + }, + ]); } else if (currentStep === 2) { setRoles(makeRoles("正在上线", { 1: "在线" })); } else { setRoles(makeRoles("已完成")); } - }, [currentStep]); + }, [currentStep, intentThreadId, intentInitError]); // Inject user input to the roundtable room — sends it as a new leader call. const handleInjectToRoundtable = () => { @@ -1426,157 +1586,95 @@ export default function RoundtablePlanningPage() { 任务意图理解 (人机问答) + {/* Real turn counter — counts user messages in the live + interactiveMessages list. "X 轮" instead of "X/3" because + we don't bound the number of clarification turns. */} - 问答轮次:3/3 + 问答轮次:{interactiveMessages.filter(m => m.sender === "user").length} 轮 {/* Chat list viewport */}
- {/* Message 1 (User / 用户) */} -
-
- -
-
- 用户 + {/* Step 1 dialogue is rendered by `interactiveMessages.map()` + inside the 问答交互区 sub-block below. The static demo + bubbles that previously lived here have been removed. */} + + {/* --- INTENT SUMMARY BOX --- emerald (ready) / slate (waiting) --- + Rendered from `intentReady` when the agent has emitted a + structured summary; otherwise shows a placeholder with + a disabled "进入下一步" button so the user knows what they + need to wait for. */} + {intentReady ? ( +
+
+
+ + + 已形成明确任务意图 +
-

- 请帮我围绕某复杂任务制定详细规划方案。 -

+
-
- 10:32:15 -
- {/* Message 2 (Agent) */} -
-
- -
-
- 任务理解智能体 +
+
+ 任务目标 +

{intentReady.objective}

-

- 请明确目标、时间要求与资源限制。 -

-
-
- 10:32:22 -
- - {/* Message 3 (User / 用户) */} -
-
- -
-
- 用户 +
+ 硬约束 +
    + {intentReady.constraints.map((c, i) => ( +
  • {c}
  • + ))} +
-

- 72小时内完成,可用预算500万,人员30人以内。 -

+ {intentReady.assumptions && intentReady.assumptions.length > 0 && ( +
+ 关键假设 +
    + {intentReady.assumptions.map((a, i) => ( +
  • {a}
  • + ))} +
+
+ )}
- 10:33:02 -
- - {/* Message 4 (Agent) */} -
-
- -
-
- 任务理解智能体 + ) : ( +
+
+
+ + + 等待任务理解智能体澄清完成... +
-

- 是否优先保证主方案可执行性,并同时给出保守备选方案? + +

+ {intentInitError && ( +

+ 智能体初始化失败: {intentInitError}

-
+ )}
- 10:33:15 -
- - {/* Message 5 (User / 用户) */} -
-
- -
-
- 用户 -
-

- 是,请保留主方案并给出备选方案。 -

-
-
- 10:33:36 -
- - {/* Message 6 (Agent) */} -
-
- -
-
- 任务理解智能体 -
-

- 已基本形成任务意图,请确认。 -

-
-
- 10:33:52 -
- - {/* --- GREEN INTENT BOX (1-to-1) --- */} -
- {/* Top row */} -
-
- - - 已形成明确任务意图 - -
- - -
- - {/* Structured properties in columns */} -
-
- 目标 -

制定可执行的
详细规划方案

-
-
- 时间边界 -

72小时内完成

-
-
- 资源约束 -

预算 ≤ 500万,
人员 ≤ 30人

-
-
- 优先级 -

主方案可执行性优先,
兼顾备选方案

-
-
- 待确认事项 -

备选方案范围、
预算触发条件

-
-
-
+ )} {/* --- Q&A INTERACTION AREA (问答交互区) --- */}
@@ -1594,26 +1692,41 @@ export default function RoundtablePlanningPage() { {/* Content dialogue lines inside interaction */}
- {interactiveMessages.map((msg) => ( -
-
- {msg.sender === "user" ? ( - - ) : ( - - )} -
- - {msg.senderName} - - - {msg.content} - + {interactiveMessages.map((msg) => { + const isStreaming = (msg as { streaming?: boolean }).streaming === true; + const hasContent = (msg.content ?? "").length > 0; + return ( +
+
+ {msg.sender === "user" ? ( + + ) : ( + + )} +
+ + {msg.senderName} + + {/* Pre-text spinner: shown only when streaming AND no content has arrived. */} + {isStreaming && !hasContent ? ( + + + 正在思考... + + ) : ( + + {msg.content} + {isStreaming && ( + + )} + + )} +
+ {msg.time}
- {msg.time} -
- ))} + ); + })}
@@ -1662,13 +1775,14 @@ export default function RoundtablePlanningPage() { {/* Left row buttons */}
-
diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index e1e10155..9678a42d 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -32,6 +32,7 @@ from app.gateway.routers import ( auth, channels, feedback, + intent, llm_metrics, mcp, memory, @@ -864,6 +865,12 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an # Multi-agent orchestration (coordinator + sub-agent broadcast/dispatch) app.include_router(multi_agent.router) + # Task-intent agent (Step 1 of the roundtable flow): single built-in + # agent runs a 2-5 turn clarification dialogue, then hands a structured + # {objective, constraints, assumptions} summary off to the multi-agent + # roundtable as its initial intent. + app.include_router(intent.router) + @app.get("/health", tags=["health"]) async def health_check() -> dict: """Health check endpoint. diff --git a/offline-backend-20260512/backend/app/gateway/routers/intent.py b/offline-backend-20260512/backend/app/gateway/routers/intent.py new file mode 100644 index 00000000..3c9c4fb3 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/intent.py @@ -0,0 +1,167 @@ +"""Task-intent agent endpoints (Step 1 of the roundtable flow). + +Lightweight wrapper around the lead-agent runtime that runs a single built-in +agent (``roundtable-intent``) on its own thread. The agent's job is to do 2-5 +clarifying turns with the user and then emit a structured +``{objective, constraints, assumptions}`` summary the frontend hands off to +the multi-agent roundtable as its initial intent. + +Same SSE shape as ``multi_agent.py`` (transparent passthrough + appended +status frame), so the frontend can reuse the same reader pattern. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +from typing import Any, AsyncIterator + +import httpx +from fastapi import APIRouter, Body, HTTPException, Request +from fastapi.responses import StreamingResponse + +# Reuse the building blocks already in multi_agent.py — keeps both routers +# identical in connection handling, payload shape, and error mapping. +from app.gateway.routers.multi_agent import ( + _auth_headers, + _create_thread, + _flatten_content, + _loopback_base, + _parse_last_messages, + _run_payload, + _stream_upstream, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/intent", tags=["intent"]) + +INTENT_AGENT_ID = "roundtable-intent" + +# Matches the SOUL-mandated handoff signal: a [INTENT_READY] line followed by +# a ```json ... ``` block. The model is told to emit this only when it judges +# the user's task is fully specified. +_INTENT_READY_PATTERN = re.compile( + r"\[INTENT_READY\]\s*```json\s*(\{.*?\})\s*```", + re.DOTALL, +) + + +@router.post("/init") +async def init_intent(request: Request, payload: dict = Body(...)) -> dict[str, Any]: + """Create a fresh thread for an intent-clarification session. + + Returns the stable built-in ``agent_id`` so the frontend doesn't need to + know it. Unlike multi-agent ``/init``, no coordinator is built — the + intent agent is long-lived and already registered via the legacy + agent-sync path on disk. + """ + base = _loopback_base(request) + headers = _auth_headers(request) + async with httpx.AsyncClient(timeout=None) as client: + thread_id = await _create_thread(client, base, headers) + return { + "status": "success", + "agent_id": INTENT_AGENT_ID, + "thread_id": thread_id, + } + + +@router.post("/stream") +async def stream_intent(request: Request, payload: dict = Body(...)) -> StreamingResponse: + """Drive one turn of the intent dialogue via SSE. + + Body: ``{thread_id, message, model?}``. Streams upstream LangGraph frames + through transparently, then appends one status frame: + + { + "status": "asking" | "done" | "error", + "content": "", + "summary": {...} | null // only present when status == "done" + } + """ + thread_id = str(payload.get("thread_id") or "").strip() + message = str(payload.get("message") or "").strip() + model_name = str(payload.get("model") or "deepseek-chat") + if not thread_id: + raise HTTPException(status_code=400, detail="thread_id is required") + if not message: + raise HTTPException(status_code=400, detail="message is required") + + base = _loopback_base(request) + headers = _auth_headers(request) + + async def stream_generator() -> AsyncIterator[str]: + async with httpx.AsyncClient(timeout=None) as client: + try: + body = _run_payload( + agent_name=INTENT_AGENT_ID, + model_name=model_name, + thread_id=thread_id, + new_message=message, + # Intent agent shouldn't dispatch, search, write files, or + # view images — its only tool of note is ask_clarification. + excluded_tools=[ + "web_search", + "present_files", + "view_image", + "agent_orchestration", + ], + skill_stop_names=[], + ) + + captured: list[str] = [] + async for line, done in _stream_upstream(client, base, headers, thread_id, body): + if line is not None: + yield line if line.endswith("\n") else line + "\n" + elif done is not None: + captured = done + + messages = _parse_last_messages(captured) + # Find the latest AIMessage's text content — that's what the + # frontend renders and what we scan for the handoff signal. + last_ai: dict[str, Any] = {} + for msg in reversed(messages): + if not isinstance(msg, dict): + continue + if (msg.get("type") or "").lower() in ("ai", "aimessage", "aimessagechunk"): + last_ai = msg + break + content = _flatten_content(last_ai.get("content", "")) + + summary: dict[str, Any] | None = None + match = _INTENT_READY_PATTERN.search(content) + if match: + try: + summary = json.loads(match.group(1)) + except json.JSONDecodeError as exc: + logger.warning("intent summary JSON parse failed: %s", exc) + summary = None + + status_value = "done" if summary else "asking" + final_frame: dict[str, Any] = { + "status": status_value, + "content": content, + "summary": summary, + } + yield f"data: {json.dumps(final_frame, ensure_ascii=False)}\n\n" + + except HTTPException as exc: + logger.warning("intent stream HTTPException: %s %s", exc.status_code, exc.detail) + err = {"status": "error", "content": f"[{exc.status_code}] {exc.detail}", "summary": None} + yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n" + except Exception as exc: + logger.exception("intent stream unexpected failure") + err = {"status": "error", "content": f"unexpected: {exc!r}", "summary": None} + yield f"data: {json.dumps(err, ensure_ascii=False)}\n\n" + + return StreamingResponse( + stream_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + "Connection": "keep-alive", + }, + )