403 lines
13 KiB
JavaScript
403 lines
13 KiB
JavaScript
/**
|
|
* API sends the odidere HTTP requests and decodes the server's
|
|
* Server-Sent Events (SSE) streams. It is the only module that opens
|
|
* network connections. It never reads the DOM and never touches
|
|
* localStorage: callers keep what it returns and render it elsewhere.
|
|
*
|
|
* The two streaming methods, inference and subscribe, are async
|
|
* generators. Each yielded value is one decoded SSE event:
|
|
* { type, data }. The server terminates a stream with a 'done'
|
|
* event. A generator that ends without 'done' reports that delivery
|
|
* stopped, not that the work stopped: the server keeps an accepted
|
|
* inference running after the client disconnects, and a new
|
|
* subscribe call re-attaches to it and replays what was saved.
|
|
*
|
|
* Every failed request throws ApiError, which carries the HTTP
|
|
* status. Status 409 reports either that the conversation is already
|
|
* running inference, or that a message changed since the caller last
|
|
* read it; the caller should re-sync before it retries.
|
|
*/
|
|
|
|
export class ApiError extends Error {
|
|
/**
|
|
* @param {number} status - HTTP status code.
|
|
* @param {string} body - response body, often empty.
|
|
*/
|
|
constructor(status, body) {
|
|
super(`HTTP ${status}${body ? `: ${body}` : ''}`);
|
|
this.name = 'ApiError';
|
|
this.status = status;
|
|
this.body = body;
|
|
}
|
|
}
|
|
|
|
export class Api {
|
|
// --- Models and voices ---
|
|
|
|
/**
|
|
* getModels lists the models of every configured provider, each
|
|
* with an availability status, and the server's default selection.
|
|
* @returns {Promise<{providers: Object, default_provider: string, default_model: string}>}
|
|
*/
|
|
async getModels() {
|
|
return this._json('/models');
|
|
}
|
|
|
|
/**
|
|
* getVoices lists the voices of the TTS server. The TTS server is
|
|
* a separate service, not part of the odidere API.
|
|
* @param {string} url - TTS server base URL.
|
|
* @returns {Promise<Object>}
|
|
*/
|
|
async getVoices(url) {
|
|
return this._json(`${url}/v1/audio/voices`);
|
|
}
|
|
|
|
// --- Conversations ---
|
|
|
|
/**
|
|
* listConversations returns every conversation of the user, most
|
|
* recently updated first. Each row holds id, title, created_at,
|
|
* and updated_at. It holds no messages.
|
|
* @returns {Promise<Array<Object>>}
|
|
*/
|
|
async listConversations() {
|
|
return this._json('/conversations');
|
|
}
|
|
|
|
/**
|
|
* getConversation returns one conversation row. Use it to verify
|
|
* that a conversation exists before the local selection moves to
|
|
* it. It returns no messages.
|
|
* @param {string} id
|
|
* @returns {Promise<Object>}
|
|
*/
|
|
async getConversation(id) {
|
|
return this._json(`/conversations/${encodeURIComponent(id)}`);
|
|
}
|
|
|
|
/**
|
|
* createConversation saves a new conversation with its initial
|
|
* system message. An empty systemMessage makes the server use its
|
|
* configured default.
|
|
* @param {{title?: string, systemMessage?: string}} [body]
|
|
* @returns {Promise<Object>} the saved conversation row.
|
|
*/
|
|
async createConversation({ title, systemMessage } = {}) {
|
|
const body = {};
|
|
if (title !== undefined) body.title = title;
|
|
if (systemMessage !== undefined) body.system_message = systemMessage;
|
|
return this._json('/conversations', { method: 'POST', body });
|
|
}
|
|
|
|
/**
|
|
* renameConversation sets a new title for a conversation. The
|
|
* server trims the title and rejects an empty one. The returned
|
|
* row holds the saved title; the caller uses it to update the
|
|
* picker.
|
|
* @param {string} id
|
|
* @param {string} title
|
|
* @returns {Promise<Object>} the saved conversation row.
|
|
*/
|
|
async renameConversation(id, title) {
|
|
return this._json(`/conversations/${encodeURIComponent(id)}`, {
|
|
method: 'PATCH',
|
|
body: { title },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* deleteConversation removes the conversation, all its messages,
|
|
* and its tool execution records. It also cancels an ongoing
|
|
* inference. This cannot be undone.
|
|
* @param {string} id
|
|
*/
|
|
async deleteConversation(id) {
|
|
await this._request(`/conversations/${encodeURIComponent(id)}`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* stopConversation asks the server to cancel the conversation's
|
|
* ongoing inference. The response acknowledges the request, not
|
|
* completed cancellation: the events that follow report the
|
|
* outcome of the work that already finished.
|
|
* @param {string} id
|
|
*/
|
|
async stopConversation(id) {
|
|
await this._request(`/conversations/${encodeURIComponent(id)}/stop`, {
|
|
method: 'POST',
|
|
});
|
|
}
|
|
|
|
// --- Streaming ---
|
|
|
|
/**
|
|
* inference starts one inference run and yields its events. The
|
|
* optional message is new user input; without it the run continues
|
|
* from saved history, which is how a run resumes after a tool
|
|
* approval or denial.
|
|
*
|
|
* The server rejects the request with status 409 while another run
|
|
* is reserved or ongoing.
|
|
*
|
|
* @param {string} id - conversation ID.
|
|
* @param {Object} body - request fields; message, provider, model,
|
|
* max_iterations, thinking_budget_tokens, stream.
|
|
* @param {AbortSignal} [signal] - ends delivery, not the run.
|
|
*/
|
|
async *inference(id, body, signal) {
|
|
yield* this._stream(
|
|
`/conversations/${encodeURIComponent(id)}/inference`,
|
|
body,
|
|
signal,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* subscribe synchronizes the caller's message cache with the saved
|
|
* conversation and yields the events that follow.
|
|
*
|
|
* manifest lists the message IDs and timestamps the caller already
|
|
* holds. The first events are a diff: one message_saved per row
|
|
* that is missing or newer than the manifest, then one
|
|
* message_deleted per manifest ID that is no longer saved. An
|
|
* empty manifest requests every saved row.
|
|
*
|
|
* When an inference is reserved or ongoing, the stream then follows
|
|
* its events and ends with done. When none is, the stream ends
|
|
* after the diff without done.
|
|
*
|
|
* @param {string} id - conversation ID.
|
|
* @param {Array<{id: string, updated_at: string}>} manifest
|
|
* @param {AbortSignal} [signal]
|
|
*/
|
|
async *subscribe(id, manifest, signal) {
|
|
yield* this._stream(
|
|
`/conversations/${encodeURIComponent(id)}/subscribe`,
|
|
{ manifest },
|
|
signal,
|
|
);
|
|
}
|
|
|
|
// --- Message edits ---
|
|
|
|
/**
|
|
* insertMessage saves a new message. For a user or assistant
|
|
* message, pass message only. For a tool result, pass the tool
|
|
* message and message_id and updated_at of its parent assistant
|
|
* message: message_id names the assistant, updated_at is the
|
|
* assistant's timestamp from the caller's last read.
|
|
* @param {string} id - conversation ID.
|
|
* @param {Object} body - message, and for tool results
|
|
* message_id and updated_at.
|
|
* @returns {Promise<Object>} the saved message row.
|
|
*/
|
|
async insertMessage(id, body) {
|
|
return this._json(`/conversations/${encodeURIComponent(id)}/messages`, {
|
|
method: 'POST',
|
|
body,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* updateMessage replaces the model-visible content of one saved
|
|
* message. The role may not change. updated_at is the row's
|
|
* timestamp from the caller's last read; a mismatch returns 409.
|
|
* For a tool result, mid identifies the parent assistant message
|
|
* and updated_at is the tool result's timestamp.
|
|
* @param {string} id - conversation ID.
|
|
* @param {string} mid - message ID.
|
|
* @param {Object} body - message and updated_at.
|
|
* @returns {Promise<Object>} the saved message row.
|
|
*/
|
|
async updateMessage(id, mid, body) {
|
|
return this._json(
|
|
`/conversations/${encodeURIComponent(id)}` +
|
|
`/messages/${encodeURIComponent(mid)}`,
|
|
{ method: 'PUT', body },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* deleteMessage removes one saved message. updated_at is the
|
|
* row's timestamp from the caller's last read; a mismatch returns
|
|
* 409.
|
|
* @param {string} id - conversation ID.
|
|
* @param {string} mid - message ID.
|
|
* @param {string} updatedAt
|
|
*/
|
|
async deleteMessage(id, mid, updatedAt) {
|
|
await this._request(
|
|
`/conversations/${encodeURIComponent(id)}` +
|
|
`/messages/${encodeURIComponent(mid)}`,
|
|
{ method: 'DELETE', body: { updated_at: updatedAt } },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* updateToolCall replaces one call of a saved assistant message.
|
|
* The replacement must keep the call ID: the execution record and
|
|
* any saved result are keyed by it. updated_at is the assistant
|
|
* message's timestamp from the caller's last read.
|
|
* @param {string} id - conversation ID.
|
|
* @param {string} mid - assistant message ID.
|
|
* @param {string} cid - tool call ID.
|
|
* @param {Object} body - call and updated_at.
|
|
* @returns {Promise<Object>} the saved assistant message row.
|
|
*/
|
|
async updateToolCall(id, mid, cid, body) {
|
|
return this._json(
|
|
`/conversations/${encodeURIComponent(id)}` +
|
|
`/messages/${encodeURIComponent(mid)}` +
|
|
`/calls/${encodeURIComponent(cid)}`,
|
|
{ method: 'PUT', body },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* deleteToolCall removes the call, its saved result, and its
|
|
* execution record together. updated_at is the assistant message's
|
|
* timestamp from the caller's last read.
|
|
* @param {string} id - conversation ID.
|
|
* @param {string} mid - assistant message ID.
|
|
* @param {string} cid - tool call ID.
|
|
* @param {string} updatedAt
|
|
*/
|
|
async deleteToolCall(id, mid, cid, updatedAt) {
|
|
await this._request(
|
|
`/conversations/${encodeURIComponent(id)}` +
|
|
`/messages/${encodeURIComponent(mid)}` +
|
|
`/calls/${encodeURIComponent(cid)}`,
|
|
{ method: 'DELETE', body: { updated_at: updatedAt } },
|
|
);
|
|
}
|
|
|
|
// --- Tool approvals ---
|
|
|
|
/**
|
|
* listPendingExecutions lists every tool call that waits for a
|
|
* user decision, across all conversations. Filter the result to
|
|
* the conversation in view before showing it.
|
|
* @returns {Promise<Array<Object>>} execution rows with status
|
|
* 'pending'.
|
|
*/
|
|
async listPendingExecutions() {
|
|
return this._json('/executions');
|
|
}
|
|
|
|
/**
|
|
* decideExecution records the user's decision for one tool call.
|
|
* An approved call runs on the conversation's next inference; a
|
|
* denied call does not. The status transition is refused once the
|
|
* row has left 'pending'.
|
|
* @param {string} executionId
|
|
* @param {'approved' | 'denied'} status
|
|
*/
|
|
async decideExecution(executionId, status) {
|
|
await this._request(`/executions/${encodeURIComponent(executionId)}`, {
|
|
method: 'PUT',
|
|
body: { status },
|
|
});
|
|
}
|
|
|
|
// --- Private ---
|
|
|
|
/**
|
|
* _request sends one request and checks the response. It returns
|
|
* the open Response and throws ApiError when the server rejects
|
|
* the request. Signals from the caller propagate as AbortError.
|
|
* @private
|
|
*/
|
|
async _request(path, { method = 'GET', body, signal } = {}) {
|
|
const res = await fetch(path, {
|
|
method,
|
|
signal,
|
|
headers:
|
|
body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
|
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
});
|
|
if (!res.ok) {
|
|
throw new ApiError(res.status, (await res.text().catch(() => '')).trim());
|
|
}
|
|
return res;
|
|
}
|
|
|
|
/**
|
|
* _json sends one request and parses its JSON body. A response
|
|
* with no body (204 No Content) resolves to null.
|
|
* @private
|
|
*/
|
|
async _json(path, options) {
|
|
const res = await this._request(path, options);
|
|
return res.status === 204 ? null : res.json();
|
|
}
|
|
|
|
/**
|
|
* _stream sends one POST request and yields the response's decoded
|
|
* SSE events. See readEvents.
|
|
* @private
|
|
*/
|
|
async *_stream(path, body, signal) {
|
|
const res = await this._request(path, {
|
|
method: 'POST',
|
|
body,
|
|
signal,
|
|
});
|
|
yield* readEvents(res);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* readEvents decodes one SSE response into { type, data } events.
|
|
* It buffers across network chunk boundaries, accepts LF and CRLF
|
|
* line endings, joins multi-line data fields, and skips comment
|
|
* lines. Events without an event field get type 'message'. A line
|
|
* with invalid JSON is logged and skipped; the stream continues.
|
|
* @param {Response} response
|
|
*/
|
|
export async function* readEvents(response) {
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
let type = 'message';
|
|
let data = [];
|
|
try {
|
|
while (true) {
|
|
const result = await reader.read();
|
|
buffer += decoder.decode(result.value, { stream: !result.done });
|
|
while (true) {
|
|
const newline = buffer.indexOf('\n');
|
|
if (newline === -1) break;
|
|
const line = buffer.slice(0, newline).replace(/\r$/, '');
|
|
buffer = buffer.slice(newline + 1);
|
|
if (line === '') {
|
|
if (data.length) {
|
|
const text = data.join('\n');
|
|
try {
|
|
yield { type, data: JSON.parse(text) };
|
|
} catch (error) {
|
|
console.error('failed to parse SSE data:', text, error);
|
|
}
|
|
}
|
|
type = 'message';
|
|
data = [];
|
|
continue;
|
|
}
|
|
if (line.startsWith(':')) continue;
|
|
const colon = line.indexOf(':');
|
|
const field = colon === -1 ? line : line.slice(0, colon);
|
|
const value =
|
|
colon === -1 ? '' : line.slice(colon + 1).replace(/^ /, '');
|
|
if (field === 'event') type = value;
|
|
if (field === 'data') data.push(value);
|
|
}
|
|
if (result.done) break;
|
|
}
|
|
} finally {
|
|
await reader.cancel().catch(() => {});
|
|
reader.releaseLock();
|
|
}
|
|
}
|