nanoClaw/frontend/src/api/index.js

244 lines
6.2 KiB
JavaScript

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 },
})
},
}