接口联调

This commit is contained in:
董宏凯 committed 2026-05-23 23:36:41 +08:00
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",
},
)