import { apiFetch } from "@/lib/apiFetch"; import { config } from "@config/config"; const AGENT_SESSIONS_URL = `${config.AGENT_URL}/api/v1/agent/sessions`; const getAgentSessionUrl = (sessionId: string, suffix = "") => `${AGENT_SESSIONS_URL}/${encodeURIComponent(sessionId)}${suffix}`; export type AgentModel = string; export type PermissionDecision = "once" | "always" | "reject"; export type PermissionReply = PermissionDecision; export type AgentApprovalMode = "request" | "auto" | "always"; export type AgentQuestionStatus = | "pending" | "submitting" | "answered" | "rejected" | "error"; export type AgentQuestionRequest = { requestId: string; sessionId: string; questions: Array<{ header: string; question: string; options: Array<{ label: string; description: string; }>; multiple?: boolean; custom?: boolean; }>; tool?: { messageID: string; callID: string; }; createdAt: number; repliedAt?: number; status: AgentQuestionStatus; answers?: string[][]; error?: string; }; export type AgentTodoItem = { id: string; content: string; status: "pending" | "in_progress" | "completed" | "cancelled"; priority?: "low" | "medium" | "high"; createdAt?: number; updatedAt?: number; }; export type AgentTodoUpdate = { sessionId: string; messageId?: string; todos: AgentTodoItem[]; createdAt: number; }; export type AgentActivityStatus = "running" | "completed" | "error" | "cancelled"; export type AgentActivityAction = { id: string; tool: string; title: string; status: "running" | "completed" | "error"; target?: string; error?: string; startedAt: number; endedAt?: number; elapsedMs?: number; elapsedSnapshotAt?: number; durationMs?: number; }; export type AgentActivity = { id: string; title: string; reason: string; status: AgentActivityStatus; actions: AgentActivityAction[]; startedAt: number; endedAt?: number; elapsedMs?: number; elapsedSnapshotAt?: number; durationMs?: number; }; export type StreamEvent = | { type: "state"; sessionId: string; messages: unknown[]; isStreaming: boolean; runStatus?: string; } | { type: "token"; sessionId: string; content: string } | { type: "final_answer"; sessionId: string; content: string } | { type: "done"; sessionId: string; totalDurationMs?: number } | { type: "session_title"; sessionId: string; title: string } | { type: "activity_update"; sessionId: string; activity: AgentActivity; todos?: AgentTodoItem[]; todosCreatedAt?: number; } | { type: "progress"; sessionId: string; id: string; phase: string; status: "running" | "completed" | "error"; title: string; detail?: string; startedAt?: number; endedAt?: number; elapsedMs?: number; durationMs?: number; } | { type: "error"; sessionId?: string; message: string; detail?: string; totalDurationMs?: number; } | { type: "auth_required"; sessionId?: string; reason?: string; message: string; } | { type: "credential_refresh_required"; sessionId: string; requestId: string; reason?: string; timeoutMs?: number; } | { type: "credential_refreshed"; sessionId: string; requestId: string; } | { type: "credential_refresh_failed"; sessionId: string; requestId: string; message: string; } | { type: "tool_call"; sessionId: string; tool: string; params: Record; } | { type: "permission_request"; sessionId: string; requestId: string; permission: string; patterns: string[]; target?: string; activityId?: string; reason?: string; always: string[]; tool?: { messageID: string; callID: string; }; createdAt: number; } | { type: "permission_response"; sessionId: string; requestId: string; reply: PermissionReply; } | { type: "question_request"; sessionId: string; requestId: string; questions: AgentQuestionRequest["questions"]; tool?: AgentQuestionRequest["tool"]; createdAt: number; } | { type: "question_response"; sessionId: string; requestId: string; answers?: string[][]; rejected?: boolean; } | { type: "todo_update"; sessionId: string; messageId?: string; todos: AgentTodoItem[]; createdAt: number; }; type StreamOptions = { message: string; sessionId?: string; model?: AgentModel; approvalMode?: AgentApprovalMode; signal?: AbortSignal; onEvent: (event: StreamEvent) => void; }; type ResumeStreamOptions = { sessionId: string; signal?: AbortSignal; onEvent: (event: StreamEvent) => void; }; const parseEventBlock = (block: string): { event?: string; data?: string } => { const lines = block.split("\n"); let event: string | undefined; const dataLines: string[] = []; for (const line of lines) { if (line.startsWith("event:")) { event = line.slice("event:".length).trim(); } else if (line.startsWith("data:")) { dataLines.push(line.slice("data:".length).trim()); } } return { event, data: dataLines.length ? dataLines.join("\n") : undefined, }; }; const isObjectRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); const resolveToolParams = ( params: unknown, argumentsPayload: unknown, ): Record => { if (isObjectRecord(params) && Object.keys(params).length > 0) { return params; } if (isObjectRecord(argumentsPayload)) { return argumentsPayload; } if (typeof argumentsPayload === "string") { try { const parsed = JSON.parse(argumentsPayload) as unknown; return isObjectRecord(parsed) ? parsed : {}; } catch { return {}; } } return isObjectRecord(params) ? params : {}; }; const normalizeQuestionList = (value: unknown): AgentQuestionRequest["questions"] => { if (!Array.isArray(value)) return []; return value .filter(isObjectRecord) .map((question) => ({ header: typeof question.header === "string" ? question.header : "", question: typeof question.question === "string" ? question.question : "", options: Array.isArray(question.options) ? question.options.filter(isObjectRecord).map((option) => ({ label: typeof option.label === "string" ? option.label : "", description: typeof option.description === "string" ? option.description : "", })) : [], multiple: typeof question.multiple === "boolean" ? question.multiple : undefined, custom: typeof question.custom === "boolean" ? question.custom : undefined, })); }; const normalizeAnswers = (value: unknown): string[][] | undefined => { if (!Array.isArray(value)) return undefined; return value.map((answer) => Array.isArray(answer) ? answer.filter((item): item is string => typeof item === "string") : [], ); }; const normalizeQuestionTool = (value: unknown): AgentQuestionRequest["tool"] => { if (!isObjectRecord(value)) return undefined; const messageID = typeof value.messageID === "string" ? value.messageID : typeof value.message_id === "string" ? value.message_id : undefined; const callID = typeof value.callID === "string" ? value.callID : typeof value.call_id === "string" ? value.call_id : undefined; return messageID && callID ? { messageID, callID } : undefined; }; const normalizeTodoStatus = (value: unknown): AgentTodoItem["status"] => { if (value === "in_progress" || value === "completed" || value === "cancelled") { return value; } return "pending"; }; const normalizeTodoPriority = (value: unknown): AgentTodoItem["priority"] => { if (value === "low" || value === "medium" || value === "high") { return value; } return undefined; }; const normalizeTodos = (value: unknown): AgentTodoItem[] => { if (!Array.isArray(value)) return []; return value.filter(isObjectRecord).map((todo, index) => ({ id: typeof todo.id === "string" && todo.id.trim() ? todo.id : `todo-${index}`, content: typeof todo.content === "string" ? todo.content : "", status: normalizeTodoStatus(todo.status), priority: normalizeTodoPriority(todo.priority), createdAt: typeof todo.created_at === "number" ? todo.created_at : undefined, updatedAt: typeof todo.updated_at === "number" ? todo.updated_at : undefined, })); }; const normalizeActivityStatus = (value: unknown): AgentActivityStatus => { if (value === "completed" || value === "error" || value === "cancelled") { return value; } return "running"; }; const normalizeActivity = (value: unknown): AgentActivity | undefined => { if (!isObjectRecord(value) || typeof value.id !== "string") return undefined; const now = Date.now(); const actions: AgentActivityAction[] = Array.isArray(value.actions) ? value.actions.filter(isObjectRecord).map((action, index) => ({ id: typeof action.id === "string" ? action.id : `${value.id}-action-${index}`, tool: typeof action.tool === "string" ? action.tool : "tool", title: typeof action.title === "string" ? action.title : "执行操作", status: action.status === "completed" || action.status === "error" ? action.status : "running", target: typeof action.target === "string" ? action.target : undefined, error: typeof action.error === "string" ? action.error : undefined, startedAt: typeof action.started_at === "number" ? action.started_at : now, endedAt: typeof action.ended_at === "number" ? action.ended_at : undefined, elapsedMs: typeof action.elapsed_ms === "number" ? action.elapsed_ms : undefined, elapsedSnapshotAt: typeof action.elapsed_ms === "number" ? now : undefined, durationMs: typeof action.duration_ms === "number" ? action.duration_ms : undefined, })) : []; return { id: value.id, title: typeof value.title === "string" ? value.title : "正在处理", reason: typeof value.reason === "string" ? value.reason : "", status: normalizeActivityStatus(value.status), actions, startedAt: typeof value.started_at === "number" ? value.started_at : now, endedAt: typeof value.ended_at === "number" ? value.ended_at : undefined, elapsedMs: typeof value.elapsed_ms === "number" ? value.elapsed_ms : undefined, elapsedSnapshotAt: typeof value.elapsed_ms === "number" ? now : undefined, durationMs: typeof value.duration_ms === "number" ? value.duration_ms : undefined, }; }; const emitParsedStreamEvent = ( event: string, data: string, onEvent: (event: StreamEvent) => void, ) => { try { const parsed = JSON.parse(data) as { session_id?: string; content?: string; message?: string; detail?: string; tool?: unknown; params?: Record; arguments?: unknown; id?: string; phase?: string; status?: "running" | "completed" | "error"; title?: string; messages?: unknown[]; is_streaming?: boolean; run_status?: string; started_at?: number; ended_at?: number; elapsed_ms?: number; duration_ms?: number; total_duration_ms?: number; request_id?: string; permission?: string; patterns?: unknown; target?: string; always?: unknown; created_at?: number; todos_created_at?: number; reply?: PermissionReply; questions?: unknown; answers?: unknown; rejected?: boolean; message_id?: string; todos?: unknown; reason?: string; timeout_ms?: number; activity?: unknown; activity_id?: string; }; if (event === "state") { onEvent({ type: "state", sessionId: parsed.session_id ?? "", messages: Array.isArray(parsed.messages) ? parsed.messages : [], isStreaming: parsed.is_streaming ?? false, runStatus: parsed.run_status, }); } else if (event === "token") { onEvent({ type: "token", sessionId: parsed.session_id ?? "", content: parsed.content ?? "", }); } else if (event === "final_answer") { onEvent({ type: "final_answer", sessionId: parsed.session_id ?? "", content: parsed.content ?? "", }); } else if (event === "progress") { onEvent({ type: "progress", sessionId: parsed.session_id ?? "", id: parsed.id ?? `${parsed.phase ?? "progress"}-${Date.now()}`, phase: parsed.phase ?? "progress", status: parsed.status ?? "running", title: parsed.title ?? "正在处理", detail: parsed.detail, startedAt: parsed.started_at, endedAt: parsed.ended_at, elapsedMs: parsed.elapsed_ms, durationMs: parsed.duration_ms, }); } else if (event === "activity_update") { const activity = normalizeActivity(parsed.activity); if (activity) { onEvent({ type: "activity_update", sessionId: parsed.session_id ?? "", activity, todos: Array.isArray(parsed.todos) ? normalizeTodos(parsed.todos) : undefined, todosCreatedAt: parsed.todos_created_at, }); } } else if (event === "done") { onEvent({ type: "done", sessionId: parsed.session_id ?? "", totalDurationMs: parsed.total_duration_ms, }); } else if (event === "session_title") { onEvent({ type: "session_title", sessionId: parsed.session_id ?? "", title: typeof parsed.title === "string" ? parsed.title : "", }); } else if (event === "error") { onEvent({ type: "error", sessionId: parsed.session_id, message: parsed.message ?? "unknown error", detail: parsed.detail, totalDurationMs: parsed.total_duration_ms, }); } else if (event === "auth_required") { onEvent({ type: "auth_required", sessionId: parsed.session_id, reason: parsed.reason, message: parsed.message ?? "登录态已过期,请刷新登录后重试", }); } else if (event === "credential_refresh_required") { onEvent({ type: "credential_refresh_required", sessionId: parsed.session_id ?? "", requestId: parsed.request_id ?? "", reason: parsed.reason, timeoutMs: parsed.timeout_ms, }); } else if (event === "credential_refreshed") { onEvent({ type: "credential_refreshed", sessionId: parsed.session_id ?? "", requestId: parsed.request_id ?? "", }); } else if (event === "credential_refresh_failed") { onEvent({ type: "credential_refresh_failed", sessionId: parsed.session_id ?? "", requestId: parsed.request_id ?? "", message: parsed.message ?? "登录凭据续期失败", }); } else if (event === "tool_call") { onEvent({ type: "tool_call", sessionId: parsed.session_id ?? "", tool: typeof parsed.tool === "string" ? parsed.tool : "", params: resolveToolParams(parsed.params, parsed.arguments), }); } else if (event === "permission_request") { onEvent({ type: "permission_request", sessionId: parsed.session_id ?? "", requestId: parsed.request_id ?? "", permission: parsed.permission ?? "", patterns: Array.isArray(parsed.patterns) ? parsed.patterns.filter((item): item is string => typeof item === "string") : [], target: typeof parsed.target === "string" ? parsed.target : undefined, activityId: typeof parsed.activity_id === "string" ? parsed.activity_id : undefined, reason: typeof parsed.reason === "string" ? parsed.reason : undefined, always: Array.isArray(parsed.always) ? parsed.always.filter((item): item is string => typeof item === "string") : [], tool: isObjectRecord(parsed.tool) && typeof parsed.tool.messageID === "string" && typeof parsed.tool.callID === "string" ? { messageID: parsed.tool.messageID, callID: parsed.tool.callID, } : undefined, createdAt: parsed.created_at ?? Date.now(), }); } else if (event === "permission_response") { onEvent({ type: "permission_response", sessionId: parsed.session_id ?? "", requestId: parsed.request_id ?? "", reply: parsed.reply ?? "reject", }); } else if (event === "question_request") { onEvent({ type: "question_request", sessionId: parsed.session_id ?? "", requestId: parsed.request_id ?? "", questions: normalizeQuestionList(parsed.questions), tool: normalizeQuestionTool(parsed.tool), createdAt: parsed.created_at ?? Date.now(), }); } else if (event === "question_response") { onEvent({ type: "question_response", sessionId: parsed.session_id ?? "", requestId: parsed.request_id ?? "", answers: normalizeAnswers(parsed.answers), rejected: parsed.rejected === true, }); } else if (event === "todo_update") { onEvent({ type: "todo_update", sessionId: parsed.session_id ?? "", messageId: parsed.message_id, todos: normalizeTodos(parsed.todos), createdAt: parsed.created_at ?? Date.now(), }); } } catch { onEvent({ type: "error", message: "invalid SSE data payload", detail: data, }); } }; const readStreamEvents = async ( response: Response, onEvent: (event: StreamEvent) => void, ) => { if (!response.body) { return; } const reader = response.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const blocks = buffer.split("\n\n"); buffer = blocks.pop() ?? ""; for (const block of blocks) { const { event, data } = parseEventBlock(block); if (!event || !data) continue; emitParsedStreamEvent(event, data, onEvent); } } }; const ensureAgentSession = async (sessionId?: string) => { if (sessionId) return sessionId; const response = await apiFetch(AGENT_SESSIONS_URL, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({}), projectHeaderMode: "include", skipAuthRedirect: true, }); if (!response.ok) { throw new Error( (await response.text()) || `session creation failed: ${response.status}`, ); } const payload = (await response.json()) as { session_id?: string }; if (!payload.session_id) { throw new Error("session creation returned no session_id"); } return payload.session_id; }; export const streamAgentChat = async ({ message, sessionId, model, approvalMode, signal, onEvent, }: StreamOptions) => { let response: Response; try { const effectiveSessionId = await ensureAgentSession(sessionId); response = await apiFetch( getAgentSessionUrl(effectiveSessionId, "/runs"), { method: "POST", signal, headers: { "Content-Type": "application/json", Accept: "text/event-stream", }, body: JSON.stringify({ message, model, approval_mode: approvalMode, }), projectHeaderMode: "include", skipAuthRedirect: true, }, ); } catch (error) { const detail = error instanceof Error ? error.message : String(error); onEvent({ type: "error", message: "network request failed", detail, }); return; } if (!response.ok || !response.body) { const detail = await response.text(); let message = "stream request failed"; if (response.status === 403) { message = "Permission denied. Please contact administrator."; } else if (response.status === 401) { message = "Login expired. Please sign in again."; } onEvent({ type: "error", message, detail: response.status === 403 || response.status === 401 ? undefined : detail, }); return; } await readStreamEvents(response, onEvent); }; export const resumeAgentChatStream = async ({ sessionId, signal, onEvent, }: ResumeStreamOptions) => { let response: Response; try { response = await apiFetch( getAgentSessionUrl(sessionId, "/runs/current/events"), { method: "GET", signal, headers: { Accept: "text/event-stream", }, projectHeaderMode: "include", skipAuthRedirect: true, }, ); } catch (error) { const detail = error instanceof Error ? error.message : String(error); onEvent({ type: "error", sessionId, message: "network request failed", detail, }); return; } if (!response.ok || !response.body) { const detail = await response.text(); onEvent({ type: "error", sessionId, message: "stream request failed", detail, }); return; } await readStreamEvents(response, onEvent); }; export const abortAgentChat = async (sessionId?: string) => { if (!sessionId) { return; } const response = await apiFetch( getAgentSessionUrl(sessionId, "/runs/current"), { method: "DELETE", projectHeaderMode: "include", skipAuthRedirect: true, }, ); if (!response.ok) { const detail = await response.text(); throw new Error(detail || `abort request failed: ${response.status}`); } }; export const replyAgentPermission = async ( sessionId: string, requestId: string, reply: PermissionDecision, ) => { const response = await apiFetch( getAgentSessionUrl(sessionId, "/permission-responses"), { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ request_id: requestId, reply, }), projectHeaderMode: "include", skipAuthRedirect: true, }, ); if (!response.ok) { const detail = await response.text(); throw new Error(detail || `permission reply failed: ${response.status}`); } }; export const replyAgentCredentialRefresh = async ( sessionId: string, requestId: string, ) => { const response = await apiFetch( getAgentSessionUrl(sessionId, "/credential-refreshes"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ request_id: requestId }), projectHeaderMode: "include", skipAuthRedirect: true, }, ); if (!response.ok) { const detail = await response.text(); throw new Error( detail || `credential refresh reply failed: ${response.status}`, ); } }; export const replyAgentQuestion = async ( sessionId: string, requestId: string, answers: string[][], ) => { const response = await apiFetch( getAgentSessionUrl(sessionId, "/question-responses"), { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ request_id: requestId, action: "reply", answers, }), projectHeaderMode: "include", skipAuthRedirect: true, }, ); if (!response.ok) { const detail = await response.text(); throw new Error(detail || `question reply failed: ${response.status}`); } }; export const rejectAgentQuestion = async ( sessionId: string, requestId: string, ) => { const response = await apiFetch( getAgentSessionUrl(sessionId, "/question-responses"), { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ request_id: requestId, action: "reject", }), projectHeaderMode: "include", skipAuthRedirect: true, }, ); if (!response.ok) { const detail = await response.text(); throw new Error(detail || `question reject failed: ${response.status}`); } }; export const forkAgentChat = async ( sessionId: string | undefined, keepMessageCount: number, ) => { const response = await apiFetch( getAgentSessionUrl(sessionId ?? "", "/forks"), { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ keep_message_count: keepMessageCount, }), projectHeaderMode: "include", skipAuthRedirect: true, }, ); if (!response.ok) { const detail = await response.text(); throw new Error(detail || `fork request failed: ${response.status}`); } const payload = (await response.json()) as { session_id?: string }; if (!payload.session_id) { throw new Error("fork request returned no session_id"); } return payload.session_id; };