import axios from 'axios'; const API_BASE_URL = 'https://api.hoyidata.com/aura/v1'; const APP_BASE = (import.meta.env.BASE_URL || '/').replace(/\/$/, ''); const withAppBase = (path: string) => `${APP_BASE}${path.startsWith('/') ? path : `/${path}`}`; const withApiBase = (path: string) => `${API_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`; const isMockAuth = () => typeof localStorage !== 'undefined' && localStorage.getItem('mock-auth') === '1'; export const api = axios.create({ baseURL: API_BASE_URL, timeout: 90000, withCredentials: true // 关键:跨域请求带 cookie }); // 401 拦截:自动跳登录 api.interceptors.response.use( (r) => r, (err) => { const isLoginPage = location.pathname === '/login' || location.pathname === withAppBase('/login'); if (err?.response?.status === 401 && !isLoginPage && !isMockAuth()) { const next = encodeURIComponent(location.pathname + location.search); location.href = `${withAppBase('/login')}?next=${next}`; } return Promise.reject(err); } ); export type KnowledgeStatus = 'pending' | 'indexing' | 'ready' | 'failed'; export interface KnowledgeFile { id: string; originalName: string; filename: string; size: number; mime: string; status: KnowledgeStatus; error?: string; chunkCount: number; createdAt: number; } export type SkillType = 'prompt' | 'http' | 'js'; export interface SkillBrief { id: string; name: string; filename: string; description?: string; type: SkillType; enabled: number; createdAt: number; } export interface SkillDetail extends SkillBrief { content: string; parameters: string; handler: string; config: string; } export interface Agent { id: string; name: string; description: string; avatar: string; prompt: string; model: string; temperature: number; owner_id?: string | null; team_id?: string | null; visibility?: 'private' | 'team' | 'public'; fork_count?: number; forked_from?: string | null; created_at: number; updated_at: number; knowledge?: KnowledgeFile[]; skills?: SkillBrief[]; _access?: 'owner' | 'team' | 'view' | 'none'; } export interface RetrievedSnippet { fileName: string; chunkIndex: number; score: number; preview: string; } export interface ToolCallTrace { name: string; args: any; result: any; } export interface ChatMessage { id: string; role: 'user' | 'assistant'; content: string; reasoning?: string | null; parentId?: string | null; createdAt: number; meta?: { retrieved?: RetrievedSnippet[]; toolCalls?: ToolCallTrace[]; aborted?: boolean; reasoning?: string; } | null; } export interface BranchInfo { total: number; activeIndex: number; ids: string[]; } export interface ChatHistoryResp { messages: ChatMessage[]; branches: Record; } export interface AiModel { model_name: string; icon: string; model_ratio: number; completion_ratio: number; } export const ModelAPI = { list: () => api.get<{ data: AiModel[] }>('/models').then((r) => r.data.data), }; export const AgentAPI = { list: () => api.get('/agents').then((r) => r.data), detail: (id: string) => api.get(`/agents/${id}`).then((r) => r.data), create: (payload: Partial) => api.post('/agents', payload).then((r) => r.data), update: (id: string, payload: Partial) => api.put(`/agents/${id}`, payload).then((r) => r.data), remove: (id: string) => api.delete(`/agents/${id}`).then((r) => r.data), updateAvatar: (id: string, avatar: string) => api.patch(`/agents/${id}/avatar`, { avatar }).then((r) => r.data), uploadKnowledge: (id: string, files: File[]) => { const fd = new FormData(); files.forEach((f) => fd.append('files', f)); return api.post(`/agents/${id}/knowledge`, fd).then((r) => r.data); }, reindexKnowledge: (agentId: string, fileId: string) => api.post(`/agents/${agentId}/knowledge/${fileId}/reindex`).then((r) => r.data), deleteKnowledge: (agentId: string, fileId: string) => api.delete(`/agents/${agentId}/knowledge/${fileId}`).then((r) => r.data), getKnowledgeStatus: (agentId: string) => api.get<{ files: KnowledgeFile[] }>(`/agents/${agentId}/knowledge/status`).then((r) => r.data), uploadSkills: (id: string, files: File[]) => { const fd = new FormData(); files.forEach((f) => fd.append('files', f)); return api.post(`/agents/${id}/skills`, fd).then((r) => r.data); }, createSkill: (id: string, payload: { name: string; content: string }) => api.post(`/agents/${id}/skills/manual`, payload).then((r) => r.data), getSkill: (agentId: string, skillId: string) => api.get(`/agents/${agentId}/skills/${skillId}`).then((r) => r.data), updateSkill: (agentId: string, skillId: string, payload: { content?: string; enabled?: boolean }) => api.put(`/agents/${agentId}/skills/${skillId}`, payload).then((r) => r.data), deleteSkill: (agentId: string, skillId: string) => api.delete(`/agents/${agentId}/skills/${skillId}`).then((r) => r.data) }; export const ChatAPI = { history: (agentId: string, sessionId?: string) => api .get(`/chat/${agentId}/messages`, { params: sessionId ? { sessionId } : {} }) .then((r) => r.data), send: ( agentId: string, content: string, sessionId?: string, model?: string, imageUrls?: string[] ) => api .post<{ user: ChatMessage; assistant: ChatMessage }>(`/chat/${agentId}/messages`, { content, sessionId, model, imageUrls }) .then((r) => r.data), clear: (agentId: string, sessionId?: string) => api .delete(`/chat/${agentId}/messages`, { params: sessionId ? { sessionId } : {} }) .then((r) => r.data), /** 切换分支:把 user 消息下的某条 assistant 兄弟设为激活 */ switchBranch: (agentId: string, userMsgId: string, branchId: string) => api .post(`/chat/${agentId}/messages/${userMsgId}/switch-branch`, { branchId }) .then((r) => r.data) }; export interface ChatAttachment { name: string; size: number; mime: string; text: string; truncated: boolean; } export const ChatAttachmentsAPI = { upload: (files: File[]) => { const fd = new FormData(); files.forEach((f) => fd.append('files', f)); return api .post<{ files: ChatAttachment[] }>('/chat/attachments', fd) .then((r) => r.data); } }; // ============== MCP ============== export interface McpServer { id: string; name: string; transport: 'stdio' | 'sse' | 'http'; command: string; args: string[]; env: Record; url: string; enabled: boolean; createdAt: number; } export interface McpStatus { id: string; name: string; error?: string; toolCount: number; resourceCount: number; promptCount: number; capabilities: { tools: boolean; resources: boolean; prompts: boolean }; tools: { name: string; description?: string }[]; resources: { uri: string; name?: string; description?: string; mimeType?: string }[]; prompts: { name: string; description?: string; arguments?: any[] }[]; } export const McpAPI = { list: (agentId: string) => api.get(`/agents/${agentId}/mcp-servers`).then((r) => r.data), status: (agentId: string) => api.get(`/agents/${agentId}/mcp-status`).then((r) => r.data), create: (agentId: string, payload: Partial) => api.post(`/agents/${agentId}/mcp-servers`, payload).then((r) => r.data), update: (agentId: string, serverId: string, payload: Partial) => api.put(`/agents/${agentId}/mcp-servers/${serverId}`, payload).then((r) => r.data), remove: (agentId: string, serverId: string) => api.delete(`/agents/${agentId}/mcp-servers/${serverId}`).then((r) => r.data), restart: (agentId: string) => api.post(`/agents/${agentId}/mcp-restart`).then((r) => r.data), importJSON: (agentId: string, config: any, replace = false) => api.post(`/agents/${agentId}/mcp-import`, { config, replace }).then((r) => r.data), readResource: (agentId: string, serverId: string, uri: string) => api.post(`/agents/${agentId}/mcp-read-resource`, { serverId, uri }).then((r) => r.data), getPrompt: (agentId: string, serverId: string, name: string, args: Record = {}) => api.post(`/agents/${agentId}/mcp-get-prompt`, { serverId, name, args }).then((r) => r.data) }; // ============== 会话 ============== export interface ChatSession { id: string; agentId: string; title: string; archived?: boolean; createdAt: number; updatedAt: number; messageCount?: number; lastPreview?: string; lastAt?: number; } export interface SearchHit { id: string; sessionId: string; sessionTitle: string; sessionArchived: boolean; role: 'user' | 'assistant'; content: string; snippet: string; createdAt: number; } export interface SearchResult { sessions: { id: string; title: string; archived: boolean; created_at: number; updated_at: number }[]; messages: SearchHit[]; } export const SessionAPI = { list: (agentId: string, archived: '0' | '1' | 'all' = '0') => api.get(`/agents/${agentId}/sessions`, { params: { archived } }).then((r) => r.data), create: (agentId: string, title?: string) => api.post(`/agents/${agentId}/sessions`, { title }).then((r) => r.data), rename: (agentId: string, sessionId: string, title: string) => api.put(`/agents/${agentId}/sessions/${sessionId}`, { title }).then((r) => r.data), remove: (agentId: string, sessionId: string) => api.delete(`/agents/${agentId}/sessions/${sessionId}`).then((r) => r.data), archive: (agentId: string, sessionId: string, archived: boolean) => api.post(`/agents/${agentId}/sessions/${sessionId}/archive`, { archived }).then((r) => r.data), share: (agentId: string, sessionId: string, ttlHours = 0) => api .post<{ token: string; expiresAt?: number }>(`/agents/${agentId}/sessions/${sessionId}/share`, { ttlHours }) .then((r) => r.data), revokeShare: (agentId: string, sessionId: string) => api.delete(`/agents/${agentId}/sessions/${sessionId}/share`).then((r) => r.data), search: (agentId: string, q: string, opts: { includeArchived?: boolean; limit?: number } = {}) => api .get(`/agents/${agentId}/sessions/search`, { params: { q, includeArchived: opts.includeArchived ? '1' : '0', limit: opts.limit ?? 30 } }) .then((r) => r.data), /** 导出会话为文件并触发浏览器下载 */ exportSession: (agentId: string, sessionId: string, format: 'md' | 'json' = 'md') => { const url = withApiBase(`/agents/${agentId}/sessions/${sessionId}/export?format=${format}`); const a = document.createElement('a'); a.href = url; a.rel = 'noopener'; document.body.appendChild(a); a.click(); a.remove(); } }; // 公开会话访问(不需要登录) export const SharedAPI = { get: (token: string) => api.get<{ agent: { name: string; description: string }; session: { id: string; title: string; createdAt: number; updatedAt: number }; messages: { id: string; role: 'user' | 'assistant'; content: string; createdAt: number }[]; }>(`/shared/${token}`).then((r) => r.data) }; // 图床 export interface UploadedImage { url: string; name: string; size: number; mime: string; } export const ImageAPI = { upload: (files: File[]) => { const fd = new FormData(); files.forEach((f) => fd.append('file', f)); return api.post<{ files: UploadedImage[] }>('/uploads/image', fd).then((r) => r.data); } }; // ============== 全局搜索(v0.7 命令面板) ============== export interface GlobalSearchResult { agents: { id: string; name: string; description: string; model: string }[]; sessions: { id: string; agentId: string; agentName: string; title: string; updatedAt: number; archived: boolean }[]; messages: { id: string; sessionId: string; agentId: string; agentName: string; sessionTitle: string; role: 'user' | 'assistant'; snippet: string; createdAt: number; }[]; } export const SearchAPI = { global: (q: string, limit = 8) => api.get('/search', { params: { q, limit } }).then((r) => r.data) }; // ============== 鉴权 / 用户 / 团队 / 广场 ============== export interface AuthUser { id: string; email: string; name: string; role: 'admin' | 'user'; } export const AuthAPI = { me: () => api.get('/auth/me').then((r) => r.data), verify: async (email: string, password: string) => { // 根据要求,这里直接请求真实地址,未启动时 mock 返回 true try { const res = await axios.post('https://api.hoyidata.com/aura/v1/urser', { email, password }, { timeout: 3000 }); return res.data; } catch (e) { console.warn('Backend /urser not available, fallback to mock true', e); return true; } }, login: (email: string, password: string) => api.post('/auth/login', { email, password }).then((r) => r.data), register: (payload: { email: string; password: string; name: string; inviteCode?: string }) => api.post('/auth/register', payload).then((r) => r.data), logout: () => api.post('/auth/logout').then((r) => r.data), listInvites: () => api.get('/auth/invites').then((r) => r.data), createInvite: (payload: { email?: string; teamId?: string; role?: string; ttlHours?: number }) => api.post('/auth/invites', payload).then((r) => r.data), deleteInvite: (code: string) => api.delete(`/auth/invites/${code}`).then((r) => r.data) }; export interface Team { id: string; name: string; ownerId: string; createdAt: number; myRole?: 'owner' | 'admin' | 'member'; members?: { id: string; email: string; name: string; role: string; joinedAt: number }[]; agentCount?: number; } export const TeamAPI = { list: () => api.get('/teams').then((r) => r.data), detail: (id: string) => api.get(`/teams/${id}`).then((r) => r.data), create: (name: string) => api.post('/teams', { name }).then((r) => r.data), rename: (id: string, name: string) => api.put(`/teams/${id}`, { name }).then((r) => r.data), remove: (id: string) => api.delete(`/teams/${id}`).then((r) => r.data), removeMember: (id: string, userId: string) => api.delete(`/teams/${id}/members/${userId}`).then((r) => r.data) }; export interface MarketplaceAgent extends Agent { ownerName?: string; fork_count: number; skillCount: number; kbCount: number; } export const MarketplaceAPI = { list: () => api.get('/agents/_/marketplace').then((r) => r.data), detail: (id: string) => api.get(`/agents/_/marketplace/${id}`).then((r) => r.data), fork: (id: string) => api.post(`/agents/_/marketplace/${id}/fork`).then((r) => r.data) }; // ============== Prompt 模板库 (v0.8 P1) ============== export interface PromptTemplate { id: string; ownerId: string; ownerName?: string; title: string; body: string; category: string; variables: string; visibility: 'private' | 'public'; useCount: number; createdAt: number; updatedAt: number; } export const PromptTemplateAPI = { list: (opts: { scope?: 'mine' | 'public' | 'all'; q?: string } = {}) => api .get('/prompt-templates', { params: { scope: opts.scope ?? 'all', q: opts.q ?? '' } }) .then((r) => r.data), create: (payload: Partial) => api.post<{ id: string }>('/prompt-templates', payload).then((r) => r.data), update: (id: string, payload: Partial) => api.put(`/prompt-templates/${id}`, payload).then((r) => r.data), remove: (id: string) => api.delete(`/prompt-templates/${id}`).then((r) => r.data), use: (id: string) => api.post(`/prompt-templates/${id}/use`).then((r) => r.data) }; // ============== 积分商城 (v1) ============== export interface PointsMallMe { points: number; level?: string; } export interface PointsMallCategory { id: string; name: string; sort: number; } export interface PointsMallAnnouncement { id: string; title: string; content: string; linkUrl?: string; } export interface PointsMallBanner { id: string; title: string; subtitle?: string; imageUrl: string; linkUrl?: string; } export interface PointsMallPromoEntry { id: string; title: string; subtitle?: string; iconUrl?: string; linkUrl?: string; } export interface PointsMallProduct { id: string; categoryId: string; name: string; subtitle?: string; coverUrl: string; pointsPrice: number; stock: number; sold: number; tags?: string[]; } export interface PointsMallOverview { me: PointsMallMe; categories: PointsMallCategory[]; announcements: PointsMallAnnouncement[]; banners: PointsMallBanner[]; promoEntries: PointsMallPromoEntry[]; } export interface PointsMallProductsResponse { page: number; pageSize: number; total: number; items: PointsMallProduct[]; } export const PointsMallAPI = { overview: () => api.get('/points-mall/overview').then((r) => r.data), products: (opts: { categoryId?: string; q?: string; sort?: string; page?: number; pageSize?: number; } = {}) => api .get('/points-mall/products', { params: { categoryId: opts.categoryId, q: opts.q ?? '', sort: opts.sort ?? 'popular', page: opts.page ?? 1, pageSize: opts.pageSize ?? 24 } }) .then((r) => r.data) }; // ============== 调用统计 (v0.8 P1) ============== export interface StatsOverview { agentCount: number; sessionCount: number; messageCount: number; daily: { day: string; total: number; user: number; assistant: number }[]; topAgents: { id: string; name: string; messageCount: number }[]; } export interface AgentStats { sessionCount: number; messageCount: number; avgMessageLen: number; assistantWithToolCalls: number; knowledgeFiles: number; skillCount: number; daily: { day: string; count: number }[]; } export interface AgentTokenStatsByModel { providerKind: string; model: string; calls: number; promptTokens: number; completionTokens: number; totalTokens: number; costUSD: number; } export interface AgentTokenStatsDaily { day: string; calls: number; promptTokens: number; completionTokens: number; totalTokens: number; costUSD: number; } export interface AgentTokenStats { agentId: string; days: number; promptTokens: number; completionTokens: number; totalTokens: number; costUSD: number; byModel: AgentTokenStatsByModel[]; daily: AgentTokenStatsDaily[]; } export const StatsAPI = { overview: () => api.get('/stats/overview').then((r) => r.data), agent: (id: string) => api.get(`/stats/agents/${id}`).then((r) => r.data), agentTokens: (id: string, opts: { days?: number; limit?: number } = {}) => api .get(`/stats/agents/${id}/tokens`, { params: { days: opts.days ?? 30, limit: opts.limit ?? 10 } }) .then((r) => r.data) }; // ============== 流式(手写 SSE,不用 EventSource,因为它不能 POST) ============== // ============== LLM 提供商 (v0.9) ============== export type LLMKind = 'openai' | 'openai-compatible' | 'anthropic' | 'ollama' | 'tencent-tokenplan'; export interface LLMProvider { id: string; name: string; kind: LLMKind; baseUrl: string; apiKeyMasked: string; hasApiKey: boolean; models: string[]; defaultModel: string; enabled: boolean; isDefault: boolean; createdAt: number; updatedAt: number; } export interface AgentAllowedLLMResponse { agentId: string; model: string; } export const LLMProviderAPI = { list: () => api.get('/llm-providers').then((r) => r.data), allowedModels: (agentId: string) => api .get('/llm-providers', { params: { agent_id: agentId } }) .then((r) => r.data), create: (payload: Partial & { apiKey?: string }) => api.post<{ id: string }>('/llm-providers', payload).then((r) => r.data), update: (id: string, payload: Partial & { apiKey?: string }) => api.put(`/llm-providers/${id}`, payload).then((r) => r.data), remove: (id: string) => api.delete(`/llm-providers/${id}`).then((r) => r.data), test: (id: string, model?: string) => api.post<{ ok: boolean; reply?: string; error?: string; usage?: any; model?: string }>( `/llm-providers/${id}/test`, { model } ).then((r) => r.data), setDefault: (id: string) => api.post(`/llm-providers/${id}/default`).then((r) => r.data) }; export interface StreamEvents { onMeta?: (data: any) => void; onReasoningDelta?: (text: string) => void; onDelta?: (text: string) => void; onRetry?: (data: any) => void; onToolCall?: (data: { id: string; name: string; args: any }) => void; onToolResult?: (data: { id: string; name: string; result: any }) => void; onDone?: (data: { user: ChatMessage; assistant: ChatMessage }) => void; onAborted?: (data: { assistant: ChatMessage }) => void; onError?: (msg: string) => void; } export interface ModelOverrides { model?: string; temperature?: number; topP?: number; maxTokens?: number; } export async function streamChat( agentId: string, content: string, handlers: StreamEvents, signal?: AbortSignal, sessionId?: string, model?: string, imageUrls?: string[] ) { const resp = await fetch(`https://api.hoyidata.com/aura/v1/chat/${agentId}/messages/stream`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' }, body: JSON.stringify({ content, sessionId, model, imageUrls: imageUrls ?? [] }), signal, credentials: 'include' }); return await consumeSSE(resp, handlers, signal); } /** 重新生成(开新分支);行为同 streamChat */ export async function regenerateMessage( agentId: string, messageId: string, handlers: StreamEvents, signal?: AbortSignal, overrides?: ModelOverrides, attachmentsText?: string ) { const resp = await fetch(`https://api.hoyidata.com/aura/v1/chat/${agentId}/messages/${messageId}/regenerate`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' }, body: JSON.stringify({ overrides, attachmentsText }), signal, credentials: 'include' }); return await consumeSSE(resp, handlers, signal); } async function consumeSSE(resp: Response, h: StreamEvents, signal?: AbortSignal) { if (!resp.ok || !resp.body) { const txt = await resp.text().catch(() => ''); h.onError?.(`HTTP ${resp.status}: ${txt}`); return; } const reader = resp.body.getReader(); const decoder = new TextDecoder('utf-8'); let buf = ''; try { while (true) { const { value, done } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); buf = buf.replace(/\r\n/g, '\n'); let idx; while ((idx = buf.indexOf('\n\n')) !== -1) { const raw = buf.slice(0, idx); buf = buf.slice(idx + 2); if (!raw.trim() || raw.startsWith(':')) continue; let event = 'message'; let dataStr = ''; for (const line of raw.split('\n')) { if (line.startsWith('event:')) event = line.slice(6).trim(); else if (line.startsWith('data:')) { let part = line.slice(5); if (part.startsWith(' ')) part = part.slice(1); dataStr += (dataStr ? '\n' : '') + part; } } if (!dataStr) continue; let data: any; try { data = JSON.parse(dataStr); } catch { continue; } switch (event) { case 'meta': h.onMeta?.(data); break; case 'retry': h.onRetry?.(data); break; case 'reasoning_delta': h.onReasoningDelta?.(data.content || ''); break; case 'delta': h.onDelta?.(data.content || ''); break; case 'tool_call': h.onToolCall?.(data); break; case 'tool_result': h.onToolResult?.(data); break; case 'done': h.onDone?.(data); break; case 'aborted': h.onAborted?.(data); break; case 'error': h.onError?.(data.message || 'stream error'); break; } } } } catch (e: any) { if (signal?.aborted || e?.name === 'AbortError') { // 静默:本地已 abort return; } h.onError?.(e?.message ?? String(e)); } } // ============== Workflow (v1.1) ============== export type WorkflowNodeType = 'agent' | 'skill' | 'http' | 'transform' | 'branch'; export interface WorkflowNode { id: string; type: WorkflowNodeType; name: string; config: Record; next?: string; elseNext?: string; } export interface WorkflowGraph { entry: string; nodes: WorkflowNode[]; variables?: Record; } export interface Workflow { id: string; name: string; description: string; graph: WorkflowGraph; scheduleCron: string; scheduleEnabled: boolean; enabled: boolean; lastRunAt: number; runCount: number; createdAt: number; updatedAt: number; } export interface WorkflowRun { id: string; workflowId: string; trigger: 'manual' | 'cron' | 'api'; status: 'running' | 'success' | 'failed' | 'aborted'; error: string; startedAt: number; finishedAt: number; durationMs: number; } export interface WorkflowRunStep { id: string; nodeId: string; nodeType: string; stepIndex: number; status: 'running' | 'success' | 'failed' | 'skipped'; input: any; output: any; error: string; startedAt: number; finishedAt: number; durationMs: number; } export interface WorkflowRunDetail extends WorkflowRun { input: any; output: any; steps: WorkflowRunStep[]; } export const WorkflowAPI = { list: () => api.get('/workflows').then((r) => r.data), get: (id: string) => api.get(`/workflows/${id}`).then((r) => r.data), create: (payload: Partial) => api.post<{ id: string }>('/workflows', payload).then((r) => r.data), update: (id: string, payload: Partial) => api.put(`/workflows/${id}`, payload).then((r) => r.data), remove: (id: string) => api.delete(`/workflows/${id}`).then((r) => r.data), run: (id: string, input?: Record) => api.post<{ runId: string }>(`/workflows/${id}/run`, { input }).then((r) => r.data), listRuns: (id: string, limit = 30) => api.get(`/workflows/${id}/runs`, { params: { limit } }).then((r) => r.data), getRun: (runId: string) => api.get(`/workflows/runs/${runId}`).then((r) => r.data) }; // SSE 流式执行 workflow(用 EventSource,cookie 由浏览器自动带) export function streamWorkflowRun( workflowId: string, input: Record | undefined, handlers: { onReady?: (data: any) => void; onStepStart?: (data: any) => void; onStepFinish?: (data: any) => void; onRunFinish?: (data: any) => void; onError?: (msg: string) => void; } ): () => void { const qs = input ? `?input=${encodeURIComponent(JSON.stringify(input))}` : ''; const url = withApiBase(`/workflows/${workflowId}/run-stream${qs}`); const es = new EventSource(url, { withCredentials: true } as any); es.addEventListener('ready', (e: any) => handlers.onReady?.(JSON.parse(e.data))); es.addEventListener('step_start', (e: any) => handlers.onStepStart?.(JSON.parse(e.data))); es.addEventListener('step_finish', (e: any) => handlers.onStepFinish?.(JSON.parse(e.data))); es.addEventListener('run_finish', (e: any) => { handlers.onRunFinish?.(JSON.parse(e.data)); es.close(); }); es.addEventListener('error', () => { if (es.readyState === EventSource.CLOSED) return; handlers.onError?.('stream error'); es.close(); }); return () => es.close(); }