Merge origin/dhk-1: resolve config.yaml database backend conflict
Use sqlite as temporary fallback while MySQL is unavailable; keeps local persistence instead of in-memory backend. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
7fcf22de90
171 files changed
+41064
-10709
No files matched your search
@@ -0,0 +1,233 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export type ThreadIdMap = Record<string, string>;
|
||||
|
||||
export interface InitMultiAgentRequest {
|
||||
agentNames: string[];
|
||||
mainAgentName: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface InitMultiAgentResponse {
|
||||
status: "success";
|
||||
main_agent_name: string;
|
||||
thread_ids: ThreadIdMap;
|
||||
}
|
||||
|
||||
export async function initMultiAgent(
|
||||
payload: InitMultiAgentRequest,
|
||||
): Promise<InitMultiAgentResponse> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/init`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
agent_names: payload.agentNames,
|
||||
main_agent_name: payload.mainAgentName,
|
||||
model: payload.model,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`init multi-agent failed (${res.status}): ${text}`);
|
||||
}
|
||||
return (await res.json()) as InitMultiAgentResponse;
|
||||
}
|
||||
|
||||
export type StreamAgentType = "leader" | "special";
|
||||
|
||||
export type LeaderDispatch = [string, string];
|
||||
|
||||
export interface StreamMultiAgentRequest {
|
||||
agentType: StreamAgentType;
|
||||
dAgentThreadId: ThreadIdMap;
|
||||
agentName: string;
|
||||
newMessage: string;
|
||||
model?: string;
|
||||
skillStopNames?: string[];
|
||||
signal?: AbortSignal;
|
||||
/** Fires for every non-status data frame the upstream emits. */
|
||||
onUpstreamFrame?: (frame: unknown) => void;
|
||||
/**
|
||||
* Fires for every AI text chunk parsed out of LangGraph messages-tuple frames.
|
||||
* `text` is the incremental delta (concatenate to rebuild the message);
|
||||
* `messageId` is stable per AI message so callers can group deltas.
|
||||
*/
|
||||
onTextDelta?: (text: string, messageId: string) => void;
|
||||
}
|
||||
|
||||
export interface StreamMultiAgentResult {
|
||||
/** For leader runs: dispatched [agentName, task] tuples. Empty = consensus. */
|
||||
dispatched: LeaderDispatch[];
|
||||
/** For special runs: the completion status string. */
|
||||
message: string | null;
|
||||
/** Final assistant text emitted by the underlying agent. */
|
||||
content: string;
|
||||
/** Echoed agent_name of the agent that produced this run. */
|
||||
agentName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify the final status frame our backend appends after the LangGraph
|
||||
* stream. Required keys are `status` (array for leader, string for special)
|
||||
* and `content` (final assistant text); `agent_name` is also expected.
|
||||
*/
|
||||
function isFinalStatusFrame(value: unknown): value is {
|
||||
status: LeaderDispatch[] | string;
|
||||
content?: string;
|
||||
agent_name?: string;
|
||||
} {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
if (!("status" in value) || !("content" in value)) return false;
|
||||
const status = (value as { status: unknown }).status;
|
||||
if (typeof status === "string") return true;
|
||||
if (Array.isArray(status)) {
|
||||
return status.every(
|
||||
(item) =>
|
||||
Array.isArray(item) && item.length === 2 && typeof item[0] === "string" && typeof item[1] === "string",
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an AI text delta from a LangGraph `messages-tuple` data frame.
|
||||
* Frames look like `[chunk, metadata]`; an AI text chunk carries `type` in
|
||||
* {"ai","AIMessageChunk"} and a `content` that is either a string or a list of
|
||||
* structured content blocks. Returns null when the frame is not an AI text
|
||||
* chunk (e.g., tool calls, tool results, value snapshots).
|
||||
*/
|
||||
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();
|
||||
// Accept any AI-shaped message chunk LangChain might emit across versions:
|
||||
// "ai" (older), "AIMessageChunk" (newer streaming), or plain "AIMessage".
|
||||
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/multi-agent/run/stream and resolve once the
|
||||
* stream completes. The final frame (`{status: ...}`) is interpreted; every
|
||||
* other `data:` frame is forwarded to `onUpstreamFrame` so the caller can do
|
||||
* its own rendering (we don't try to mimic LangGraph SDK semantics here).
|
||||
*/
|
||||
export async function streamMultiAgent(
|
||||
req: StreamMultiAgentRequest,
|
||||
): Promise<StreamMultiAgentResult> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/multi-agent/run/stream`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
agent_type: req.agentType,
|
||||
d_agent_thread_id: req.dAgentThreadId,
|
||||
agent_name: req.agentName,
|
||||
new_message: req.newMessage,
|
||||
model: req.model,
|
||||
skill_stop_names: req.skillStopNames ?? [],
|
||||
}),
|
||||
signal: req.signal,
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`run/stream failed (${res.status}): ${text}`);
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
// Track the last `event:` line so logging can show which event a `data:`
|
||||
// belongs to. The frontend doesn't actually dispatch by event name — every
|
||||
// data frame goes through the same parser — but the label is invaluable
|
||||
// for diagnosing why a sub-agent stream "looks empty".
|
||||
let lastEvent = "";
|
||||
let frameSeq = 0;
|
||||
|
||||
const debugLabel = `[stream:${req.agentType}:${req.agentName}]`;
|
||||
const debugEnabled =
|
||||
typeof window !== "undefined" &&
|
||||
(window as unknown as { __MULTI_AGENT_DEBUG__?: boolean }).__MULTI_AGENT_DEBUG__ !== false;
|
||||
|
||||
const result: StreamMultiAgentResult = { dispatched: [], message: null, content: "", agentName: 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 {
|
||||
if (debugEnabled) console.debug(debugLabel, `bad JSON in event=${lastEvent}:`, payload.slice(0, 200));
|
||||
continue;
|
||||
}
|
||||
|
||||
frameSeq += 1;
|
||||
if (debugEnabled) console.debug(debugLabel, `frame#${frameSeq} event=${lastEvent}`, parsed);
|
||||
|
||||
if (isFinalStatusFrame(parsed)) {
|
||||
if (Array.isArray(parsed.status)) {
|
||||
result.dispatched = parsed.status;
|
||||
} else {
|
||||
result.message = parsed.status;
|
||||
}
|
||||
if (typeof parsed.content === "string") {
|
||||
result.content = parsed.content;
|
||||
}
|
||||
if (typeof parsed.agent_name === "string") {
|
||||
result.agentName = parsed.agent_name;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const delta = extractAiTextDelta(parsed);
|
||||
if (delta) {
|
||||
if (debugEnabled) console.debug(debugLabel, `→ delta (${delta.text.length} chars)`);
|
||||
req.onTextDelta?.(delta.text, delta.id);
|
||||
}
|
||||
|
||||
req.onUpstreamFrame?.(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
if (debugEnabled) {
|
||||
console.debug(debugLabel, `stream ended after ${frameSeq} frames; final content length=${result.content.length}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user