接口联调
This commit is contained in:
1 parent
8673eb5eca
commit
21254a060a
4 files changed
+698
-219
No files matched your search
@@ -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<IntentInitResponse> {
|
||||
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<string, unknown>;
|
||||
const type = String(obj.type ?? "").toLowerCase();
|
||||
if (type !== "ai" && type !== "aimessagechunk" && type !== "aimessage") return null;
|
||||
const content = obj.content;
|
||||
let text = "";
|
||||
if (typeof content === "string") {
|
||||
text = content;
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const item of content) {
|
||||
if (typeof item === "string") text += item;
|
||||
else if (item && typeof item === "object") {
|
||||
const block = item as Record<string, unknown>;
|
||||
const blockText = block.text;
|
||||
if (typeof blockText === "string") text += blockText;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!text) return null;
|
||||
const id = typeof obj.id === "string" ? obj.id : "";
|
||||
return { text, id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an SSE connection to /api/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<StreamIntentResult> {
|
||||
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;
|
||||
}
|
||||
@@ -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<boolean>(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<string | null>(null);
|
||||
const [intentReady, setIntentReady] = useState<IntentSummary | null>(null);
|
||||
const [intentInitError, setIntentInitError] = useState<string | null>(null);
|
||||
const [isIntentSending, setIsIntentSending] = useState<boolean>(false);
|
||||
const step1InitRef = useRef<boolean>(false);
|
||||
|
||||
// Multi-agent orchestration state (lives only for one step-2 visit).
|
||||
const [threadIds, setThreadIds] = useState<ThreadIdMap | null>(null);
|
||||
const [coordinatorName, setCoordinatorName] = useState<string | null>(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() {
|
||||
任务意图理解 (人机问答)
|
||||
</h3>
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<span className="text-[11px] font-bold text-slate-400 bg-white border px-2 py-0.5 rounded-md">
|
||||
问答轮次:3/3
|
||||
问答轮次:{interactiveMessages.filter(m => m.sender === "user").length} 轮
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Chat list viewport */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4 font-normal">
|
||||
|
||||
{/* Message 1 (User / 用户) */}
|
||||
<div id="msg-item-1" className="flex items-start justify-between gap-3 border-b border-slate-50 pb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Man1Avatar />
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs font-bold text-primary">用户</span>
|
||||
{/* 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 ? (
|
||||
<div className="border border-emerald-200 bg-[#f0fdf4]/60 rounded-2xl p-4.5 shadow-[0_2px_12px_rgba(16,185,129,0.04)]">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-emerald-100 pb-3 mb-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-emerald-500 fill-emerald-50" strokeWidth={2.5} />
|
||||
<span className="text-sm font-extrabold text-slate-900">
|
||||
已形成明确任务意图
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-700 leading-relaxed max-w-[500px]">
|
||||
请帮我围绕某复杂任务制定详细规划方案。
|
||||
</p>
|
||||
<button
|
||||
id="confirm-roundtable-btn"
|
||||
onClick={() => {
|
||||
setCurrentStep(2);
|
||||
triggerToast("成功确认!已切换至第二阶段圆桌研讨系统");
|
||||
}}
|
||||
className="bg-primary hover:bg-[var(--brand-900)] text-white font-bold text-xs px-4 py-2 rounded-xl transition shadow-[0_2px_8px_rgba(245,34,45,0.2)] active:scale-95 shrink-0"
|
||||
>
|
||||
用户确认,进入多智能体研讨
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-mono shrink-0">10:32:15</span>
|
||||
</div>
|
||||
|
||||
{/* Message 2 (Agent) */}
|
||||
<div id="msg-item-2" className="flex items-start justify-between gap-3 border-b border-slate-50 pb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<RobotAvatar bg="bg-[var(--brand-200)]/50" />
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs font-bold text-[var(--brand-900)]">任务理解智能体</span>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div className="space-y-0.5 md:col-span-3">
|
||||
<span className="text-[10px] font-bold text-slate-400 block tracking-tight">任务目标</span>
|
||||
<p className="text-xs font-bold text-slate-800 leading-snug">{intentReady.objective}</p>
|
||||
</div>
|
||||
<p className="text-xs text-slate-700 leading-relaxed max-w-[500px] font-medium bg-primary/5 px-3 py-2 rounded-xl border border-primary/10">
|
||||
请明确目标、时间要求与资源限制。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-mono shrink-0">10:32:22</span>
|
||||
</div>
|
||||
|
||||
{/* Message 3 (User / 用户) */}
|
||||
<div id="msg-item-3" className="flex items-start justify-between gap-3 border-b border-slate-50 pb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Man1Avatar />
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs font-bold text-primary">用户</span>
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<span className="text-[10px] font-bold text-slate-400 block tracking-tight">硬约束</span>
|
||||
<ul className="text-xs font-bold text-slate-800 leading-snug list-disc pl-4 space-y-0.5">
|
||||
{intentReady.constraints.map((c, i) => (
|
||||
<li key={i}>{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<p className="text-xs text-slate-700 leading-relaxed max-w-[500px]">
|
||||
72小时内完成,可用预算500万,人员30人以内。
|
||||
</p>
|
||||
{intentReady.assumptions && intentReady.assumptions.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] font-bold text-slate-400 block tracking-tight">关键假设</span>
|
||||
<ul className="text-xs font-medium text-slate-600 leading-snug list-disc pl-4 space-y-0.5">
|
||||
{intentReady.assumptions.map((a, i) => (
|
||||
<li key={i}>{a}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-mono shrink-0">10:33:02</span>
|
||||
</div>
|
||||
|
||||
{/* Message 4 (Agent) */}
|
||||
<div id="msg-item-4" className="flex items-start justify-between gap-3 border-b border-slate-50 pb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<RobotAvatar bg="bg-[var(--brand-200)]/50" />
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs font-bold text-[var(--brand-900)]">任务理解智能体</span>
|
||||
) : (
|
||||
<div className="border border-slate-200 bg-slate-50/60 rounded-2xl p-4.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-5 w-5 text-slate-400 animate-spin" />
|
||||
<span className="text-sm font-bold text-slate-700">
|
||||
等待任务理解智能体澄清完成...
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-700 leading-relaxed max-w-[500px] font-medium bg-primary/5 px-3 py-2 rounded-xl border border-primary/10">
|
||||
是否优先保证主方案可执行性,并同时给出保守备选方案?
|
||||
<button
|
||||
disabled
|
||||
className="bg-slate-200 text-slate-400 font-bold text-xs px-4 py-2 rounded-xl shrink-0 cursor-not-allowed"
|
||||
>
|
||||
用户确认,进入多智能体研讨
|
||||
</button>
|
||||
</div>
|
||||
{intentInitError && (
|
||||
<p className="mt-2 text-[11px] text-red-500 font-medium">
|
||||
智能体初始化失败: {intentInitError}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-mono shrink-0">10:33:15</span>
|
||||
</div>
|
||||
|
||||
{/* Message 5 (User / 用户) */}
|
||||
<div id="msg-item-5" className="flex items-start justify-between gap-3 border-b border-slate-50 pb-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Man1Avatar />
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs font-bold text-primary">用户</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-700 leading-relaxed max-w-[500px]">
|
||||
是,请保留主方案并给出备选方案。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-mono shrink-0">10:33:36</span>
|
||||
</div>
|
||||
|
||||
{/* Message 6 (Agent) */}
|
||||
<div id="msg-item-6" className="flex items-start justify-between gap-3 pb-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<RobotAvatar bg="bg-[var(--brand-200)]/50" />
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs font-bold text-[var(--brand-900)]">任务理解智能体</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-700 leading-relaxed max-w-[500px] font-medium bg-primary/5 px-3 py-2 rounded-xl border border-primary/10">
|
||||
已基本形成任务意图,请确认。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-mono shrink-0">10:33:52</span>
|
||||
</div>
|
||||
|
||||
{/* --- GREEN INTENT BOX (1-to-1) --- */}
|
||||
<div className="border border-emerald-200 bg-[#f0fdf4]/60 rounded-2xl p-4.5 shadow-[0_2px_12px_rgba(16,185,129,0.04)]">
|
||||
{/* Top row */}
|
||||
<div className="flex items-center justify-between gap-3 border-b border-emerald-100 pb-3 mb-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-emerald-500 fill-emerald-50" strokeWidth={2.5} />
|
||||
<span className="text-sm font-extrabold text-slate-900">
|
||||
已形成明确任务意图
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="confirm-roundtable-btn"
|
||||
onClick={() => {
|
||||
setCurrentStep(2);
|
||||
triggerToast("成功确认!已切换至第二阶段圆桌研讨系统");
|
||||
}}
|
||||
className="bg-primary hover:bg-[var(--brand-900)] text-white font-bold text-xs px-4 py-2 rounded-xl transition shadow-[0_2px_8px_rgba(245,34,45,0.2)] active:scale-95 shrink-0"
|
||||
>
|
||||
用户确认,进入多智能体研讨
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Structured properties in columns */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-[10px] font-bold text-slate-400 block tracking-tight">目标</span>
|
||||
<p className="text-xs font-bold text-slate-800 leading-tight">制定可执行的<br className="hidden md:inline" />详细规划方案</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-[10px] font-bold text-slate-400 block tracking-tight">时间边界</span>
|
||||
<p className="text-xs font-bold text-slate-800 leading-tight">72小时内完成</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-[10px] font-bold text-slate-400 block tracking-tight">资源约束</span>
|
||||
<p className="text-xs font-bold text-slate-800 leading-tight">预算 ≤ 500万,<br />人员 ≤ 30人</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<span className="text-[10px] font-bold text-slate-400 block tracking-tight">优先级</span>
|
||||
<p className="text-xs font-bold text-slate-800 leading-tight">主方案可执行性优先,<br />兼顾备选方案</p>
|
||||
</div>
|
||||
<div className="space-y-0.5 col-span-2 md:col-span-1">
|
||||
<span className="text-[10px] font-bold text-slate-400 block tracking-tight">待确认事项</span>
|
||||
<p className="text-xs font-bold text-slate-800 leading-normal">备选方案范围、<br />预算触发条件</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- Q&A INTERACTION AREA (问答交互区) --- */}
|
||||
<div className="border border-red-100 rounded-2xl bg-slate-50/20 shadow-[0_2px_12px_rgba(245,34,45,0.01)] overflow-hidden">
|
||||
@@ -1594,26 +1692,41 @@ export default function RoundtablePlanningPage() {
|
||||
|
||||
{/* Content dialogue lines inside interaction */}
|
||||
<div className="p-4.5 space-y-3.5 max-h-[280px] overflow-y-auto">
|
||||
{interactiveMessages.map((msg) => (
|
||||
<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">
|
||||
{msg.sender === "user" ? (
|
||||
<Man1Avatar size="h-6 w-6" />
|
||||
) : (
|
||||
<RobotAvatar bg="bg-[var(--brand-200)]/50" size="h-6 w-6" />
|
||||
)}
|
||||
<div>
|
||||
<span className={`text-[10.5px] font-bold block mb-0.5 ${msg.sender === 'user' ? 'text-primary' : 'text-slate-700'}`}>
|
||||
{msg.senderName}
|
||||
</span>
|
||||
<span className="text-xs text-slate-700 leading-normal font-normal">
|
||||
{msg.content}
|
||||
</span>
|
||||
{interactiveMessages.map((msg) => {
|
||||
const isStreaming = (msg as { streaming?: boolean }).streaming === true;
|
||||
const hasContent = (msg.content ?? "").length > 0;
|
||||
return (
|
||||
<div key={msg.id} className="flex items-start justify-between gap-3 border-b border-slate-100/50 pb-2.5 last:border-0 last:pb-0">
|
||||
<div className="flex items-start gap-2.5">
|
||||
{msg.sender === "user" ? (
|
||||
<Man1Avatar size="h-6 w-6" />
|
||||
) : (
|
||||
<RobotAvatar bg="bg-[var(--brand-200)]/50" size="h-6 w-6" />
|
||||
)}
|
||||
<div>
|
||||
<span className={`text-[10.5px] font-bold block mb-0.5 ${msg.sender === 'user' ? 'text-primary' : 'text-slate-700'}`}>
|
||||
{msg.senderName}
|
||||
</span>
|
||||
{/* Pre-text spinner: shown only when streaming AND no content has arrived. */}
|
||||
{isStreaming && !hasContent ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] text-slate-400 italic">
|
||||
<Loader2 className="w-3 h-3 animate-spin text-primary" />
|
||||
正在思考...
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-slate-700 leading-normal font-normal whitespace-pre-wrap break-words">
|
||||
{msg.content}
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-1 h-3 ml-0.5 bg-primary align-middle animate-pulse" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[9px] text-slate-400 font-mono shrink-0">{msg.time}</span>
|
||||
</div>
|
||||
<span className="text-[9px] text-slate-400 font-mono shrink-0">{msg.time}</span>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -1662,13 +1775,14 @@ export default function RoundtablePlanningPage() {
|
||||
|
||||
{/* Left row buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<button
|
||||
id="send-message-btn"
|
||||
onClick={handleSendMessage}
|
||||
className="bg-primary text-white hover:bg-[var(--brand-900)] text-xs font-bold px-4 py-2.5 rounded-xl flex items-center gap-1.5 transition-all shadow-[0_2px_8px_rgba(245,34,45,0.15)] active:scale-95"
|
||||
disabled={isIntentSending || !intentThreadId}
|
||||
className="bg-primary text-white hover:bg-[var(--brand-900)] disabled:bg-slate-300 disabled:cursor-not-allowed text-xs font-bold px-4 py-2.5 rounded-xl flex items-center gap-1.5 transition-all shadow-[0_2px_8px_rgba(245,34,45,0.15)] active:scale-95"
|
||||
>
|
||||
<Send className="w-3 h-3" />
|
||||
发送
|
||||
{isIntentSending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Send className="w-3 h-3" />}
|
||||
{isIntentSending ? "发送中" : "发送"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -2526,28 +2640,33 @@ export default function RoundtablePlanningPage() {
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-slate-50/50 border border-slate-100 rounded-xl space-y-3 mr-0.5">
|
||||
<ul className="text-xs text-slate-700 space-y-2.5 font-normal">
|
||||
<li className="flex items-baseline gap-2 leading-relaxed">
|
||||
<span className="text-primary text-base leading-none">•</span>
|
||||
<span><b className="text-slate-900 font-bold">目标</b>: 制定可执行 of 详细规划方案</span>
|
||||
</li>
|
||||
<li className="flex items-baseline gap-2 leading-relaxed">
|
||||
<span className="text-primary text-base leading-none">•</span>
|
||||
<span><b className="text-slate-900 font-bold">时间</b>: 72小时内完成</span>
|
||||
</li>
|
||||
<li className="flex items-baseline gap-2 leading-relaxed">
|
||||
<span className="text-primary text-base leading-none">•</span>
|
||||
<span><b className="text-slate-900 font-bold">资源</b>: 预算 ≤ 500万,人员 ≤ 30人</span>
|
||||
</li>
|
||||
<li className="flex items-baseline gap-2 leading-relaxed">
|
||||
<span className="text-primary text-base leading-none">•</span>
|
||||
<span><b className="text-slate-900 font-bold">优先级</b>: 主方案优先,妥善调配备用</span>
|
||||
</li>
|
||||
<li className="flex items-baseline gap-2 leading-relaxed">
|
||||
<span className="text-primary text-base leading-none">•</span>
|
||||
<span><b className="text-slate-900 font-bold">待确认</b>: 备选模块范围,资源阈值警戒</span>
|
||||
</li>
|
||||
</ul>
|
||||
{/* Driven by the intent agent's structured summary. Stays in
|
||||
"等待" state until the agent emits [INTENT_READY]; then
|
||||
flips to live objective + constraints + assumptions. */}
|
||||
{intentReady ? (
|
||||
<ul className="text-xs text-slate-700 space-y-2.5 font-normal">
|
||||
<li className="flex items-baseline gap-2 leading-relaxed">
|
||||
<span className="text-primary text-base leading-none">•</span>
|
||||
<span><b className="text-slate-900 font-bold">目标</b>: {intentReady.objective}</span>
|
||||
</li>
|
||||
{intentReady.constraints.map((c, i) => (
|
||||
<li key={`c-${i}`} className="flex items-baseline gap-2 leading-relaxed">
|
||||
<span className="text-primary text-base leading-none">•</span>
|
||||
<span><b className="text-slate-900 font-bold">约束</b>: {c}</span>
|
||||
</li>
|
||||
))}
|
||||
{(intentReady.assumptions ?? []).map((a, i) => (
|
||||
<li key={`a-${i}`} className="flex items-baseline gap-2 leading-relaxed">
|
||||
<span className="text-slate-400 text-base leading-none">•</span>
|
||||
<span className="text-slate-500"><b className="text-slate-700 font-bold">假设</b>: {a}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400 italic leading-relaxed">
|
||||
等待任务理解智能体澄清完成...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": "<final AI text>",
|
||||
"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",
|
||||
},
|
||||
)
|
||||
Reference in new issue
Block a user