export type AgentRunStatus = "running" | "completed" | "error" | "aborted"; export type AgentPermissionReply = "once" | "always" | "reject"; export type AgentModelOption = { id: string; label: string; description: string; icon: "bolt" | "sparkle"; }; export type AgentModelsResponse = { defaultModel: string; models: AgentModelOption[]; }; export type AgentChatSession = { id?: string; session_id: string; title?: string; status?: string; run_status?: AgentRunStatus; is_streaming?: boolean; messages?: unknown[]; created_at?: number | string; updated_at?: number | string; is_title_manually_edited?: boolean; parent_session_id?: string; }; export type AgentChatSessionSummary = { id: string; title: string; createdAt: number; updatedAt: number; status?: string; parentSessionId?: string; isStreaming?: boolean; runStatus?: AgentRunStatus; }; export type AgentLoadedChatSession = AgentChatSessionSummary & { sessionId: string; isTitleManuallyEdited: boolean; messages: unknown[]; }; export type AgentSessionStreamDataEventType = | "token" | "progress" | "todo_update" | "permission_request" | "permission_response" | "question_request" | "question_response" | "tool_call" | "ui_envelope" | "session_title" | "done" | "error" | "auth_required"; export type AgentSessionStreamEvent = | { type: "state"; sessionId: string; messages: unknown[]; isStreaming: boolean; runStatus?: AgentRunStatus; } | { type: AgentSessionStreamDataEventType; sessionId?: string; data: Record; }; export type AgentApiClient = { createSession: () => Promise; listSessions: () => Promise; loadSession: (sessionId: string) => Promise; streamSession: ( sessionId: string, options: { signal?: AbortSignal; onEvent: (event: AgentSessionStreamEvent) => void; } ) => Promise; updateSessionTitle: ( sessionId: string, title: string, options?: { isTitleManuallyEdited?: boolean } ) => Promise; deleteSession: (sessionId: string) => Promise; getModels: () => Promise; getUiRegistry: () => Promise; resolveRenderRef: (renderRef: string, sessionId: string) => Promise; replyPermission: ( requestId: string, options: { sessionId: string; reply: AgentPermissionReply; message?: string } ) => Promise; replyQuestion: ( requestId: string, options: { sessionId: string; answers: string[][] } ) => Promise; rejectQuestion: (requestId: string, options: { sessionId: string }) => Promise; abort: (sessionId: string) => Promise; }; const AGENT_API_BASE_URLS = process.env.NEXT_PUBLIC_AGENT_API_BASE_URL ? [process.env.NEXT_PUBLIC_AGENT_API_BASE_URL.replace(/\/$/, "")] : ["", "http://127.0.0.1:8787"]; const CHAT_PATH = "/api/v1/agent/chat"; export function createAgentApiClient(baseUrls: string | string[] = AGENT_API_BASE_URLS): AgentApiClient { const candidates = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).map((item) => item.replace(/\/$/, "")); let activeBaseUrl = candidates[0] ?? ""; return { async createSession() { return requestJsonWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, "/session", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}) }); }, async listSessions() { const payload = await requestJsonWithFallback<{ sessions?: unknown[] }>( candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, "/sessions" ); return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries); }, async loadSession(sessionId) { const response = await fetchWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, `/session/${encodeURIComponent(sessionId)}`); const text = await response.text(); const data = text ? JSON.parse(text) : null; if (response.status === 404) { return null; } if (!response.ok) { throw new Error(getResponseErrorMessage(data, response.status)); } return toLoadedSession(data); }, async streamSession(sessionId, options) { const response = await fetchWithFallback( candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, `/session/${encodeURIComponent(sessionId)}/stream`, { signal: options.signal } ); if (!response.ok) { const text = await response.text(); const data = text ? JSON.parse(text) : null; throw new Error(getResponseErrorMessage(data, response.status)); } await readSessionSseStream(response, options.onEvent); }, async updateSessionTitle(sessionId, title, options) { const normalizedTitle = title.trim(); if (!normalizedTitle) { return; } await requestJsonWithFallback( candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, `/session/${encodeURIComponent(sessionId)}/title`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: normalizedTitle, is_title_manually_edited: options?.isTitleManuallyEdited }) } ); }, async deleteSession(sessionId) { await requestJsonWithFallback( candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, `/session/${encodeURIComponent(sessionId)}`, { method: "DELETE" } ); }, async getModels() { const payload = await requestJsonWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, "/models"); return toModelsResponse(payload); }, async getUiRegistry() { return requestJsonWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, "/ui-registry"); }, async resolveRenderRef(renderRef, sessionId) { const params = new URLSearchParams({ session_id: sessionId }); return requestJsonWithFallback( candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, `/render-ref/${encodeURIComponent(renderRef)}?${params.toString()}` ); }, async replyPermission(requestId, options) { await requestJsonWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, `/permission/${encodeURIComponent(requestId)}/reply`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_id: options.sessionId, reply: options.reply, message: options.message }) }); }, async replyQuestion(requestId, options) { await requestJsonWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, `/question/${encodeURIComponent(requestId)}/reply`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_id: options.sessionId, answers: options.answers }) }); }, async rejectQuestion(requestId, options) { await requestJsonWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, `/question/${encodeURIComponent(requestId)}/reject`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_id: options.sessionId }) }); }, async abort(sessionId) { await requestJsonWithFallback(candidates, activeBaseUrl, (nextBaseUrl) => { activeBaseUrl = nextBaseUrl; }, "/abort", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_id: sessionId }) }); } }; } async function requestJsonWithFallback( baseUrls: string[], activeBaseUrl: string, setActiveBaseUrl: (baseUrl: string) => void, path: string, init?: RequestInit ) { const response = await fetchWithFallback(baseUrls, activeBaseUrl, setActiveBaseUrl, path, init); const text = await response.text(); const data = text ? JSON.parse(text) : null; if (!response.ok) { throw new Error(getResponseErrorMessage(data, response.status)); } return data as T; } async function fetchWithFallback( baseUrls: string[], activeBaseUrl: string, setActiveBaseUrl: (baseUrl: string) => void, path: string, init?: RequestInit ) { const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)]; let lastError: unknown; let lastResponse: Response | null = null; for (const baseUrl of orderedBaseUrls) { try { const response = await fetch(`${baseUrl}${CHAT_PATH}${path}`, init); if (response.ok) { setActiveBaseUrl(baseUrl); return response; } lastResponse = response; if (shouldFallbackOnHttpStatus(response.status) && baseUrl !== orderedBaseUrls[orderedBaseUrls.length - 1]) { await response.body?.cancel(); continue; } return response; } catch (error) { lastError = error; } } if (lastResponse) { return lastResponse; } throw lastError instanceof Error ? lastError : new Error("Agent API unavailable"); } function shouldFallbackOnHttpStatus(status: number) { return status === 404 || status === 405 || status === 502 || status === 503 || status === 504; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } function isPresent(value: T | null | undefined): value is T { return value !== null && value !== undefined; } function toModelsResponse(value: unknown): AgentModelsResponse { if (!isRecord(value)) { return { defaultModel: "", models: [] }; } const models = Array.isArray(value.models) ? value.models.map(toModelOption).filter(isPresent) : []; const defaultModel = typeof value.default_model === "string" ? value.default_model : models[0]?.id ?? ""; return { defaultModel, models }; } function toModelOption(value: unknown): AgentModelOption | null { if (!isRecord(value) || typeof value.id !== "string") { return null; } const label = typeof value.label === "string" && value.label.trim() ? value.label : value.id; const description = typeof value.description === "string" && value.description.trim() ? value.description : label; const icon = value.icon === "bolt" || value.icon === "sparkle" ? value.icon : "sparkle"; return { id: value.id, label, description, icon }; } function getResponseErrorMessage(data: unknown, status: number) { return isRecord(data) && typeof data.message === "string" ? data.message : `Agent API failed with HTTP ${status}`; } function toMillis(value: unknown) { if (typeof value === "number" && Number.isFinite(value)) { return value; } if (typeof value === "string" && value.trim()) { const parsed = new Date(value).getTime(); return Number.isFinite(parsed) ? parsed : Date.now(); } return Date.now(); } function normalizeTitle(value: unknown) { return typeof value === "string" && value.trim() ? value.trim() : "新对话"; } function readRunStatus(value: unknown): AgentRunStatus | undefined { return value === "running" || value === "completed" || value === "error" || value === "aborted" ? value : undefined; } function toSessionSummary(value: unknown): AgentChatSessionSummary | null { if (!isRecord(value)) { return null; } const id = typeof value.id === "string" ? value.id : typeof value.session_id === "string" ? value.session_id : ""; if (!id) { return null; } return { id, title: normalizeTitle(value.title), createdAt: toMillis(value.created_at), updatedAt: toMillis(value.updated_at), status: typeof value.status === "string" ? value.status : undefined, parentSessionId: typeof value.parent_session_id === "string" ? value.parent_session_id : undefined, isStreaming: typeof value.is_streaming === "boolean" ? value.is_streaming : undefined, runStatus: readRunStatus(value.run_status) }; } function toLoadedSession(value: unknown): AgentLoadedChatSession { const summary = toSessionSummary(value); if (!summary || !isRecord(value)) { throw new Error("Invalid agent session payload"); } return { ...summary, sessionId: typeof value.session_id === "string" ? value.session_id : summary.id, isTitleManuallyEdited: typeof value.is_title_manually_edited === "boolean" ? value.is_title_manually_edited : false, messages: Array.isArray(value.messages) ? value.messages : [] }; } async function readSessionSseStream( response: Response, onEvent: (event: AgentSessionStreamEvent) => void ) { if (!response.body) { return; } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); buffer += decoder.decode(value, { stream: !done }); let delimiterIndex = buffer.indexOf("\n\n"); while (delimiterIndex !== -1) { const rawEvent = buffer.slice(0, delimiterIndex); buffer = buffer.slice(delimiterIndex + 2); emitSessionSseEvent(rawEvent, onEvent); delimiterIndex = buffer.indexOf("\n\n"); } if (done) { break; } } if (buffer.trim()) { emitSessionSseEvent(buffer, onEvent); } } function emitSessionSseEvent( rawEvent: string, onEvent: (event: AgentSessionStreamEvent) => void ) { const lines = rawEvent.split(/\r?\n/); const eventType = lines .find((line) => line.startsWith("event:")) ?.slice("event:".length) .trim(); const dataText = lines .filter((line) => line.startsWith("data:")) .map((line) => line.slice("data:".length).trimStart()) .join("\n"); if (!eventType || !dataText) { return; } const data = JSON.parse(dataText) as unknown; if (!isRecord(data)) { return; } if (eventType === "state") { const sessionId = typeof data.session_id === "string" ? data.session_id : ""; onEvent({ type: "state", sessionId, messages: Array.isArray(data.messages) ? data.messages : [], isStreaming: data.is_streaming === true, runStatus: readRunStatus(data.run_status) }); return; } if (!isSessionStreamDataEventType(eventType)) { return; } onEvent({ type: eventType, sessionId: typeof data.session_id === "string" ? data.session_id : undefined, data }); } function isSessionStreamDataEventType(value: string): value is AgentSessionStreamDataEventType { return ( value === "token" || value === "progress" || value === "todo_update" || value === "permission_request" || value === "permission_response" || value === "question_request" || value === "question_response" || value === "tool_call" || value === "ui_envelope" || value === "session_title" || value === "done" || value === "error" || value === "auth_required" ); } function compareSessionSummaries(left: AgentChatSessionSummary, right: AgentChatSessionSummary) { const createdAtDiff = right.createdAt - left.createdAt; if (createdAtDiff !== 0) { return createdAtDiff; } const updatedAtDiff = right.updatedAt - left.updatedAt; if (updatedAtDiff !== 0) { return updatedAtDiff; } return right.id.localeCompare(left.id); }