Something went wrong. Try again.
I'm going to build my own OpenClaw, with blackjack... and bun!
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975// @ts-nocheck/** * API client for Vibes backend */
import { recordAppPerfRequest } from './ui/app-perf-tracing.js';
const API_BASE = '';
/** * Fetch wrapper with error handling */async function request(url, options = {}) { const startedAt = typeof performance !== 'undefined' && typeof performance.now === 'function' ? performance.now() : Date.now(); let response; try { response = await fetch(API_BASE + url, { ...options, headers: { 'Content-Type': 'application/json', ...options.headers, }, }); } catch (error) { recordAppPerfRequest({ method: String(options.method || 'GET').toUpperCase(), url, startedAt, durationMs: (typeof performance !== 'undefined' && typeof performance.now === 'function' ? performance.now() : Date.now()) - startedAt, ok: false, detail: { failedBeforeResponse: true }, }); throw error; }
const durationMs = (typeof performance !== 'undefined' && typeof performance.now === 'function' ? performance.now() : Date.now()) - startedAt; recordAppPerfRequest({ method: String(options.method || 'GET').toUpperCase(), url, startedAt, durationMs, status: response.status, ok: response.ok, requestId: response.headers?.get?.('x-request-id') || null, serverTiming: response.headers?.get?.('Server-Timing') || null, }); if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Unknown error' })); throw new Error(error.error || `HTTP ${response.status}`); } return response.json();}
function parseEventStreamBlock(block) { const lines = String(block || '').split('\n'); let event = 'message'; const dataLines = []; for (const line of lines) { if (line.startsWith('event:')) { event = line.slice(6).trim() || 'message'; } else if (line.startsWith('data:')) { dataLines.push(line.slice(5).trim()); } } const rawData = dataLines.join('\n'); if (!rawData) return null; try { return { event, data: JSON.parse(rawData) }; } catch { return { event, data: rawData }; }}
async function consumeEventStream(response, onEvent) { if (!response.body) throw new Error('Missing event stream body'); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = '';
while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const parts = buffer.split('\n\n'); buffer = parts.pop() || ''; for (const part of parts) { const parsed = parseEventStreamBlock(part); if (parsed) onEvent(parsed.event, parsed.data); } }
buffer += decoder.decode(); const tail = parseEventStreamBlock(buffer); if (tail) onEvent(tail.event, tail.data);}
/** * Get timeline posts (chat style - returns oldest first) */export async function getTimeline(limit = 10, beforeId = null, chatJid = null) { let url = `/timeline?limit=${limit}`; if (beforeId) { url += `&before=${beforeId}`; } if (chatJid) { url += `&chat_jid=${encodeURIComponent(chatJid)}`; } return request(url);}
/** * Get posts by hashtag */export async function getPostsByHashtag(hashtag, limit = 50, offset = 0, chatJid = null) { const query = chatJid ? `&chat_jid=${encodeURIComponent(chatJid)}` : ''; return request(`/hashtag/${encodeURIComponent(hashtag)}?limit=${limit}&offset=${offset}${query}`);}
/** * Search posts */export async function searchPosts(query, limit = 50, offset = 0, chatJid = null, scope = 'current', rootChatJid = null) { const chatQuery = chatJid ? `&chat_jid=${encodeURIComponent(chatJid)}` : ''; const scopeQuery = scope ? `&scope=${encodeURIComponent(scope)}` : ''; const rootQuery = rootChatJid ? `&root_chat_jid=${encodeURIComponent(rootChatJid)}` : ''; return request(`/search?q=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}${chatQuery}${scopeQuery}${rootQuery}`);}
/** * Get a thread by ID */export async function getThread(threadId, chatJid = null) { const query = chatJid ? `?chat_jid=${encodeURIComponent(chatJid)}` : ''; return request(`/thread/${threadId}${query}`);}
export async function getSystemMetrics() { return request('/agent/system-metrics');}
/** * Create a new post */export async function createPost(content, mediaIds = [], chatJid = null) { const query = chatJid ? `?chat_jid=${encodeURIComponent(chatJid)}` : ''; return request(`/post${query}`, { method: 'POST', body: JSON.stringify({ content, media_ids: mediaIds }), });}
/** * Reply to a thread. */export async function createReply(threadId, content, mediaIds = [], chatJid = null) { const query = chatJid ? `?chat_jid=${encodeURIComponent(chatJid)}` : ''; return request(`/post/reply${query}`, { method: 'POST', body: JSON.stringify({ thread_id: threadId, content, media_ids: mediaIds }), });}
/** * Delete a post (optionally cascade replies) */export async function deletePost(postId, cascade = false, chatJid = null) { const chatQuery = chatJid ? `&chat_jid=${encodeURIComponent(chatJid)}` : ''; const url = `/post/${postId}?cascade=${cascade ? 'true' : 'false'}${chatQuery}`; return request(url, { method: 'DELETE' });}
/** * Send message to agent */export async function sendAgentMessage(agentId, content, threadId = null, mediaIds = [], mode = null, chatJid = null) { const query = chatJid ? `?chat_jid=${encodeURIComponent(chatJid)}` : ''; const payload = { content, thread_id: threadId, media_ids: mediaIds }; if (mode === 'auto' || mode === 'queue' || mode === 'steer') { payload.mode = mode; } return request(`/agent/${agentId}/message${query}`, { method: 'POST', body: JSON.stringify(payload), });}
/** * List currently active chat agents/branches known to the backend session pool. */export async function getActiveChatAgents() { return request('/agent/active-chats');}
/** * List known branch/session records from the branch registry. */export async function getChatBranches(rootChatJid = null, options = {}) { const params = new URLSearchParams(); if (rootChatJid) params.set('root_chat_jid', String(rootChatJid)); if (options?.includeArchived) params.set('include_archived', '1'); const query = params.toString() ? `?${params.toString()}` : ''; return request(`/agent/branches${query}`);}
/** * Create a first-class forked branch from an existing chat branch. */export async function forkChatBranch(sourceChatJid, options = {}) { return request('/agent/branch-fork', { method: 'POST', body: JSON.stringify({ source_chat_jid: sourceChatJid, ...(options?.agentName ? { agent_name: options.agentName } : {}), }), });}
/** * Rename a registry-backed chat branch / agent identity. */export async function renameChatBranch(chatJid, options = {}) { return request('/agent/branch-rename', { method: 'POST', body: JSON.stringify({ chat_jid: chatJid, ...(options && Object.prototype.hasOwnProperty.call(options, 'agentName') ? { agent_name: options.agentName } : {}), }), });}
/** * Archive/prune a registry-backed chat branch / agent identity. */export async function pruneChatBranch(chatJid) { return request('/agent/branch-prune', { method: 'POST', body: JSON.stringify({ chat_jid: chatJid }), });}
/** * Restore/reopen an archived branch into active discovery. */export async function restoreChatBranch(chatJid, options = {}) { return request('/agent/branch-restore', { method: 'POST', body: JSON.stringify({ chat_jid: chatJid, ...(options && Object.prototype.hasOwnProperty.call(options, 'agentName') ? { agent_name: options.agentName } : {}), }), });}
/** * Relay a peer message from one chat agent/window to another. */export async function sendPeerAgentMessage(sourceChatJid, targetChatOrName, content, mode = 'auto', options = {}) { const payload = { source_chat_jid: sourceChatJid, content, mode, ...(options?.sourceAgentName ? { source_agent_name: options.sourceAgentName } : {}), ...(options?.targetBy === 'agent_name' ? { target_agent_name: targetChatOrName } : { target_chat_jid: targetChatOrName }), }; return request('/agent/peer-message', { method: 'POST', body: JSON.stringify(payload), });}
/** * Get available agents / current agent roster. */export async function getAgents() { return request('/agent/roster');}
/** * Get current agent status */export async function getAgentStatus(chatJid = null) { const query = chatJid ? `?chat_jid=${encodeURIComponent(chatJid)}` : ''; return request(`/agent/status${query}`);}
/** * Get context window usage (tokens, contextWindow, percent). * Returns null fields when the session has no usage data yet. */export async function getAgentContext(chatJid = null) { const query = chatJid ? `?chat_jid=${encodeURIComponent(chatJid)}` : ''; return request(`/agent/context${query}`);}
/** * Get the live autoresearch status-panel widget payload for the current chat. */export async function getAutoresearchStatus(chatJid = null) { const query = chatJid ? `?chat_jid=${encodeURIComponent(chatJid)}` : ''; return request(`/agent/autoresearch/status${query}`);}
/** * Stop the currently running autoresearch experiment for the current chat. */export async function stopAutoresearch(chatJid = null, options = {}) { return request('/agent/autoresearch/stop', { method: 'POST', body: JSON.stringify({ chat_jid: chatJid || undefined, generate_report: options?.generateReport !== false, }), });}
export async function dismissAutoresearch(chatJid = null) { return request('/agent/autoresearch/dismiss', { method: 'POST', body: JSON.stringify({ chat_jid: chatJid || undefined, }), });}
/** * Get queued follow-up state for the default web chat (count + pending items). */export async function getAgentQueueState(chatJid = null) { const query = chatJid ? `?chat_jid=${encodeURIComponent(chatJid)}` : ''; return request(`/agent/queue-state${query}`);}
/** * Remove one queued follow-up item by placeholder row id. */export async function removeAgentQueueItem(rowId, chatJid = null) { const response = await fetch(API_BASE + '/agent/queue-remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ row_id: rowId, chat_jid: chatJid || undefined }), });
if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Failed to remove queued item' })); throw new Error(error.error || `HTTP ${response.status}`); }
return response.json();}
/** * Atomically convert one queued follow-up into steering (when active) or an * immediate send (when no active stream remains). */export async function steerAgentQueueItem(rowId, chatJid = null) { const response = await fetch(API_BASE + '/agent/queue-steer', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ row_id: rowId, chat_jid: chatJid || undefined }), });
if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Failed to steer queued item' })); throw new Error(error.error || `HTTP ${response.status}`); }
return response.json();}
export async function reorderAgentQueueItem(fromIndex, toIndex, chatJid = null) { const response = await fetch(API_BASE + '/agent/queue-reorder', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ from_index: fromIndex, to_index: toIndex, chat_jid: chatJid || undefined }), });
if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Failed to reorder queued item' })); throw new Error(error.error || `HTTP ${response.status}`); }
return response.json();}
/** * Get available models and current selection. */export async function getAgentModels(chatJid = null) { const query = chatJid ? `?chat_jid=${encodeURIComponent(chatJid)}` : ''; return request(`/agent/models${query}`);}
/** * Upload media file */export async function uploadMedia(file) { const formData = new FormData(); formData.append('file', file); const response = await fetch(API_BASE + '/media/upload', { method: 'POST', body: formData, }); if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Upload failed' })); throw new Error(error.error || `HTTP ${response.status}`); } return response.json();}
/** * Respond to an agent request (permission, choice) */export async function respondToAgentRequest(requestId, outcome, chatJid = null) { const response = await fetch(API_BASE + '/agent/respond', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ request_id: requestId, outcome, chat_jid: chatJid || undefined }), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Failed to respond' })); throw new Error(error.error || `HTTP ${response.status}`); } return response.json();}
/** * Submit an Adaptive Card action back to the web channel. */export async function submitAdaptiveCardAction(payload) { const response = await fetch(API_BASE + '/agent/card-action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), });
if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Adaptive Card action failed' })); throw new Error(error.error || `HTTP ${response.status}`); }
return response.json();}
export async function streamSidePrompt(prompt, options = {}) { const response = await fetch(API_BASE + '/agent/side-prompt/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt, system_prompt: options.systemPrompt || undefined, chat_jid: options.chatJid || undefined, }), signal: options.signal, });
if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Side prompt failed' })); throw new Error(error.error || `HTTP ${response.status}`); }
let finalPayload = null; let errorPayload = null; await consumeEventStream(response, (eventType, data) => { options.onEvent?.(eventType, data); if (eventType === 'side_prompt_thinking_delta') { options.onThinkingDelta?.(data?.delta || ''); } else if (eventType === 'side_prompt_text_delta') { options.onTextDelta?.(data?.delta || ''); } else if (eventType === 'side_prompt_done') { finalPayload = data; } else if (eventType === 'side_prompt_error') { errorPayload = data; } });
if (errorPayload) { const error = new Error(errorPayload?.error || 'Side prompt failed'); error.payload = errorPayload; throw error; }
return finalPayload;}
/** * Add pattern to permission whitelist */export async function addToWhitelist(pattern, description) { const response = await fetch(API_BASE + '/agent/whitelist', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pattern, description }), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Failed to add to whitelist' })); throw new Error(error.error || `HTTP ${response.status}`); } return response.json();}
/** Fetch the agent thought/plan panel content for a given turn. */export async function getAgentThought(turnId, panel = 'thought') { const url = `/agent/thought?turn_id=${encodeURIComponent(turnId)}&panel=${encodeURIComponent(panel)}`; return request(url);}
/** Toggle visibility of a thought/plan panel in the UI. */export async function setAgentThoughtVisibility(turnId, panel, expanded) { return request('/agent/thought/visibility', { method: 'POST', body: JSON.stringify({ turn_id: turnId, panel, expanded: Boolean(expanded) }), });}
/** * Get media URL */export function getMediaUrl(mediaId) { return `${API_BASE}/media/${mediaId}`;}
/** * Get media thumbnail URL */export function getThumbnailUrl(mediaId) { return `${API_BASE}/media/${mediaId}/thumbnail`;}
/** * Get media info (metadata without data) */export async function getMediaInfo(mediaId) { const response = await fetch(`${API_BASE}/media/${mediaId}/info`); if (!response.ok) throw new Error('Failed to get media info'); return response.json();}
/** Load media as text for text-like attachment previews. */export async function getMediaText(mediaId) { const response = await fetch(`${API_BASE}/media/${mediaId}`); if (!response.ok) throw new Error('Failed to load media text'); return response.text();}
/** Load media as a Blob for preview flows that need object URLs (e.g. PDFs). */export async function getMediaBlob(mediaId) { const response = await fetch(`${API_BASE}/media/${mediaId}`); if (!response.ok) throw new Error('Failed to load media blob'); return response.blob();}
/** * Get workspace tree */export async function getWorkspaceTree(path = '', depth = 2, showHidden = false) { const url = `/workspace/tree?path=${encodeURIComponent(path)}&depth=${depth}&show_hidden=${showHidden ? '1' : '0'}`; return request(url);}
/** Get the current workspace git branch for the nearest enclosing repo. */export async function getWorkspaceBranch(path = '') { const url = `/workspace/branch?path=${encodeURIComponent(path || '')}`; return request(url);}
/** Get the current workspace FTS indexing status snapshot. */export async function getWorkspaceIndexStatus(scope = 'all') { const url = `/workspace/index-status?scope=${encodeURIComponent(scope || 'all')}`; return request(url);}
/** Trigger a workspace FTS reindex and return the updated status snapshot. */export async function reindexWorkspace(scope = 'all') { return request('/workspace/reindex', { method: 'POST', body: JSON.stringify({ scope }), });}
/** * Get workspace file preview */export async function getWorkspaceFile(path, maxBytes = 20000, mode = null) { const modeParam = mode ? `&mode=${encodeURIComponent(mode)}` : ''; const url = `/workspace/file?path=${encodeURIComponent(path)}&max=${maxBytes}${modeParam}`; return request(url);}
/** * Update workspace file contents */export async function updateWorkspaceFile(path, content) { return request('/workspace/file', { method: 'PUT', body: JSON.stringify({ path, content }), });}
/** * Create a download attachment for a workspace file */export async function attachWorkspaceFile(path) { return request('/workspace/attach', { method: 'POST', body: JSON.stringify({ path }), });}
/** Upload a file to the workspace via multipart form data. *//** Maximum client-side upload size (256 MB). Larger files need chunked upload. */const MAX_UPLOAD_SIZE = 256 * 1024 * 1024;
export async function uploadWorkspaceFile(file, targetPath = '', options = {}) { if (file?.size > MAX_UPLOAD_SIZE) { const sizeMB = (file.size / (1024 * 1024)).toFixed(0); const limitMB = (MAX_UPLOAD_SIZE / (1024 * 1024)).toFixed(0); const err = new Error(`File too large (${sizeMB} MB). Maximum upload size is ${limitMB} MB.`); err.code = 'file_too_large'; throw err; }
const formData = new FormData(); formData.append('file', file); const params = new URLSearchParams(); if (targetPath) params.set('path', targetPath); if (options.overwrite) params.set('overwrite', '1'); const query = params.toString(); const url = query ? `/workspace/upload?${query}` : '/workspace/upload';
if (typeof options.onProgress === 'function') { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('POST', API_BASE + url); xhr.upload.onprogress = (e) => { if (e.lengthComputable) { options.onProgress({ loaded: e.loaded, total: e.total, percent: Math.round((e.loaded / e.total) * 100) }); } }; xhr.onload = () => { try { const body = JSON.parse(xhr.responseText); if (xhr.status >= 200 && xhr.status < 300) { resolve(body); } else { const err = new Error(body.error || `HTTP ${xhr.status}`); err.status = xhr.status; err.code = body.code; reject(err); } } catch { reject(new Error(`HTTP ${xhr.status}`)); } }; xhr.onerror = () => reject(new Error('Upload failed (network error)')); xhr.ontimeout = () => reject(new Error('Upload timed out')); xhr.send(formData); }); }
const response = await fetch(API_BASE + url, { method: 'POST', body: formData, });
if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Upload failed' })); const err = new Error(error.error || `HTTP ${response.status}`); err.status = response.status; err.code = error.code; throw err; }
return response.json();}
/** Create a new workspace file. */export async function createWorkspaceFile(path, name, content = '') { const response = await fetch(API_BASE + '/workspace/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, name, content }), });
if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Create failed' })); const err = new Error(error.error || `HTTP ${response.status}`); err.status = response.status; err.code = error.code; throw err; }
return response.json();}
/** Rename a workspace file or folder. */export async function renameWorkspaceFile(path, name) { const response = await fetch(API_BASE + '/workspace/rename', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, name }), });
if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Rename failed' })); const err = new Error(error.error || `HTTP ${response.status}`); err.status = response.status; err.code = error.code; throw err; }
return response.json();}
/** Move a workspace file or folder into another directory. */export async function moveWorkspaceEntry(path, target) { const response = await fetch(API_BASE + '/workspace/move', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, target }), });
if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Move failed' })); const err = new Error(error.error || `HTTP ${response.status}`); err.status = response.status; err.code = error.code; throw err; }
return response.json();}
/** Delete a file from the workspace. */export async function deleteWorkspaceFile(path) { const url = `/workspace/file?path=${encodeURIComponent(path || '')}`; return request(url, { method: 'DELETE' });}
/** Toggle workspace explorer visibility and hidden-file display. */export async function setWorkspaceVisibility(visible, showHidden = false) { return request('/workspace/visibility', { method: 'POST', body: JSON.stringify({ visible: Boolean(visible), show_hidden: Boolean(showHidden) }), });}
/** * Get raw workspace file URL (images/SVG) */export function getWorkspaceRawUrl(path, options = {}) { const query = new URLSearchParams({ path: String(path || '') }); if (options.download) query.set('download', '1'); return `${API_BASE}/workspace/raw?${query.toString()}`;}
/** * Get workspace file download URL. */export function getWorkspaceFileDownloadUrl(path) { return getWorkspaceRawUrl(path, { download: true });}
/** * Get workspace folder download URL (zip) */export function getWorkspaceDownloadUrl(path, showHidden = false) { const query = `path=${encodeURIComponent(path || '')}&show_hidden=${showHidden ? '1' : '0'}`; return `${API_BASE}/workspace/download?${query}`;}
/** * SSE client for live updates */export class SSEClient { constructor(onEvent, onStatusChange, options = {}) { this.onEvent = onEvent; this.onStatusChange = onStatusChange; this.chatJid = typeof options?.chatJid === 'string' && options.chatJid.trim() ? options.chatJid.trim() : null; this.eventSource = null; this.reconnectTimeout = null; this.reconnectDelay = 1000; this.status = 'disconnected'; this.reconnectAttempts = 0; this.cooldownUntil = 0; this.connecting = false; this.lastActivityAt = 0; this.staleCheckTimer = null; this.staleThresholdMs = 70000; }
markActivity() { this.lastActivityAt = Date.now(); }
clearStaleMonitor() { if (this.staleCheckTimer) { clearInterval(this.staleCheckTimer); this.staleCheckTimer = null; } }
startStaleMonitor() { this.clearStaleMonitor(); this.staleCheckTimer = setInterval(() => { if (this.status !== 'connected') return; if (!this.lastActivityAt) return; if (Date.now() - this.lastActivityAt <= this.staleThresholdMs) return; console.warn('SSE connection went stale; forcing reconnect'); this.forceReconnect(); }, 15000); }
forceReconnect() { this.connecting = false; if (this.eventSource) { this.eventSource.close(); this.eventSource = null; } this.clearStaleMonitor(); this.status = 'disconnected'; this.onStatusChange('disconnected'); this.reconnectAttempts += 1; this.scheduleReconnect(); } connect() { if (this.connecting) return; if (this.eventSource && this.status === 'connected') return; this.connecting = true; if (this.eventSource) { this.eventSource.close(); } this.clearStaleMonitor(); const query = this.chatJid ? `?chat_jid=${encodeURIComponent(this.chatJid)}` : ''; this.eventSource = new EventSource(API_BASE + '/sse/stream' + query);
const bindJsonEvent = (eventType) => { this.eventSource.addEventListener(eventType, (e) => { this.markActivity(); this.onEvent(eventType, JSON.parse(e.data)); }); }; this.eventSource.onopen = () => { this.connecting = false; this.reconnectDelay = 1000; this.reconnectAttempts = 0; this.cooldownUntil = 0; this.status = 'connected'; this.markActivity(); this.startStaleMonitor(); this.onStatusChange('connected'); }; this.eventSource.onerror = () => { this.connecting = false; this.clearStaleMonitor(); this.status = 'disconnected'; this.onStatusChange('disconnected'); this.reconnectAttempts += 1; this.scheduleReconnect(); }; // Event handlers this.eventSource.addEventListener('connected', () => { this.markActivity(); console.log('SSE connected'); this.onEvent('connected', {}); });
this.eventSource.addEventListener('heartbeat', () => { this.markActivity(); }); bindJsonEvent('new_post'); bindJsonEvent('new_reply'); bindJsonEvent('agent_response'); bindJsonEvent('interaction_updated'); bindJsonEvent('interaction_deleted'); bindJsonEvent('agent_status'); bindJsonEvent('agent_steer_queued'); bindJsonEvent('agent_followup_queued'); bindJsonEvent('agent_followup_consumed'); bindJsonEvent('agent_followup_removed'); bindJsonEvent('workspace_update'); bindJsonEvent('agent_draft'); bindJsonEvent('agent_draft_delta'); bindJsonEvent('agent_thought'); bindJsonEvent('agent_thought_delta'); bindJsonEvent('model_changed'); bindJsonEvent('ui_theme'); bindJsonEvent('ui_meters');
[ 'extension_ui_request', 'extension_ui_timeout', 'extension_ui_notify', 'extension_ui_status', 'extension_ui_working', 'extension_ui_widget', 'extension_ui_title', 'extension_ui_editor_text', 'extension_ui_error', ].forEach(bindJsonEvent); } scheduleReconnect() { if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); }
const MAX_ATTEMPTS = 10; const COOLDOWN_MS = 60000; const now = Date.now(); if (this.reconnectAttempts >= MAX_ATTEMPTS) { this.cooldownUntil = Math.max(this.cooldownUntil, now + COOLDOWN_MS); this.reconnectAttempts = 0; }
const cooldownDelay = Math.max(this.cooldownUntil - now, 0); const delay = Math.max(this.reconnectDelay, cooldownDelay); this.reconnectTimeout = setTimeout(() => { console.log('Reconnecting SSE...'); this.connect(); }, delay); // Exponential backoff, max 30 seconds this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000); }
reconnectIfNeeded() { const now = Date.now(); if (this.status === 'connected') { if (this.lastActivityAt && now - this.lastActivityAt > this.staleThresholdMs) { this.forceReconnect(); } return; } if (this.cooldownUntil && now < this.cooldownUntil) return; if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; } this.connect(); } disconnect() { this.connecting = false; this.clearStaleMonitor(); if (this.eventSource) { this.eventSource.close(); this.eventSource = null; } if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; } }}