Refactor frontend
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* 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 ---
|
||||
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user