接口联调
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 StreamPhase,
|
||||||
type ThreadIdMap,
|
type ThreadIdMap,
|
||||||
} from "@/roundtable-planning/api/multi-agent";
|
} from "@/roundtable-planning/api/multi-agent";
|
||||||
|
import { initIntent, streamIntent, type IntentSummary } from "@/roundtable-planning/api/intent";
|
||||||
import {
|
import {
|
||||||
Shield,
|
Shield,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
@@ -305,6 +306,17 @@ export default function RoundtablePlanningPage() {
|
|||||||
// Collapsing gives the conversation log more vertical space.
|
// Collapsing gives the conversation log more vertical space.
|
||||||
const [isUpperPanelCollapsed, setIsUpperPanelCollapsed] = useState<boolean>(false);
|
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).
|
// Multi-agent orchestration state (lives only for one step-2 visit).
|
||||||
const [threadIds, setThreadIds] = useState<ThreadIdMap | null>(null);
|
const [threadIds, setThreadIds] = useState<ThreadIdMap | null>(null);
|
||||||
const [coordinatorName, setCoordinatorName] = useState<string | 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
|
// Build the Step 2 leader's initial prompt. Prefer the intent agent's
|
||||||
// generic prompt if the user never spoke.
|
// 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(() => {
|
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 lastUserMessage = [...interactiveMessages].reverse().find(m => m.sender === "user");
|
||||||
const userIntent = lastUserMessage?.content || "用户尚未给出明确意图";
|
const userIntent = lastUserMessage?.content || "用户尚未给出明确意图";
|
||||||
const constraints = `预算硬约束:${budgetLimit} 万元`;
|
const constraints = `预算硬约束:${budgetLimit} 万元`;
|
||||||
return `${userIntent}\n\n约束条件:${constraints}\n\n请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨。`;
|
return `${userIntent}\n\n约束条件:${constraints}\n\n请基于上述目标与约束,在圆桌内调度合适的子智能体完成研讨。`;
|
||||||
}, [interactiveMessages, budgetLimit]);
|
}, [interactiveMessages, budgetLimit, intentReady]);
|
||||||
|
|
||||||
// Run one full orchestration step: 1) leader call → 2) dispatch specials in
|
// Run one full orchestration step: 1) leader call → 2) dispatch specials in
|
||||||
// parallel → 3) recurse with a synthesis prompt until leader stops dispatching.
|
// parallel → 3) recurse with a synthesis prompt until leader stops dispatching.
|
||||||
@@ -868,49 +898,111 @@ export default function RoundtablePlanningPage() {
|
|||||||
setShowToast(msg);
|
setShowToast(msg);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Chat sender logic for Step 1 interaction
|
// Chat sender logic for Step 1 interaction — drives the real intent agent
|
||||||
const handleSendMessage = () => {
|
// via /api/intent/stream. The agent streams its reply into a placeholder
|
||||||
if (!inputText.trim()) return;
|
// 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 = {
|
const userMsg = {
|
||||||
id: interactiveMessages.length + 1,
|
id: userMsgId,
|
||||||
sender: "user",
|
sender: "user",
|
||||||
senderName: "用户",
|
senderName: "用户",
|
||||||
content: inputText,
|
content: userText,
|
||||||
time: userMsgTime,
|
time: userMsgTime,
|
||||||
};
|
};
|
||||||
|
setInteractiveMessages(prev => [...prev, userMsg]);
|
||||||
// Append user message immediately
|
|
||||||
const updatedMessages = [...interactiveMessages, userMsg];
|
|
||||||
setInteractiveMessages(updatedMessages);
|
|
||||||
setInputText("");
|
setInputText("");
|
||||||
setCharactersLeft(0);
|
setCharactersLeft(0);
|
||||||
|
|
||||||
// Update Clarification timeline list on the right beautifully!
|
// Right-side timeline gets a short summary too.
|
||||||
const newTimelineEntry = {
|
setTimelineEntries(prev => [
|
||||||
id: timelineEntries.length + 1,
|
...prev,
|
||||||
time: userMsgTime,
|
{
|
||||||
title: "用户实时反馈",
|
id: prev.length + 1,
|
||||||
desc: userMsg.content.length > 25 ? userMsg.content.substring(0, 25) + "..." : userMsg.content
|
time: userMsgTime,
|
||||||
};
|
title: "用户实时反馈",
|
||||||
setTimelineEntries(prev => [...prev, newTimelineEntry]);
|
desc: userText.length > 25 ? userText.substring(0, 25) + "..." : userText,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
triggerToast("消息发送成功!任务意图分析中...");
|
// Placeholder agent bubble — text deltas accumulate into it.
|
||||||
|
const agentMsgId = Date.now() + Math.random() + 0.01;
|
||||||
// Simulated responsive intelligence robot typing back
|
setInteractiveMessages(prev => [
|
||||||
setTimeout(() => {
|
...prev,
|
||||||
const responseTime = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
{
|
||||||
const automatedReplyLine = {
|
id: agentMsgId,
|
||||||
id: updatedMessages.length + 2,
|
|
||||||
sender: "agent",
|
sender: "agent",
|
||||||
senderName: "任务理解智能体",
|
senderName: "任务理解智能体",
|
||||||
content: `已收到关于“${userMsg.content}”的更新!我已自动校对预算配比与合规规范。当前规划框架可信度提升 4.2%。请您继续审视下方待确认事项,或随时确认以进入多智能体圆桌研讨。`,
|
content: "",
|
||||||
time: responseTime,
|
time: nowTime(),
|
||||||
};
|
streaming: true,
|
||||||
setInteractiveMessages(prev => [...prev, automatedReplyLine]);
|
} as any,
|
||||||
triggerToast("任务理解智能体已回复!");
|
]);
|
||||||
}, 1500);
|
|
||||||
|
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
|
// Skip step logic
|
||||||
@@ -939,20 +1031,88 @@ export default function RoundtablePlanningPage() {
|
|||||||
triggerToast("追问生成就绪!");
|
triggerToast("追问生成就绪!");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Step 2 role statuses transition when entering roundtable!
|
// Step 1 init effect: create a fresh intent-agent thread the first time
|
||||||
// On Step 2 entry we set every agent seat to "正在上线" (spinner state);
|
// the user lands on step 1 (and on every re-entry, since clearing thread
|
||||||
// the /init SSE stream then flips each one to "在线" as its thread/agent
|
// history is desirable here — each new planning session starts clean).
|
||||||
// actually finishes on the backend. Seat 1 (human commander) stays online
|
//
|
||||||
// throughout — it's not part of the bring-up.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (currentStep === 1) {
|
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) {
|
} else if (currentStep === 2) {
|
||||||
setRoles(makeRoles("正在上线", { 1: "在线" }));
|
setRoles(makeRoles("正在上线", { 1: "在线" }));
|
||||||
} else {
|
} else {
|
||||||
setRoles(makeRoles("已完成"));
|
setRoles(makeRoles("已完成"));
|
||||||
}
|
}
|
||||||
}, [currentStep]);
|
}, [currentStep, intentThreadId, intentInitError]);
|
||||||
|
|
||||||
// Inject user input to the roundtable room — sends it as a new leader call.
|
// Inject user input to the roundtable room — sends it as a new leader call.
|
||||||
const handleInjectToRoundtable = () => {
|
const handleInjectToRoundtable = () => {
|
||||||
@@ -1426,157 +1586,95 @@ export default function RoundtablePlanningPage() {
|
|||||||
任务意图理解 (人机问答)
|
任务意图理解 (人机问答)
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chat list viewport */}
|
{/* Chat list viewport */}
|
||||||
<div className="flex-1 overflow-y-auto p-6 space-y-4 font-normal">
|
<div className="flex-1 overflow-y-auto p-6 space-y-4 font-normal">
|
||||||
|
|
||||||
{/* Message 1 (User / 用户) */}
|
{/* Step 1 dialogue is rendered by `interactiveMessages.map()`
|
||||||
<div id="msg-item-1" className="flex items-start justify-between gap-3 border-b border-slate-50 pb-3">
|
inside the 问答交互区 sub-block below. The static demo
|
||||||
<div className="flex items-start gap-3">
|
bubbles that previously lived here have been removed. */}
|
||||||
<Man1Avatar />
|
|
||||||
<div className="space-y-1">
|
{/* --- INTENT SUMMARY BOX --- emerald (ready) / slate (waiting) ---
|
||||||
<div className="flex items-baseline gap-2">
|
Rendered from `intentReady` when the agent has emitted a
|
||||||
<span className="text-xs font-bold text-primary">用户</span>
|
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>
|
</div>
|
||||||
<p className="text-xs text-slate-700 leading-relaxed max-w-[500px]">
|
<button
|
||||||
请帮我围绕某复杂任务制定详细规划方案。
|
id="confirm-roundtable-btn"
|
||||||
</p>
|
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>
|
||||||
</div>
|
|
||||||
<span className="text-[10px] text-slate-400 font-mono shrink-0">10:32:15</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Message 2 (Agent) */}
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
<div id="msg-item-2" className="flex items-start justify-between gap-3 border-b border-slate-50 pb-3">
|
<div className="space-y-0.5 md:col-span-3">
|
||||||
<div className="flex items-start gap-3">
|
<span className="text-[10px] font-bold text-slate-400 block tracking-tight">任务目标</span>
|
||||||
<RobotAvatar bg="bg-[var(--brand-200)]/50" />
|
<p className="text-xs font-bold text-slate-800 leading-snug">{intentReady.objective}</p>
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<span className="text-xs font-bold text-[var(--brand-900)]">任务理解智能体</span>
|
|
||||||
</div>
|
</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">
|
<div className="space-y-1 md:col-span-2">
|
||||||
请明确目标、时间要求与资源限制。
|
<span className="text-[10px] font-bold text-slate-400 block tracking-tight">硬约束</span>
|
||||||
</p>
|
<ul className="text-xs font-bold text-slate-800 leading-snug list-disc pl-4 space-y-0.5">
|
||||||
</div>
|
{intentReady.constraints.map((c, i) => (
|
||||||
</div>
|
<li key={i}>{c}</li>
|
||||||
<span className="text-[10px] text-slate-400 font-mono shrink-0">10:32:22</span>
|
))}
|
||||||
</div>
|
</ul>
|
||||||
|
|
||||||
{/* 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>
|
</div>
|
||||||
<p className="text-xs text-slate-700 leading-relaxed max-w-[500px]">
|
{intentReady.assumptions && intentReady.assumptions.length > 0 && (
|
||||||
72小时内完成,可用预算500万,人员30人以内。
|
<div className="space-y-1">
|
||||||
</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] text-slate-400 font-mono shrink-0">10:33:02</span>
|
) : (
|
||||||
</div>
|
<div className="border border-slate-200 bg-slate-50/60 rounded-2xl p-4.5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
{/* Message 4 (Agent) */}
|
<div className="flex items-center gap-2">
|
||||||
<div id="msg-item-4" className="flex items-start justify-between gap-3 border-b border-slate-50 pb-3">
|
<Loader2 className="h-5 w-5 text-slate-400 animate-spin" />
|
||||||
<div className="flex items-start gap-3">
|
<span className="text-sm font-bold text-slate-700">
|
||||||
<RobotAvatar bg="bg-[var(--brand-200)]/50" />
|
等待任务理解智能体澄清完成...
|
||||||
<div className="space-y-1">
|
</span>
|
||||||
<div className="flex items-baseline gap-2">
|
|
||||||
<span className="text-xs font-bold text-[var(--brand-900)]">任务理解智能体</span>
|
|
||||||
</div>
|
</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>
|
</p>
|
||||||
</div>
|
)}
|
||||||
</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 (问答交互区) --- */}
|
{/* --- 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">
|
<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 */}
|
{/* Content dialogue lines inside interaction */}
|
||||||
<div className="p-4.5 space-y-3.5 max-h-[280px] overflow-y-auto">
|
<div className="p-4.5 space-y-3.5 max-h-[280px] overflow-y-auto">
|
||||||
{interactiveMessages.map((msg) => (
|
{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">
|
const isStreaming = (msg as { streaming?: boolean }).streaming === true;
|
||||||
<div className="flex items-start gap-2.5">
|
const hasContent = (msg.content ?? "").length > 0;
|
||||||
{msg.sender === "user" ? (
|
return (
|
||||||
<Man1Avatar size="h-6 w-6" />
|
<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">
|
||||||
<RobotAvatar bg="bg-[var(--brand-200)]/50" size="h-6 w-6" />
|
{msg.sender === "user" ? (
|
||||||
)}
|
<Man1Avatar 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'}`}>
|
<RobotAvatar bg="bg-[var(--brand-200)]/50" size="h-6 w-6" />
|
||||||
{msg.senderName}
|
)}
|
||||||
</span>
|
<div>
|
||||||
<span className="text-xs text-slate-700 leading-normal font-normal">
|
<span className={`text-[10.5px] font-bold block mb-0.5 ${msg.sender === 'user' ? 'text-primary' : 'text-slate-700'}`}>
|
||||||
{msg.content}
|
{msg.senderName}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
<span className="text-[9px] text-slate-400 font-mono shrink-0">{msg.time}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[9px] text-slate-400 font-mono shrink-0">{msg.time}</span>
|
);
|
||||||
</div>
|
})}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -1662,13 +1775,14 @@ export default function RoundtablePlanningPage() {
|
|||||||
|
|
||||||
{/* Left row buttons */}
|
{/* Left row buttons */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
id="send-message-btn"
|
id="send-message-btn"
|
||||||
onClick={handleSendMessage}
|
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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -2526,28 +2640,33 @@ export default function RoundtablePlanningPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-3 bg-slate-50/50 border border-slate-100 rounded-xl space-y-3 mr-0.5">
|
<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">
|
{/* Driven by the intent agent's structured summary. Stays in
|
||||||
<li className="flex items-baseline gap-2 leading-relaxed">
|
"等待" state until the agent emits [INTENT_READY]; then
|
||||||
<span className="text-primary text-base leading-none">•</span>
|
flips to live objective + constraints + assumptions. */}
|
||||||
<span><b className="text-slate-900 font-bold">目标</b>: 制定可执行 of 详细规划方案</span>
|
{intentReady ? (
|
||||||
</li>
|
<ul className="text-xs text-slate-700 space-y-2.5 font-normal">
|
||||||
<li className="flex items-baseline gap-2 leading-relaxed">
|
<li className="flex items-baseline gap-2 leading-relaxed">
|
||||||
<span className="text-primary text-base leading-none">•</span>
|
<span className="text-primary text-base leading-none">•</span>
|
||||||
<span><b className="text-slate-900 font-bold">时间</b>: 72小时内完成</span>
|
<span><b className="text-slate-900 font-bold">目标</b>: {intentReady.objective}</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-baseline gap-2 leading-relaxed">
|
{intentReady.constraints.map((c, i) => (
|
||||||
<span className="text-primary text-base leading-none">•</span>
|
<li key={`c-${i}`} className="flex items-baseline gap-2 leading-relaxed">
|
||||||
<span><b className="text-slate-900 font-bold">资源</b>: 预算 ≤ 500万,人员 ≤ 30人</span>
|
<span className="text-primary text-base leading-none">•</span>
|
||||||
</li>
|
<span><b className="text-slate-900 font-bold">约束</b>: {c}</span>
|
||||||
<li className="flex items-baseline gap-2 leading-relaxed">
|
</li>
|
||||||
<span className="text-primary text-base leading-none">•</span>
|
))}
|
||||||
<span><b className="text-slate-900 font-bold">优先级</b>: 主方案优先,妥善调配备用</span>
|
{(intentReady.assumptions ?? []).map((a, i) => (
|
||||||
</li>
|
<li key={`a-${i}`} className="flex items-baseline gap-2 leading-relaxed">
|
||||||
<li className="flex items-baseline gap-2 leading-relaxed">
|
<span className="text-slate-400 text-base leading-none">•</span>
|
||||||
<span className="text-primary text-base leading-none">•</span>
|
<span className="text-slate-500"><b className="text-slate-700 font-bold">假设</b>: {a}</span>
|
||||||
<span><b className="text-slate-900 font-bold">待确认</b>: 备选模块范围,资源阈值警戒</span>
|
</li>
|
||||||
</li>
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-slate-400 italic leading-relaxed">
|
||||||
|
等待任务理解智能体澄清完成...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from app.gateway.routers import (
|
|||||||
auth,
|
auth,
|
||||||
channels,
|
channels,
|
||||||
feedback,
|
feedback,
|
||||||
|
intent,
|
||||||
llm_metrics,
|
llm_metrics,
|
||||||
mcp,
|
mcp,
|
||||||
memory,
|
memory,
|
||||||
@@ -864,6 +865,12 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
|
|||||||
# Multi-agent orchestration (coordinator + sub-agent broadcast/dispatch)
|
# Multi-agent orchestration (coordinator + sub-agent broadcast/dispatch)
|
||||||
app.include_router(multi_agent.router)
|
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"])
|
@app.get("/health", tags=["health"])
|
||||||
async def health_check() -> dict:
|
async def health_check() -> dict:
|
||||||
"""Health check endpoint.
|
"""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