const BASE = '/api' // Cache for models list let modelsCache = null function buildQueryParams(params) { const sp = new URLSearchParams() for (const [key, value] of Object.entries(params)) { if (value != null && value !== '') sp.set(key, value) } const qs = sp.toString() return qs ? `?${qs}` : '' } async function request(url, options = {}) { const res = await fetch(`${BASE}${url}`, { headers: { 'Content-Type': 'application/json' }, ...options, body: options.body ? JSON.stringify(options.body) : undefined, }) const data = await res.json() if (data.code !== 0) { throw new Error(data.message || 'Request failed') } return data } /** * Shared SSE stream processor - parses SSE events and dispatches to callbacks * @param {string} url - API URL (without BASE prefix) * @param {object} body - Request body * @param {object} callbacks - Event handlers: { onProcessStep, onDone, onError } * @returns {{ abort: () => void }} */ function createSSEStream(url, body, { onProcessStep, onDone, onError }) { const controller = new AbortController() const promise = (async () => { try { const res = await fetch(`${BASE}${url}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: controller.signal, }) if (!res.ok) { const err = await res.json().catch(() => ({})) throw new Error(err.message || `HTTP ${res.status}`) } const reader = res.body.getReader() const decoder = new TextDecoder() let buffer = '' let completed = false 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() || '' let currentEvent = '' for (const line of lines) { if (line.startsWith('event: ')) { currentEvent = line.slice(7).trim() } else if (line.startsWith('data: ')) { const data = JSON.parse(line.slice(6)) if (currentEvent === 'process_step' && onProcessStep) { onProcessStep(data) } else if (currentEvent === 'done' && onDone) { completed = true onDone(data) } else if (currentEvent === 'error' && onError) { onError(data.content) } } } } // Connection closed without receiving 'done' event — clean up if (!completed && onError) { onError('stream ended unexpectedly') } } catch (e) { if (e.name !== 'AbortError' && onError) { onError(e.message) } } })() promise.abort = () => controller.abort() return promise } export const modelApi = { list() { return request('/models') }, // Get cached models or fetch from server async getCached() { if (modelsCache) { return { data: modelsCache } } // Try localStorage cache first const cached = localStorage.getItem('models_cache') if (cached) { try { modelsCache = JSON.parse(cached) return { data: modelsCache } } catch (_) {} } // Fetch from server const res = await this.list() modelsCache = res.data localStorage.setItem('models_cache', JSON.stringify(modelsCache)) return res }, } export const statsApi = { getTokens(period = 'daily') { return request(`/stats/tokens${buildQueryParams({ period })}`) }, } export const conversationApi = { list(cursor, limit = 20, projectId = null) { return request(`/conversations${buildQueryParams({ cursor, limit, project_id: projectId })}`) }, create(payload = {}) { return request('/conversations', { method: 'POST', body: payload, }) }, get(id) { return request(`/conversations/${id}`) }, update(id, payload) { return request(`/conversations/${id}`, { method: 'PATCH', body: payload, }) }, delete(id) { return request(`/conversations/${id}`, { method: 'DELETE' }) }, } export const messageApi = { list(convId, cursor, limit = 50) { return request(`/conversations/${convId}/messages${buildQueryParams({ cursor, limit })}`) }, send(convId, data, callbacks) { return createSSEStream(`/conversations/${convId}/messages`, { text: data.text, attachments: data.attachments, stream: true, tools_enabled: callbacks.toolsEnabled !== false, project_id: data.projectId, }, callbacks) }, delete(convId, msgId) { return request(`/conversations/${convId}/messages/${msgId}`, { method: 'DELETE' }) }, regenerate(convId, msgId, callbacks) { return createSSEStream(`/conversations/${convId}/regenerate/${msgId}`, { tools_enabled: callbacks.toolsEnabled !== false, project_id: callbacks.projectId, }, callbacks) }, } export const projectApi = { list(cursor, limit = 20) { return request(`/projects${buildQueryParams({ cursor, limit })}`) }, create(data) { return request('/projects', { method: 'POST', body: data, }) }, delete(projectId) { return request(`/projects/${projectId}`, { method: 'DELETE' }) }, listFiles(projectId, path = '') { return request(`/projects/${projectId}/files${buildQueryParams({ path })}`) }, readFile(projectId, filepath) { return request(`/projects/${projectId}/files/${filepath}`) }, readFileRaw(projectId, filepath) { return fetch(`${BASE}/projects/${projectId}/files/${filepath}`).then(res => { if (!res.ok) throw new Error(`HTTP ${res.status}`) return res }) }, writeFile(projectId, filepath, content) { return request(`/projects/${projectId}/files/${filepath}`, { method: 'PUT', body: { content }, }) }, renameFile(projectId, filepath, newPath) { return request(`/projects/${projectId}/files/${filepath}`, { method: 'PATCH', body: { new_path: newPath }, }) }, deleteFile(projectId, filepath) { return request(`/projects/${projectId}/files/${filepath}`, { method: 'DELETE' }) }, mkdir(projectId, dirPath) { return request(`/projects/${projectId}/directories`, { method: 'POST', body: { path: dirPath }, }) }, }