Refactor frontend

This commit is contained in:
dwrz
2026-10-30 22:42:51 +00:00
parent cedb3d8854
commit e1457b5130
9 changed files with 3598 additions and 2973 deletions
+365
View File
@@ -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();
}
}